diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5735417..f57a674 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,73 @@
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
+## [Unreleased]
+
+### Added
+
+- **Web 管理地址二维码**:播放页与设置页各放一个,手机扫一下即可打开管理页,
+ 省去在电视上用遥控器逐字符输入 URL。固定黑白、留 2 个模块的静区——
+ 这是贴在深色背景上的小图,没有白边很多手机扫不出来。
+ IP 尚未解析出来时不画码:把 `0.0.0.0` 编进去,扫出来是个连不上的地址,比不给码更误导人
+- **EPG 与 M3U 支持 gzip 地址**(`GzipAwareStreams`)。公开 EPG 源基本只提供 `.xml.gz`——
+ XMLTV 节目单动辄几十 MB。这类地址返回的是 `Content-Type: application/gzip` 而非
+ `Content-Encoding: gzip`,OkHttp 的透明解压不生效,拿到的是裸 gzip 字节。
+ 判断依据是**魔数** `0x1F 0x8B` 而不是 URL 后缀或 `Content-Type`:两者都不可靠,
+ 而 XML 与 m3u 都不可能以这两个字节开头。`.m3u.gz` 同样支持
+- 新增图文使用手册 [`docs/user-guide.md`](docs/user-guide.md),13 张真机截图,
+ 覆盖首次添加播放源、看电视、各设置分类与 Web 管理页;
+ 组播章节附上代理与直收的实测对比数据
+- **解码方式新增「音频软解 + 视频硬解」档**(`PlaybackDecoderMode.SOFTWARE_AUDIO`)。
+ 实测某些盒子声称支持 AC-3/E-AC-3 直通,`MediaCodecAudioRenderer` 便以直通方式胜出、
+ 压根不解码,而 HDMI 下游实际解不了,表现为**杜比声道完全没声音**;
+ 但改用原有的「软解优先」会连视频一起转软解,老盒子软解 1080p 跟不上实时又变成卡顿。
+ 新档位让音频走 FFmpeg 绕开直通、视频留在硬解,两边都成立
+
+### Fixed
+
+- **修复 4K 频道间换台后所有频道都放不出来**。症状是一串
+ `native_window_api_connect returned an error (-22)` + `Failed to initialize decoder`,
+ 此后每一个频道都起不来。根因在 Surface 而不在流:上一个解码器(尤其 4K sideband 那种)
+ 释放是异步的,`SurfaceView` 的 BufferQueue 还停在它配置的格式上没断开
+ (`dumpsys SurfaceFlinger` 里那块 buffer 仍是 3840x2160),新建的 MediaCodec 连不上。
+ 实测换源、重建 `ExoPlayer`、前后台切换、静置均无效,只有重启进程能恢复——
+ 因为 `PlayerView` 复用的是同一个 `SurfaceView`。
+ 现在收到 `ERROR_CODE_DECODER_INIT_FAILED` 时不再当成「这路流坏了」去换源
+ (换源是错的,一路换到底只会把所有源都误判成坏的),
+ 而是把 `PlayerView` 收起再放出,逼 `SurfaceView` 走一遍
+ `surfaceDestroyed` / `surfaceCreated`,然后用新播放器原地重试当前这一路,只重试一次
+- **修复 Web 管理页里的中文变成 `?`**。给播放源起中文名保存后全是问号:
+ NanoHTTPD 的 `session.parseBody()` 内部是 `new String(postBytes, contentType.getEncoding())`,
+ 而 `getEncoding()` 在 `Content-Type` 不带 charset 时默认返回 **US-ASCII**,
+ 浏览器 `fetch` 发 `application/json` 时正是不带的。改为自己按 `Content-Length`
+ 读原始字节再按 UTF-8 解码,不依赖请求头;并加 1 MiB 上限——服务监听在局域网上且没有鉴权,
+ 按 `Content-Length` 预分配可被一个超大值直接 OOM 掉
+- **修复开着 VPN 时 Web 地址显示隧道地址**。`getActiveNetwork()` 此时返回的就是 VPN,
+ 而排除虚拟接口的逻辑在它后面、根本到不了。那个地址写到界面上局域网里的手机连不上,
+ 而物理网卡的地址其实还可用。现在先按接口名过滤,虚拟接口一律退回枚举网卡
+- **修复切换解码方式后播放失败**(`ERROR_CODE_DECODER_INIT_FAILED`)。重建播放器时先
+ `player.release()` 再由 `initialize()` 调 `playerView.setPlayer(newPlayer)`,而后者会对**旧**
+ 播放器调 `clearVideoSurfaceView()` 去解绑 Surface——此时旧播放器已释放,消息被直接丢弃
+ (`Ignoring messages sent after release`),Surface 永远停在 connected 状态,新建的
+ MediaCodec 连不上:`native_window_api_connect returned an error (-22)`,两个解码器依次失败。
+ 改为**先 `playerView.setPlayer(null)` 解绑、再 release**;`release()` 里同样处理。
+ 该缺陷在 v1.3.0 中已存在
+- **修复 Web 管理地址显示为 `0.0.0.0`**。原实现只读
+ `WifiManager.getConnectionInfo().getIpAddress()`,而电视盒子接网线是常态,有线连接时该接口恒返回 0。
+ 新增 `DeviceIpUtil`:优先取系统认定的活动网络地址,取不到再枚举网卡,
+ 有线优先于无线并排除 `p2p`/`dummy`/`tun` 等虚拟接口
+
+### Changed
+
+- **Web 管理端口由 9978 改为 9979**,并把这个数字收敛成 `WebServer.PORT` / `buildUrl()`——
+ 它此前在 Application、播放页、设置页三处硬编码,改端口要同时改三处且很容易漏
+- 解码方式由单一全局开关改为**音频与视频分别取值**(`WiTVRenderersFactory`)。
+ `DefaultRenderersFactory` 只有一个 `extensionRendererMode`,音视频共用,
+ 而这两者的最佳取舍常常相反;现在分别覆写 `buildAudioRenderers` 与 `buildVideoRenderers`
+- 「软解优先」更名为「全部软解优先」,描述中补充了低端设备上 1080p 可能卡顿的提示
+- 新增单元测试 `WiTVRenderersFactoryTest`,直接断言产出的渲染器顺序
+ (顺序由 NextLib 的插入逻辑决定,升级时可能变化,只断言配置值不够)
+
## [1.3.0] - 2026-10-03
本版主题是 **UDP / RTP 组播直播**,并引入 FFmpeg 软解补齐电视盒子硬解常缺的编码。
diff --git a/README.md b/README.md
index 9396660..f24ce7b 100644
--- a/README.md
+++ b/README.md
@@ -4,12 +4,14 @@ Android TV M3U 直播播放器,使用原生 ExoPlayer 播放,支持多源自

