From 089eab76e48e7fc00d2f2998a0a465a412e591a6 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 8 Jul 2026 23:48:09 +0200 Subject: [PATCH] =?UTF-8?q?fix(plex):=20player=20UAT=20round=20=E2=80=94?= =?UTF-8?q?=20resume,=20back,=20subtitles,=20skip,=20ETA,=20volume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resume: closing with Back now leaves the resume point correct. The position was saved server-side, but reopening the item in the same session read react-query's cached detail (old position); patch that cache on unmount so a reopen resumes where you left off (F5 was fine — empty cache after a reload). - Back cascade: the on-screen Back arrow and Backspace now close an open tuning/tracks menu, then the shortcuts sheet, then the info overlay, and only leave the player when nothing is layered on top (it used to jump straight to the feed with a panel still open). - Subtitle shadow: replace the single 0-offset blur (invisible even at max) with an eight-direction outline plus glow that scales with the slider — legible over any frame. - Skip Intro/Credits: ~25% larger button and text; add a crisp bottom progress bar to the auto-skip countdown; and freeze the countdown while the video is paused so it no longer auto-seeks a paused video. - Time readout: append the projected finish time (wall clock + time remaining) in the clock's own 24h/12h format. - Volume: replace the native slider with a bar that shows the 0-100 value under the cursor (like the seek bar) and a loudness gradient — blue (too quiet) → green (normal) → red (too loud). --- frontend/src/components/PlexPlayer.tsx | 149 ++++++++++++++++++++----- 1 file changed, 120 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index da50469..4cfceb9 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -115,6 +115,22 @@ const DEFAULT_PREFS: PlexPlayerPrefs = { }; const clamp01 = (n: number) => Math.max(0, Math.min(1, n)); +// A punchy subtitle shadow. The old single 0-offset blur washed out to nothing on bright scenes even +// at the max slider value; instead lay down eight offset copies (a solid dark OUTLINE around the +// glyphs) plus a soft glow, all scaled by the slider — legible over anything, e.g. yellow text on a +// snow-white frame. Empty string when the slider is 0 (shadow off). +function subShadowCss(px: number, color: string): string { + if (px <= 0) return ""; + const o = px; + const b = Math.max(1, Math.round(px / 2)); + const dirs: [number, number][] = [ + [o, 0], [-o, 0], [0, o], [0, -o], + [o, o], [o, -o], [-o, o], [-o, -o], + ]; + const outline = dirs.map(([x, y]) => `${x}px ${y}px ${b}px ${color}`).join(","); + return `text-shadow:${outline},0 0 ${o * 2}px ${color};`; +} + // Top-right wall clock text: time (24h European or 12h am/pm) + optional date in one of two fixed // styles — EN "08-JUL-2026 Wed" or HU "2026-JÚL-08 Sze" (uppercase short month, short weekday). function clockParts(now: Date, p: PlexPlayerPrefs): { time: string; date: string | null } { @@ -431,6 +447,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { window.clearInterval(iv); window.removeEventListener("pagehide", onHide); flush(); + // Back (unmount) persists the position server-side via flush(), but reopening the same item in + // the SAME session read react-query's CACHED detail — whose position_seconds predated this + // watch — so the resume landed at the old spot (the progress GET could also race the POST). + // Patch the cached detail here so a reopen resumes from where we left off. (F5 was always fine: + // a reload starts with an empty cache and fetches the fresh position.) Safe to do only on + // unmount — patching while mounted would retrigger the loadSession effect and reseek mid-play. + if (absRef.current > 0 && durationRef.current > 0) { + const pos = Math.floor(absRef.current); + qc.setQueryData(["plex-item", id], (old) => + old ? { ...old, position_seconds: pos } : old, + ); + } qc.invalidateQueries({ queryKey: ["plex-browse"] }); qc.invalidateQueries({ queryKey: ["plex-show"] }); }; @@ -669,6 +697,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { }, 3000); }, []); + // "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel + // off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info + // overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back + // with the info/shortcuts panel open jumped straight to the feed instead of just closing the panel. + const handleBack = useCallback(() => { + if (menuOpenRef.current) { + setMenuOpen(false); + setTracksOpen(false); + } else if (helpOpenRef.current) setHelpOpen(false); + else if (infoOpenRef.current) setInfoOpen(false); + else onClose(); + }, [onClose]); + // --- keyboard -------------------------------------------------------------------------------- useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -722,15 +763,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { setInfoOpen((v) => !v); break; case "Backspace": - // HTPC-style "stop & back to the feed" — mirrors the mouse Back button. + // HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade). e.preventDefault(); if (skipProgressRef.current != null) cancelAutoSkipRef.current(); // cancel auto-skip, don't go back - else if (menuOpenRef.current) { - setMenuOpen(false); - setTracksOpen(false); - } else if (helpOpenRef.current) setHelpOpen(false); - else if (infoOpenRef.current) setInfoOpen(false); - else onClose(); + else handleBack(); break; case "Escape": if (skipProgressRef.current != null) cancelAutoSkipRef.current(); @@ -745,7 +781,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [togglePlay, toggleFs, seekTo, go, nudgeVolume, toggleMute, cycleAudio, cycleSubtitle, detail, prevId, nextId, onClose, wake]); + }, [togglePlay, toggleFs, seekTo, go, nudgeVolume, toggleMute, cycleAudio, cycleSubtitle, detail, prevId, nextId, onClose, wake, handleBack]); const activeMarker: PlexMarker | undefined = detail?.markers.find( (m) => abs >= m.start_s && abs < m.end_s - 1, @@ -784,6 +820,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { let elapsed = 0; setSkipProgress(0); const iv = window.setInterval(() => { + // Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a + // deliberately-paused video. It resumes from where it stopped when playback resumes. + if (videoRef.current?.paused) return; elapsed += 0.1; const p = Math.min(1, elapsed / prefs.autoSkipDelay); setSkipProgress(p); @@ -798,6 +837,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { // Seekbar hover: a timestamp tooltip that follows the cursor. const [hover, setHover] = useState<{ x: number; t: number } | null>(null); + // Volume bar: a 0–100 value tooltip under the cursor (mirrors the seekbar), plus click/drag to set. + const [volHover, setVolHover] = useState(null); // 0..1 under the cursor + const volBarRef = useRef(null); + const volAt = useCallback((clientX: number) => { + const el = volBarRef.current; + if (!el) return 0; + const r = el.getBoundingClientRect(); + return clamp01((clientX - r.left) / r.width); + }, []); const pct = duration > 0 ? (abs / duration) * 100 : 0; const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0; @@ -813,9 +861,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { > {/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this player owns the only