diff --git a/CHANGELOG.md b/CHANGELOG.md index a0bca2b..1da1f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 的组播能力真正跑通在电视盒子上:杜比频道没声音、切解码方式报错、 diff --git a/README.md b/README.md index f24ce7b..7b70f3b 100644 --- a/README.md +++ b/README.md @@ -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 键直接进入直播;返回键弹窗里的「切换应用」可打开其他桌面与任意应用,随时切回原桌面 ## 遥控器操作 @@ -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: diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ffc96bb..a958d9f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,22 @@ android:name="android.hardware.touchscreen" android:required="false" /> + + + + + + + + + + + + + + + + @@ -44,6 +61,38 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/whyun/witv/ui/AppSwitcherDialog.java b/app/src/main/java/com/whyun/witv/ui/AppSwitcherDialog.java new file mode 100644 index 0000000..054905b --- /dev/null +++ b/app/src/main/java/com/whyun/witv/ui/AppSwitcherDialog.java @@ -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 items = new ArrayList<>(); + items.add(new Item(activity.getString(R.string.app_switcher_change_default), null, false)); + + List 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 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; + } +} diff --git a/app/src/main/java/com/whyun/witv/ui/LauncherHelper.java b/app/src/main/java/com/whyun/witv/ui/LauncherHelper.java new file mode 100644 index 0000000..bc94fbb --- /dev/null +++ b/app/src/main/java/com/whyun/witv/ui/LauncherHelper.java @@ -0,0 +1,200 @@ +package com.whyun.witv.ui; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.graphics.drawable.Drawable; +import android.provider.Settings; +import android.widget.Toast; + +import androidx.annotation.Nullable; + +import com.whyun.witv.R; + +import java.text.Collator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * 「作为桌面启动」相关操作。 + * + *

