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/images/epg.png) +完整的图文使用说明见 [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 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},音视频共用。 + * 但这两者的最佳取舍常常相反,实测遇到过的典型情况是: + * + *

+ * + *

所以这里分别覆写两个 {@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,有线连接)。 + +--- + +## 一、首次使用:添加播放源 + +首次启动时没有任何频道,播放页会显示一个二维码和局域网管理地址: + +![首次启动](images/first-run.png) + +频道地址不在电视上输入——用遥控器敲 URL 太痛苦。做法是用**手机或电脑**打开那个地址: + +- 手机:直接扫屏幕上的二维码; +- 电脑:在浏览器里输入下方的地址,例如 `http://192.168.6.134:9979`。 + +> 地址里的 IP 是盒子自己的局域网地址,接网线和连 Wi-Fi 都能正确识别。 +> 如果显示的是 `0.0.0.0`,说明盒子还没拿到 IP,检查网线或 Wi-Fi 后点「刷新」。 +> 手机/电脑必须和盒子在同一个局域网里。 + +打开后是 Web 管理页: + +![Web 管理页](images/web-home.png) + +在「添加播放源」里填: + +| 字段 | 说明 | +|------|------| +| 名称 | 随便起,用来区分多个订阅,例如「北京联通组播」。支持中文 | +| M3U 地址 | 直播源的 m3u / m3u8 地址,必填。也支持 `.m3u.gz` 压缩地址 | + +点「添加」后会立即拉取并解析,解析完成就能在同一页看到频道列表与分组。 + +回到电视上点「刷新」,频道就加载进来了。 + +--- + +## 二、看电视 + +### 播放画面与信息 + +按**信息键**(或空格)显示/隐藏底部的节目与信号面板: + +![播放信息面板](images/info-overlay.jpg) + +左下是当前频道、当前节目与下一档节目;右下是实际解出来的信号参数——分辨率、视频编码、 +音频编码与声道数。排查「有画面没声音」「画面卡顿」时先看这里。 + +### 频道列表 + +按**确认键**打开频道列表: + +![频道列表](images/channel-list.jpg) + +- 左栏是分组(含「我的收藏」与「全部频道」),**左/右键**在分组栏和频道栏之间切换; +- 中间是频道,右侧是该频道当天的完整节目单; +- 在频道列表中**长按确认键**可以收藏/取消收藏当前这一行。 + +### 换台的几种方式 + +| 操作 | 效果 | +|------|------| +| 上 / 下键 | 切换上一个 / 下一个频道(方向可在设置里反转) | +| 数字键 | 直接输入频道号跳转,例如按 `1` `5` 跳到 15 号 | +| 确认键 | 打开频道列表挑选 | +| 收藏键 / `F` | 收藏或取消收藏当前频道 | + +### 退出 + +在播放页按返回键会先确认一次,避免误触退出: + +![退出确认](images/exit-dialog.jpg) + +--- + +## 三、设置 + +按**菜单键**(或 `F6`)打开设置,左右两栏:右侧是分类,选中后左侧展开该分类的内容。 +返回键先收起当前分类,再按一次才关闭设置。 + +![设置菜单](images/settings-menu.jpg) + +### 地址管理 + +![地址管理](images/settings-sources.jpg) + +这里能看到管理地址的二维码、已添加的播放源和当前正在使用的那一个。增删改仍然在 Web 页面上做。 + +### EPG(节目单) + +![EPG 设置](images/settings-epg.png) + +填 XMLTV 地址即可,**支持 `.xml.gz` 压缩地址**(公开 EPG 源基本都是 gz,几十 MB 的节目单压缩后只有几 MB)。 +如果 M3U 里带了 `x-tvg-url`,这一栏会自动填好,一般不用手动改。 + +改完按「保存 EPG 设置」,再按「刷新 EPG 数据」立即拉取。 + +### 组播 / UDP + +![组播设置](images/settings-multicast.png) + +频道地址是 `udp://` 或 `rtp://` 时才用得上: + +- **留空**:直接收组播。要求盒子所在的网络能收到运营商组播,**强烈建议走网线**—— + 2.4G Wi-Fi 常常丢大包,表现为花屏和不断缓冲; +- **填 udpxy 地址**(如 `http://192.168.1.1:4022`):组播改走 HTTP 单播,由路由器/软路由代理。 + 适合盒子收不到组播、或只能用 Wi-Fi 的场景。 + +填好按「保存代理地址」。输入框下方会实时显示改写后的结果,确认无误再保存: + +![配置了代理的组播设置](images/settings-multicast-proxy.jpg) + +改写规则是把 `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。 + +### 解码方式 + +![解码方式](images/settings-decoder.png) + +| 选项 | 什么时候用 | +|------|-----------| +| 硬解优先(推荐) | 默认。优先用盒子的硬件解码器 | +| 音频软解 + 视频硬解 | **杜比声道没声音时选这个**。音频交给 FFmpeg,绕开杜比直通;视频仍走硬解,不增加 CPU 负担 | +| 全部软解优先 | 音视频都用 FFmpeg。CPU 占用高,低端盒子上 1080p 以上会卡 | +| 仅硬解 | 完全不加载软解,用于排查软解自身引入的问题 | + +切换后播放器会重建,画面会黑一下再恢复,属于正常现象。 + +### 播放选项 + +![播放选项](images/settings-playback.png) + +- **启动播放上次频道**:开机直接进上次看的台; +- **启动时刷新 M3U 直播源**:每次进播放页先刷新订阅再起播,刷新失败仍回退到本地缓存; +- **使用硬盘缓存预取直播分片**:为 HLS 的 ts 分片启用磁盘预取与缓存命中; +- **播放页显示视频加载速度**:右上角实时显示估算速度,排查网络时有用; +- **反转换台键**:上/下键的换台方向与默认相反。 + +### 切换源 / 超时换源 + +同一个频道在 M3U 里可能有多条播放地址。「切换源」用于手动在这几条之间切换; +「超时换源」则是卡住多久之后自动换下一条。 + +### 帮助与说明 + +![按键说明](images/help-keys.png) + +「媒体信息」显示当前播放的详细参数,「帮助说明」就是上图这份按键速查表,「关于 APP」显示版本号。 + +--- + +## 四、Web 管理页 + +![Web 管理页设置区](images/web-settings.png) + +除了增删播放源,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 及以上版本。