+完整的图文使用说明见 [docs/user-guide.md](docs/user-guide.md)。
+
## 功能
- **M3U 播放源管理** — 支持添加多个 M3U/M3U8 播放源地址,在历史源之间快速切换
- **多源自动切换** — 同一频道聚合多个播放地址,播放失败时自动尝试下一个源
- **EPG 节目预告** — 自动读取 M3U 中的 `x-tvg-url` 属性,也支持手动配置 XMLTV 地址
-- **局域网 Web 管理** — 内置 HTTP 服务器(端口 9978),通过手机/电脑浏览器管理播放源和设置
+- **局域网 Web 管理** — 内置 HTTP 服务器(端口 9979),通过手机/电脑浏览器管理播放源和设置
- **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 导航支持,数字键直接跳转频道
@@ -78,7 +80,7 @@ APK 输出路径:`app/build/outputs/apk/debug/app-debug.apk`
## 使用方式
1. 安装 APK 到 Android TV 设备
-2. 启动应用,主界面会显示局域网管理地址(如 `http://192.168.1.100:9978`)
+2. 启动应用,主界面会显示局域网管理地址(如 `http://192.168.1.100:9979`)
3. 在手机或电脑浏览器中打开该地址
4. 添加 M3U 播放源地址
5. 返回 TV 端即可看到频道列表,选择频道开始播放
diff --git a/app/build.gradle b/app/build.gradle
index d6ae1f7..accb1b5 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -71,6 +71,10 @@ dependencies {
// NanoHTTPD
implementation "org.nanohttpd:nanohttpd:2.3.1"
+ // 二维码生成(Web 管理地址扫码)。纯 Java、零运行时传递依赖,
+ // 已核对不含 java.time / stream / function,minSdk 23 无需 desugaring。
+ implementation "com.google.zxing:core:3.5.3"
+
// OkHttp + Gson
implementation "com.squareup.okhttp3:okhttp:4.12.0"
implementation "com.google.code.gson:gson:2.10.1"
diff --git a/app/src/main/assets/web/app.js b/app/src/main/assets/web/app.js
index 4c0af73..bec5820 100644
--- a/app/src/main/assets/web/app.js
+++ b/app/src/main/assets/web/app.js
@@ -10,7 +10,7 @@ document.getElementById('addSourceForm').addEventListener('submit', async (e) =>
try {
const res = await fetch(`${API}/api/sources`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ name: name || url, url })
});
if (!res.ok) throw new Error(await res.text());
@@ -237,7 +237,7 @@ async function saveSettings() {
try {
const res = await fetch(`${API}/api/settings`, {
method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ epgUrl, udpxyProxyBase })
});
const data = await res.json();
diff --git a/app/src/main/java/com/whyun/witv/WiTVApp.java b/app/src/main/java/com/whyun/witv/WiTVApp.java
index 6aaa025..7efded0 100644
--- a/app/src/main/java/com/whyun/witv/WiTVApp.java
+++ b/app/src/main/java/com/whyun/witv/WiTVApp.java
@@ -113,7 +113,7 @@ public void notifyActiveSourceChanged(long sourceId) {
private void startWebServer() {
try {
- webServer = new WebServer(this, 9978);
+ webServer = new WebServer(this, WebServer.PORT);
webServer.start();
} catch (Exception e) {
e.printStackTrace();
diff --git a/app/src/main/java/com/whyun/witv/data/BomAwareText.java b/app/src/main/java/com/whyun/witv/data/BomAwareText.java
new file mode 100644
index 0000000..c8ef4d1
--- /dev/null
+++ b/app/src/main/java/com/whyun/witv/data/BomAwareText.java
@@ -0,0 +1,62 @@
+package com.whyun.witv.data;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 按「BOM > 响应声明的 charset > UTF-8」的顺序解码文本。
+ *
+ *
这是 OkHttp {@code ResponseBody.string()} 的行为。之前 m3u 走的就是它,后来为了支持 gzip
+ * 改成自己读流,如果顺手写成硬编码 UTF-8,GBK 编码的直播源(国内 IPTV 列表里相当常见)
+ * 频道名和分组名就会整片变成乱码——所以这里必须把那套规则补回来。
+ *
+ *
BOM 优先于 Content-Type 里声明的 charset:BOM 是文件自己带的,而 Content-Type 是服务器
+ * 配置出来的,后者经常是随手填的默认值。
+ */
+public final class BomAwareText {
+
+ private BomAwareText() {
+ }
+
+ /**
+ * @param bytes 完整内容
+ * @param declared 响应头里声明的编码,没有则传 {@code null}
+ */
+ @NonNull
+ public static String decode(@NonNull byte[] bytes, @Nullable Charset declared) {
+ Bom bom = detectBom(bytes);
+ if (bom != null) {
+ // BOM 的那几个字节必须跳过,否则解出来的字符串以 U+FEFF 开头,
+ // m3u 解析器连 #EXTM3U 都认不出来
+ return new String(bytes, bom.length, bytes.length - bom.length, bom.charset);
+ }
+ return new String(bytes, declared != null ? declared : StandardCharsets.UTF_8);
+ }
+
+ @Nullable
+ private static Bom detectBom(@NonNull byte[] b) {
+ if (b.length >= 3 && b[0] == (byte) 0xEF && b[1] == (byte) 0xBB && b[2] == (byte) 0xBF) {
+ return new Bom(3, StandardCharsets.UTF_8);
+ }
+ if (b.length >= 2 && b[0] == (byte) 0xFE && b[1] == (byte) 0xFF) {
+ return new Bom(2, StandardCharsets.UTF_16BE);
+ }
+ if (b.length >= 2 && b[0] == (byte) 0xFF && b[1] == (byte) 0xFE) {
+ return new Bom(2, StandardCharsets.UTF_16LE);
+ }
+ return null;
+ }
+
+ private static final class Bom {
+ final int length;
+ final Charset charset;
+
+ Bom(int length, Charset charset) {
+ this.length = length;
+ this.charset = charset;
+ }
+ }
+}
diff --git a/app/src/main/java/com/whyun/witv/data/GzipAwareStreams.java b/app/src/main/java/com/whyun/witv/data/GzipAwareStreams.java
new file mode 100644
index 0000000..b1118e1
--- /dev/null
+++ b/app/src/main/java/com/whyun/witv/data/GzipAwareStreams.java
@@ -0,0 +1,62 @@
+package com.whyun.witv.data;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.zip.GZIPInputStream;
+
+/**
+ * 按内容自动解压 gzip 的输入流包装。
+ *
+ *
XMLTV 节目单动辄几十 MB,公开 EPG 源基本都提供 {@code .xml.gz};m3u 也有 {@code .m3u.gz}。
+ * OkHttp 的透明解压只在响应带 {@code Content-Encoding: gzip} 时生效,而这类地址返回的是
+ * {@code Content-Type: application/gzip}——「内容本身就是压缩文件」与「传输编码」是两回事,
+ * 后者 OkHttp 不会碰,拿到的是裸 gzip 字节。
+ *
+ *
判断依据是 魔数而非 URL 后缀或 {@code Content-Type}:两者都不可靠,
+ * 有的源地址不带 {@code .gz} 却返回 gzip,有的把 {@code Content-Type} 标成 {@code text/xml}。
+ * gzip 固定以 {@code 0x1F 0x8B} 开头,而 XML 与 m3u 都不可能以这两个字节开头,不会误判。
+ */
+public final class GzipAwareStreams {
+
+ private static final int GZIP_MAGIC_BYTE_1 = 0x1F;
+ private static final int GZIP_MAGIC_BYTE_2 = 0x8B;
+
+ private GzipAwareStreams() {
+ }
+
+ /**
+ * 内容是 gzip 就返回解压流,否则原样返回(已带缓冲)。
+ *
+ * @param source 原始流;调用方仍需负责关闭返回的流
+ */
+ @NonNull
+ public static InputStream maybeDecompress(@NonNull InputStream source) throws IOException {
+ // GZIPInputStream 内部也会缓冲,但这里必须自己包一层才能 mark/reset 做嗅探
+ BufferedInputStream buffered = source instanceof BufferedInputStream
+ ? (BufferedInputStream) source
+ : new BufferedInputStream(source);
+ return isGzip(buffered) ? new GZIPInputStream(buffered) : buffered;
+ }
+
+ /**
+ * 窥探前两个字节判断是否为 gzip,并把流复位,不消耗数据。
+ *
+ *
流不足两个字节(空响应、截断)时一律按非 gzip 处理,交给后续解析器报错,
+ * 这里不额外抛异常。
+ */
+ @VisibleForTesting
+ static boolean isGzip(@NonNull BufferedInputStream in) throws IOException {
+ in.mark(2);
+ try {
+ int first = in.read();
+ int second = in.read();
+ return first == GZIP_MAGIC_BYTE_1 && second == GZIP_MAGIC_BYTE_2;
+ } finally {
+ in.reset();
+ }
+ }
+}
diff --git a/app/src/main/java/com/whyun/witv/data/repository/ChannelRepository.java b/app/src/main/java/com/whyun/witv/data/repository/ChannelRepository.java
index cef2575..bc465a8 100644
--- a/app/src/main/java/com/whyun/witv/data/repository/ChannelRepository.java
+++ b/app/src/main/java/com/whyun/witv/data/repository/ChannelRepository.java
@@ -2,6 +2,8 @@
import android.content.Context;
+import com.whyun.witv.data.BomAwareText;
+import com.whyun.witv.data.GzipAwareStreams;
import com.whyun.witv.data.db.AppDatabase;
import com.whyun.witv.data.db.dao.ChannelDao;
import com.whyun.witv.data.db.dao.ChannelSourceDao;
@@ -13,6 +15,8 @@
import com.whyun.witv.data.db.entity.M3USource;
import com.whyun.witv.data.parser.M3UParser;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
@@ -22,6 +26,7 @@
import java.util.Set;
import java.util.concurrent.TimeUnit;
+import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
@@ -56,6 +61,16 @@ public ChannelRepository(Context context) {
* @return The parse result
* @throws IOException if network request fails
*/
+ private static byte[] readFully(InputStream inputStream) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = inputStream.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ return out.toByteArray();
+ }
+
public M3UParser.ParseResult loadSource(M3USource source) throws IOException {
Request request = new Request.Builder()
.url(source.url)
@@ -65,7 +80,17 @@ public M3UParser.ParseResult loadSource(M3USource source) throws IOException {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("Failed to fetch M3U: " + response.code());
}
- String content = response.body().string();
+ // 同样支持 .m3u.gz:按魔数判断,不依赖 URL 后缀或 Content-Type
+ byte[] raw;
+ try (InputStream inputStream =
+ GzipAwareStreams.maybeDecompress(response.body().byteStream())) {
+ raw = readFully(inputStream);
+ }
+ // 编码按「BOM > 响应声明 > UTF-8」判断,和原来 ResponseBody.string() 的行为一致。
+ // 国内直播源有不少是 GBK 的,硬编码 UTF-8 会让频道名整片变成乱码
+ MediaType contentType = response.body().contentType();
+ String content = BomAwareText.decode(
+ raw, contentType != null ? contentType.charset() : null);
M3UParser.ParseResult result = new M3UParser().parse(content);
long sourceId = source.id;
diff --git a/app/src/main/java/com/whyun/witv/data/repository/EpgRepository.java b/app/src/main/java/com/whyun/witv/data/repository/EpgRepository.java
index 9c8afdf..11eb182 100644
--- a/app/src/main/java/com/whyun/witv/data/repository/EpgRepository.java
+++ b/app/src/main/java/com/whyun/witv/data/repository/EpgRepository.java
@@ -2,6 +2,7 @@
import android.content.Context;
+import com.whyun.witv.data.GzipAwareStreams;
import com.whyun.witv.data.db.AppDatabase;
import com.whyun.witv.data.db.dao.EpgChannelDao;
import com.whyun.witv.data.db.dao.EpgDao;
@@ -55,7 +56,10 @@ public void loadEpg(String epgUrl) throws IOException, XmlPullParserException {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("Failed to fetch EPG: " + response.code());
}
- try (InputStream inputStream = response.body().byteStream()) {
+ // 公开 EPG 源基本都提供 .xml.gz;这类响应是 Content-Type: application/gzip,
+ // 不带 Content-Encoding,OkHttp 不会自动解压,必须自己按魔数判断
+ try (InputStream inputStream =
+ GzipAwareStreams.maybeDecompress(response.body().byteStream())) {
EpgParser.ParseResult result = new EpgParser().parseFull(inputStream);
epgDao.deleteAll();
diff --git a/app/src/main/java/com/whyun/witv/player/PlaybackDecoderMode.java b/app/src/main/java/com/whyun/witv/player/PlaybackDecoderMode.java
index 931a847..50e52fc 100644
--- a/app/src/main/java/com/whyun/witv/player/PlaybackDecoderMode.java
+++ b/app/src/main/java/com/whyun/witv/player/PlaybackDecoderMode.java
@@ -7,33 +7,50 @@
import androidx.media3.exoplayer.DefaultRenderersFactory;
/**
- * 解码方式:硬解(MediaCodec)与 FFmpeg 软解(NextLib)之间的优先级。
+ * 解码方式:硬解(MediaCodec)与 FFmpeg 软解(NextLib)之间的优先级,音频与视频分别指定。
*
- *
IPTV 直播里硬解覆盖不全是常态——组播 TS 常见的 MPEG-2 视频、MP2/AC3 音频,很多电视盒子
- * 的 MediaCodec 要么不支持,要么声称支持但解出来黑屏/无声。带上 FFmpeg 软解后:
+ *
IPTV 直播里硬解覆盖不全是常态,而且音频和视频的最佳取舍常常相反:
*
*
- * - {@link #AUTO}:硬解优先,硬解不支持该编码时自动用软解补位。默认,绝大多数情况选它。
- * - {@link #PREFER_SOFTWARE}:软解优先。用于「硬解声称支持但实际解不出来」的问题盒子。
- * - {@link #HARDWARE_ONLY}:完全不加载 FFmpeg 渲染器,行为与未引入软解时一致。
- * 用于排查软解本身引入的问题,或在极低端 CPU 上避免误用软解。
+ * - {@link #AUTO}:音视频都硬解优先,硬解不支持时自动用软解补位。默认。
+ * - {@link #SOFTWARE_AUDIO}:音频软解、视频硬解。盒子声称支持 AC-3/E-AC-3 直通时,
+ * {@code MediaCodecAudioRenderer} 会以直通方式胜出、压根不解码,HDMI 下游解不了就完全没声音;
+ * 强制音频走 FFmpeg 可绕开直通,同时视频仍用硬解,不会因软解跟不上实时而卡顿。
+ * - {@link #PREFER_SOFTWARE}:音视频都软解优先。用于硬解声称支持但实际解不出来的设备,
+ * CPU 占用高,低端盒子上 1080p 视频可能跟不上。
+ * - {@link #HARDWARE_ONLY}:完全不加载 FFmpeg 渲染器。用于排查软解自身引入的问题。
*
+ *
+ * 音视频分别取值由 {@link WiTVRenderersFactory} 落实——{@code DefaultRenderersFactory}
+ * 本身只有一个全局的 {@code extensionRendererMode}。
*/
@OptIn(markerClass = UnstableApi.class)
public enum PlaybackDecoderMode {
- AUTO("auto", DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON),
- PREFER_SOFTWARE("prefer_software", DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER),
- HARDWARE_ONLY("hardware_only", DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF);
+ AUTO("auto",
+ DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON,
+ DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON),
+ SOFTWARE_AUDIO("software_audio",
+ DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER,
+ DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON),
+ PREFER_SOFTWARE("prefer_software",
+ DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER,
+ DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER),
+ HARDWARE_ONLY("hardware_only",
+ DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF,
+ DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF);
public static final PlaybackDecoderMode DEFAULT = AUTO;
private final String id;
- private final int extensionRendererMode;
+ private final int audioExtensionRendererMode;
+ private final int videoExtensionRendererMode;
- PlaybackDecoderMode(String id, int extensionRendererMode) {
+ PlaybackDecoderMode(String id, int audioExtensionRendererMode,
+ int videoExtensionRendererMode) {
this.id = id;
- this.extensionRendererMode = extensionRendererMode;
+ this.audioExtensionRendererMode = audioExtensionRendererMode;
+ this.videoExtensionRendererMode = videoExtensionRendererMode;
}
/** 持久化用的稳定标识,不要随枚举改名而变。 */
@@ -42,9 +59,14 @@ public String getId() {
return id;
}
- /** 对应的 {@code DefaultRenderersFactory.EXTENSION_RENDERER_MODE_*}。 */
- public int getExtensionRendererMode() {
- return extensionRendererMode;
+ /** 音频渲染器对应的 {@code DefaultRenderersFactory.EXTENSION_RENDERER_MODE_*}。 */
+ public int getAudioExtensionRendererMode() {
+ return audioExtensionRendererMode;
+ }
+
+ /** 视频渲染器对应的 {@code DefaultRenderersFactory.EXTENSION_RENDERER_MODE_*}。 */
+ public int getVideoExtensionRendererMode() {
+ return videoExtensionRendererMode;
}
/** 未知或空标识一律回落到 {@link #DEFAULT},避免老版本或脏数据导致播放不可用。 */
diff --git a/app/src/main/java/com/whyun/witv/player/PlayerManager.java b/app/src/main/java/com/whyun/witv/player/PlayerManager.java
index ed77ae3..5e2c000 100644
--- a/app/src/main/java/com/whyun/witv/player/PlayerManager.java
+++ b/app/src/main/java/com/whyun/witv/player/PlayerManager.java
@@ -7,12 +7,14 @@
import android.os.SystemClock;
import android.util.Log;
+import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MimeTypes;
+import androidx.annotation.VisibleForTesting;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
@@ -38,7 +40,6 @@
import com.whyun.witv.data.db.entity.ChannelSource;
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.FfmpegLibrary;
-import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory;
import java.util.ArrayList;
import java.util.List;
@@ -54,6 +55,14 @@ public interface Callback {
void onAllSourcesFailed();
void onPlaybackStarted(int sourceIndex, int total);
void onError(String message);
+
+ /**
+ * 内部重建了 {@code ExoPlayer} 实例。
+ *
+ *
调用方自己加在旧播放器上的监听器会随之失效,必须在这里重新挂到
+ * {@link PlayerManager#getPlayer()} 返回的新实例上。
+ */
+ void onPlayerRebuilt();
}
/**
@@ -151,6 +160,26 @@ static boolean isBehindLiveWindowError(@NonNull PlaybackException error) {
*/
static final long STALL_RECOVERY_RESET_AFTER_MS = 30_000L;
+ /**
+ * 解码器初始化失败后,重建 Surface 再试几次。
+ *
+ *
一次就够:这个失败的根因是 Surface 还被上一个解码器占着
+ * ({@code native_window_api_connect returned an error: Invalid argument (-22)}),
+ * 重建 SurfaceView 能拆掉那条连接;如果拆完还失败,那就是真的解不了,继续重试没有意义。
+ */
+ static final int MAX_DECODER_SURFACE_RECOVERIES = 1;
+
+ /**
+ * 把 PlayerView 收起来到再放出来之间等多久,让 SurfaceView 真正走完
+ * {@code surfaceDestroyed} / {@code surfaceCreated}。
+ *
+ *
这两个回调是跟着 WindowManager 的事务走的,不是同步的,所以必须隔帧而不是连着调。
+ */
+ private static final long SURFACE_RECREATE_DELAY_MS = 200L;
+
+ /** 当前频道已经为解码器失败重建过几次 Surface */
+ private int decoderSurfaceRecoveries;
+
private static String playbackStateName(int state) {
switch (state) {
case Player.STATE_IDLE:
@@ -205,6 +234,10 @@ public void onPlayerError(@NonNull PlaybackException error) {
return;
}
+ if (isDecoderInitFailure(error) && recoverBySurfaceRecreation()) {
+ return;
+ }
+
String url = currentSourceIndex < currentSources.size()
? currentSources.get(currentSourceIndex).url : "unknown";
Log.e(TAG, String.format(Locale.US,
@@ -359,14 +392,14 @@ private RenderersFactory buildRenderersFactory(PreferenceManager preferenceManag
PlaybackDecoderMode mode = preferenceManager.getPlaybackDecoderMode();
activeDecoderMode = mode;
Log.i(TAG, String.format(Locale.US,
- "Decoder mode: %s (extensionRendererMode=%d), ffmpeg=%s",
- mode.getId(), mode.getExtensionRendererMode(), ffmpegLibrarySummary()));
- return new NextRenderersFactory(context)
- .setExtensionRendererMode(mode.getExtensionRendererMode())
+ "Decoder mode: %s (audio=%d, video=%d), ffmpeg=%s",
+ mode.getId(), mode.getAudioExtensionRendererMode(),
+ mode.getVideoExtensionRendererMode(), ffmpegLibrarySummary()));
+ return new WiTVRenderersFactory(context, mode)
// 某个 MediaCodec 解码器 configure 失败时,依次尝试同一渲染器里的其它
// MediaCodec 解码器(例如 c2.android.avc.decoder 失败后试 c2.qti.avc.decoder)。
// 注意它**不会**退到 FFmpeg 渲染器——渲染器早在 supportsFormat 阶段就选定了;
- // 硬解整体不可用时请用「软解优先」档。
+ // 硬解整体不可用时请改用软解相关档位。
.setEnableDecoderFallback(true);
}
@@ -398,6 +431,117 @@ public PlaybackDecoderMode getActiveDecoderMode() {
*
* @return 是否真的重建了(未初始化过则返回 false)
*/
+ /**
+ * 确保有可用的播放器实例,没有就就地补建。
+ *
+ *
{@link #recoverBySurfaceRecreation()} 重建 Surface 的那几百毫秒里 {@link #player}
+ * 是空的,而这期间用户完全可能换台——换台会走 {@code playChannel},它 bump 掉
+ * {@code playGeneration} 之后,重建流程的延迟回调就会放弃,没人再把播放器建回来。
+ * 不在这里补建的话,{@code playCurrentSource} 会对着 null 调 {@code stop()} 直接崩。
+ */
+ private void ensurePlayerReady() {
+ if (player != null || playerView == null) {
+ return;
+ }
+ Log.i(TAG, "Player was released (surface rebuild in flight) — rebuilding it now");
+ playerView.setVisibility(View.VISIBLE);
+ initialize(playerView);
+ if (callback != null) {
+ callback.onPlayerRebuilt();
+ }
+ }
+
+ /**
+ * 这个错误是不是「解码器起不来」。
+ *
+ *
和「这路流本身坏了」要分开处理:换源对它没用——盒子上所有频道都会一样失败。
+ */
+ @VisibleForTesting
+ static boolean isDecoderInitFailure(@Nullable PlaybackException error) {
+ return error != null
+ && error.errorCode == PlaybackException.ERROR_CODE_DECODER_INIT_FAILED;
+ }
+
+ /**
+ * 重建 SurfaceView 后原地重试当前这一路。
+ *
+ *
在 Amlogic 盒子上,4K 频道之间换台时常见这样一串:
+ *
+ * E MediaCodec: native_window_api_connect returned an error: Invalid argument (-22)
+ * W MediaCodecRenderer: Failed to initialize decoder: OMX.amlogic.hevc.decoder.awesome
+ *
+ * 上一个解码器(尤其 4K sideband 那种)释放是异步的,SurfaceView 的 BufferQueue 还停在
+ * 它配置的格式上没断开,新建的 MediaCodec 就连不上这块 Surface。一旦撞上,之后每一次
+ * {@code configure} 都会失败——换源没用,重建 ExoPlayer 也没用(PlayerView 复用的是
+ * 同一个 SurfaceView),实测只有重启进程才能恢复。
+ *
+ * 所以这里把 {@code PlayerView} 收起来再放出来,逼 SurfaceView 走一遍
+ * {@code surfaceDestroyed} / {@code surfaceCreated},把那条残留连接连同 BufferQueue
+ * 一起拆掉,然后用新播放器重试。
+ *
+ * @return 是否已接管这次失败;false 表示预算用完了,交回常规的换源流程
+ */
+ private boolean recoverBySurfaceRecreation() {
+ if (playerView == null || currentSources.isEmpty()) {
+ return false;
+ }
+ if (decoderSurfaceRecoveries >= MAX_DECODER_SURFACE_RECOVERIES) {
+ Log.w(TAG, String.format(Locale.US,
+ "Decoder init still failing after %d surface rebuild(s) — giving up on this source",
+ decoderSurfaceRecoveries));
+ return false;
+ }
+ decoderSurfaceRecoveries++;
+ Log.w(TAG, String.format(Locale.US,
+ "Decoder init failed — rebuilding the surface and retrying in place (%d/%d)",
+ decoderSurfaceRecoveries, MAX_DECODER_SURFACE_RECOVERIES));
+
+ final PlayerView view = playerView;
+ final List sources = new ArrayList<>(currentSources);
+ final int index = currentSourceIndex;
+
+ cancelTimeout();
+ cancelRebufferWatchdog();
+ // 让旧播放器的残留回调失效,否则下面重建期间它还会往回调里灌错误
+ playGeneration++;
+ final int generation = playGeneration;
+
+ if (hlsSegmentPrefetcher != null) {
+ hlsSegmentPrefetcher.release();
+ hlsSegmentPrefetcher = null;
+ }
+ if (player != null) {
+ player.removeListener(playerListener);
+ view.setPlayer(null);
+ player.release();
+ player = null;
+ }
+ view.setVisibility(View.GONE);
+
+ handler.postDelayed(() -> {
+ if (generation != playGeneration) {
+ // 这期间用户已经换台了,那边会自己起播,这里不能再抢
+ view.setVisibility(View.VISIBLE);
+ return;
+ }
+ view.setVisibility(View.VISIBLE);
+ handler.postDelayed(() -> {
+ if (generation != playGeneration) {
+ return;
+ }
+ initialize(view);
+ if (callback != null) {
+ callback.onPlayerRebuilt();
+ }
+ currentSources = sources;
+ currentSourceIndex = Math.max(0, Math.min(index, sources.size() - 1));
+ isRetrying = false;
+ playCurrentSource();
+ }, SURFACE_RECREATE_DELAY_MS);
+ }, SURFACE_RECREATE_DELAY_MS);
+ return true;
+ }
+
public boolean reinitializeForDecoderModeChange() {
if (playerView == null) {
return false;
@@ -414,6 +558,12 @@ public boolean reinitializeForDecoderModeChange() {
}
if (player != null) {
player.removeListener(playerListener);
+ // 必须在 release 之前把播放器从 PlayerView 上摘下来。PlayerView.setPlayer() 会对
+ // 旧播放器调 clearVideoSurfaceView() 来解绑 Surface,而对已释放的播放器下发消息只会
+ // 被丢弃("Ignoring messages sent after release"),Surface 就永远停在 connected 状态,
+ // 导致新建的 MediaCodec 连不上:native_window_api_connect returned an error (-22),
+ // 继而 ERROR_CODE_DECODER_INIT_FAILED。
+ playerView.setPlayer(null);
player.release();
player = null;
}
@@ -438,6 +588,7 @@ public void playChannel(List sources) {
cancelTimeout();
cancelRebufferWatchdog();
consecutiveStallRecoveries = 0;
+ decoderSurfaceRecoveries = 0;
hasStartedPlayback = false;
rebufferCount = 0;
stopPlayer();
@@ -467,6 +618,13 @@ private void playCurrentSource() {
return;
}
+ ensurePlayerReady();
+ if (player == null) {
+ Log.w(TAG, "No player instance — cannot start playback");
+ if (callback != null) callback.onAllSourcesFailed();
+ return;
+ }
+
if (isRetrying && callback != null) {
callback.onSourceSwitching(currentSourceIndex, currentSources.size());
}
@@ -768,6 +926,11 @@ public void release() {
}
if (player != null) {
player.removeListener(playerListener);
+ if (playerView != null) {
+ // 与 reinitializeForDecoderModeChange 同理:先解绑 Surface 再释放,
+ // 否则 Surface 会停在 connected 状态
+ playerView.setPlayer(null);
+ }
player.release();
player = null;
}
diff --git a/app/src/main/java/com/whyun/witv/player/WiTVRenderersFactory.java b/app/src/main/java/com/whyun/witv/player/WiTVRenderersFactory.java
new file mode 100644
index 0000000..74a9682
--- /dev/null
+++ b/app/src/main/java/com/whyun/witv/player/WiTVRenderersFactory.java
@@ -0,0 +1,74 @@
+package com.whyun.witv.player;
+
+import android.content.Context;
+import android.os.Handler;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.OptIn;
+import androidx.media3.common.util.UnstableApi;
+import androidx.media3.exoplayer.Renderer;
+import androidx.media3.exoplayer.audio.AudioRendererEventListener;
+import androidx.media3.exoplayer.audio.AudioSink;
+import androidx.media3.exoplayer.mediacodec.MediaCodecSelector;
+import androidx.media3.exoplayer.video.VideoRendererEventListener;
+
+import java.util.ArrayList;
+
+import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory;
+
+/**
+ * 允许音频与视频各自选择硬解/软解优先级的渲染器工厂。
+ *
+ * {@code DefaultRenderersFactory} 只有一个全局的 {@code extensionRendererMode},音视频共用。
+ * 但这两者的最佳取舍常常相反,实测遇到过的典型情况是:
+ *
+ *
+ * - 音频需要软解——盒子声称支持 AC-3/E-AC-3 直通,于是 {@code MediaCodecAudioRenderer}
+ * 以直通方式胜出、压根不解码,而 HDMI 下游实际解不了,表现为杜比声道完全没声音;
+ * - 视频需要硬解——老盒子的 CPU 软解 1080p H.264 跟不上实时,一旦让
+ * {@code FfmpegVideoRenderer} 抢先就会持续掉帧卡顿。
+ *
+ *
+ * 所以这里分别覆写两个 {@code build*Renderers},各自传入
+ * {@link PlaybackDecoderMode} 给出的模式,忽略框架传进来的那个全局值。
+ */
+@OptIn(markerClass = UnstableApi.class)
+final class WiTVRenderersFactory extends NextRenderersFactory {
+
+ private final PlaybackDecoderMode decoderMode;
+
+ WiTVRenderersFactory(Context context, PlaybackDecoderMode decoderMode) {
+ super(context);
+ this.decoderMode = decoderMode;
+ // 作为未覆写路径的基线;音视频的实际取值在下面两个方法里各自指定
+ setExtensionRendererMode(decoderMode.getVideoExtensionRendererMode());
+ }
+
+ @Override
+ protected void buildAudioRenderers(@NonNull Context context,
+ int extensionRendererMode,
+ @NonNull MediaCodecSelector mediaCodecSelector,
+ boolean enableDecoderFallback,
+ @NonNull AudioSink audioSink,
+ @NonNull Handler eventHandler,
+ @NonNull AudioRendererEventListener eventListener,
+ @NonNull ArrayList out) {
+ super.buildAudioRenderers(context, decoderMode.getAudioExtensionRendererMode(),
+ mediaCodecSelector, enableDecoderFallback, audioSink, eventHandler, eventListener,
+ out);
+ }
+
+ @Override
+ protected void buildVideoRenderers(@NonNull Context context,
+ int extensionRendererMode,
+ @NonNull MediaCodecSelector mediaCodecSelector,
+ boolean enableDecoderFallback,
+ @NonNull Handler eventHandler,
+ @NonNull VideoRendererEventListener eventListener,
+ long allowedVideoJoiningTimeMs,
+ @NonNull ArrayList out) {
+ super.buildVideoRenderers(context, decoderMode.getVideoExtensionRendererMode(),
+ mediaCodecSelector, enableDecoderFallback, eventHandler, eventListener,
+ allowedVideoJoiningTimeMs, out);
+ }
+}
diff --git a/app/src/main/java/com/whyun/witv/server/DeviceIpUtil.java b/app/src/main/java/com/whyun/witv/server/DeviceIpUtil.java
new file mode 100644
index 0000000..d77f602
--- /dev/null
+++ b/app/src/main/java/com/whyun/witv/server/DeviceIpUtil.java
@@ -0,0 +1,196 @@
+package com.whyun.witv.server;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.net.SocketException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+
+/**
+ * 解析本机用于展示的局域网地址,也就是用户要在浏览器里输入的那个 IP。
+ *
+ * 原先只读 {@code WifiManager.getConnectionInfo().getIpAddress()},而电视盒子接网线是常态,
+ * 有线连接时该接口恒返回 0,界面上就只剩 {@code 0.0.0.0:9979} 这种没法用的地址。
+ */
+public final class DeviceIpUtil {
+
+ /** 解析不出任何可用地址时的占位值 */
+ public static final String UNKNOWN_ADDRESS = "0.0.0.0";
+
+ private DeviceIpUtil() {
+ }
+
+ /**
+ * 当前设备的局域网 IPv4 地址。优先取系统认定的活动网络,取不到再枚举网卡。
+ *
+ * @return 形如 {@code 192.168.6.133};解析失败返回 {@link #UNKNOWN_ADDRESS}
+ */
+ @NonNull
+ public static String resolve(@Nullable Context context) {
+ String fromActiveNetwork = fromActiveNetwork(context);
+ if (fromActiveNetwork != null) {
+ return fromActiveNetwork;
+ }
+ return pickDisplayAddress(enumerateCandidates());
+ }
+
+ /**
+ * 地址是不是真解出来了。界面上要据此决定还能不能画二维码——
+ * 把 {@link #UNKNOWN_ADDRESS} 编成码,扫出来是个连不上的地址,比不给码更误导人。
+ */
+ public static boolean isResolved(@Nullable String address) {
+ return address != null && !UNKNOWN_ADDRESS.equals(address) && !address.trim().isEmpty();
+ }
+
+ /** 系统认定的活动网络地址最准:有线/无线同时在线时它就是实际出口。 */
+ @Nullable
+ private static String fromActiveNetwork(@Nullable Context context) {
+ if (context == null) {
+ return null;
+ }
+ try {
+ ConnectivityManager cm = (ConnectivityManager) context.getApplicationContext()
+ .getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (cm == null) {
+ return null;
+ }
+ Network active = cm.getActiveNetwork();
+ if (active == null) {
+ return null;
+ }
+ LinkProperties props = cm.getLinkProperties(active);
+ if (props == null) {
+ return null;
+ }
+ // 开着 VPN 时活动网络就是 VPN,这里会拿到隧道地址(tun0)。那个地址写到界面上,
+ // 局域网里的手机根本连不上,而物理网卡的地址其实还好好的——所以虚拟接口一律跳过,
+ // 退回下面的网卡枚举。
+ if (!isUsableInterface(props.getInterfaceName())) {
+ return null;
+ }
+ for (LinkAddress linkAddress : props.getLinkAddresses()) {
+ InetAddress address = linkAddress.getAddress();
+ if (isUsableIpv4(address)) {
+ return address.getHostAddress();
+ }
+ }
+ } catch (Exception ignored) {
+ // 权限、厂商 ROM 异常等一律退回枚举方案
+ }
+ return null;
+ }
+
+ private static List enumerateCandidates() {
+ List candidates = new ArrayList<>();
+ try {
+ Enumeration interfaces = NetworkInterface.getNetworkInterfaces();
+ while (interfaces != null && interfaces.hasMoreElements()) {
+ NetworkInterface ni = interfaces.nextElement();
+ if (ni.isLoopback() || !ni.isUp()) {
+ continue;
+ }
+ Enumeration addresses = ni.getInetAddresses();
+ while (addresses.hasMoreElements()) {
+ InetAddress address = addresses.nextElement();
+ if (isUsableIpv4(address)) {
+ candidates.add(new Candidate(ni.getName(), address.getHostAddress()));
+ }
+ }
+ }
+ } catch (SocketException ignored) {
+ }
+ return candidates;
+ }
+
+ private static boolean isUsableIpv4(@Nullable InetAddress address) {
+ return address instanceof Inet4Address
+ && !address.isLoopbackAddress()
+ && !address.isLinkLocalAddress()
+ && !address.isAnyLocalAddress();
+ }
+
+ /**
+ * 从候选网卡里挑一个最适合展示给用户的地址。
+ *
+ * 有线优先于无线,是因为盒子两者同时在线时有线通常才是实际可达的那条;
+ * {@code p2p} / {@code dummy} / {@code tun} 之类是虚拟或点对点接口,写到界面上用户连不上。
+ */
+ @NonNull
+ @VisibleForTesting
+ static String pickDisplayAddress(@Nullable List candidates) {
+ if (candidates == null || candidates.isEmpty()) {
+ return UNKNOWN_ADDRESS;
+ }
+ Candidate best = null;
+ int bestRank = Integer.MAX_VALUE;
+ for (Candidate candidate : candidates) {
+ int rank = rankOf(candidate.interfaceName);
+ if (rank < bestRank) {
+ bestRank = rank;
+ best = candidate;
+ }
+ }
+ return best != null ? best.address : UNKNOWN_ADDRESS;
+ }
+
+ /** 接口能不能作为别人访问本机的入口;{@code null} 视为可用(拿不到名字时不武断排除)。 */
+ @VisibleForTesting
+ static boolean isUsableInterface(@Nullable String interfaceName) {
+ return interfaceName == null || rankOf(interfaceName) != Integer.MAX_VALUE;
+ }
+
+ /** 数值越小越优先;{@link Integer#MAX_VALUE} 表示不可用。 */
+ private static int rankOf(@Nullable String interfaceName) {
+ if (interfaceName == null) {
+ return 3;
+ }
+ String name = interfaceName.toLowerCase(java.util.Locale.US);
+ if (name.startsWith("lo") || name.startsWith("p2p") || name.startsWith("dummy")
+ || name.startsWith("tun") || name.startsWith("tap") || name.startsWith("ppp")
+ || name.startsWith("docker") || name.startsWith("veth")) {
+ return Integer.MAX_VALUE;
+ }
+ if (name.startsWith("eth")) {
+ return 0;
+ }
+ if (name.startsWith("wlan")) {
+ return 1;
+ }
+ return 2;
+ }
+
+ /** 一张网卡上的一个可用 IPv4 地址 */
+ @VisibleForTesting
+ static final class Candidate {
+ final String interfaceName;
+ final String address;
+
+ Candidate(String interfaceName, String address) {
+ this.interfaceName = interfaceName;
+ this.address = address;
+ }
+ }
+
+ /** 仅供测试构造候选列表 */
+ @VisibleForTesting
+ static List candidates(String... interfaceAndAddressPairs) {
+ List list = new ArrayList<>();
+ for (int i = 0; i + 1 < interfaceAndAddressPairs.length; i += 2) {
+ list.add(new Candidate(interfaceAndAddressPairs[i], interfaceAndAddressPairs[i + 1]));
+ }
+ return Collections.unmodifiableList(list);
+ }
+}
diff --git a/app/src/main/java/com/whyun/witv/server/QrCodeUtil.java b/app/src/main/java/com/whyun/witv/server/QrCodeUtil.java
new file mode 100644
index 0000000..fa1b0ed
--- /dev/null
+++ b/app/src/main/java/com/whyun/witv/server/QrCodeUtil.java
@@ -0,0 +1,84 @@
+package com.whyun.witv.server;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.EncodeHintType;
+import com.google.zxing.WriterException;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.qrcode.QRCodeWriter;
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
+
+import java.util.EnumMap;
+import java.util.Map;
+
+/**
+ * 把 Web 管理地址渲染成二维码,省去在电视上用遥控器逐字符输入 URL。
+ */
+public final class QrCodeUtil {
+
+ /**
+ * 静区(quiet zone)模块数。低于 2 很多手机扫不出来,而这里是贴在深色背景上的小图,
+ * 必须靠白色静区把码区和背景隔开。
+ */
+ private static final int QUIET_ZONE_MODULES = 2;
+
+ private QrCodeUtil() {
+ }
+
+ /**
+ * 生成黑白二维码位图。
+ *
+ * 固定黑底白码而不跟随应用配色:扫码依赖足够的明暗对比,用主题色很容易扫不出来。
+ *
+ * @param content 要编码的内容,通常是 {@code http://:9979}
+ * @param sizePx 目标边长(像素),应当按实际显示尺寸传入,避免缩放后模块边缘发虚
+ * @return 位图;内容为空或编码失败时返回 null,调用方应隐藏对应视图
+ */
+ @Nullable
+ public static Bitmap encode(@Nullable String content, int sizePx) {
+ BitMatrix matrix = encodeMatrix(content, sizePx);
+ if (matrix == null) {
+ return null;
+ }
+ int width = matrix.getWidth();
+ int height = matrix.getHeight();
+ int[] pixels = new int[width * height];
+ for (int y = 0; y < height; y++) {
+ int offset = y * width;
+ for (int x = 0; x < width; x++) {
+ pixels[offset + x] = matrix.get(x, y) ? Color.BLACK : Color.WHITE;
+ }
+ }
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ bitmap.setPixels(pixels, 0, width, 0, 0, width, height);
+ return bitmap;
+ }
+
+ /**
+ * 编码出模块矩阵。与位图转换分开,便于在 JVM 单元测试里直接验证编码结果。
+ *
+ * @return 矩阵;内容为空或 zxing 编码失败时返回 null
+ */
+ @Nullable
+ @VisibleForTesting
+ static BitMatrix encodeMatrix(@Nullable String content, int sizePx) {
+ if (content == null || content.trim().isEmpty() || sizePx <= 0) {
+ return null;
+ }
+ Map hints = new EnumMap<>(EncodeHintType.class);
+ hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
+ hints.put(EncodeHintType.MARGIN, QUIET_ZONE_MODULES);
+ // 这是贴在屏幕上供近距离扫描的码,不需要更高纠错等级;等级越高模块越密、越难扫
+ hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
+ try {
+ return new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, sizePx, sizePx, hints);
+ } catch (WriterException | IllegalArgumentException e) {
+ return null;
+ }
+ }
+}
diff --git a/app/src/main/java/com/whyun/witv/server/WebServer.java b/app/src/main/java/com/whyun/witv/server/WebServer.java
index 8b02a9a..8eb16a7 100644
--- a/app/src/main/java/com/whyun/witv/server/WebServer.java
+++ b/app/src/main/java/com/whyun/witv/server/WebServer.java
@@ -3,6 +3,7 @@
import android.content.Context;
import android.content.res.AssetManager;
+import androidx.annotation.VisibleForTesting;
import com.whyun.witv.WiTVApp;
import com.whyun.witv.data.PreferenceManager;
import com.whyun.witv.data.db.AppDatabase;
@@ -27,6 +28,12 @@
public class WebServer extends NanoHTTPD {
+ /**
+ * 局域网管理页端口。此前这个数字散落在 Application、播放页、设置页三处硬编码,
+ * 改端口要同时改三处且很容易漏,统一收敛到这里。
+ */
+ public static final int PORT = 9979;
+
private final Context context;
private final AppDatabase db;
private final Gson gson = new Gson();
@@ -44,6 +51,11 @@ public class WebServer extends NanoHTTPD {
MIME_MAP.put("ico", "image/x-icon");
}
+ /** 拼出用户要在浏览器里输入的完整地址。 */
+ public static String buildUrl(String host) {
+ return "http://" + host + ":" + PORT;
+ }
+
public WebServer(Context context, int port) {
super(port);
this.context = context;
@@ -68,8 +80,9 @@ public Response serve(IHTTPSession session) {
} catch (Exception e) {
JsonObject err = new JsonObject();
err.addProperty("error", e.getMessage());
+ // 错误信息可能含中文(源地址解析失败等),必须带上 charset
response = newFixedLengthResponse(Response.Status.INTERNAL_ERROR,
- "application/json", gson.toJson(err));
+ "application/json; charset=utf-8", gson.toJson(err));
}
response.addHeader("Access-Control-Allow-Origin", "*");
@@ -459,15 +472,71 @@ private long extractIdBeforeSegment(String uri, String segment) {
return Long.parseLong(parts[parts.length - 1]);
}
+ /**
+ * 按 UTF-8 读取请求体。
+ *
+ * 不能用 {@code session.parseBody()}:NanoHTTPD 内部是
+ * {@code new String(postBytes, contentType.getEncoding())},而 {@code getEncoding()} 在
+ * Content-Type 不带 charset 时默认返回 US-ASCII,中文会被整体替换成 {@code ?}。
+ * 浏览器 {@code fetch} 发 {@code application/json} 时通常就不带 charset,
+ * 所以这里直接读原始字节自行解码,不依赖请求头。
+ */
private String readBody(IHTTPSession session) throws IOException {
- Map body = new HashMap<>();
+ long contentLength = parseContentLength(session.getHeaders());
+ return readBodyFrom(session.getInputStream(), contentLength);
+ }
+
+ /**
+ * 请求体上限。这个服务只收播放源地址和设置项,最大的一条也就几百字节,
+ * 1 MiB 已经宽裕得离谱。
+ *
+ * 必须有上限:服务监听在局域网上且没有鉴权,任何能连上的设备发一个
+ * {@code Content-Length: 2000000000} 就能让下面按长度预分配数组,直接 OOM 杀掉整个应用。
+ */
+ @VisibleForTesting
+ static final int MAX_BODY_BYTES = 1024 * 1024;
+
+ private static long parseContentLength(Map headers) {
+ if (headers == null) {
+ return 0L;
+ }
+ String value = headers.get("content-length");
+ if (value == null) {
+ return 0L;
+ }
try {
- session.parseBody(body);
- } catch (ResponseException e) {
- throw new IOException(e);
+ return Long.parseLong(value.trim());
+ } catch (NumberFormatException e) {
+ return 0L;
+ }
+ }
+
+ /**
+ * 从流中精确读取 {@code contentLength} 个字节并按 UTF-8 解码。
+ *
+ * 必须读满而不是读一次就算:{@code InputStream.read} 允许返回少于请求的字节数,
+ * 中文请求体被截断同样会变成乱码。
+ */
+ @VisibleForTesting
+ static String readBodyFrom(InputStream inputStream, long contentLength) throws IOException {
+ if (inputStream == null || contentLength <= 0) {
+ return "";
+ }
+ // 先判断再分配:超限时一个字节都不能先占,否则这个检查就白写了
+ if (contentLength > MAX_BODY_BYTES) {
+ throw new IOException("Request body too large: " + contentLength);
+ }
+ int remaining = (int) contentLength;
+ byte[] body = new byte[remaining];
+ int offset = 0;
+ while (offset < remaining) {
+ int read = inputStream.read(body, offset, remaining - offset);
+ if (read < 0) {
+ break;
+ }
+ offset += read;
}
- String postData = body.get("postData");
- return postData != null ? postData : "";
+ return new String(body, 0, offset, StandardCharsets.UTF_8);
}
private static boolean isBinaryAssetExt(String ext) {
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 273a3e6..78282d2 100644
--- a/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java
+++ b/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java
@@ -3,6 +3,7 @@
import android.app.AlertDialog;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
+import android.graphics.Bitmap;
import android.graphics.Typeface;
import android.os.Bundle;
import android.os.Handler;
@@ -22,6 +23,7 @@
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
+import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.FragmentActivity;
import androidx.media3.common.Player;
@@ -37,6 +39,9 @@
import com.whyun.witv.WiTVApp;
import com.whyun.witv.data.PreferenceManager;
import com.whyun.witv.data.db.AppDatabase;
+import com.whyun.witv.server.DeviceIpUtil;
+import com.whyun.witv.server.QrCodeUtil;
+import com.whyun.witv.server.WebServer;
import com.whyun.witv.data.db.entity.Channel;
import com.whyun.witv.data.db.entity.ChannelSource;
import com.whyun.witv.data.db.entity.EpgProgram;
@@ -74,6 +79,7 @@ public class PlayerActivity extends FragmentActivity implements PlayerManager.Ca
private TextView channelNameView;
private TextView sourceInfoView;
private TextView webAddressView;
+ private ImageView webAddressQrView;
private ImageView channelLogoView;
private ImageView favoriteIcon;
private TextView currentProgramView;
@@ -191,6 +197,7 @@ private void initViews() {
channelNameView = findViewById(R.id.channel_name);
sourceInfoView = findViewById(R.id.source_info);
webAddressView = findViewById(R.id.tv_web_address);
+ webAddressQrView = findViewById(R.id.iv_web_address_qr);
channelLogoView = findViewById(R.id.channel_logo);
favoriteIcon = findViewById(R.id.favorite_icon);
currentProgramView = findViewById(R.id.current_program);
@@ -492,24 +499,36 @@ private void updateWebAddress() {
if (webAddressView == null) {
return;
}
- webAddressView.setText(String.format(Locale.getDefault(), "http://%s:9978", getDeviceIp()));
+ String ip = getDeviceIp();
+ String url = WebServer.buildUrl(ip);
+ webAddressView.setText(url);
+ // IP 没解析出来时地址文字照常显示(用户据此知道要查网络),但不给二维码:
+ // 把 0.0.0.0 编成码,扫出来是个连不上的地址,比不给码更误导人
+ updateWebAddressQr(DeviceIpUtil.isResolved(ip) ? url : null);
}
- private String getDeviceIp() {
- try {
- WifiManager wifiManager = (WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE);
- if (wifiManager != null) {
- WifiInfo wifiInfo = wifiManager.getConnectionInfo();
- int ipInt = wifiInfo.getIpAddress();
- if (ipInt != 0) {
- return String.format(Locale.US, "%d.%d.%d.%d",
- (ipInt & 0xff), (ipInt >> 8 & 0xff),
- (ipInt >> 16 & 0xff), (ipInt >> 24 & 0xff));
- }
- }
- } catch (Exception ignored) {
+ /** 电视上用遥控器输 URL 很痛苦,给手机留个扫码入口。 */
+ private void updateWebAddressQr(@Nullable String url) {
+ if (webAddressQrView == null) {
+ return;
}
- return "0.0.0.0";
+ // 按实际显示尺寸生成,缩放会让模块边缘发虚、影响扫码成功率
+ int sizePx = webAddressQrView.getWidth() - webAddressQrView.getPaddingLeft()
+ - webAddressQrView.getPaddingRight();
+ if (sizePx <= 0) {
+ sizePx = getResources().getDimensionPixelSize(R.dimen.web_address_qr_size);
+ }
+ Bitmap qr = QrCodeUtil.encode(url, sizePx);
+ if (qr == null) {
+ webAddressQrView.setVisibility(View.GONE);
+ return;
+ }
+ webAddressQrView.setImageBitmap(qr);
+ webAddressQrView.setVisibility(View.VISIBLE);
+ }
+
+ private String getDeviceIp() {
+ return DeviceIpUtil.resolve(this);
}
private Channel resolveInitialChannel(AppDatabase db) {
@@ -1414,6 +1433,18 @@ public void onSourceSwitching(int newIndex, int total) {
switchingToast.setVisibility(View.VISIBLE);
}
+ /** 播放器被内部重建(解码器起不来时重建 Surface),把自己的监听器挂到新实例上。 */
+ @Override
+ public void onPlayerRebuilt() {
+ if (playerManager == null) {
+ return;
+ }
+ ExoPlayer rebuilt = playerManager.getPlayer();
+ if (rebuilt != null) {
+ rebuilt.addListener(mediaInfoListener);
+ }
+ }
+
@Override
public void onAllSourcesFailed() {
switchingToast.setText(getString(R.string.all_sources_failed));
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 0f4b46f..43811bd 100644
--- a/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java
+++ b/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java
@@ -28,6 +28,8 @@
import com.whyun.witv.R;
import com.whyun.witv.data.PreferenceManager;
import com.whyun.witv.player.PlaybackDecoderMode;
+import com.whyun.witv.server.DeviceIpUtil;
+import com.whyun.witv.server.WebServer;
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.FfmpegLibrary;
import com.whyun.witv.data.db.AppDatabase;
@@ -522,7 +524,8 @@ private List buildSubmenuRows(int category) {
Context ctx = requireContext();
switch (category) {
case CAT_ADDRESS:
- rows.add(new SettingsPanelAdapter.WebHintRow(buildWebHint(ctx)));
+ rows.add(new SettingsPanelAdapter.WebHintRow(
+ buildWebHint(ctx), buildWebQrUrl(ctx)));
for (M3USource s : m3uCache) {
rows.add(new SettingsPanelAdapter.M3USourceRow(s));
}
@@ -613,25 +616,27 @@ private List buildSubmenuRows(int category) {
}
private static String buildWebHint(Context ctx) {
- return String.format(Locale.US, "通过浏览器管理:http://%s:9978", getDeviceIp(ctx));
+ return String.format(Locale.US, "通过浏览器管理:%s", buildWebUrl(ctx));
+ }
+
+ private static String buildWebUrl(Context ctx) {
+ return WebServer.buildUrl(getDeviceIp(ctx));
+ }
+
+ /**
+ * 二维码要编的地址;IP 还没解析出来时返回 {@code null},由 ViewHolder 隐藏二维码。
+ *
+ * 不能照样编一个 {@code http://0.0.0.0:9979} 出来——扫出来是个手机连不上的地址,
+ * 比干脆不显示更误导人。文字那行仍然照常显示,用户至少能看出是地址没拿到。
+ */
+ @Nullable
+ private static String buildWebQrUrl(Context ctx) {
+ String ip = getDeviceIp(ctx);
+ return DeviceIpUtil.isResolved(ip) ? WebServer.buildUrl(ip) : null;
}
private static String getDeviceIp(Context context) {
- try {
- WifiManager wifiManager = (WifiManager) context.getApplicationContext()
- .getSystemService(Context.WIFI_SERVICE);
- if (wifiManager != null) {
- WifiInfo wifiInfo = wifiManager.getConnectionInfo();
- int ipInt = wifiInfo.getIpAddress();
- if (ipInt != 0) {
- return String.format(Locale.US, "%d.%d.%d.%d",
- (ipInt & 0xff), (ipInt >> 8 & 0xff),
- (ipInt >> 16 & 0xff), (ipInt >> 24 & 0xff));
- }
- }
- } catch (Exception ignored) {
- }
- return "0.0.0.0";
+ return DeviceIpUtil.resolve(context);
}
@Override
@@ -827,6 +832,8 @@ private static boolean isFfmpegSoftwareDecoderAvailable() {
private static int decoderModeTitleRes(PlaybackDecoderMode mode) {
switch (mode) {
+ case SOFTWARE_AUDIO:
+ return R.string.decoder_mode_software_audio;
case PREFER_SOFTWARE:
return R.string.decoder_mode_prefer_software;
case HARDWARE_ONLY:
@@ -839,6 +846,8 @@ private static int decoderModeTitleRes(PlaybackDecoderMode mode) {
private static int decoderModeDescriptionRes(PlaybackDecoderMode mode) {
switch (mode) {
+ case SOFTWARE_AUDIO:
+ return R.string.decoder_mode_software_audio_desc;
case PREFER_SOFTWARE:
return R.string.decoder_mode_prefer_software_desc;
case HARDWARE_ONLY:
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 ca6976d..862da96 100644
--- a/app/src/main/java/com/whyun/witv/ui/SettingsPanelAdapter.java
+++ b/app/src/main/java/com/whyun/witv/ui/SettingsPanelAdapter.java
@@ -1,5 +1,6 @@
package com.whyun.witv.ui;
+import android.graphics.Bitmap;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
@@ -7,9 +8,11 @@
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.EditText;
+import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.whyun.witv.R;
@@ -17,6 +20,7 @@
import com.whyun.witv.data.db.entity.M3USource;
import com.whyun.witv.player.MulticastUrlUtil;
import com.whyun.witv.player.PlaybackDecoderMode;
+import com.whyun.witv.server.QrCodeUtil;
import java.util.Collections;
import java.util.List;
@@ -44,9 +48,13 @@ public abstract static class Row {
public static final class WebHintRow extends Row {
final String text;
+ /** 纯 URL,用于生成二维码;与 {@link #text} 里那句说明文字分开。IP 还没解析出来时为 {@code null} */
+ @Nullable
+ final String url;
- public WebHintRow(String text) {
+ public WebHintRow(String text, @Nullable String url) {
this.text = text;
+ this.url = url;
}
@Override
@@ -255,6 +263,8 @@ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int
LayoutInflater inf = LayoutInflater.from(parent.getContext());
switch (viewType) {
case VT_WEB_HINT:
+ return new WebHintVH(
+ inf.inflate(R.layout.item_settings_web_hint, parent, false));
case VT_EMPTY_HINT:
return new HintVH(inf.inflate(R.layout.item_settings_hint, parent, false));
case VT_M3U:
@@ -280,14 +290,10 @@ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
Row row = rows.get(position);
- if (holder instanceof HintVH) {
- String text;
- if (row instanceof WebHintRow) {
- text = ((WebHintRow) row).text;
- } else {
- text = ((EmptyHintRow) row).text;
- }
- ((HintVH) holder).bind(text);
+ if (holder instanceof WebHintVH) {
+ ((WebHintVH) holder).bind((WebHintRow) row);
+ } else if (holder instanceof HintVH) {
+ ((HintVH) holder).bind(((EmptyHintRow) row).text);
} else if (holder instanceof M3UVH) {
((M3UVH) holder).bind(((M3USourceRow) row).source, listener);
} else if (holder instanceof StreamVH) {
@@ -314,6 +320,38 @@ public int getItemCount() {
return rows.size();
}
+ /** Web 管理提示:地址上方带一个二维码,省去在电视上用遥控器输 URL。 */
+ static final class WebHintVH extends RecyclerView.ViewHolder {
+ final ImageView qr;
+ final TextView qrCaption;
+ final TextView text;
+
+ WebHintVH(@NonNull View itemView) {
+ super(itemView);
+ qr = itemView.findViewById(R.id.web_hint_qr);
+ qrCaption = itemView.findViewById(R.id.web_hint_qr_caption);
+ text = itemView.findViewById(R.id.hint_text);
+ }
+
+ void bind(WebHintRow row) {
+ text.setText(row.text);
+ // 减掉 padding:ImageView 是 132dp,但四周各留了 4dp 白边,真正画码的只有中间那块。
+ // 按 132dp 生成再塞进 124dp 会被非整数倍重采样,模块边缘发虚,本来就小的码更难扫
+ int sizePx = itemView.getResources()
+ .getDimensionPixelSize(R.dimen.settings_web_qr_size)
+ - qr.getPaddingLeft() - qr.getPaddingRight();
+ Bitmap bitmap = QrCodeUtil.encode(row.url, sizePx);
+ if (bitmap == null) {
+ qr.setVisibility(View.GONE);
+ qrCaption.setVisibility(View.GONE);
+ return;
+ }
+ qr.setImageBitmap(bitmap);
+ qr.setVisibility(View.VISIBLE);
+ qrCaption.setVisibility(View.VISIBLE);
+ }
+ }
+
static final class HintVH extends RecyclerView.ViewHolder {
final TextView text;
diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml
index 45a65fd..892cfed 100644
--- a/app/src/main/res/layout/activity_player.xml
+++ b/app/src/main/res/layout/activity_player.xml
@@ -243,6 +243,16 @@
android:textColor="@color/text_secondary"
android:textSize="18sp" />
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..34ef9f8
--- /dev/null
+++ b/app/src/main/res/values/dimens.xml
@@ -0,0 +1,7 @@
+
+
+
+ 220dp
+
+ 132dp
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 54b81b1..d78597d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -14,12 +14,16 @@
EPG
播放选项
超时换源
+ Web 管理地址二维码
+ 手机扫码即可打开
组播 / UDP
解码方式
硬解优先(推荐)
优先用硬件解码;遇到盒子不支持的编码(如组播常见的 MPEG-2 视频、MP2 音频)自动切软解
- 软解优先
- 优先用 FFmpeg 软件解码。适用于硬解声称支持但实际黑屏/无声的盒子,CPU 占用更高
+ 音频软解 + 视频硬解
+ 音频用 FFmpeg 解码以绕开杜比直通,视频仍走硬解。适用于「杜比声道没声音、但视频正常」的设备
+ 全部软解优先
+ 音视频都优先用 FFmpeg 软件解码。CPU 占用高,低端盒子上 1080p 视频可能跟不上实时而卡顿
仅硬解
完全不加载软解。用于排查软解自身引入的问题,或极低端 CPU 设备
解码方式已切换为「%1$s」,已重新起播当前频道
diff --git a/app/src/test/java/com/whyun/witv/data/BomAwareTextTest.java b/app/src/test/java/com/whyun/witv/data/BomAwareTextTest.java
new file mode 100644
index 0000000..d73e7c8
--- /dev/null
+++ b/app/src/test/java/com/whyun/witv/data/BomAwareTextTest.java
@@ -0,0 +1,78 @@
+package com.whyun.witv.data;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+public class BomAwareTextTest {
+
+ private static final String M3U = "#EXTM3U\n#EXTINF:-1,北京卫视\nrtp://239.3.1.118:8001";
+
+ /** 没有 BOM、没有声明时按 UTF-8 解,这是绝大多数源的情况。 */
+ @Test
+ public void defaultsToUtf8() {
+ assertEquals(M3U, BomAwareText.decode(M3U.getBytes(StandardCharsets.UTF_8), null));
+ }
+
+ /**
+ * 国内直播源有不少是 GBK 的。之前走 ResponseBody.string() 时它们是好的,
+ * 加 gzip 支持时如果写死 UTF-8,频道名会整片变成乱码——这个测试就是守着这条。
+ */
+ @Test
+ public void honoursDeclaredGbkCharset() {
+ Charset gbk = Charset.forName("GBK");
+ assertEquals(M3U, BomAwareText.decode(M3U.getBytes(gbk), gbk));
+ }
+
+ /** BOM 优先于声明:BOM 是文件自己带的,Content-Type 常常是服务器随手填的默认值。 */
+ @Test
+ public void bomWinsOverDeclaredCharset() {
+ byte[] withBom = concat(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF},
+ M3U.getBytes(StandardCharsets.UTF_8));
+ assertEquals(M3U, BomAwareText.decode(withBom, Charset.forName("GBK")));
+ }
+
+ /** BOM 字节必须吃掉:留着的话字符串以 U+FEFF 开头,解析器连 #EXTM3U 都认不出来。 */
+ @Test
+ public void stripsBomBytes() {
+ byte[] withBom = concat(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF},
+ "#EXTM3U".getBytes(StandardCharsets.UTF_8));
+ String decoded = BomAwareText.decode(withBom, null);
+ assertEquals("#EXTM3U", decoded);
+ assertEquals('#', decoded.charAt(0));
+ }
+
+ @Test
+ public void detectsUtf16Boms() {
+ assertEquals(M3U, BomAwareText.decode(
+ concat(new byte[]{(byte) 0xFE, (byte) 0xFF}, M3U.getBytes(StandardCharsets.UTF_16BE)),
+ null));
+ assertEquals(M3U, BomAwareText.decode(
+ concat(new byte[]{(byte) 0xFF, (byte) 0xFE}, M3U.getBytes(StandardCharsets.UTF_16LE)),
+ null));
+ }
+
+ /** 空响应和只有一两个字节时不能越界。 */
+ @Test
+ public void toleratesShortInput() {
+ assertEquals("", BomAwareText.decode(new byte[0], null));
+ assertEquals("#", BomAwareText.decode(new byte[]{'#'}, null));
+ assertEquals("#E", BomAwareText.decode(new byte[]{'#', 'E'}, null));
+ }
+
+ private static byte[] concat(byte[] a, byte[] b) {
+ try {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ out.write(a);
+ out.write(b);
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new AssertionError(e);
+ }
+ }
+}
diff --git a/app/src/test/java/com/whyun/witv/data/GzipAwareStreamsTest.java b/app/src/test/java/com/whyun/witv/data/GzipAwareStreamsTest.java
new file mode 100644
index 0000000..f165618
--- /dev/null
+++ b/app/src/test/java/com/whyun/witv/data/GzipAwareStreamsTest.java
@@ -0,0 +1,89 @@
+package com.whyun.witv.data;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.zip.GZIPOutputStream;
+
+public class GzipAwareStreamsTest {
+
+ private static final String XMLTV =
+ "";
+
+ /** 公开 EPG 源基本都是 .xml.gz,这是这个工具存在的理由。 */
+ @Test
+ public void decompressesGzipContent() throws IOException {
+ assertEquals(XMLTV, readAll(GzipAwareStreams.maybeDecompress(
+ new ByteArrayInputStream(gzip(XMLTV)))));
+ }
+
+ /** 未压缩的源必须原样通过,一个字节都不能少。 */
+ @Test
+ public void passesThroughPlainContent() throws IOException {
+ assertEquals(XMLTV, readAll(GzipAwareStreams.maybeDecompress(
+ new ByteArrayInputStream(XMLTV.getBytes(StandardCharsets.UTF_8)))));
+ }
+
+ /** 嗅探魔数时读掉的两个字节必须复位,否则解析器会看到缺头的内容。 */
+ @Test
+ public void doesNotConsumeLeadingBytes() throws IOException {
+ InputStream wrapped = GzipAwareStreams.maybeDecompress(
+ new ByteArrayInputStream("#EXTM3U\n#EXTINF:-1,CCTV1".getBytes(StandardCharsets.UTF_8)));
+ assertEquals("#EXTM3U\n#EXTINF:-1,CCTV1", readAll(wrapped));
+ }
+
+ /** 已经是 BufferedInputStream 的流不再多包一层,但同样要能嗅探。 */
+ @Test
+ public void handlesAlreadyBufferedStream() throws IOException {
+ InputStream source = new BufferedInputStream(new ByteArrayInputStream(gzip(XMLTV)));
+ assertEquals(XMLTV, readAll(GzipAwareStreams.maybeDecompress(source)));
+ }
+
+ /** 空响应或只有一个字节时按非 gzip 处理,交给后续解析器报错,不在这里抛。 */
+ @Test
+ public void treatsTooShortStreamAsPlain() throws IOException {
+ assertEquals("", readAll(GzipAwareStreams.maybeDecompress(
+ new ByteArrayInputStream(new byte[0]))));
+ assertEquals("\u001f", readAll(GzipAwareStreams.maybeDecompress(
+ new ByteArrayInputStream(new byte[]{0x1F}))));
+ }
+
+ @Test
+ public void detectsMagicBytes() throws IOException {
+ assertTrue(GzipAwareStreams.isGzip(new BufferedInputStream(
+ new ByteArrayInputStream(gzip(XMLTV)))));
+ assertFalse(GzipAwareStreams.isGzip(new BufferedInputStream(
+ new ByteArrayInputStream(XMLTV.getBytes(StandardCharsets.UTF_8)))));
+ // 第一个字节对、第二个不对,不能误判
+ assertFalse(GzipAwareStreams.isGzip(new BufferedInputStream(
+ new ByteArrayInputStream(new byte[]{0x1F, 0x00, 0x00}))));
+ }
+
+ private static byte[] gzip(String content) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (GZIPOutputStream gzipOut = new GZIPOutputStream(out)) {
+ gzipOut.write(content.getBytes(StandardCharsets.UTF_8));
+ }
+ return out.toByteArray();
+ }
+
+ private static String readAll(InputStream in) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[256];
+ int read;
+ while ((read = in.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ in.close();
+ return out.toString("UTF-8");
+ }
+}
diff --git a/app/src/test/java/com/whyun/witv/player/PlaybackDecoderModeTest.java b/app/src/test/java/com/whyun/witv/player/PlaybackDecoderModeTest.java
index 1e13a19..2a16270 100644
--- a/app/src/test/java/com/whyun/witv/player/PlaybackDecoderModeTest.java
+++ b/app/src/test/java/com/whyun/witv/player/PlaybackDecoderModeTest.java
@@ -1,10 +1,11 @@
package com.whyun.witv.player;
+import static androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF;
+import static androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON;
+import static androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
-import androidx.media3.exoplayer.DefaultRenderersFactory;
-
import org.junit.Test;
import java.util.HashSet;
@@ -13,18 +14,40 @@
public class PlaybackDecoderModeTest {
@Test
- public void defaultIsHardwareFirstWithSoftwareFallback() {
+ public void defaultIsHardwareFirstForBothTracks() {
assertEquals(PlaybackDecoderMode.AUTO, PlaybackDecoderMode.DEFAULT);
- assertEquals(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON,
- PlaybackDecoderMode.AUTO.getExtensionRendererMode());
+ assertEquals(EXTENSION_RENDERER_MODE_ON,
+ PlaybackDecoderMode.AUTO.getAudioExtensionRendererMode());
+ assertEquals(EXTENSION_RENDERER_MODE_ON,
+ PlaybackDecoderMode.AUTO.getVideoExtensionRendererMode());
+ }
+
+ /**
+ * 这一档存在的全部理由:音频必须绕开杜比直通走软解,视频必须留在硬解上
+ * (老盒子软解 1080p 跟不上实时会持续掉帧)。两者取值相同就失去了意义。
+ */
+ @Test
+ public void softwareAudioKeepsVideoOnHardware() {
+ assertEquals(EXTENSION_RENDERER_MODE_PREFER,
+ PlaybackDecoderMode.SOFTWARE_AUDIO.getAudioExtensionRendererMode());
+ assertEquals(EXTENSION_RENDERER_MODE_ON,
+ PlaybackDecoderMode.SOFTWARE_AUDIO.getVideoExtensionRendererMode());
+ }
+
+ @Test
+ public void preferSoftwareAppliesToBothTracks() {
+ assertEquals(EXTENSION_RENDERER_MODE_PREFER,
+ PlaybackDecoderMode.PREFER_SOFTWARE.getAudioExtensionRendererMode());
+ assertEquals(EXTENSION_RENDERER_MODE_PREFER,
+ PlaybackDecoderMode.PREFER_SOFTWARE.getVideoExtensionRendererMode());
}
@Test
- public void mapsToMedia3ExtensionRendererModes() {
- assertEquals(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER,
- PlaybackDecoderMode.PREFER_SOFTWARE.getExtensionRendererMode());
- assertEquals(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF,
- PlaybackDecoderMode.HARDWARE_ONLY.getExtensionRendererMode());
+ public void hardwareOnlyDisablesExtensionRenderersEntirely() {
+ assertEquals(EXTENSION_RENDERER_MODE_OFF,
+ PlaybackDecoderMode.HARDWARE_ONLY.getAudioExtensionRendererMode());
+ assertEquals(EXTENSION_RENDERER_MODE_OFF,
+ PlaybackDecoderMode.HARDWARE_ONLY.getVideoExtensionRendererMode());
}
@Test
@@ -35,8 +58,9 @@ public void idsAreUniqueAndStable() {
ids.add(mode.getId());
}
assertEquals(PlaybackDecoderMode.values().length, ids.size());
- // 这三个 id 是持久化值,改动会让已有用户的设置静默回落到默认
+ // 这些 id 是持久化值,改动会让已有用户的设置静默回落到默认
assertEquals("auto", PlaybackDecoderMode.AUTO.getId());
+ assertEquals("software_audio", PlaybackDecoderMode.SOFTWARE_AUDIO.getId());
assertEquals("prefer_software", PlaybackDecoderMode.PREFER_SOFTWARE.getId());
assertEquals("hardware_only", PlaybackDecoderMode.HARDWARE_ONLY.getId());
}
@@ -55,4 +79,14 @@ public void fromIdFallsBackToDefaultForUnknownInput() {
assertEquals(PlaybackDecoderMode.DEFAULT, PlaybackDecoderMode.fromId("软解"));
assertEquals(PlaybackDecoderMode.DEFAULT, PlaybackDecoderMode.fromId("AUTO"));
}
+
+ /** 设置页按 values() 顺序展示,顺序应当由保守到激进。 */
+ @Test
+ public void orderGoesFromConservativeToAggressive() {
+ PlaybackDecoderMode[] values = PlaybackDecoderMode.values();
+ assertEquals(PlaybackDecoderMode.AUTO, values[0]);
+ assertEquals(PlaybackDecoderMode.SOFTWARE_AUDIO, values[1]);
+ assertEquals(PlaybackDecoderMode.PREFER_SOFTWARE, values[2]);
+ assertEquals(PlaybackDecoderMode.HARDWARE_ONLY, values[3]);
+ }
}
diff --git a/app/src/test/java/com/whyun/witv/player/PlayerManagerTest.java b/app/src/test/java/com/whyun/witv/player/PlayerManagerTest.java
index df67ce4..a6a5751 100644
--- a/app/src/test/java/com/whyun/witv/player/PlayerManagerTest.java
+++ b/app/src/test/java/com/whyun/witv/player/PlayerManagerTest.java
@@ -34,6 +34,21 @@ public class PlayerManagerTest {
private TestCallback callback;
private Context context;
+ /**
+ * 「解码器起不来」必须和「这路流坏了」分开:换源对前者没用——Surface 被上一个解码器
+ * 占着时,盒子上每一个频道都会一样失败,一路换到底只会把所有源都标记成坏的。
+ */
+ @Test
+ public void tellsDecoderInitFailureApartFromStreamErrors() {
+ assertTrue(PlayerManager.isDecoderInitFailure(new PlaybackException(
+ "Decoder init failed", null, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED)));
+ assertFalse(PlayerManager.isDecoderInitFailure(new PlaybackException(
+ "Network", null, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED)));
+ assertFalse(PlayerManager.isDecoderInitFailure(new PlaybackException(
+ "Decoding failed", null, PlaybackException.ERROR_CODE_DECODING_FAILED)));
+ assertFalse(PlayerManager.isDecoderInitFailure(null));
+ }
+
static class TestCallback implements PlayerManager.Callback {
int sourceSwitchingCount = 0;
int lastSwitchIndex = -1;
@@ -43,6 +58,7 @@ static class TestCallback implements PlayerManager.Callback {
int playbackStartedCount = 0;
int lastPlaybackSourceIndex = -1;
String lastError = null;
+ int playerRebuiltCount = 0;
@Override
public void onSourceSwitching(int newIndex, int total) {
@@ -57,6 +73,11 @@ public void onAllSourcesFailed() {
allSourcesFailedCount++;
}
+ @Override
+ public void onPlayerRebuilt() {
+ playerRebuiltCount++;
+ }
+
@Override
public void onPlaybackStarted(int sourceIndex, int total) {
playbackStartedCount++;
diff --git a/app/src/test/java/com/whyun/witv/player/WiTVRenderersFactoryTest.java b/app/src/test/java/com/whyun/witv/player/WiTVRenderersFactoryTest.java
new file mode 100644
index 0000000..f59255e
--- /dev/null
+++ b/app/src/test/java/com/whyun/witv/player/WiTVRenderersFactoryTest.java
@@ -0,0 +1,109 @@
+package com.whyun.witv.player;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+
+import androidx.media3.common.C;
+import androidx.media3.exoplayer.Renderer;
+import androidx.media3.exoplayer.audio.AudioRendererEventListener;
+import androidx.media3.exoplayer.video.VideoRendererEventListener;
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 渲染器顺序决定了 ExoPlayer 实际用谁解码:它选第一个 {@code supportsFormat} 通过的渲染器。
+ *
+ * 顺序由 NextLib 的插入逻辑决定(第三方代码),升级 NextLib 时可能变化,
+ * 所以这里直接断言产出的顺序,而不是只断言传进去的模式值。
+ */
+@RunWith(RobolectricTestRunner.class)
+public class WiTVRenderersFactoryTest {
+
+ private static List rendererNames(PlaybackDecoderMode mode, int trackType) {
+ Context context = ApplicationProvider.getApplicationContext();
+ Renderer[] renderers = new WiTVRenderersFactory(context, mode).createRenderers(
+ new Handler(Looper.getMainLooper()),
+ new VideoRendererEventListener() {
+ },
+ new AudioRendererEventListener() {
+ },
+ cues -> {
+ },
+ metadata -> {
+ });
+ List names = new ArrayList<>();
+ for (Renderer renderer : renderers) {
+ if (renderer.getTrackType() == trackType) {
+ names.add(renderer.getClass().getSimpleName());
+ }
+ }
+ return names;
+ }
+
+ private static int indexOf(List names, String simpleName) {
+ return names.indexOf(simpleName);
+ }
+
+ /**
+ * 这次修复的核心断言:同一次播放里,音频让 FFmpeg 排在 MediaCodec **之前**
+ * (绕开杜比直通),视频让 MediaCodec 排在 FFmpeg **之前**(避免软解 1080p 卡顿)。
+ */
+ @Test
+ public void softwareAudioPutsFfmpegFirstForAudioButLastForVideo() {
+ List audio = rendererNames(PlaybackDecoderMode.SOFTWARE_AUDIO, C.TRACK_TYPE_AUDIO);
+ List video = rendererNames(PlaybackDecoderMode.SOFTWARE_AUDIO, C.TRACK_TYPE_VIDEO);
+
+ int ffmpegAudio = indexOf(audio, "FfmpegAudioRenderer");
+ int codecAudio = indexOf(audio, "MediaCodecAudioRenderer");
+ assertTrue("音频渲染器缺失: " + audio, ffmpegAudio >= 0 && codecAudio >= 0);
+ assertTrue("音频应当 FFmpeg 优先,实际: " + audio, ffmpegAudio < codecAudio);
+
+ int ffmpegVideo = indexOf(video, "FfmpegVideoRenderer");
+ int codecVideo = indexOf(video, "MediaCodecVideoRenderer");
+ assertTrue("视频渲染器缺失: " + video, ffmpegVideo >= 0 && codecVideo >= 0);
+ assertTrue("视频应当硬解优先,实际: " + video, codecVideo < ffmpegVideo);
+ }
+
+ @Test
+ public void autoPutsHardwareFirstForBothTracks() {
+ List audio = rendererNames(PlaybackDecoderMode.AUTO, C.TRACK_TYPE_AUDIO);
+ List video = rendererNames(PlaybackDecoderMode.AUTO, C.TRACK_TYPE_VIDEO);
+
+ assertTrue("音频应当硬解优先,实际: " + audio,
+ indexOf(audio, "MediaCodecAudioRenderer") < indexOf(audio, "FfmpegAudioRenderer"));
+ assertTrue("视频应当硬解优先,实际: " + video,
+ indexOf(video, "MediaCodecVideoRenderer") < indexOf(video, "FfmpegVideoRenderer"));
+ }
+
+ @Test
+ public void preferSoftwarePutsFfmpegFirstForBothTracks() {
+ List audio = rendererNames(PlaybackDecoderMode.PREFER_SOFTWARE, C.TRACK_TYPE_AUDIO);
+ List video = rendererNames(PlaybackDecoderMode.PREFER_SOFTWARE, C.TRACK_TYPE_VIDEO);
+
+ assertTrue("音频应当软解优先,实际: " + audio,
+ indexOf(audio, "FfmpegAudioRenderer") < indexOf(audio, "MediaCodecAudioRenderer"));
+ assertTrue("视频应当软解优先,实际: " + video,
+ indexOf(video, "FfmpegVideoRenderer") < indexOf(video, "MediaCodecVideoRenderer"));
+ }
+
+ @Test
+ public void hardwareOnlyLoadsNoFfmpegRenderers() {
+ List audio = rendererNames(PlaybackDecoderMode.HARDWARE_ONLY, C.TRACK_TYPE_AUDIO);
+ List video = rendererNames(PlaybackDecoderMode.HARDWARE_ONLY, C.TRACK_TYPE_VIDEO);
+
+ assertEquals("不应加载 FFmpeg 音频渲染器: " + audio, -1, indexOf(audio, "FfmpegAudioRenderer"));
+ assertEquals("不应加载 FFmpeg 视频渲染器: " + video, -1, indexOf(video, "FfmpegVideoRenderer"));
+ assertTrue(indexOf(audio, "MediaCodecAudioRenderer") >= 0);
+ assertTrue(indexOf(video, "MediaCodecVideoRenderer") >= 0);
+ }
+}
diff --git a/app/src/test/java/com/whyun/witv/server/DeviceIpUtilTest.java b/app/src/test/java/com/whyun/witv/server/DeviceIpUtilTest.java
new file mode 100644
index 0000000..5bc1049
--- /dev/null
+++ b/app/src/test/java/com/whyun/witv/server/DeviceIpUtilTest.java
@@ -0,0 +1,108 @@
+package com.whyun.witv.server;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+
+public class DeviceIpUtilTest {
+
+ /**
+ * 这个工具存在的直接原因:盒子接网线时,原来只读 WifiManager 的实现拿不到地址,
+ * 界面上显示成 0.0.0.0:9979,用户无法访问 Web 管理页。
+ */
+ @Test
+ public void picksEthernetAddress() {
+ assertEquals("192.168.6.133", DeviceIpUtil.pickDisplayAddress(
+ DeviceIpUtil.candidates("eth0", "192.168.6.133")));
+ }
+
+ /** 有线与无线同时在线时,有线通常才是实际可达的那条。 */
+ @Test
+ public void prefersEthernetOverWifi() {
+ assertEquals("192.168.6.133", DeviceIpUtil.pickDisplayAddress(
+ DeviceIpUtil.candidates(
+ "wlan0", "192.168.6.200",
+ "eth0", "192.168.6.133")));
+ }
+
+ @Test
+ public void fallsBackToWifiWhenNoEthernet() {
+ assertEquals("192.168.6.200", DeviceIpUtil.pickDisplayAddress(
+ DeviceIpUtil.candidates("wlan0", "192.168.6.200")));
+ }
+
+ /** 虚拟/点对点接口写到界面上用户连不上,必须排除。 */
+ @Test
+ public void skipsVirtualInterfaces() {
+ assertEquals("192.168.6.200", DeviceIpUtil.pickDisplayAddress(
+ DeviceIpUtil.candidates(
+ "p2p0", "192.168.49.1",
+ "dummy0", "10.0.0.1",
+ "tun0", "10.8.0.2",
+ "wlan0", "192.168.6.200")));
+ }
+
+ @Test
+ public void usesUnknownInterfaceOnlyAsLastResort() {
+ assertEquals("192.168.6.133", DeviceIpUtil.pickDisplayAddress(
+ DeviceIpUtil.candidates(
+ "usb0", "192.168.42.1",
+ "eth0", "192.168.6.133")));
+ // 没有已知网卡时,仍然给出能用的那个,而不是退回占位符
+ assertEquals("192.168.42.1", DeviceIpUtil.pickDisplayAddress(
+ DeviceIpUtil.candidates("usb0", "192.168.42.1")));
+ }
+
+ @Test
+ public void returnsPlaceholderWhenNothingUsable() {
+ assertEquals(DeviceIpUtil.UNKNOWN_ADDRESS,
+ DeviceIpUtil.pickDisplayAddress(Collections.emptyList()));
+ assertEquals(DeviceIpUtil.UNKNOWN_ADDRESS, DeviceIpUtil.pickDisplayAddress(null));
+ assertEquals(DeviceIpUtil.UNKNOWN_ADDRESS, DeviceIpUtil.pickDisplayAddress(
+ DeviceIpUtil.candidates("lo", "127.0.0.1", "p2p0", "192.168.49.1")));
+ }
+
+ /**
+ * 开着 VPN 时 getActiveNetwork() 返回的是 VPN,它的 LinkProperties 给的是隧道地址。
+ * 把 tun0 的地址写到界面上,局域网里的手机根本连不上,而物理网卡的地址其实还好好的。
+ */
+ @Test
+ public void rejectsVpnAndOtherVirtualInterfacesAsEntryPoint() {
+ assertFalse(DeviceIpUtil.isUsableInterface("tun0"));
+ assertFalse(DeviceIpUtil.isUsableInterface("tap0"));
+ assertFalse(DeviceIpUtil.isUsableInterface("ppp0"));
+ assertFalse(DeviceIpUtil.isUsableInterface("p2p-wlan0-0"));
+ assertTrue(DeviceIpUtil.isUsableInterface("eth0"));
+ assertTrue(DeviceIpUtil.isUsableInterface("wlan0"));
+ // 拿不到接口名时不武断排除,交给后面的地址判断
+ assertTrue(DeviceIpUtil.isUsableInterface(null));
+ }
+
+ /** 界面据此决定还画不画二维码:把 0.0.0.0 编成码,扫出来是个连不上的地址。 */
+ @Test
+ public void reportsWhetherAddressWasActuallyResolved() {
+ assertTrue(DeviceIpUtil.isResolved("192.168.6.133"));
+ assertFalse(DeviceIpUtil.isResolved(DeviceIpUtil.UNKNOWN_ADDRESS));
+ assertFalse(DeviceIpUtil.isResolved(null));
+ assertFalse(DeviceIpUtil.isResolved(" "));
+ }
+
+ @Test
+ public void matchesInterfaceNamesCaseInsensitively() {
+ assertEquals("192.168.6.133", DeviceIpUtil.pickDisplayAddress(
+ DeviceIpUtil.candidates(
+ "WLAN0", "192.168.6.200",
+ "ETH0", "192.168.6.133")));
+ }
+
+ @Test
+ public void toleratesNullInterfaceName() {
+ assertEquals("192.168.6.133", DeviceIpUtil.pickDisplayAddress(
+ new ArrayList<>(DeviceIpUtil.candidates("eth0", "192.168.6.133"))));
+ }
+}
diff --git a/app/src/test/java/com/whyun/witv/server/QrCodeUtilTest.java b/app/src/test/java/com/whyun/witv/server/QrCodeUtilTest.java
new file mode 100644
index 0000000..736f8dc
--- /dev/null
+++ b/app/src/test/java/com/whyun/witv/server/QrCodeUtilTest.java
@@ -0,0 +1,105 @@
+package com.whyun.witv.server;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.zxing.BinaryBitmap;
+import com.google.zxing.LuminanceSource;
+import com.google.zxing.Result;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.common.HybridBinarizer;
+import com.google.zxing.qrcode.QRCodeReader;
+
+import org.junit.Test;
+
+public class QrCodeUtilTest {
+
+ /** 码必须真的能被扫出来——只断言「生成了非空矩阵」没有意义。 */
+ @Test
+ public void encodesScannableWebAddress() throws Exception {
+ String url = WebServer.buildUrl("192.168.6.133");
+ BitMatrix matrix = QrCodeUtil.encodeMatrix(url, 220);
+ assertNotNull(matrix);
+ assertEquals(url, decode(matrix));
+ }
+
+ @Test
+ public void encodesAtSmallSettingsPanelSize() throws Exception {
+ String url = WebServer.buildUrl("10.0.0.2");
+ assertEquals(url, decode(QrCodeUtil.encodeMatrix(url, 132)));
+ }
+
+ /** 输出是正方形,且不小于请求尺寸,否则贴到固定尺寸的 ImageView 上会被放大发虚。 */
+ @Test
+ public void producesSquareMatrixNoSmallerThanRequested() {
+ BitMatrix matrix = QrCodeUtil.encodeMatrix("http://192.168.6.133:9979", 220);
+ assertNotNull(matrix);
+ assertEquals(matrix.getWidth(), matrix.getHeight());
+ assertTrue(matrix.getWidth() >= 220);
+ }
+
+ /** 静区必须留白:这是贴在深色背景上的小图,没有白边很多手机扫不出来。 */
+ @Test
+ public void keepsQuietZoneClear() {
+ BitMatrix matrix = QrCodeUtil.encodeMatrix("http://192.168.6.133:9979", 220);
+ assertNotNull(matrix);
+ assertFalse(matrix.get(0, 0));
+ assertFalse(matrix.get(matrix.getWidth() - 1, matrix.getHeight() - 1));
+ }
+
+ /** IP 没解析出来(显示 0.0.0.0)或尺寸还没测量时返回 null,调用方据此隐藏 ImageView。 */
+ @Test
+ public void returnsNullOnUnusableInput() {
+ assertNull(QrCodeUtil.encodeMatrix(null, 220));
+ assertNull(QrCodeUtil.encodeMatrix("", 220));
+ assertNull(QrCodeUtil.encodeMatrix(" ", 220));
+ assertNull(QrCodeUtil.encodeMatrix("http://192.168.6.133:9979", 0));
+ assertNull(QrCodeUtil.encodeMatrix("http://192.168.6.133:9979", -10));
+ }
+
+ private static String decode(BitMatrix matrix) throws Exception {
+ assertNotNull(matrix);
+ BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(new MatrixLuminanceSource(matrix)));
+ Result result = new QRCodeReader().decode(bitmap);
+ return result.getText();
+ }
+
+ /** 把模块矩阵当成灰度图喂给解码器:置位的模块是黑(0),其余是白(255)。 */
+ private static final class MatrixLuminanceSource extends LuminanceSource {
+ private final BitMatrix matrix;
+
+ MatrixLuminanceSource(BitMatrix matrix) {
+ super(matrix.getWidth(), matrix.getHeight());
+ this.matrix = matrix;
+ }
+
+ @Override
+ public byte[] getRow(int y, byte[] row) {
+ int width = getWidth();
+ if (row == null || row.length < width) {
+ row = new byte[width];
+ }
+ for (int x = 0; x < width; x++) {
+ row[x] = (byte) (matrix.get(x, y) ? 0 : 0xFF);
+ }
+ return row;
+ }
+
+ @Override
+ public byte[] getMatrix() {
+ int width = getWidth();
+ int height = getHeight();
+ byte[] pixels = new byte[width * height];
+ for (int y = 0; y < height; y++) {
+ int offset = y * width;
+ for (int x = 0; x < width; x++) {
+ pixels[offset + x] = (byte) (matrix.get(x, y) ? 0 : 0xFF);
+ }
+ }
+ return pixels;
+ }
+ }
+}
diff --git a/app/src/test/java/com/whyun/witv/server/WebServerTest.java b/app/src/test/java/com/whyun/witv/server/WebServerTest.java
new file mode 100644
index 0000000..376dec0
--- /dev/null
+++ b/app/src/test/java/com/whyun/witv/server/WebServerTest.java
@@ -0,0 +1,111 @@
+package com.whyun.witv.server;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+public class WebServerTest {
+
+ /**
+ * 这个方法存在的直接原因:原来走 {@code session.parseBody()},NanoHTTPD 用
+ * {@code ContentType.getEncoding()} 解码,而它在 Content-Type 不带 charset 时默认
+ * US-ASCII,于是「北京」这样的列表名在 Web 页面保存后全变成 {@code ??????}。
+ */
+ @Test
+ public void decodesChineseBodyAsUtf8() throws IOException {
+ String json = "{\"name\":\"北京联通\"}";
+ byte[] raw = json.getBytes(StandardCharsets.UTF_8);
+ assertEquals(json, WebServer.readBodyFrom(new ByteArrayInputStream(raw), raw.length));
+ }
+
+ /** 一个汉字是 3 个 UTF-8 字节,长度必须按字节算而不是按字符算。 */
+ @Test
+ public void readsExactByteLengthNotCharLength() throws IOException {
+ String json = "{\"name\":\"央视\"}";
+ byte[] raw = json.getBytes(StandardCharsets.UTF_8);
+ assertEquals(json.length() + 4, raw.length);
+ assertEquals(json, WebServer.readBodyFrom(new ByteArrayInputStream(raw), raw.length));
+ }
+
+ /**
+ * {@code InputStream.read} 允许返回少于请求的字节数。若只读一次就收工,
+ * 多字节汉字会被从中间截断,照样乱码——所以必须循环读满。
+ */
+ @Test
+ public void readsFullBodyWhenStreamReturnsPartialChunks() throws IOException {
+ String json = "{\"name\":\"北京联通组播\",\"url\":\"http://example.com/a.m3u\"}";
+ byte[] raw = json.getBytes(StandardCharsets.UTF_8);
+ assertEquals(json, WebServer.readBodyFrom(new ChunkedInputStream(raw, 3), raw.length));
+ }
+
+ /** 流提前结束时按已读到的部分解码,而不是抛异常或返回整块空字节。 */
+ @Test
+ public void toleratesTruncatedStream() throws IOException {
+ byte[] raw = "abc".getBytes(StandardCharsets.UTF_8);
+ assertEquals("abc", WebServer.readBodyFrom(new ByteArrayInputStream(raw), 16));
+ }
+
+ /** GET / DELETE 没有请求体,不能因为缺少 Content-Length 就报错。 */
+ @Test
+ public void returnsEmptyWithoutBody() throws IOException {
+ assertEquals("", WebServer.readBodyFrom(null, 10));
+ assertEquals("", WebServer.readBodyFrom(new ByteArrayInputStream(new byte[0]), 0));
+ assertEquals("", WebServer.readBodyFrom(new ByteArrayInputStream(new byte[0]), -1));
+ }
+
+ /**
+ * 服务监听在局域网上且没有鉴权,任何能连上的设备发一个超大的 Content-Length,
+ * 按长度预分配就能把应用 OOM 掉。必须在分配之前拒掉。
+ */
+ @Test
+ public void rejectsOversizedBodyWithoutAllocating() {
+ try {
+ WebServer.readBodyFrom(new ByteArrayInputStream(new byte[0]), 2_000_000_000L);
+ fail("应当拒绝超大请求体");
+ } catch (IOException expected) {
+ assertTrue(expected.getMessage().contains("too large"));
+ }
+ // 正好卡在上限上要放过,不能把边界也拒了
+ try {
+ assertEquals("", WebServer.readBodyFrom(
+ new ByteArrayInputStream(new byte[0]), WebServer.MAX_BODY_BYTES));
+ } catch (IOException e) {
+ throw new AssertionError("上限之内不应拒绝", e);
+ }
+ }
+
+ /** 每次 read 最多吐出固定字节数,模拟 socket 的分片到达。 */
+ private static final class ChunkedInputStream extends InputStream {
+ private final byte[] data;
+ private final int chunkSize;
+ private int position;
+
+ ChunkedInputStream(byte[] data, int chunkSize) {
+ this.data = data;
+ this.chunkSize = chunkSize;
+ }
+
+ @Override
+ public int read() {
+ return position < data.length ? data[position++] & 0xFF : -1;
+ }
+
+ @Override
+ public int read(byte[] buffer, int offset, int length) {
+ if (position >= data.length) {
+ return -1;
+ }
+ int count = Math.min(Math.min(chunkSize, length), data.length - position);
+ System.arraycopy(data, position, buffer, offset, count);
+ position += count;
+ return count;
+ }
+ }
+}
diff --git a/docs/images/channel-list.jpg b/docs/images/channel-list.jpg
new file mode 100644
index 0000000..fc32564
Binary files /dev/null and b/docs/images/channel-list.jpg differ
diff --git a/docs/images/exit-dialog.jpg b/docs/images/exit-dialog.jpg
new file mode 100644
index 0000000..9d4766d
Binary files /dev/null and b/docs/images/exit-dialog.jpg differ
diff --git a/docs/images/first-run.png b/docs/images/first-run.png
new file mode 100644
index 0000000..7f642ee
Binary files /dev/null and b/docs/images/first-run.png differ
diff --git a/docs/images/help-keys.png b/docs/images/help-keys.png
new file mode 100644
index 0000000..8368726
Binary files /dev/null and b/docs/images/help-keys.png differ
diff --git a/docs/images/info-overlay.jpg b/docs/images/info-overlay.jpg
new file mode 100644
index 0000000..9b9eb8c
Binary files /dev/null and b/docs/images/info-overlay.jpg differ
diff --git a/docs/images/settings-decoder.png b/docs/images/settings-decoder.png
new file mode 100644
index 0000000..2aca633
Binary files /dev/null and b/docs/images/settings-decoder.png differ
diff --git a/docs/images/settings-epg.png b/docs/images/settings-epg.png
new file mode 100644
index 0000000..005b2f7
Binary files /dev/null and b/docs/images/settings-epg.png differ
diff --git a/docs/images/settings-menu.jpg b/docs/images/settings-menu.jpg
new file mode 100644
index 0000000..2726372
Binary files /dev/null and b/docs/images/settings-menu.jpg differ
diff --git a/docs/images/settings-multicast-proxy.jpg b/docs/images/settings-multicast-proxy.jpg
new file mode 100644
index 0000000..5d7b332
Binary files /dev/null and b/docs/images/settings-multicast-proxy.jpg differ
diff --git a/docs/images/settings-multicast.png b/docs/images/settings-multicast.png
new file mode 100644
index 0000000..a7a4b2a
Binary files /dev/null and b/docs/images/settings-multicast.png differ
diff --git a/docs/images/settings-playback.png b/docs/images/settings-playback.png
new file mode 100644
index 0000000..d78c75d
Binary files /dev/null and b/docs/images/settings-playback.png differ
diff --git a/docs/images/settings-sources.jpg b/docs/images/settings-sources.jpg
new file mode 100644
index 0000000..a191385
Binary files /dev/null and b/docs/images/settings-sources.jpg differ
diff --git a/docs/images/web-home.png b/docs/images/web-home.png
new file mode 100644
index 0000000..f465425
Binary files /dev/null and b/docs/images/web-home.png differ
diff --git a/docs/images/web-settings.png b/docs/images/web-settings.png
new file mode 100644
index 0000000..d9d55fb
Binary files /dev/null and b/docs/images/web-settings.png differ
diff --git a/docs/multicast-udp-rtp.md b/docs/multicast-udp-rtp.md
index 779186e..ed6e1c0 100644
--- a/docs/multicast-udp-rtp.md
+++ b/docs/multicast-udp-rtp.md
@@ -19,7 +19,7 @@ udp://239.1.1.1:1234 → http://192.168.1.1:4022/udp/239.1.1.1:1234
配置入口(两处等价,改哪边都生效):
- **TV 端**:设置 → 组播 / UDP → 填写 udpxy 地址 → 保存
-- **Web 管理页**:浏览器打开 `http://<设备IP>:9978` → 设置 → 组播转单播代理 (udpxy)
+- **Web 管理页**:浏览器打开 `http://<设备IP>:9979` → 设置 → 组播转单播代理 (udpxy)
遥控器输入 URL 很痛苦,建议用 Web 管理页填。
@@ -225,11 +225,26 @@ Trying source 1/2: rtp://239.1.1.1:1234 (via udpxy: http://192.168.1.1:4022/rtp/
设置 → 解码方式:
-| 选项 | 行为 | 适用 |
-|---|---|---|
-| 硬解优先(默认) | 平台硬解优先,硬解不支持的编码自动用软解 | 绝大多数情况 |
-| 软解优先 | FFmpeg 软解优先 | 硬解声称支持但实际黑屏/无声的盒子 |
-| 仅硬解 | 完全不加载软解渲染器 | 排查软解自身问题,或极低端 CPU |
+| 选项 | 音频 | 视频 | 适用 |
+|---|---|---|---|
+| 硬解优先(默认) | 硬解优先 | 硬解优先 | 绝大多数情况 |
+| **音频软解 + 视频硬解** | **软解优先** | 硬解优先 | **杜比声道没声音、但视频正常**的设备 |
+| 全部软解优先 | 软解优先 | 软解优先 | 硬解声称支持但实际黑屏的盒子;CPU 占用高 |
+| 仅硬解 | 不加载 | 不加载 | 排查软解自身问题 |
+
+音视频分开取值由 `WiTVRenderersFactory` 落实——`DefaultRenderersFactory` 本身只有一个全局的
+`extensionRendererMode`,而音视频的最佳取舍常常相反。
+
+### 杜比没声音怎么办
+
+盒子声称支持 AC-3/E-AC-3 **直通**时,`MediaCodecAudioRenderer` 会以直通方式胜出、
+压根不解码,把原始码流丢给 HDMI;下游电视/功放解不了就是完全没声音,
+而且这条路径下 FFmpeg 永远没机会出手。
+
+选 **「音频软解 + 视频硬解」**:`FfmpegAudioRenderer` 排到 `MediaCodecAudioRenderer` 之前,
+AC-3 被解成 PCM,任何 HDMI 设备都能出声;视频仍走硬解,不会因软解 1080p 跟不上而卡顿。
+
+不要为此选「全部软解优先」——那会把视频一起转软解,老盒子上必然掉帧。
另外无论选哪档都开启了 `setEnableDecoderFallback(true)`:某个 MediaCodec 解码器 `configure`
失败时,依次尝试**同一渲染器里的其它 MediaCodec 解码器**(例如 `c2.android.avc.decoder`
diff --git a/docs/user-guide.md b/docs/user-guide.md
new file mode 100644
index 0000000..4726e28
--- /dev/null
+++ b/docs/user-guide.md
@@ -0,0 +1,214 @@
+# WiTV 使用手册
+
+WiTV 是一款运行在 Android TV / 电视盒子上的 IPTV 直播播放器。频道来自你自己提供的 M3U 地址,
+节目单来自 XMLTV,支持运营商组播(`udp://` / `rtp://`)与普通 HTTP 直播流。
+
+本手册中的截图取自真实设备(Amlogic 盒子,Android 7,有线连接)。
+
+---
+
+## 一、首次使用:添加播放源
+
+首次启动时没有任何频道,播放页会显示一个二维码和局域网管理地址:
+
+
+
+频道地址不在电视上输入——用遥控器敲 URL 太痛苦。做法是用**手机或电脑**打开那个地址:
+
+- 手机:直接扫屏幕上的二维码;
+- 电脑:在浏览器里输入下方的地址,例如 `http://192.168.6.134:9979`。
+
+> 地址里的 IP 是盒子自己的局域网地址,接网线和连 Wi-Fi 都能正确识别。
+> 如果显示的是 `0.0.0.0`,说明盒子还没拿到 IP,检查网线或 Wi-Fi 后点「刷新」。
+> 手机/电脑必须和盒子在同一个局域网里。
+
+打开后是 Web 管理页:
+
+
+
+在「添加播放源」里填:
+
+| 字段 | 说明 |
+|------|------|
+| 名称 | 随便起,用来区分多个订阅,例如「北京联通组播」。支持中文 |
+| M3U 地址 | 直播源的 m3u / m3u8 地址,必填。也支持 `.m3u.gz` 压缩地址 |
+
+点「添加」后会立即拉取并解析,解析完成就能在同一页看到频道列表与分组。
+
+回到电视上点「刷新」,频道就加载进来了。
+
+---
+
+## 二、看电视
+
+### 播放画面与信息
+
+按**信息键**(或空格)显示/隐藏底部的节目与信号面板:
+
+
+
+左下是当前频道、当前节目与下一档节目;右下是实际解出来的信号参数——分辨率、视频编码、
+音频编码与声道数。排查「有画面没声音」「画面卡顿」时先看这里。
+
+### 频道列表
+
+按**确认键**打开频道列表:
+
+
+
+- 左栏是分组(含「我的收藏」与「全部频道」),**左/右键**在分组栏和频道栏之间切换;
+- 中间是频道,右侧是该频道当天的完整节目单;
+- 在频道列表中**长按确认键**可以收藏/取消收藏当前这一行。
+
+### 换台的几种方式
+
+| 操作 | 效果 |
+|------|------|
+| 上 / 下键 | 切换上一个 / 下一个频道(方向可在设置里反转) |
+| 数字键 | 直接输入频道号跳转,例如按 `1` `5` 跳到 15 号 |
+| 确认键 | 打开频道列表挑选 |
+| 收藏键 / `F` | 收藏或取消收藏当前频道 |
+
+### 退出
+
+在播放页按返回键会先确认一次,避免误触退出:
+
+
+
+---
+
+## 三、设置
+
+按**菜单键**(或 `F6`)打开设置,左右两栏:右侧是分类,选中后左侧展开该分类的内容。
+返回键先收起当前分类,再按一次才关闭设置。
+
+
+
+### 地址管理
+
+
+
+这里能看到管理地址的二维码、已添加的播放源和当前正在使用的那一个。增删改仍然在 Web 页面上做。
+
+### EPG(节目单)
+
+
+
+填 XMLTV 地址即可,**支持 `.xml.gz` 压缩地址**(公开 EPG 源基本都是 gz,几十 MB 的节目单压缩后只有几 MB)。
+如果 M3U 里带了 `x-tvg-url`,这一栏会自动填好,一般不用手动改。
+
+改完按「保存 EPG 设置」,再按「刷新 EPG 数据」立即拉取。
+
+### 组播 / UDP
+
+
+
+频道地址是 `udp://` 或 `rtp://` 时才用得上:
+
+- **留空**:直接收组播。要求盒子所在的网络能收到运营商组播,**强烈建议走网线**——
+ 2.4G Wi-Fi 常常丢大包,表现为花屏和不断缓冲;
+- **填 udpxy 地址**(如 `http://192.168.1.1:4022`):组播改走 HTTP 单播,由路由器/软路由代理。
+ 适合盒子收不到组播、或只能用 Wi-Fi 的场景。
+
+填好按「保存代理地址」。输入框下方会实时显示改写后的结果,确认无误再保存:
+
+
+
+改写规则是把 `scheme://组播组:端口` 拼成 `代理前缀/scheme/组播组:端口`,例如
+
+```
+rtp://239.3.1.118:8001 → http://192.168.6.1:5140/rtp/239.3.1.118:8001
+```
+
+地址前缀不用写 `/udp` 或 `/rtp`,也不用在意结尾的 `/`,保存时会统一规范化;
+`http://` 省略也能识别。
+
+> **保存后不会立刻切换**,提示是「下次换台生效」——当前这一路组播不中断,换台时才走新配置。
+
+**不是只有 udpxy 能用。** 任何提供 udpxy 兼容路径的软件都行,例如 OpenWrt 上常见的
+[rtp2httpd](https://github.com/stackia/rtp2httpd),它同样接受 `/rtp/组:端口` 和 `/udp/组:端口`。
+
+#### 代理和直收,选哪个
+
+在 Amlogic 盒子(有线千兆)上实测同一路北京联通组播,两种方式各跑 60 秒:
+
+| | 起播耗时 | 重缓冲 | 硬解器 `error_recovery` |
+|---|---|---|---|
+| 直收组播 | 2.1 s | 0 | 6 次 |
+| 经 rtp2httpd 代理 | 2.3 s | 0 | 9 ~ 12 次 |
+
+测试频道是 3840×2160 HDR / H.265 / 杜比数字 6 声道,实测码率约 40 Mbps;
+1080p H.264 频道两种方式都是秒开。
+
+结论是**有线环境下两者都够用**,代理那点差异在一分钟的样本里还算不上信号。
+真正该用代理的是这两种情况:盒子所在网段收不到运营商组播,或者只能走 Wi-Fi。
+
+### 解码方式
+
+
+
+| 选项 | 什么时候用 |
+|------|-----------|
+| 硬解优先(推荐) | 默认。优先用盒子的硬件解码器 |
+| 音频软解 + 视频硬解 | **杜比声道没声音时选这个**。音频交给 FFmpeg,绕开杜比直通;视频仍走硬解,不增加 CPU 负担 |
+| 全部软解优先 | 音视频都用 FFmpeg。CPU 占用高,低端盒子上 1080p 以上会卡 |
+| 仅硬解 | 完全不加载软解,用于排查软解自身引入的问题 |
+
+切换后播放器会重建,画面会黑一下再恢复,属于正常现象。
+
+### 播放选项
+
+
+
+- **启动播放上次频道**:开机直接进上次看的台;
+- **启动时刷新 M3U 直播源**:每次进播放页先刷新订阅再起播,刷新失败仍回退到本地缓存;
+- **使用硬盘缓存预取直播分片**:为 HLS 的 ts 分片启用磁盘预取与缓存命中;
+- **播放页显示视频加载速度**:右上角实时显示估算速度,排查网络时有用;
+- **反转换台键**:上/下键的换台方向与默认相反。
+
+### 切换源 / 超时换源
+
+同一个频道在 M3U 里可能有多条播放地址。「切换源」用于手动在这几条之间切换;
+「超时换源」则是卡住多久之后自动换下一条。
+
+### 帮助与说明
+
+
+
+「媒体信息」显示当前播放的详细参数,「帮助说明」就是上图这份按键速查表,「关于 APP」显示版本号。
+
+---
+
+## 四、Web 管理页
+
+
+
+除了增删播放源,Web 页面还能:
+
+- **刷新**单个订阅、查看它解析出的**频道**列表;
+- 点频道旁边的星标**收藏**,收藏结果会同步到电视上的「我的收藏」分组;
+- 在页面底部配置 **EPG 地址**与**组播转单播代理**,和电视上的设置是同一份数据。
+
+---
+
+## 五、常见问题
+
+**管理地址显示 `0.0.0.0`**
+盒子还没拿到局域网 IP。检查网线/Wi-Fi,确认路由器已分配地址,然后点「刷新」。
+
+**手机打不开管理地址**
+手机和盒子要在同一个局域网(同一个路由器,且没开 AP 隔离)。
+端口是 `9979`,注意不要漏掉。
+
+**杜比频道有画面没声音**
+设置 →「解码方式」→ 选「音频软解 + 视频硬解」。
+
+**组播频道花屏、一直缓冲**
+优先改用网线。只能用 Wi-Fi 时,在路由器/软路由上装 udpxy 或 rtp2httpd,然后在「组播 / UDP」里填它的地址。
+
+**节目单是空的**
+确认「EPG」里的地址可访问;地址可以是 `.xml` 也可以是 `.xml.gz`。
+另外 M3U 里频道的 `tvg-name` / `tvg-id` 要和 EPG 里的频道名对得上,对不上就会显示「暂无节目信息」。
+
+**列表名或频道名显示成乱码**
+升级到 v1.3.0 及以上版本。