From 9d6c3c79b06652793c53bac0f610cb2a207af078 Mon Sep 17 00:00:00 2001 From: screentinker Date: Thu, 23 Jul 2026 11:34:25 -0500 Subject: [PATCH] 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) --- server/player/index.html | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/server/player/index.html b/server/player/index.html index 9f0cc6b..b7133a0 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -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.