Skip to content

fix(player): recover silent live stalls by data arrival, prefetch and silent swap - #800

Open
gdwengf wants to merge 3 commits into
stackia:mainfrom
gdwengf:fix/data-driven-stall-recovery
Open

gdwengf wants to merge 3 commits into
stackia:mainfrom
gdwengf:fix/data-driven-stall-recovery

Conversation

@gdwengf

@gdwengf gdwengf commented Oct 22, 2026 •

Copy link
Copy Markdown

现象

手机浏览器(Android / 鸿蒙 Chromium)播放直播 MPEG-TS 频道(音频是 MP2,必须软解):

  1. 播一两分钟后画面定格,不会自己恢复,只能手动重新点台;
  2. 加入恢复机制后,恢复的那一下画面要黑 1~3 秒;
  3. 恢复过程中声音会把刚听过的一小段(实测 0.2 秒)再播一遍,画面偶尔也会回退。

服务端一侧全程正常:/status 里队列 0~80KB(上限 4.7MB)、slow=0、连接保持,同一条 URL 用 VLC 连播 20 分钟不断。问题都在播放器内部。

根因

1. 卡死判定的定时器根本不跑(commit 1)

看门狗用 setInterval 每秒检查播放位置。断流发生时主线程被解码/渲染占满,定时器被推迟,6 秒断流期内一条 tick 都没有,解除阻塞后一次性补跑。所以看门狗在真正需要它的时刻是缺席的。

2. 恢复 = 重载可见槽(commit 2)

原本的重试链路把播放中的 video 元素重新 loadSegments(),等于把画面拆掉重建,黑屏 1~3 秒;而且旧连接要等新连接建好之后才释放。

3. 交接时重播已看过/听过的内容(commit 3)

  • resyncFromBuffer() 从目标点重填待播队列后把游标留空,pump() 于是用 pendingChunks[0].time(保留缓冲的头)作锚点 —— 缓冲起点早于目标时(新 attach 的 MSE、隐藏槽预热、重连回填),音频从缓冲头起播。
  • 对齐目标是 video.currentTime,但调度器最多提前 SCHEDULE_AHEAD(0.6s)把样本送进 WebAudio。音频已出声到画面之前时,按画面位置对齐等于把游标退回到输出队列内部,队列里那段被再播一次。抓到的现场:
ev=aresync want=8.62 got=8.80 furthest=8.82 back=0.20
  • 转正瞬间旧槽还在出声、新槽已开始播,两个链路相差几百毫秒,同一句台词听到两遍。

修改

看门狗:判定改为由数据到达驱动,并把"页面自己被挂起"的情况一起覆盖(commit 1)。

恢复:在隐藏槽预热新连接,成功后整体切换,可见槽不再重建;旧槽在新槽可见后才释放(commit 2)。

交接(commit 3):

  • resyncFromBuffer() 重填后显式 anchor(targetTime),让已有的 overlap 裁剪丢掉目标之前的块;
  • 新增 clampToAudiblePosition():基准取"已出声位置" = outputStreamCursor - max(0, nextStartTime - ctx.currentTime),回退 >20ms 就钳住;>5s 视为新时间轴丢弃记忆;缓冲覆盖不到时不钳;
  • 新增 skipAudioAhead():交接时一次性丢掉 0.9s 音频(新槽 PCM 时间轴比画面落后一段);
  • 新增 alignToLiveLatency():goLive() 在 continuous-live-TS 下忽略参数,交接需要显式指定落点;
  • 切换顺序改为:静音旧槽 → 应用设置 → 对齐新槽 → 跳音频 → 画面再前移 0.9s,避免重看;
  • 目标延迟从 1s 提到 2s(上限 3.5s),换台多等约 1 秒,换取抗抖动余量。

验证

  • 抓包拿到 aresync back=0.20 现场,修复后该回退被拒绝,听感不再重复;
  • 恢复时画面黑屏从 1~3 秒降到切换瞬间,旧连接不再叠加;
  • 连续播放 30 分钟以上不再定格(此前 1~2 分钟必停)。