普通应用无法用代码把自己设成默认桌面,也清不掉别的应用的默认设置;能做的只有: + * 开关自己的 HOME 候选组件(manifest 里的 {@code HomeAlias}),再引导系统弹出桌面选择框。 + * 开关状态由 PackageManager 保存,不另存 SharedPreferences,免得两边不一致。 + */ +public final class LauncherHelper { + + private static final String HOME_ALIAS = "com.whyun.witv.ui.HomeAlias"; + private static final String CHOOSER_RESET_ALIAS = "com.whyun.witv.ui.HomeChooserResetAlias"; + + /** 切换面板里的一行:桌面或可启动应用 */ + public static final class AppEntry { + public final String label; + public final ComponentName component; + @Nullable + public final Drawable icon; + + public AppEntry(String label, ComponentName component, @Nullable Drawable icon) { + this.label = label; + this.component = component; + this.icon = icon; + } + } + + private LauncherHelper() { + } + + public static boolean isHomeEnabled(Context context) { + int state = context.getPackageManager() + .getComponentEnabledSetting(homeAlias(context)); + // manifest 里默认 enabled=false,所以 DEFAULT 也算关闭 + return state == PackageManager.COMPONENT_ENABLED_STATE_ENABLED; + } + + public static void setHomeEnabled(Context context, boolean enabled) { + setComponentEnabled(context, homeAlias(context), enabled); + } + + /** 当前系统默认桌面是否就是 WiTV(用户已选「始终」) */ + public static boolean isDefaultHome(Context context) { + ResolveInfo ri = context.getPackageManager() + .resolveActivity(homeIntent(), PackageManager.MATCH_DEFAULT_ONLY); + return ri != null && ri.activityInfo != null + && context.getPackageName().equals(ri.activityInfo.packageName); + } + + /** + * 引导用户重新选择默认桌面:优先跳系统「默认桌面」设置页; + * 很多电视盒子没有这个页面,就短暂启用一个伪 HOME 组件让系统丢弃已保存的默认值, + * 再发 HOME intent 触发选择框。 + */ + public static void requestChooseDefaultHome(Activity activity) { + PackageManager pm = activity.getPackageManager(); + // 不加 NEW_TASK 会压进 WiTV 自己的任务栈,之后 WiTV 被顶替时会连带关掉设置页 + Intent settings = new Intent(Settings.ACTION_HOME_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (settings.resolveActivity(pm) != null) { + try { + activity.startActivity(settings); + return; + } catch (ActivityNotFoundException | SecurityException ignored) { + // 有些 ROM 声明了却不让第三方打开,走下面的兜底 + } + } + ComponentName reset = new ComponentName(activity, CHOOSER_RESET_ALIAS); + setComponentEnabled(activity, reset, true); + try { + activity.startActivity(homeIntent().addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + } catch (ActivityNotFoundException e) { + Toast.makeText(activity, R.string.app_switcher_launch_failed, Toast.LENGTH_SHORT).show(); + } finally { + setComponentEnabled(activity, reset, false); + } + } + + /** 除 WiTV 以外的所有桌面 */ + public static List queryOtherHomes(Context context) { + PackageManager pm = context.getPackageManager(); + List homes = new ArrayList<>(); + for (ResolveInfo ri : pm.queryIntentActivities(homeIntent(), 0)) { + if (isRealHome(ri)) { + homes.add(ri); + } + } + return dedupeAndSort(toEntries(pm, homes), context.getPackageName()); + } + + /** + * 排除不是给人用的 HOME 组件:开机向导(如 com.android.provision)用正优先级抢在 + * 真正桌面之前,打开它可能重走一遍开机设置;FallbackHome 优先级为负,只在开机解锁前兜底, + * 打开是空白页。正常桌面不声明优先级,即 0。 + */ + static boolean isRealHome(ResolveInfo ri) { + return ri.priority == 0; + } + + /** 电视与手机启动器里能看到的应用,按包名去重(同一应用优先保留 Leanback 入口) */ + public static List queryLaunchableApps(Context context) { + PackageManager pm = context.getPackageManager(); + List entries = new ArrayList<>(); + entries.addAll(toEntries(pm, pm.queryIntentActivities( + launcherIntent(Intent.CATEGORY_LEANBACK_LAUNCHER), 0))); + entries.addAll(toEntries(pm, pm.queryIntentActivities( + launcherIntent(Intent.CATEGORY_LAUNCHER), 0))); + return dedupeAndSort(entries, context.getPackageName()); + } + + public static void launch(Context context, ComponentName component) { + Intent intent = new Intent(Intent.ACTION_MAIN) + .setComponent(component) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED); + try { + context.startActivity(intent); + } catch (ActivityNotFoundException | SecurityException e) { + Toast.makeText(context, R.string.app_switcher_launch_failed, Toast.LENGTH_SHORT).show(); + } + } + + /** + * 去掉自身包,按包名去重(保留先出现的),再按名称排序。 + * 纯数据处理,便于单测。 + */ + static List dedupeAndSort(List entries, String selfPackage) { + Set seen = new HashSet<>(); + List out = new ArrayList<>(); + for (AppEntry e : entries) { + String pkg = e.component.getPackageName(); + if (pkg.equals(selfPackage) || !seen.add(pkg)) { + continue; + } + out.add(e); + } + Collator collator = Collator.getInstance(Locale.CHINA); + Collections.sort(out, (a, b) -> collator.compare(a.label, b.label)); + return out; + } + + private static List toEntries(PackageManager pm, List infos) { + List out = new ArrayList<>(); + for (ResolveInfo ri : infos) { + ActivityInfo ai = ri.activityInfo; + if (ai == null) { + continue; + } + CharSequence label = ri.loadLabel(pm); + out.add(new AppEntry( + label != null ? label.toString() : ai.packageName, + new ComponentName(ai.packageName, ai.name), + ri.loadIcon(pm))); + } + return out; + } + + private static ComponentName homeAlias(Context context) { + return new ComponentName(context, HOME_ALIAS); + } + + private static void setComponentEnabled(Context context, ComponentName component, boolean enabled) { + context.getPackageManager().setComponentEnabledSetting(component, + enabled ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED + : PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP); + } + + private static Intent homeIntent() { + return new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME); + } + + private static Intent launcherIntent(String category) { + return new Intent(Intent.ACTION_MAIN).addCategory(category); + } +} diff --git a/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java b/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java index 78282d2..0f05e2d 100644 --- a/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java +++ b/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java @@ -1,6 +1,13 @@ package com.whyun.witv.ui; import android.app.AlertDialog; +import android.content.Intent; +import android.media.MediaCodecList; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkInfo; +import android.net.NetworkRequest; import android.net.wifi.WifiInfo; import android.net.wifi.WifiManager; import android.graphics.Bitmap; @@ -49,6 +56,7 @@ import com.whyun.witv.data.repository.EpgRepository; import com.whyun.witv.player.PlayerManager; +import java.lang.ref.WeakReference; import java.util.ArrayList; import java.text.SimpleDateFormat; import java.util.Date; @@ -118,6 +126,25 @@ public class PlayerActivity extends FragmentActivity implements PlayerManager.Ca private boolean overlayVisible = false; private final Runnable hideOverlayRunnable = () -> hideOverlay(); private AlertDialog exitDialog; + private AlertDialog appSwitcherDialog; + + /** + * 当前存活的播放页。作为桌面时按 Home 键,系统会在桌面栈里另起一个任务新建实例, + * singleTask 管不到原先从启动器打开的那个;不处理就会两个播放器同时拉流。 + */ + private static WeakReference liveInstance; + + /** 网络未就绪时挂起首次加载,见 {@link #loadAndPlayWhenNetworkReady()} */ + private ConnectivityManager.NetworkCallback networkWaitCallback; + + /** 进程内已成功取到过解码器列表,之后不必再预热 */ + private static volatile boolean codecListReady; + /** 预热超过这么久才提示「等待系统解码服务」,正常情况下几十毫秒就返回,不闪提示 */ + private static final long CODEC_WARMUP_HINT_DELAY_MS = 800L; + private final Runnable codecWarmupHintRunnable = () -> { + switchingToast.setText(R.string.waiting_for_codec_service); + switchingToast.setVisibility(View.VISIBLE); + }; private static final long CHANNEL_LIST_HIDE_IDLE_MS = 10_000L; private final Runnable hideChannelListIdleRunnable = () -> { @@ -175,6 +202,11 @@ public void onPlaybackStateChanged(int playbackState) { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + PlayerActivity previous = liveInstance != null ? liveInstance.get() : null; + liveInstance = new WeakReference<>(this); + if (previous != null && previous != this && !previous.isFinishing()) { + previous.finishAndRemoveTask(); + } setContentView(R.layout.activity_player); currentChannelId = getIntent().getLongExtra(EXTRA_CHANNEL_ID, -1); @@ -188,7 +220,86 @@ protected void onCreate(Bundle savedInstanceState) { .commitNow(); } applyLoadSpeedOverlayPreference(); - loadAndPlay(); + warmUpCodecListThen(this::loadAndPlayWhenNetworkReady); + } + + /** + * 起播前先在后台线程取一次系统解码器列表。 + * + *

部分盒子(p230 实测)开机时 mediacodec 进程会崩溃,要等 init 约 30 秒后重新拉起; + * 期间任何解码器查询都会阻塞。若直接起播,阻塞的是 ExoPlayer 的播放线程, + * 数据照收、画面不出,15 秒后被换源超时判为「所有播放源均不可用」,停在失败页。 + * 先在这里等到解码服务可用,换源计时才从真正能播的时候开始。 + * MediaCodecList 在进程内只初始化一次,之后 ExoPlayer 的查询直接命中缓存。 + */ + private void warmUpCodecListThen(Runnable next) { + if (codecListReady) { + next.run(); + return; + } + handler.postDelayed(codecWarmupHintRunnable, CODEC_WARMUP_HINT_DELAY_MS); + new Thread(() -> { + try { + new MediaCodecList(MediaCodecList.REGULAR_CODECS).getCodecInfos(); + codecListReady = true; + } catch (RuntimeException e) { + // 取不到也照常起播,交给播放器自己的出错与换源逻辑 + android.util.Log.w("PlayerActivity", "Codec list warm-up failed", e); + } + handler.post(() -> { + handler.removeCallbacks(codecWarmupHintRunnable); + if (isFinishing()) { + return; + } + switchingToast.setVisibility(View.GONE); + next.run(); + }); + }, "codec-warmup").start(); + } + + /** + * 作为桌面开机自启时,播放页往往比网卡拿到 IP 还早一两秒:M3U 刷新失败、 + * 首个源超时后整页停在「所有播放源均不可用」。网络未连上就先提示等待,连上后再加载。 + */ + private void loadAndPlayWhenNetworkReady() { + ConnectivityManager cm = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); + NetworkInfo active = cm != null ? cm.getActiveNetworkInfo() : null; + if (cm == null || (active != null && active.isConnected())) { + loadAndPlay(); + return; + } + switchingToast.setText(R.string.waiting_for_network); + switchingToast.setVisibility(View.VISIBLE); + networkWaitCallback = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + handler.post(() -> { + if (networkWaitCallback == null || isFinishing()) { + return; + } + unregisterNetworkWait(); + reloadInitialChannel(); + }); + } + }; + cm.registerNetworkCallback(new NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build(), networkWaitCallback); + } + + private void unregisterNetworkWait() { + if (networkWaitCallback == null) { + return; + } + ConnectivityManager cm = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); + if (cm != null) { + try { + cm.unregisterNetworkCallback(networkWaitCallback); + } catch (IllegalArgumentException ignored) { + // 已注销 + } + } + networkWaitCallback = null; } private void initViews() { @@ -1414,7 +1525,8 @@ private void showExitDialog() { .setTitle(R.string.exit_dialog_title) .setMessage(R.string.exit_dialog_message) .setNegativeButton(R.string.settings, (dialog, which) -> showSettingsPanel()) - .setPositiveButton(R.string.exit_dialog_rest, (dialog, which) -> finish()) + .setNeutralButton(R.string.exit_dialog_switch_app, (dialog, which) -> showAppSwitcher()) + .setPositiveButton(R.string.exit_dialog_rest, (dialog, which) -> rest()) .create(); exitDialog.setOnDismissListener(dialog -> exitDialog = null); exitDialog.show(); @@ -1424,6 +1536,49 @@ private void showExitDialog() { } } + /** + * 作为默认桌面时不能 finish:系统会立刻重新拉起桌面,看起来就是「退不出去」。 + * 改为把任务退到后台,回到上一个应用(没有的话保持原样)。 + */ + private void rest() { + if (LauncherHelper.isDefaultHome(this)) { + moveTaskToBack(true); + } else { + finish(); + } + } + + private void showAppSwitcher() { + if (isFinishing()) { + return; + } + if (appSwitcherDialog != null && appSwitcherDialog.isShowing()) { + return; + } + appSwitcherDialog = AppSwitcherDialog.show(this); + appSwitcherDialog.setOnDismissListener(dialog -> appSwitcherDialog = null); + } + + /** + * singleTask:作为桌面时每次按 Home 都会走到这里。只收起面板和弹窗回到全屏播放, + * 不重新加载频道,避免打断正在播的节目。 + */ + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + if (exitDialog != null && exitDialog.isShowing()) { + exitDialog.dismiss(); + } + if (appSwitcherDialog != null && appSwitcherDialog.isShowing()) { + appSwitcherDialog.dismiss(); + } + if (isSettingsPanelVisible()) { + hideSettingsPanel(); + } + hideOverlay(); + } + // PlayerManager.Callback implementations @Override @@ -1501,12 +1656,19 @@ protected void onStop() { @Override protected void onDestroy() { super.onDestroy(); + unregisterNetworkWait(); handler.removeCallbacksAndMessages(null); ExoPlayer exo = playerManager.getPlayer(); if (exo != null) { exo.removeListener(mediaInfoListener); } - WiTVApp.getInstance().setActivePlayerManager(null); + // 被新实例顶替时,新实例已经登记了自己的 PlayerManager,不能把它清掉 + if (WiTVApp.getInstance().getActivePlayerManager() == playerManager) { + WiTVApp.getInstance().setActivePlayerManager(null); + } + if (liveInstance != null && liveInstance.get() == this) { + liveInstance = null; + } playerManager.release(); executor.shutdown(); } diff --git a/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java b/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java index 43811bd..f8c2dbb 100644 --- a/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java +++ b/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java @@ -575,6 +575,10 @@ private List buildSubmenuRows(int category) { preferenceManager.isReverseChannelKeysEnabled(), ctx.getString(R.string.reverse_channel_keys), ctx.getString(R.string.reverse_channel_keys_hint))); + rows.add(new SettingsPanelAdapter.CheckRow(SettingsPanelAdapter.CheckRow.Kind.USE_AS_LAUNCHER, + LauncherHelper.isHomeEnabled(ctx), + ctx.getString(R.string.use_as_launcher), + ctx.getString(R.string.use_as_launcher_hint))); break; case CAT_MULTICAST: rows.add(new SettingsPanelAdapter.MulticastProxyRow( @@ -811,6 +815,20 @@ public void onReverseChannelKeys(boolean checked) { Toast.LENGTH_SHORT).show(); } + @Override + public void onUseAsLauncher(boolean checked) { + LauncherHelper.setHomeEnabled(requireContext(), checked); + if (checked) { + Toast.makeText(requireContext(), R.string.use_as_launcher_enabled_toast, + Toast.LENGTH_LONG).show(); + LauncherHelper.requestChooseDefaultHome(requireActivity()); + } else { + // HOME 组件被禁用后系统会自动回退到其他桌面或重新询问 + Toast.makeText(requireContext(), R.string.use_as_launcher_disabled_toast, + Toast.LENGTH_SHORT).show(); + } + } + @Override public void onSourceTimeoutSeconds(int seconds) { preferenceManager.setSourceSwitchTimeoutMs(seconds * 1000L); diff --git a/app/src/main/java/com/whyun/witv/ui/SettingsPanelAdapter.java b/app/src/main/java/com/whyun/witv/ui/SettingsPanelAdapter.java index 862da96..41e8470 100644 --- a/app/src/main/java/com/whyun/witv/ui/SettingsPanelAdapter.java +++ b/app/src/main/java/com/whyun/witv/ui/SettingsPanelAdapter.java @@ -158,7 +158,8 @@ int viewType() { public static final class CheckRow extends Row { enum Kind { - AUTO_PLAY, REFRESH_M3U_ON_STARTUP, USE_DISK_CACHE_FOR_LIVE_TS, LOAD_SPEED, REVERSE_CHANNEL_KEYS + AUTO_PLAY, REFRESH_M3U_ON_STARTUP, USE_DISK_CACHE_FOR_LIVE_TS, LOAD_SPEED, REVERSE_CHANNEL_KEYS, + USE_AS_LAUNCHER } final Kind kind; @@ -230,6 +231,8 @@ public interface Listener { void onReverseChannelKeys(boolean checked); + void onUseAsLauncher(boolean checked); + void onHelpSubmenuClick(HelpSubRow.Kind kind); void onSourceTimeoutSeconds(int seconds); @@ -562,6 +565,9 @@ void bind(CheckRow row, Listener listener) { case REVERSE_CHANNEL_KEYS: listener.onReverseChannelKeys(isChecked); break; + case USE_AS_LAUNCHER: + listener.onUseAsLauncher(isChecked); + break; } }); } diff --git a/app/src/main/res/layout/item_app_entry.xml b/app/src/main/res/layout/item_app_entry.xml new file mode 100644 index 0000000..0f0fa24 --- /dev/null +++ b/app/src/main/res/layout/item_app_entry.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_app_section.xml b/app/src/main/res/layout/item_app_section.xml new file mode 100644 index 0000000..ac7e590 --- /dev/null +++ b/app/src/main/res/layout/item_app_section.xml @@ -0,0 +1,10 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d78597d..e1f2a61 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,8 @@ 加载中… 所有播放源均不可用 正在切换播放源… + 正在等待网络连接… + 正在等待系统解码服务… 设置 EPG 地址 播放源管理 @@ -66,6 +68,16 @@ 退出应用 要先去设置,还是休息一下? 休息一下 + 切换应用 + 作为桌面启动 + 开启后按 Home 键或开机直接进入 WiTV;需在系统的「主屏幕应用」或弹出的桌面列表中选择 WiTV。返回键 → 切换应用 可打开其他桌面与应用,关闭此项即恢复原桌面 + 请把「主屏幕应用」设为 WiTV(弹出桌面列表时选 WiTV 并点「始终」) + 已关闭作为桌面启动 + 切换应用 + 更改默认桌面… + 其他桌面 + 全部应用 + 无法打开该应用 启动播放上次频道 启动时刷新 M3U 直播源 开启后进入播放页时先刷新当前激活的 M3U 订阅,再使用最新频道列表起播;刷新失败时仍回退到本地缓存 diff --git a/app/src/test/java/com/whyun/witv/ui/LauncherHelperTest.java b/app/src/test/java/com/whyun/witv/ui/LauncherHelperTest.java new file mode 100644 index 0000000..c6e3322 --- /dev/null +++ b/app/src/test/java/com/whyun/witv/ui/LauncherHelperTest.java @@ -0,0 +1,69 @@ +package com.whyun.witv.ui; + +import android.content.ComponentName; +import android.content.pm.ResolveInfo; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +public class LauncherHelperTest { + + private static LauncherHelper.AppEntry entry(String label, String pkg, String cls) { + return new LauncherHelper.AppEntry(label, new ComponentName(pkg, cls), null); + } + + @Test + public void dedupeAndSortDropsSelfPackage() { + List out = LauncherHelper.dedupeAndSort(Arrays.asList( + entry("WiTV", "com.whyun.witv", "com.whyun.witv.ui.HomeAlias"), + entry("设置", "com.android.tv.settings", "Main")), "com.whyun.witv"); + + assertEquals(1, out.size()); + assertEquals("com.android.tv.settings", out.get(0).component.getPackageName()); + } + + @Test + public void dedupeAndSortKeepsFirstEntryPerPackage() { + // Leanback 入口先加入,同包的手机启动器入口应被丢弃 + List out = LauncherHelper.dedupeAndSort(Arrays.asList( + entry("某应用 TV", "com.example.app", "TvMain"), + entry("某应用", "com.example.app", "PhoneMain")), "com.whyun.witv"); + + assertEquals(1, out.size()); + assertEquals("TvMain", out.get(0).component.getClassName()); + } + + @Test + public void isRealHomeRejectsSetupWizardAndFallbackHome() { + ResolveInfo launcher = new ResolveInfo(); + ResolveInfo provision = new ResolveInfo(); + provision.priority = 1; + ResolveInfo fallback = new ResolveInfo(); + fallback.priority = -1000; + + assertTrue(LauncherHelper.isRealHome(launcher)); + assertFalse(LauncherHelper.isRealHome(provision)); + assertFalse(LauncherHelper.isRealHome(fallback)); + } + + @Test + public void dedupeAndSortOrdersByLabel() { + List out = LauncherHelper.dedupeAndSort(Arrays.asList( + entry("Zeta", "z.pkg", "Z"), + entry("Alpha", "a.pkg", "A"), + entry("Mid", "m.pkg", "M")), "com.whyun.witv"); + + assertEquals("Alpha", out.get(0).label); + assertEquals("Mid", out.get(1).label); + assertEquals("Zeta", out.get(2).label); + } +} diff --git a/docs/images/app-switcher.png b/docs/images/app-switcher.png new file mode 100644 index 0000000..18f4806 Binary files /dev/null and b/docs/images/app-switcher.png differ diff --git a/docs/images/exit-dialog.jpg b/docs/images/exit-dialog.jpg index 9d4766d..46bcfad 100644 Binary files a/docs/images/exit-dialog.jpg and b/docs/images/exit-dialog.jpg differ diff --git a/docs/images/launcher-default-apps.png b/docs/images/launcher-default-apps.png new file mode 100644 index 0000000..a810e75 Binary files /dev/null and b/docs/images/launcher-default-apps.png differ diff --git a/docs/images/launcher-home-picker.png b/docs/images/launcher-home-picker.png new file mode 100644 index 0000000..e80abce Binary files /dev/null and b/docs/images/launcher-home-picker.png differ diff --git a/docs/images/settings-playback.png b/docs/images/settings-playback.png index d78c75d..aedcec1 100644 Binary files a/docs/images/settings-playback.png and b/docs/images/settings-playback.png differ diff --git a/docs/user-guide.md b/docs/user-guide.md index 4726e28..451ce54 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -75,6 +75,11 @@ WiTV 是一款运行在 Android TV / 电视盒子上的 IPTV 直播播放器。 ![退出确认](images/exit-dialog.jpg) +- **切换应用**:打开其他桌面或已安装的应用,见下文[作为桌面启动](#作为桌面启动); +- **设置**:打开设置面板; +- **休息一下**:退出播放。WiTV 被设为默认桌面时不会真正退出(退出后系统会立刻把桌面拉起来), + 而是退到后台,回到上一个打开的应用。 + --- ## 三、设置 @@ -164,7 +169,49 @@ rtp://239.3.1.118:8001 → http://192.168.6.1:5140/rtp/239.3.1.118:8001 - **启动时刷新 M3U 直播源**:每次进播放页先刷新订阅再起播,刷新失败仍回退到本地缓存; - **使用硬盘缓存预取直播分片**:为 HLS 的 ts 分片启用磁盘预取与缓存命中; - **播放页显示视频加载速度**:右上角实时显示估算速度,排查网络时有用; -- **反转换台键**:上/下键的换台方向与默认相反。 +- **反转换台键**:上/下键的换台方向与默认相反; +- **作为桌面启动**:让 WiTV 成为盒子的桌面,开机和按 Home 键直接看电视,详见下一节。 + +### 作为桌面启动 + +适合把盒子当成「开机就是电视」来用的家庭。默认关闭,不开启时 WiTV 不会出现在系统的桌面候选里, +按 Home 键也不会多出选择框。 + +**开启** + +1. 设置 → 播放选项 → 勾选「作为桌面启动」; +2. WiTV 会打开系统的「默认应用」页面。点「主屏幕应用」(此时会显示「没有默认主屏幕」,属正常现象): + + ![系统默认应用页](images/launcher-default-apps.png) + +3. 在列表里选 **WiTV**: + + ![选择主屏幕应用](images/launcher-home-picker.png) + + 没有「默认应用」页面的盒子会直接弹出桌面选择框,选 WiTV 并点「始终」即可。 + +之后开机会直接进入 WiTV 并播放上次看的频道,按 Home 键会回到正在播放的画面。 +开机时网络往往比 WiTV 晚就绪一两秒,播放页会先提示「正在等待网络连接…」,连上后再起播; +有些盒子开机后系统的解码服务要过二三十秒才能用,这时会提示「正在等待系统解码服务…」,等它就绪后自动起播,不用手动换台。 + +**打开其他应用** + +WiTV 当了桌面之后,其他应用从这里进:按返回键 → 「切换应用」。 + +![切换应用](images/app-switcher.png) + +- **其他桌面**:原来的桌面(例如 ATV 桌面、当贝桌面)还在,可以随时临时打开,按 Home 键又会回到 WiTV; +- **全部应用**:盒子上所有可以启动的应用,包括系统设置; +- **更改默认桌面…**:重新打开系统的桌面选择,用来换回原来的桌面。 + +**换回原来的桌面** + +两种方式任选: + +- 切换应用 → 更改默认桌面… → 主屏幕应用 → 选原来的桌面; +- 或者直接在设置里取消勾选「作为桌面启动」,WiTV 退出桌面候选,按 Home 键就回到原来的桌面。 + +部分国产 ROM 锁定了默认桌面,选择框可能不出现,这种情况只能到盒子自带的系统设置里修改。 ### 切换源 / 超时换源 @@ -193,6 +240,9 @@ rtp://239.3.1.118:8001 → http://192.168.6.1:5140/rtp/239.3.1.118:8001 ## 五、常见问题 +**设成桌面后,开机一直显示「所有播放源均不可用」** +等待网络和解码服务之后仍然失败,一般是源本身或网络有问题。按上/下键换个台,或者确认键打开频道列表重新选台。 + **管理地址显示 `0.0.0.0`** 盒子还没拿到局域网 IP。检查网线/Wi-Fi,确认路由器已分配地址,然后点「刷新」。