fix(web-player): YouTube ENDED safety net for Shorts + flaky Android TV (#219)

YouTube Shorts never fire the ENDED state via the IFrame API, and some
Android TV WebViews drop ENDED even for regular videos. The player advanced
solely on onStateChange ENDED, so a missing event stalled the playlist
indefinitely.

Arm a duration-based fallback timer in onReady (getDuration + 3s slack) that
calls nextItem() if ENDED never arrives. It is cleared on a real ENDED, on
onError, when a newer player is created, and in teardownCurrentMedia so a
stale timer can't force a spurious advance after rotation. Skipped when
looping (single-item playlists) and when duration is 0 (live streams).

The Tizen player is not affected: it embeds YouTube as a plain iframe and
already advances multi-item playlists on a duration timer rather than the
YT JS API, so it never waits for ENDED.

Closes #215
Refs #184

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-23 11:34:25 -05:00 committed by GitHub
parent 11c2890433
commit 9d6c3c79b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -513,6 +513,10 @@
let ytApiCallbacks = [];
let activeYtPlayer = null;
let ytGeneration = 0;
// #215: fallback advance timer for YouTube. Shorts (and some Android TV WebViews)
// never fire the ENDED state, so onStateChange alone can stall the playlist. Armed
// in onReady from the reported duration, cleared on ENDED/onError/teardown.
let ytSafetyNet = null;
// AudioContext is created lazily on the first user gesture. Resuming it
// is what convinces stricter browsers (Firefox) that the site is "user-
@ -1964,6 +1968,7 @@
const myGeneration = ++ytGeneration;
// Destroy old player without triggering side effects (callbacks check generation)
if (ytSafetyNet) { clearTimeout(ytSafetyNet); ytSafetyNet = null; }
if (activeYtPlayer) { try { activeYtPlayer.destroy(); } catch {} activeYtPlayer = null; }
// Vertical (Shorts) content is tagged st_aspect=vertical at ingest. Render it
@ -2040,9 +2045,26 @@
event.target.unMute();
event.target.setVolume(100);
}
// #215 safety net: if ENDED never fires (Shorts, flaky Android TV
// WebViews), advance from the reported duration + 3s slack. Cleared
// on a real ENDED, on error, or on teardown. Skip when looping (a
// single-item playlist intentionally loops forever).
if (!shouldLoop) {
let duration = 0;
try { duration = event.target.getDuration(); } catch {}
if (duration > 0) {
clearTimeout(ytSafetyNet);
ytSafetyNet = setTimeout(() => {
if (myGeneration !== ytGeneration) return;
console.log('YouTube safety net fired for:', item.filename);
nextItem();
}, (duration + 3) * 1000);
}
}
},
onError: (event) => {
if (myGeneration !== ytGeneration) return;
clearTimeout(ytSafetyNet); ytSafetyNet = null;
console.error('YouTube error', event.data, 'for:', item.filename);
if (playlist.length > 1) {
console.log('Skipping unplayable YouTube video');
@ -2056,6 +2078,7 @@
// YT.PlayerState.ENDED = 0 — advance to next video
// Ignore ENDED if video played for less than 3 seconds (spurious during init)
if (event.data === 0 && !shouldLoop && (Date.now() - playStartTime) > 3000) {
clearTimeout(ytSafetyNet); ytSafetyNet = null;
console.log('YouTube video ended:', item.filename);
nextItem();
}
@ -2085,6 +2108,9 @@
// On a buffered widget reveal (keep set) the widget's advance/refresh timer was just
// armed by renderContent and must survive; only a real teardown cancels it.
if (!keep && advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
// #215: a stale YouTube safety-net timer must never fire into a torn-down/rotated
// playlist and force a spurious advance.
if (ytSafetyNet) { clearTimeout(ytSafetyNet); ytSafetyNet = null; }
// Cancel any in-flight buffered widget swap so its deferred reveal can't fire after
// we've torn down (which would drop the incoming content). reveal() nulls this before
// calling teardownCurrentMedia(iframe), so preserving `keep` is safe.