@gdwengf gdwengf changed the title fix(player): recover live stalls from data arrival, prefetch early and swap silently fix(player): recover silent live stalls by data arrival, prefetch and silent swap Oct 22, 2026
@gdwengf
gdwengf force-pushed the fix/data-driven-stall-recovery branch 2 times, most recently from 85f5768 to 03be55d Compare October 22, 2026 12:17
Field logs from a phone watching a raw-TS live channel showed the player
freezing with no recovery while the server kept sending. The watchdog meant to
catch this never ran: it bailed out on guards a wedged MSE session satisfies
(visibilityState reported hidden while the page was on screen, video.paused
flipped true by the stall itself), and its 100ms setInterval lived on the same
main thread the MSE pipeline saturates — zero ticks across a 6s blackhole, then
five catch-up ticks in one second.

- drive the watchdog from a Web Worker heartbeat, fall back to setInterval
- stop treating visibilityState as "user is not watching"; slot geometry and the
  ended flag already say whether a slot is worth judging
- honour video.paused only while bytes still flow; once the data path has been
  dry for 0.5s the session is dead and must be judged
- prefetch on buffer starvation (peak minus now >= 0.5s and ahead <= 1.0s)
  instead of on every 0.2-0.4s segment gap, which opened and dropped a
  replacement every few hundred ms
- drop the spare only when data really resumed, never because the clock moved
- keep a spare alive at least 1s so it can reach playing before being discarded
- debounce the silence rebuild to one per 3s (it fired every 100ms)
- rate-limited tick_skip diagnostics so a stall reports which guard blocked it
@gdwengf
gdwengf force-pushed the fix/data-driven-stall-recovery branch from 03be55d to e00c2a4 Compare October 22, 2026 16:29
…loading the visible one

`handleLoadSegments()` decides between "reload the active slot" and "load on the
hidden slot, hard-switch when ready" via `useSeamlessSwitch`. That predicate is
written for a user-initiated channel switch: it requires the seamless-switch
setting, a detected stream switch and `hasStartedPlaybackRef`. Silent recovery
(`recoverOnHiddenSlot()`) fakes the stream-switch half of that by overwriting
`prevStreamRef`, but it can still fall through to the reload branch whenever the
setting is off or any other term flips false.

When that happens the recovery reloads the slot the viewer is watching. The
player detaches MSE from the video element (`media emptied`, `currentTime` back to
0, no buffered ranges), and the picture stays black until the new connection has
buffered and decoded its first segment. The field trace is unambiguous:

  ev=prefetch frozen=0.1 data=0.5 ahead=0.6
  ev=drop_spare pos=27.3 data=-0.0
  ev=media name=emptied cur=0.0 rs=0 ns=3 buf=-
  ev=media name=play / waiting / loadstart
  New client ... /IPTV-央视/CCTV-2/高清/2   ← the reload itself
  ev=media name=loadeddata buf=0.0-0.4 → playing

Two changes:

1. Silent recovery now always takes the hidden-slot path. `silentRecoverySwitch`
   is checked first in `useSeamlessSwitch`, so a recovery prepares its replacement
   off-screen and only hard-switches once that slot is actually playing. If the old
   session recovers on its own, the spare is dropped and the visible picture is
   never touched.

2. `DATA_PREFETCH_SEC` 0.2s → 0.5s. Ordinary segment gaps are 0.2–0.4s, so the
   old gate fired during healthy playback and opened (then immediately discarded)
   FCC unicast sessions — 21 prefetches per minute in the field, each one a real
   `/IPTV` request and FCC unicast start on the operator side.

Verified on the target device (OpenWrt, 1080i MP2 channel) by logging every media
element transition: before the change `drop_spare` was followed within the same
second by `emptied`/`cur=0`/new `/IPTV` request; after it, `emptied` appears only
on the hidden slot and the visible slot keeps its buffer throughout recovery.
pcm-audio-player: alignments anchor at the requested target instead of the head of the retained buffer, and refuse to align behind the audible position (the scheduler runs up to SCHEDULE_AHEAD ahead of the speakers, so clamping to the queued cursor replayed that queue). A one-shot forward skip drops audio the replacement still carries from before it was revealed.

playback-controller/types: alignToLiveLatency() places a revealed slot at an explicit distance behind its own live edge; continuous-live-TS goLive ignored its argument.

video-player: a transition now mutes the replaced slot before revealing the replacement (both play the same programme a few hundred ms apart), then aligns the new slot, skips audio forward and lands the picture slightly ahead so nothing already watched is shown again.

config: 2s live target latency (3.5s cap) for jitter headroom.
@gdwengf
gdwengf force-pushed the fix/data-driven-stall-recovery branch from 600ef1b to c8b233b Compare October 23, 2026 13:01
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