Skip to content

修复杜比无声/解码切换报错/中文乱码,支持 gz 地址与二维码,并补上使用手册 - #8

Merged
yunnysunny merged 10 commits into
mainfrom
fix/audio-video-decoder-split
Oct 4, 2026
Merged

yunnysunny merged 10 commits into
mainfrom
fix/audio-video-decoder-split

Conversation

@yunnysunny

Copy link
Copy Markdown
Member

这一批都是在真机(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:// 组播):

  • 中文源名「北京联通组播」正常存取(修复前是 ??????)
  • m3u 里的 x-tvg-url 指向 http://epg.51zmt.top:8000/e.xml.gz,节目单全天正常显示
  • BRTV北京卫视:3840×2160 / H.265 HDR / 杜比数字 6 声道,约 40 Mbps
  • 切换解码方式不再报 DECODER_INIT_FAILED,新播放器起来后 FFmpeg 正常解 ac3

组播转单播代理(实测用的是 rtp2httpd,兼容 udpxy 路径),同一路 4K 频道各跑 60 秒:

起播 重缓冲 硬解器 error_recovery
直收组播 2.1 s 0 6 次
经代理 2.3 s 0 9 ~ 12 次

有线环境下两者都够用,这点差异在一分钟的样本里算不上信号。

测试

253 个单元测试全绿,assembleDebug 通过。新增 QrCodeUtilTest(zxing 往返解码,不是只断言「生成了非空矩阵」)、
GzipAwareStreamsTest、WebServerTest(中文请求体 + 分片到达的短读)。

已知遗留

切换解码方式时,旧播放器 release() 会超时约 500 ms(internalPlayer.release() 返回 false,
EventLogger 打一条 Unexpected runtime error)—— 组播 DataSource 的 socket 超时是 3 s,
播放线程没能在释放超时内退出。监听器此时已经摘掉,不会弹到界面上,播放也正常,但不干净。

另外设置面板开着时数字键会被代理地址输入框吃掉,不会换台。是输入框抢焦点的正常行为,未改。

🤖 Generated with Claude Code

yunnysunny and others added 7 commits October 4, 2026 13:33
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +515 to +516
int remaining = (int) Math.min(contentLength, Integer.MAX_VALUE);
byte[] body = new byte[remaining];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修(d0cfb9c)。确实是真问题:服务监听在局域网上且没有鉴权,一个 Content-Length: 2000000000 就能把应用 OOM 掉。

加了 MAX_BODY_BYTES = 1 MiB,并且在分配之前判断——先 new byte[...] 再检查的话这个检查本身就白写了。这个接口最大的一条请求也就几百字节,1 MiB 已经宽裕得离谱。

补了测试覆盖超限拒绝和边界值放行。

Comment on lines +66 to +73
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修(d0cfb9c)。看漏了这条执行顺序:fromActiveNetwork() 在枚举网卡之前就返回了,后面排除 tun* 的逻辑根本到不了。

改成先用 props.getInterfaceName() 过滤,虚拟接口一律返回 null 退回枚举;顺带把 tap / ppp 也补进排除名单。拿不到接口名时不武断排除,交给后面的地址判断。

Comment on lines +69 to +71
static BitMatrix encodeMatrix(@Nullable String content, int sizePx) {
if (content == null || content.trim().isEmpty() || sizePx <= 0) {
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修(d0cfb9c)。同意「比不给码更误导人」这个判断——扫出来是个手机连不上的地址。

实现放在调用方而不是 QrCodeUtil:编码工具不该知道 DeviceIpUtil.UNKNOWN_ADDRESS 这种业务约定。DeviceIpUtil 新增 isResolved(),播放页和设置页在 IP 没解析出来时传 null,复用现有的 null 分支隐藏二维码。地址文字仍然显示,用户据此知道是网络没就绪而不是功能坏了。

Comment on lines +62 to +69
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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修(d0cfb9c)。这条是我自己引入的回归,抓得准。

原来是 ResponseBody.string(),它按「BOM > Content-Type 声明 > UTF-8」解码;我为了支持 gzip 改成自己读流时写死了 UTF-8,GBK 的直播源(国内列表里相当常见)频道名会整片变成乱码。

抽了 BomAwareText 把那套规则补回来,BOM 优先于声明(BOM 是文件自己带的,Content-Type 常常是服务器随手填的默认值),并且 BOM 字节必须吃掉——留着的话字符串以 U+FEFF 开头,解析器连 #EXTM3U 都认不出来。测试覆盖了 GBK、BOM 覆盖声明、BOM 剥离和短输入。

Comment on lines +336 to +338
int sizePx = itemView.getResources()
.getDimensionPixelSize(R.dimen.settings_web_qr_size);
Bitmap bitmap = QrCodeUtil.encode(row.url, sizePx);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修(d0cfb9c)。确实漏了——PlayerActivity.updateWebAddressQr 本来就减了 padding,设置页这份照抄时漏掉,现在按 132dp - paddingLeft - paddingRight 生成。

yunnysunny and others added 2 commits October 4, 2026 15:41
五条都成立,逐条核对后分别修掉:

- **请求体上限(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>
@yunnysunny

Copy link
Copy Markdown
Member Author

追加:4K 频道间换台后所有频道都放不出来

真机上发现一个更严重的问题,已修(08b727e)。

现象:在 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

此后每一个频道都起不来,界面停在「所有播放源均不可用」。

定位过程(逐个排除,不是猜的):

尝试 结果
换源 无效——每一路都一样失败
重建 ExoPlayer(切换解码方式触发) 无效
前后台切换 无效
静置 20 秒 无效
force-stop 重启进程 恢复

所以是进程级、且绑在 Surface 上。dumpsys SurfaceFlinger 给了实锤——SurfaceView 的 buffer 还停在上一个 4K 解码器配置的格式上:

0x772b2133c0: unknown | 3840 (3840) x 2160 | 32315659 | SurfaceView - com.whyun.witv/...PlayerActivity

上一个解码器(尤其 4K sideband 那种)释放是异步的,BufferQueue 没断开,新建的 MediaCodec 就连不上这块 Surface。重建 ExoPlayer 没用,是因为 PlayerView 复用的是同一个 SurfaceView。

修法:收到 ERROR_CODE_DECODER_INIT_FAILED 时不再当成「这路流坏了」去换源(换源是错的,一路换到底只会把所有源都误判成坏的),而是把 PlayerView 收起来再放出来,逼 SurfaceView 走一遍 surfaceDestroyed / surfaceCreated 拆掉那条残留连接,然后用新播放器原地重试当前这一路。只重试一次——拆完还失败就是真解不了。

第一版有坑:重建窗口有几百毫秒,期间 player 为空而用户完全可能换台,真机上直接 NPE 崩了。加了 ensurePlayerReady() 在 playCurrentSource 之前统一补建。

验证(Amlogic p230 / 有线 / 经 rtp2httpd 代理):4K 频道间反复换台 30 次(2 秒间隔混 4K 与 1080p),触发 2 次 Surface 重建,均在约 2 秒内自愈,0 次「所有播放源均不可用」,0 崩溃。

ensurePlayerReady 那条补建路径没能在真机上逼出来(重建窗口只有 400 ms,换台很难正好撞上),靠的是判空兜底而非实测覆盖。

🤖 Generated with Claude Code

Unreleased 一节此前只覆盖了分支最早的两个提交,后面七个(二维码与端口 9979、gz 地址、
UTF-8 请求体与体积上限、VPN 地址、Surface 重建恢复、使用手册)都没记;
顺带把那条还写着旧端口 9978 的描述改掉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@yunnysunny
yunnysunny merged commit 55939c5 into main Oct 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant