Repository navigation
Conversation
gdwengf
force-pushed
the
fix/data-driven-stall-recovery
branch
2 times, most recently
from
October 22, 2026 12:17
85f5768 to
03be55d
Compare
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
force-pushed
the
fix/data-driven-stall-recovery
branch
from
October 22, 2026 16:29
03be55d to
e00c2a4
Compare
…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
force-pushed
the
fix/data-driven-stall-recovery
branch
from
October 23, 2026 13:01
600ef1b to
c8b233b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
现象
手机浏览器(Android / 鸿蒙 Chromium)播放直播 MPEG-TS 频道(音频是 MP2,必须软解):
服务端一侧全程正常:
/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。音频已出声到画面之前时,按画面位置对齐等于把游标退回到输出队列内部,队列里那段被再播一次。抓到的现场:修改
看门狗:判定改为由数据到达驱动,并把"页面自己被挂起"的情况一起覆盖(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 下忽略参数,交接需要显式指定落点;验证
aresync back=0.20现场,修复后该回退被拒绝,听感不再重复;