Found the day the live debug log started working, which is the only reason anyone
saw it. A BrightSign XT245 playing a 40s clip as a SINGLE-item playlist logged four
`Video error` events at every loop boundary and then three back-to-back "Playing:"
lines, with `play() rejected AbortError` and `muted-fallback play() also failed` in
between as the second mount aborted the first. On a one-item playlist that just
re-plays the same file, so it looked like nothing.
On a real playlist the identical storm skips one item per surplus event. Silently.
The operator sees a playlist that drops content and nothing says why. Same family as
234.
Two independent defects produced it:
1. `video.onerror` had no once-guard — its sibling in the buffered path has
`if (done) return`, this one didn't — so every event scheduled its own nextItem.
2. Every call site wrote `advanceTimer = setTimeout(...)` DIRECTLY. A second write
before the first fired ORPHANED the earlier timer instead of cancelling it: still
pending, no longer referenced, so renderContent's clearTimeout could only ever
cancel the last one. All the others fired. That made a dozen sites capable of
leaking a timer, not just the error handlers — so the fix is a scheduleAdvance()
helper that clears before it arms, and a test asserting nothing assigns the timer
directly ever again.
The four error handlers (buffered/non-buffered x video/image) had drifted apart
because they were four copies; they now share one mediaFailureSkip(), which also
reports the actual MediaError code. The old line logged the DOM event
({"isTrusted":true}) and never touched el.error, so the log could say a video failed
but never why.
Third guard: an element that is still playable is not a failure. `error` fires with
el.error set; an event carrying no MediaError against an element with frames buffered
ahead of it did not fail at anything, and discarding a healthy item on that basis is
worse than the event being reacted to. Anything genuinely unplayable (no MediaError
AND nothing decoded) is still skipped, so a broken clip can never stall the playlist.
Verified on the XT245: 150s of playback went from 2-3 advances and an AbortError pair
per loop boundary to exactly one advance and zero AbortErrors, and the surviving
diagnostic now names the real cause -- `code=3 DECODE`, four raw error events
collapsing to one reported failure.
All three guards are mutation-tested: removing any one of them fails a test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS