修复杜比无声/解码切换报错/中文乱码,支持 gz 地址与二维码,并补上使用手册 - #8
Conversation
Dolby channels played no sound: the box advertises AC-3/E-AC-3 passthrough, so MediaCodecAudioRenderer wins supportsFormat in passthrough mode and never decodes, while the HDMI sink downstream cannot decode it either. FFmpeg never gets a chance on that path. Switching to the existing "software first" mode restored audio but also moved video onto FfmpegVideoRenderer, and this box cannot software-decode 1080p H.264 in real time, so playback stuttered instead. The two tracks want opposite answers, but DefaultRenderersFactory has a single extensionRendererMode for both. WiTVRenderersFactory overrides buildAudioRenderers and buildVideoRenderers separately so each gets its own mode, and PlaybackDecoderMode now carries one value per track. Adds a "software audio, hardware video" option for exactly this case, and renames the old mode to make clear it covers both tracks. WiTVRenderersFactoryTest asserts the resulting renderer order rather than the configured constants: the insertion logic lives in NextLib, so a version bump could silently change it. 213 unit tests pass; assembleDebug and assembleRelease both succeed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…P on Ethernet
Two defects found on a real Amlogic box (Phicomm p230, Android 7.1), both
present in the released v1.3.0.
Switching decoder mode killed playback with ERROR_CODE_DECODER_INIT_FAILED.
Rebuilding released the old player first and only then called
playerView.setPlayer(newPlayer), but PlayerView clears the video surface off
the *old* player at that point. Messages to a released player are dropped
("Ignoring messages sent after release"), so the surface stayed connected and
the new codec could not attach: native_window_api_connect returned -22, and
every decoder in the fallback list failed in turn. The surface is now detached
while the old player is still alive, in reinitializeForDecoderModeChange and in
release.
The web admin address rendered as 0.0.0.0:9978 because both copies of
getDeviceIp only read WifiManager.getConnectionInfo().getIpAddress(), which is
0 over Ethernet — the normal way a TV box is wired. DeviceIpUtil asks
ConnectivityManager for the active network first and falls back to enumerating
interfaces, preferring wired over wireless and skipping p2p/dummy/tun.
The surface ordering is not unit tested: PlayerView needs Media3 theme
resources that Robolectric does not provide, which is why PlayerManagerTest
already injects a player by reflection. It needs on-device verification.
221 unit tests pass; assembleDebug succeeds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
在电视上用遥控器敲 URL 很痛苦,所以播放页和设置页各放一个二维码,手机扫一下就能打开 管理页。二维码固定黑白、留 2 个模块的静区——贴在深色背景上的小图没有白边很多手机扫不出来。 端口从 9978 改到 9979,同时把这个数字收敛成 WebServer.PORT / buildUrl():它此前散落在 Application、播放页、设置页三处硬编码,改端口要同时改三处且很容易漏。 zxing 只引 core(无运行时传递依赖),不引带 Android 相机扫码的 android-core。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
公开 EPG 源基本都只提供 .xml.gz——XMLTV 节目单动辄几十 MB,压缩后只有几 MB。这类地址 返回的是 Content-Type: application/gzip 而非 Content-Encoding: gzip,OkHttp 的透明解压 不会生效,拿到的是裸 gzip 字节,解析器直接报错。 判断依据是魔数 0x1F 0x8B 而不是 URL 后缀或 Content-Type:有的源地址不带 .gz 却返回 gzip, 有的把 Content-Type 标成 text/xml,两者都不可靠;而 XML 与 m3u 都不可能以这两个字节开头。 m3u 同样走这条路径,顺带支持 .m3u.gz。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
在管理页给播放源起中文名,存下来会变成一串 ?:NanoHTTPD 的 session.parseBody() 内部是 new String(postBytes, contentType.getEncoding()),而 getEncoding() 在 Content-Type 不带 charset 时默认返回 US-ASCII;浏览器 fetch 发 application/json 时正是不带 charset 的。 两个汉字 6 个 UTF-8 字节,于是屏幕上就是 6 个问号。 改成自己按 Content-Length 读原始字节再按 UTF-8 解码,不依赖请求头——请求头是客户端说了算的, 服务端不该因为对方没写 charset 就把中文丢掉。读取要循环读满:InputStream.read 允许返回少于 请求的字节数,多字节汉字被从中间截断同样是乱码。 app.js 也补上 charset=utf-8,顺带给错误响应补上同样的 charset。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
截图取自真实设备(Amlogic 盒子 / Android 7 / 有线),覆盖首次添加播放源、看电视、 各设置分类与 Web 管理页;带画面的几张存成 JPEG,纯 UI 的存成 256 色 PNG,整套约 600 KB。 README 顶部加一行指向它,并把两处残留的 9978 改成 9979。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
补上实测:在 Amlogic 盒子(有线千兆)上,同一路 40 Mbps 的 4K HDR 组播直收与经代理各跑 60 秒, 起播 2.1s vs 2.3s、重缓冲均为 0、硬解器 error_recovery 6 次 vs 9~12 次——有线环境下两者都够用, 代理真正的适用场景是收不到组播或只能走 Wi-Fi。 同时写清三件用户会踩的事:改写规则长什么样、保存后要换台才生效、以及不止 udpxy 能用 (rtp2httpd 等任何兼容 udpxy 路径的实现都行,本次实测用的就是它)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e6a446bf5d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| int remaining = (int) Math.min(contentLength, Integer.MAX_VALUE); | ||
| byte[] body = new byte[remaining]; |
There was a problem hiding this comment.
Cap request bodies before allocating them
Any client that can reach the LAN server can send a very large Content-Length, and this code immediately allocates that many bytes—up to Integer.MAX_VALUE—before reading the body. A request containing only a huge length header can therefore throw an uncaught OutOfMemoryError and terminate the Android app; reject oversized JSON bodies or read them through a bounded buffer instead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
已修(d0cfb9c)。确实是真问题:服务监听在局域网上且没有鉴权,一个 Content-Length: 2000000000 就能把应用 OOM 掉。
加了 MAX_BODY_BYTES = 1 MiB,并且在分配之前判断——先 new byte[...] 再检查的话这个检查本身就白写了。这个接口最大的一条请求也就几百字节,1 MiB 已经宽裕得离谱。
补了测试覆盖超限拒绝和边界值放行。
| LinkProperties props = cm.getLinkProperties(active); | ||
| if (props == null) { | ||
| return null; | ||
| } | ||
| for (LinkAddress linkAddress : props.getLinkAddresses()) { | ||
| InetAddress address = linkAddress.getAddress(); | ||
| if (isUsableIpv4(address)) { | ||
| return address.getHostAddress(); |
There was a problem hiding this comment.
Exclude VPN addresses from the displayed management URL
When an Android TV has an active VPN, getActiveNetwork() resolves to the VPN and these lines return its tunnel IPv4 address immediately. The later interface enumeration explicitly excludes tun*, but is never reached, so the displayed URL and QR code point to an address that another LAN device cannot reach even though the physical Ethernet/Wi-Fi address remains usable; filter the active network's interface or prefer a physical-interface candidate for this inbound URL.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
已修(d0cfb9c)。看漏了这条执行顺序:fromActiveNetwork() 在枚举网卡之前就返回了,后面排除 tun* 的逻辑根本到不了。
改成先用 props.getInterfaceName() 过滤,虚拟接口一律返回 null 退回枚举;顺带把 tap / ppp 也补进排除名单。拿不到接口名时不武断排除,交给后面的地址判断。
| static BitMatrix encodeMatrix(@Nullable String content, int sizePx) { | ||
| if (content == null || content.trim().isEmpty() || sizePx <= 0) { | ||
| return null; |
There was a problem hiding this comment.
Reject the unknown address when generating a QR code
Before DHCP has assigned an address, callers construct http://0.0.0.0:9979, but this validation accepts it and renders a visible QR code on both first-run and settings screens. Scanning that code targets the phone's unusable 0.0.0.0 address; treat DeviceIpUtil.UNKNOWN_ADDRESS as unavailable and hide the QR until a real address is resolved.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
已修(d0cfb9c)。同意「比不给码更误导人」这个判断——扫出来是个手机连不上的地址。
实现放在调用方而不是 QrCodeUtil:编码工具不该知道 DeviceIpUtil.UNKNOWN_ADDRESS 这种业务约定。DeviceIpUtil 新增 isResolved(),播放页和设置页在 IP 没解析出来时传 null,复用现有的 null 分支隐藏二维码。地址文字仍然显示,用户据此知道是网络没就绪而不是功能坏了。
| private static String 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.toString("UTF-8"); |
There was a problem hiding this comment.
Preserve the playlist response charset
For an uncompressed M3U served with a declared non-UTF-8 charset, such as the GBK playlists commonly used for Chinese IPTV, this hardcoded conversion replaces channel and group names with invalid characters. The previous ResponseBody.string() path honored the response charset and BOM, so adding gzip support regresses those existing sources; retain the response body's declared/BOM-detected charset when decoding the decompressed bytes.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
已修(d0cfb9c)。这条是我自己引入的回归,抓得准。
原来是 ResponseBody.string(),它按「BOM > Content-Type 声明 > UTF-8」解码;我为了支持 gzip 改成自己读流时写死了 UTF-8,GBK 的直播源(国内列表里相当常见)频道名会整片变成乱码。
抽了 BomAwareText 把那套规则补回来,BOM 优先于声明(BOM 是文件自己带的,Content-Type 常常是服务器随手填的默认值),并且 BOM 字节必须吃掉——留着的话字符串以 U+FEFF 开头,解析器连 #EXTM3U 都认不出来。测试覆盖了 GBK、BOM 覆盖声明、BOM 剥离和短输入。
| int sizePx = itemView.getResources() | ||
| .getDimensionPixelSize(R.dimen.settings_web_qr_size); | ||
| Bitmap bitmap = QrCodeUtil.encode(row.url, sizePx); |
There was a problem hiding this comment.
Generate the settings QR at its drawable size
The settings ImageView is 132dp wide but has 4dp padding on both sides, while this generates a bitmap for the full 132dp; every rendering therefore rescales the QR into a 124dp content area. That non-integer resampling blurs module edges in the deliberately small code and can make camera scanning unreliable; subtract the view padding or generate after measuring the drawable area, as the nearby comment in PlayerActivity.updateWebAddressQr intends.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
已修(d0cfb9c)。确实漏了——PlayerActivity.updateWebAddressQr 本来就减了 padding,设置页这份照抄时漏掉,现在按 132dp - paddingLeft - paddingRight 生成。
五条都成立,逐条核对后分别修掉: - **请求体上限(P1)**:readBodyFrom 按 Content-Length 预分配,而服务监听在局域网上且没有鉴权, 任何能连上的设备发一个 Content-Length: 2000000000 就能把应用 OOM 掉。加 1 MiB 上限, 且在分配之前判断——否则这个检查本身就白写了。 - **VPN 地址(P2)**:开着 VPN 时 getActiveNetwork() 返回的就是 VPN,这条路径在枚举网卡之前 就把隧道地址返回了,而后面排除 tun* 的逻辑根本到不了。改成先按接口名过滤,虚拟接口一律 退回枚举;顺带把 tap/ppp 也补进排除名单。 - **0.0.0.0 的二维码(P2)**:IP 还没解析出来时照样会编出 http://0.0.0.0:9979 的码, 扫出来是个手机连不上的地址,比不给码更误导人。两处调用点改为传 null,由现有的 null 分支隐藏二维码;地址文字仍然显示,用户据此知道是网络没就绪。 - **编码回归(P2)**:这条是我自己引入的。原来走 ResponseBody.string(),它按 「BOM > Content-Type 声明 > UTF-8」解码;加 gzip 支持时我改成了硬编码 UTF-8, GBK 编码的直播源(国内 IPTV 列表里相当常见)频道名会整片变成乱码。 抽出 BomAwareText 把那套规则补回来。 - **二维码尺寸(P2)**:设置页的 ImageView 是 132dp 但四周各有 4dp padding, 按 132dp 生成再塞进 124dp 会被非整数倍重采样。PlayerActivity 里本来就减了 padding, 设置页这份漏了,补上。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4K 频道之间换台时,盒子上会出现这样一串,然后此后**每一个**频道都起不来:
E MediaCodec: native_window_api_connect returned an error: Invalid argument (-22)
W MediaCodecRenderer: Failed to initialize decoder: OMX.amlogic.hevc.decoder.awesome
E PlayerManager: All sources failed
根因在 Surface 而不在流:上一个解码器(尤其 4K sideband 那种)释放是异步的,SurfaceView 的
BufferQueue 还停在它配置的格式上没断开(dumpsys SurfaceFlinger 里能看到那块 buffer 还是
3840x2160),新建的 MediaCodec 就连不上这块 Surface。
实测排除了几种看起来更简单的解释:换源没用(每一路都一样失败)、重建 ExoPlayer 没用
(PlayerView 复用的是同一个 SurfaceView)、前后台切换没用、静置 20 秒也没用——只有重启进程能恢复。
所以要拆的是 Surface 本身:把 PlayerView 收起来再放出来,逼 SurfaceView 走一遍
surfaceDestroyed / surfaceCreated,然后用新播放器原地重试当前这一路。
只重试一次:拆完还失败就是真解不了,继续重试没有意义。
ERROR_CODE_DECODER_INIT_FAILED 必须和「这路流坏了」分开——对前者换源是错的,
一路换到底只会把所有源都误判成坏的。
重建窗口有几百毫秒,期间 player 为空而用户完全可能换台,所以 playCurrentSource 之前
统一走 ensurePlayerReady 补建(第一版漏了这个,真机上换台直接 NPE 崩了)。
真机验证(Amlogic p230,有线,经 rtp2httpd 代理):4K 频道间反复换台 30 次,
触发 2 次 Surface 重建,均在约 2 秒内自愈,0 次「所有播放源均不可用」,0 崩溃。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
追加:4K 频道间换台后所有频道都放不出来真机上发现一个更严重的问题,已修(08b727e)。 现象:在 4K 频道之间换台时出现一串 此后每一个频道都起不来,界面停在「所有播放源均不可用」。 定位过程(逐个排除,不是猜的):
所以是进程级、且绑在 Surface 上。 上一个解码器(尤其 4K sideband 那种)释放是异步的,BufferQueue 没断开,新建的 MediaCodec 就连不上这块 Surface。重建 ExoPlayer 没用,是因为 修法:收到 第一版有坑:重建窗口有几百毫秒,期间 验证(Amlogic p230 / 有线 / 经 rtp2httpd 代理):4K 频道间反复换台 30 次(2 秒间隔混 4K 与 1080p),触发 2 次 Surface 重建,均在约 2 秒内自愈,0 次「所有播放源均不可用」,0 崩溃。
🤖 Generated with Claude Code |
Unreleased 一节此前只覆盖了分支最早的两个提交,后面七个(二维码与端口 9979、gz 地址、 UTF-8 请求体与体积上限、VPN 地址、Surface 重建恢复、使用手册)都没记; 顺带把那条还写着旧端口 9978 的描述改掉。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
这一批都是在真机(Amlogic p230 盒子 / Android 7 / 有线千兆)上发现并验证过的问题,外加一份图文使用手册。
改了什么
1. 音视频分别选解码器(
80eaa6f)盒子上杜比频道没声音,开「软解优先」声音出来了但视频开始卡。原因是音频和视频被绑在同一个开关上:
绕开杜比直通需要音频走 FFmpeg,而视频走 FFmpeg 在这种盒子上扛不住 1080p 以上。
拆成
PlaybackDecoderMode的四档,新增「音频软解 + 视频硬解」,WiTVRenderersFactory分别覆盖
buildAudioRenderers/buildVideoRenderers。2. 释放播放器前先摘掉 Surface(
bc70da6)切换解码方式时报
ERROR_CODE_DECODER_INIT_FAILED。旧播放器release()时 Surface 还挂在PlayerView 上,新播放器拿到的是已经
native_window_api_connect(-22)的那一个。同一个提交里还修了有线连接下 Web 地址显示成
0.0.0.0—— 原来只读WifiManager.getIpAddress(),电视盒子接网线是常态,该接口恒返回 0;改成先问
ConnectivityManager的活动网络,再退回枚举网卡。3. 二维码 + 端口改 9979(
2e26e62)在电视上用遥控器敲 URL 太痛苦,播放页和设置页各放一个二维码。端口顺带从 9978 改到 9979,
并把这个数字收敛成
WebServer.PORT/buildUrl()—— 它此前在 Application、播放页、设置页三处硬编码。4. 支持 gz 的 EPG / M3U 地址(
a3fa83a)公开 EPG 源基本只提供
.xml.gz。这类地址返回Content-Type: application/gzip而不是Content-Encoding: gzip,OkHttp 的透明解压不生效,拿到的是裸 gzip 字节。按魔数
0x1F 0x8B判断而不是 URL 后缀或 Content-Type:两者都不可靠,而 XML 与 m3u都不可能以这两个字节开头。
5. Web 请求体按 UTF-8 读(
2715cb2)在管理页给播放源起中文名,存下来是一串
?。session.parseBody()内部是new String(postBytes, contentType.getEncoding()),而getEncoding()在 Content-Type 不带charset 时默认返回 US-ASCII,浏览器
fetch发application/json时正是不带的。改成自己按 Content-Length 读原始字节再按 UTF-8 解码,不依赖请求头。
6. 图文使用手册(
bd935bd、e6a446b)docs/user-guide.md,13 张真机截图(带画面的存 JPEG,纯 UI 存 256 色 PNG,整套约 650 KB)。真机验证
播放源
https://iptv-sources2.pages.dev/q_bj_iptv_unicom_m.m3u(134 个频道,全是rtp://组播):??????)x-tvg-url指向http://epg.51zmt.top:8000/e.xml.gz,节目单全天正常显示DECODER_INIT_FAILED,新播放器起来后 FFmpeg 正常解 ac3组播转单播代理(实测用的是 rtp2httpd,兼容 udpxy 路径),同一路 4K 频道各跑 60 秒:
error_recovery有线环境下两者都够用,这点差异在一分钟的样本里算不上信号。
测试
253 个单元测试全绿,
assembleDebug通过。新增QrCodeUtilTest(zxing 往返解码,不是只断言「生成了非空矩阵」)、GzipAwareStreamsTest、WebServerTest(中文请求体 + 分片到达的短读)。已知遗留
切换解码方式时,旧播放器
release()会超时约 500 ms(internalPlayer.release()返回 false,EventLogger 打一条
Unexpected runtime error)—— 组播 DataSource 的 socket 超时是 3 s,播放线程没能在释放超时内退出。监听器此时已经摘掉,不会弹到界面上,播放也正常,但不干净。
另外设置面板开着时数字键会被代理地址输入框吃掉,不会换台。是输入框抢焦点的正常行为,未改。
🤖 Generated with Claude Code