Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,31 @@

## [Unreleased]

### Added

- **可作为桌面启动**:设置 → 播放选项 → 「作为桌面启动」。默认关闭——关闭时 HOME 组件
(`HomeAlias`)是禁用的,系统完全看不到 WiTV 的桌面能力,按 Home 键不会冒出选择框。
普通应用没法用代码把自己设成默认桌面,所以开启后会跳系统「默认桌面」设置页;
电视盒子大多没有这个页面,就短暂启用一个伪 HOME 组件让系统丢弃已保存的默认值、重新弹出选择框
- **应用切换面板**:返回键弹窗新增「切换应用」,列出其他桌面与全部可启动应用,
并提供「更改默认桌面…」入口。WiTV 当桌面时这是打开其他应用的唯一途径

### Changed

- `PlayerActivity` 改为 `singleTask`:作为桌面时每次按 Home 都回到同一个实例,
只收起面板、不重新加载频道。作为默认桌面时「休息一下」改为退到后台而非 `finish()`——
否则系统会立刻把桌面重新拉起来,看起来像退不出去
- 播放页同一时间只保留一个实例:作为桌面时按 Home,系统会在桌面栈里另起任务新建播放页,
`singleTask` 管不到原先从启动器打开的那个,两个播放器会同时拉流。新实例创建时关掉旧的,
旧实例销毁时也不再清掉新实例登记的 `PlayerManager`
- 首次加载等网络就绪再起播:网络未连上时提示「正在等待网络连接…」,连上后再加载
- 起播前先在后台线程预热系统解码器列表(`MediaCodecList`)。p230 实测:开机时 mediacodec
反复 SIGILL 崩溃,约 30 秒内任何解码器查询都会阻塞。不预热时阻塞的是 ExoPlayer 播放线程——
组播数据照收(环形缓冲溢出)、画面不出,15 秒后被换源超时判为「所有播放源均不可用」,
udpxy 与组播直收都一样。预热期间提示「正在等待系统解码服务…」,换源计时从解码服务可用后才开始
- 切换面板的「其他桌面」排除开机向导(`com.android.provision`)与 `FallbackHome`:
它们以非零优先级声明 HOME,打开开机向导可能会重走一遍开机设置

## [1.4.0] - 2026-10-04

本版把 v1.3.0 的组播能力真正跑通在电视盒子上:杜比频道没声音、切解码方式报错、
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Android TV M3U 直播播放器,使用原生 ExoPlayer 播放,支持多源自
- **UDP / RTP 组播** — 支持 `udp://`、`rtp://` 组播频道(含 RTP 剥头与乱序重排);也可配置 udpxy 代理把组播改写为 HTTP 单播,详见 [docs/multicast-udp-rtp.md](docs/multicast-udp-rtp.md)
- **FFmpeg 软解兜底** — 内置 NextLib FFmpeg 解码器,补上盒子硬解常缺的 MPEG-2 视频与 MP2/AC3 音频;可在设置中切换硬解优先 / 软解优先 / 仅硬解
- **遥控器适配** — 完整的 D-Pad 导航支持,数字键直接跳转频道
- **可作为桌面启动** — 开机 / 按 Home 键直接进入直播;返回键弹窗里的「切换应用」可打开其他桌面与任意应用,随时切回原桌面

## 遥控器操作

Expand Down Expand Up @@ -85,6 +86,15 @@ APK 输出路径:`app/build/outputs/apk/debug/app-debug.apk`
4. 添加 M3U 播放源地址
5. 返回 TV 端即可看到频道列表,选择频道开始播放

### 作为桌面启动(可选)

1. 菜单键 → 设置 → 播放选项 → 勾选「作为桌面启动」
2. 在打开的系统「默认应用」页点「主屏幕应用」并选择 WiTV;没有该页面的盒子会直接弹出桌面列表,选 WiTV 并点「始终」
3. 需要打开其他应用时:返回键 → 「切换应用」,可打开其他桌面、系统设置或任意已安装应用
4. 想换回原来的桌面:「切换应用」→「更改默认桌面…」,或直接取消勾选「作为桌面启动」

