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

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 10:27:59 -05:00
parent e7483dfc24
commit 336c38d1b2

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.