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