开机时会等网络与系统解码服务就绪再起播。图文步骤见 [使用手册](docs/user-guide.md#作为桌面启动)。部分电视盒子 ROM 锁定了默认桌面,此时选择框可能不出现,需要到系统设置里修改。

## Web 管理 API

内置 HTTP 服务器提供以下 REST API:
Expand Down
49 changes: 49 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@
android:name="android.hardware.touchscreen"
android:required="false" />

<!-- targetSdk 30+ 的包可见性:应用切换面板需要列出桌面与可启动应用,见 LauncherHelper -->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
</intent>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent>
</queries>

<application
android:name=".WiTVApp"
android:allowBackup="true"
Expand All @@ -33,6 +49,7 @@
android:name=".ui.PlayerActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
Expand All @@ -44,6 +61,38 @@
</intent-filter>
</activity>

<!--
作为桌面启动:默认关闭,由设置里的「作为桌面启动」通过 LauncherHelper 开关。
关闭时系统看不到 WiTV 的 HOME 能力,按 Home 键不会出现在候选里。
-->
<activity-alias
android:name=".ui.HomeAlias"
android:enabled="false"
android:exported="true"
android:targetActivity=".ui.PlayerActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity-alias>

<!--
仅用于让系统丢弃已保存的默认桌面:短暂启用后系统发现 HOME 候选集合变了,
下次按 Home 会重新弹出选择框。见 LauncherHelper#requestChooseDefaultHome。
-->
<activity-alias
android:name=".ui.HomeChooserResetAlias"
android:enabled="false"
android:exported="true"
android:targetActivity=".ui.PlayerActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity-alias>

<activity
android:name=".ui.SettingsActivity"
android:screenOrientation="landscape" />
Expand Down
141 changes: 141 additions & 0 deletions app/src/main/java/com/whyun/witv/ui/AppSwitcherDialog.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.whyun.witv.ui;

import android.app.Activity;
import android.app.AlertDialog;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.TextView;

import androidx.annotation.Nullable;

import com.whyun.witv.R;

import java.util.ArrayList;
import java.util.List;

/**
* 轻量的应用切换面板:WiTV 当桌面时,从这里打开其他桌面、系统设置或任意应用。
* 用 AlertDialog 自带的 ListView,遥控器上下键即可移动焦点。
*/
public final class AppSwitcherDialog {

private AppSwitcherDialog() {
}

/** 列表里的一项:分组标题、「更改默认桌面」或一个应用 */
private static final class Item {
final String title;
@Nullable
final LauncherHelper.AppEntry app;
final boolean header;

Item(String title, @Nullable LauncherHelper.AppEntry app, boolean header) {
this.title = title;
this.app = app;
this.header = header;
}
}

public static AlertDialog show(Activity activity) {
List<Item> items = new ArrayList<>();
items.add(new Item(activity.getString(R.string.app_switcher_change_default), null, false));

List<LauncherHelper.AppEntry> homes = LauncherHelper.queryOtherHomes(activity);
if (!homes.isEmpty()) {
items.add(new Item(activity.getString(R.string.app_switcher_other_homes), null, true));
for (LauncherHelper.AppEntry e : homes) {
items.add(new Item(e.label, e, false));
}
}
List<LauncherHelper.AppEntry> apps = LauncherHelper.queryLaunchableApps(activity);
if (!apps.isEmpty()) {
items.add(new Item(activity.getString(R.string.app_switcher_all_apps), null, true));
for (LauncherHelper.AppEntry e : apps) {
items.add(new Item(e.label, e, false));
}
}

BaseAdapter adapter = new BaseAdapter() {
@Override
public int getCount() {
return items.size();
}

@Override
public Object getItem(int position) {
return items.get(position);
}

@Override
public long getItemId(int position) {
return position;
}

@Override
public int getViewTypeCount() {
return 2;
}

@Override
public int getItemViewType(int position) {
return items.get(position).header ? 1 : 0;
}

@Override
public boolean areAllItemsEnabled() {
return false;
}

@Override
public boolean isEnabled(int position) {
return !items.get(position).header;
}

@Override
public View getView(int position, View convertView, ViewGroup parent) {
Item item = items.get(position);
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
if (item.header) {
TextView v = convertView != null ? (TextView) convertView
: (TextView) inflater.inflate(R.layout.item_app_section, parent, false);
v.setText(item.title);
return v;
}
View v = convertView != null ? convertView
: inflater.inflate(R.layout.item_app_entry, parent, false);
ImageView icon = v.findViewById(R.id.app_icon);
TextView label = v.findViewById(R.id.app_label);
label.setText(item.title);
if (item.app != null && item.app.icon != null) {
icon.setImageDrawable(item.app.icon);
icon.setVisibility(View.VISIBLE);
} else {
icon.setImageDrawable(null);
icon.setVisibility(View.GONE);
}
return v;
}
};

AlertDialog dialog = new AlertDialog.Builder(activity)
.setTitle(R.string.app_switcher_title)
.setAdapter(adapter, (d, which) -> {
Item item = items.get(which);
if (item.header) {
return;
}
if (item.app == null) {
LauncherHelper.requestChooseDefaultHome(activity);
} else {
LauncherHelper.launch(activity, item.app.component);
}
})
.setNegativeButton(android.R.string.cancel, null)
.create();
dialog.show();
return dialog;
}
}
Loading
Loading