fix(plex): keep play state honest across a seek that restarts the session

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.
This commit is contained in:
npeter83 2026-07-09 02:20:04 +02:00
parent 2659991d84
commit 488e809f2b

View file

@ -271,7 +271,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// --- media setup: (re)load a session for `id` at a start offset ------------------------------- // --- media setup: (re)load a session for `id` at a start offset -------------------------------
const loadSession = useCallback( const loadSession = useCallback(
async (startAt: number) => { async (startAt: number, resumePlay = true) => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
setReady(false); 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; const trueStart = sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s;
sessionStartRef.current = trueStart; sessionStartRef.current = trueStart;
setSessionK(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) { if (hlsRef.current) {
hlsRef.current.destroy(); hlsRef.current.destroy();
hlsRef.current = null; hlsRef.current = null;
@ -313,7 +323,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Subtitles are native <track>s (not muxed into the HLS), so nothing subtitle-related here. // Subtitles are native <track>s (not muxed into the HLS), so nothing subtitle-related here.
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.on(Hls.Events.MANIFEST_PARSED, () => {
setReady(true); 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 // 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 // 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 = () => { const onMeta = () => {
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt; if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
setReady(true); setReady(true);
if (aliveRef.current) video.play().catch(() => {}); if (aliveRef.current && resumePlay) video.play().catch(() => {});
video.removeEventListener("loadedmetadata", onMeta); video.removeEventListener("loadedmetadata", onMeta);
}; };
video.addEventListener("loadedmetadata", onMeta); video.addEventListener("loadedmetadata", onMeta);
@ -417,13 +427,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setPlaying(false); setPlaying(false);
patchPrefs({ wasPlaying: 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("timeupdate", onTime);
video.addEventListener("play", onPlay); video.addEventListener("play", onPlay);
video.addEventListener("pause", onPause); video.addEventListener("pause", onPause);
video.addEventListener("emptied", syncPlaying);
video.addEventListener("canplay", syncPlaying);
video.addEventListener("seeked", syncPlaying);
return () => { return () => {
video.removeEventListener("timeupdate", onTime); video.removeEventListener("timeupdate", onTime);
video.removeEventListener("play", onPlay); video.removeEventListener("play", onPlay);
video.removeEventListener("pause", onPause); video.removeEventListener("pause", onPause);
video.removeEventListener("emptied", syncPlaying);
video.removeEventListener("canplay", syncPlaying);
video.removeEventListener("seeked", syncPlaying);
}; };
}, [id, patchPrefs]); }, [id, patchPrefs]);
@ -470,14 +491,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
(target: number) => { (target: number) => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
const wasPlaying = !video.paused; // preserve play/pause intent across the seek
const dur = durationRef.current || target; const dur = durationRef.current || target;
const T = Math.max(0, Math.min(target, dur - 1)); 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 absRef.current = T; // reflect the target immediately so a save/beacon right after a seek is accurate
const rel = T - sessionStartRef.current; const rel = T - sessionStartRef.current;
if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) { 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 { } else {
loadSession(T); loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
} }
}, },
[loadSession], [loadSession],