From 488e809f2b41964bbc853825205a1ee5799be268 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 9 Jul 2026 02:20:04 +0200 Subject: [PATCH] fix(plex): keep play state honest across a seek that restarts the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeking to a spot outside the current remux window restarts the hls.js session, which detaches/re-attaches the media and fires 'emptied' — that silently flips the element to paused WITHOUT a 'pause' event. Because the play flag only tracked 'play'/'pause', the button kept showing 'playing' while the video had actually stopped, and on a slow (proxied) load the post-manifest play() could be rejected, leaving it stuck paused until a click on the video. Root cause confirmed by instrumenting the media events during a seek: emptied(paused=true) with no accompanying pause event. Fix: resync the play flag from video.paused on the settling events (emptied/canplay/seeked) so the button can never contradict reality; preserve the pre-seek play/pause intent through the session restart (loadSession takes resumePlay); and retry play once on 'canplay' so a rejected early play() still resumes. Verified on :8080 — backward seek (session restart), forward seek (native) and manual pause all keep the icon in sync (0 desync samples) and resume correctly. --- frontend/src/components/PlexPlayer.tsx | 32 ++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 9d840ff..792b2dd 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -271,7 +271,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { // --- media setup: (re)load a session for `id` at a start offset ------------------------------- const loadSession = useCallback( - async (startAt: number) => { + async (startAt: number, resumePlay = true) => { const video = videoRef.current; if (!video) return; setReady(false); @@ -301,6 +301,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const trueStart = sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s; sessionStartRef.current = trueStart; setSessionK(trueStart); + // Belt-and-suspenders resume: the play() fired on MANIFEST_PARSED / loadedmetadata can be + // rejected when the media isn't ready yet (slow first segments over a proxy), which would leave + // the video paused after a seek even though the user was watching. Retry once the element can + // actually play. Idempotent — only acts if still paused. + if (resumePlay) { + const onCanPlay = () => { + if (aliveRef.current && videoRef.current?.paused) videoRef.current.play().catch(() => {}); + }; + video.addEventListener("canplay", onCanPlay, { once: true }); + } if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; @@ -313,7 +323,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { // Subtitles are native s (not muxed into the HLS), so nothing subtitle-related here. hls.on(Hls.Events.MANIFEST_PARSED, () => { setReady(true); - if (aliveRef.current) video.play().catch(() => {}); + if (aliveRef.current && resumePlay) video.play().catch(() => {}); }); // Restore the selected audio track ONCE hls.js has parsed the renditions — doing it on // MANIFEST_PARSED was too early (the track list wasn't populated, so the set was dropped and @@ -342,7 +352,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const onMeta = () => { if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt; setReady(true); - if (aliveRef.current) video.play().catch(() => {}); + if (aliveRef.current && resumePlay) video.play().catch(() => {}); video.removeEventListener("loadedmetadata", onMeta); }; video.addEventListener("loadedmetadata", onMeta); @@ -417,13 +427,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { setPlaying(false); patchPrefs({ wasPlaying: false }); }; + // A seek that restarts the hls.js session detaches/re-attaches the media, firing 'emptied' — + // which silently flips paused=true WITHOUT a 'pause' event, so the play/pause pair alone let the + // button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events + // so the icon can never contradict the video. (Does not touch the wasPlaying pref.) + const syncPlaying = () => setPlaying(!video.paused); video.addEventListener("timeupdate", onTime); video.addEventListener("play", onPlay); video.addEventListener("pause", onPause); + video.addEventListener("emptied", syncPlaying); + video.addEventListener("canplay", syncPlaying); + video.addEventListener("seeked", syncPlaying); return () => { video.removeEventListener("timeupdate", onTime); video.removeEventListener("play", onPlay); video.removeEventListener("pause", onPause); + video.removeEventListener("emptied", syncPlaying); + video.removeEventListener("canplay", syncPlaying); + video.removeEventListener("seeked", syncPlaying); }; }, [id, patchPrefs]); @@ -470,14 +491,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { (target: number) => { const video = videoRef.current; if (!video) return; + const wasPlaying = !video.paused; // preserve play/pause intent across the seek const dur = durationRef.current || target; const T = Math.max(0, Math.min(target, dur - 1)); absRef.current = T; // reflect the target immediately so a save/beacon right after a seek is accurate const rel = T - sessionStartRef.current; if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) { - video.currentTime = rel; + video.currentTime = rel; // native seek within the loaded region keeps playing on its own } else { - loadSession(T); + loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing } }, [loadSession],