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 直播播放器。

+- **切换应用**:打开其他桌面或已安装的应用,见下文[作为桌面启动](#作为桌面启动);
+- **设置**:打开设置面板;
+- **休息一下**:退出播放。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 会打开系统的「默认应用」页面。点「主屏幕应用」(此时会显示「没有默认主屏幕」,属正常现象):
+
+ 
+
+3. 在列表里选 **WiTV**:
+
+ 
+
+ 没有「默认应用」页面的盒子会直接弹出桌面选择框,选 WiTV 并点「始终」即可。
+
+之后开机会直接进入 WiTV 并播放上次看的频道,按 Home 键会回到正在播放的画面。
+开机时网络往往比 WiTV 晚就绪一两秒,播放页会先提示「正在等待网络连接…」,连上后再起播;
+有些盒子开机后系统的解码服务要过二三十秒才能用,这时会提示「正在等待系统解码服务…」,等它就绪后自动起播,不用手动换台。
+
+**打开其他应用**
+
+WiTV 当了桌面之后,其他应用从这里进:按返回键 → 「切换应用」。
+
+
+
+- **其他桌面**:原来的桌面(例如 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,确认路由器已分配地址,然后点「刷新」。