diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 85825658..838d8fa9 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -24,7 +24,7 @@ import { VolumeX, X, } from "lucide-react"; -import { api, HttpError, type PlexMarker } from "../lib/api"; +import { api, HttpError } from "../lib/api"; import { audioOrdForLang, clamp01, @@ -38,6 +38,7 @@ import { } from "../lib/plexPlayerPrefs"; import { Ctrl, PanelHead, Slider, Toggle } from "./plexPlayerControls"; import { + useAutoSkip, useControlsVisibility, useFullscreen, usePlayerVolume, @@ -90,10 +91,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { // client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh. const multiAudioRef = useRef(false); multiAudioRef.current = (detail?.audio_streams.length ?? 0) > 1; - // Auto-skip countdown state, via refs so the keyboard handler (defined earlier than the skip - // machinery) can read/cancel it without a declaration-order dependency. - const skipProgressRef = useRef(null); - const cancelAutoSkipRef = useRef<() => void>(() => {}); const [playing, setPlaying] = useState(false); const [abs, setAbs] = useState(0); // absolute current time @@ -417,6 +414,21 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { : null : detail?.next_id; + // Auto-skip intro/credits (countdown → jump to marker end / next item). The refs bridge to the + // earlier keyboard/back handlers that cancel a running countdown. + const { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip } = useAutoSkip({ + id, + abs, + markers: detail?.markers, + autoSkipIntro: prefs.autoSkipIntro, + autoSkipCredits: prefs.autoSkipCredits, + autoSkipDelay: prefs.autoSkipDelay, + nextId, + go, + seekTo, + videoRef, + }); + // Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link. const download = useCallback(() => { const a = document.createElement("a"); @@ -593,7 +605,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { return; } handleBack(); - }, [handleBack]); + }, [handleBack, skipProgressRef, cancelAutoSkipRef]); // This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own // settings/tracks menus included) takes Escape first and this player defers (R8 S3). @@ -704,87 +716,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { onClose, wake, handleBack, - ]); - - const activeMarker: PlexMarker | undefined = detail?.markers.find( - (m) => abs >= m.start_s && abs < m.end_s - 1, - ); - const activeMarkerRef = useRef(activeMarker); - activeMarkerRef.current = activeMarker; - - // Auto-skip intro/credits: entering a marker whose auto-skip is on runs a countdown (skipProgress - // 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — intro → jump to the - // marker end, credits → next item (binge). Esc/Backspace during the countdown cancels it (and must - // NOT navigate back). 0s delay = skip immediately. - const [skipProgress, setSkipProgress] = useState(null); - skipProgressRef.current = skipProgress; - const cancelledMarkerRef = useRef(null); - // Forget a cancelled-skip when the item changes: the ref holds a `${type}-${start_s}` key, and a - // marker at the same offset on the NEXT episode would otherwise be suppressed by a cancel made on - // the previous one (auto-skip silently dead for the rest of a binge). - useEffect(() => { - cancelledMarkerRef.current = null; - }, [id]); - // The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress - // alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway. - const skipTimerRef = useRef(null); - const doSkip = useCallback( - (m: PlexMarker) => { - setSkipProgress(null); - if (m.type === "credits" && nextId) { - // Binge auto-advance: the episode we're leaving was watched through the credits, so mark IT - // (the current id, captured here — not the reactive id, which go() is about to change). - api.plexSetState(id, "watched").catch(() => {}); - go(nextId); - } else seekTo(m.end_s); - }, - [id, go, nextId, seekTo], - ); - const cancelAutoSkip = useCallback(() => { - const m = activeMarkerRef.current; - if (m) cancelledMarkerRef.current = `${m.type}-${m.start_s}`; // don't re-arm this same marker - if (skipTimerRef.current) { - window.clearInterval(skipTimerRef.current); // actually stop the countdown, not just blank it - skipTimerRef.current = null; - } - setSkipProgress(null); - }, []); - cancelAutoSkipRef.current = cancelAutoSkip; - useEffect(() => { - const m = activeMarker; - if (!m) return setSkipProgress(null); - const key = `${m.type}-${m.start_s}`; - const enabled = m.type === "intro" ? prefs.autoSkipIntro : prefs.autoSkipCredits; - if (!enabled || cancelledMarkerRef.current === key) return setSkipProgress(null); - if (prefs.autoSkipDelay <= 0) return doSkip(m); // immediate - 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); - if (p >= 1) { - window.clearInterval(iv); - skipTimerRef.current = null; - doSkip(m); - } - }, 100); - skipTimerRef.current = iv; - return () => { - window.clearInterval(iv); - skipTimerRef.current = null; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - activeMarker?.type, - activeMarker?.start_s, - prefs.autoSkipIntro, - prefs.autoSkipCredits, - prefs.autoSkipDelay, - doSkip, + skipProgressRef, + cancelAutoSkipRef, ]); // Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index b5bf76ca..954068ef 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -14,7 +14,7 @@ import { onFullscreenChange, requestFullscreen, } from "./fullscreen"; -import { api } from "./api"; +import { api, type PlexMarker } from "./api"; import { qk } from "./queryKeys"; import { clamp01, type PlexPlayerPrefs } from "./plexPlayerPrefs"; @@ -192,3 +192,121 @@ export function usePlayerVolume(opts: { ); return { setVol, nudgeVolume, toggleMute, onWheel }; } + +// Auto-skip intro/credits. Entering a marker whose auto-skip is on runs a countdown (skipProgress +// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — intro → jump to the +// marker end, credits → next item (binge). The returned refs (skipProgressRef / cancelAutoSkipRef) +// are the forward-declaration bridge the earlier-defined keyboard/back handlers read to cancel a +// running countdown (Esc/Backspace during the countdown cancels it and must NOT navigate back). +export function useAutoSkip(opts: { + id: string; + abs: number; + markers: PlexMarker[] | undefined; + autoSkipIntro: boolean; + autoSkipCredits: boolean; + autoSkipDelay: number; + nextId: string | null | undefined; + go: (id: string | null | undefined) => void; + seekTo: (t: number) => void; + videoRef: RefObject; +}): { + activeMarker: PlexMarker | undefined; + skipProgress: number | null; + skipProgressRef: MutableRefObject; + cancelAutoSkipRef: MutableRefObject<() => void>; + doSkip: (m: PlexMarker) => void; +} { + const { + id, + abs, + markers, + autoSkipIntro, + autoSkipCredits, + autoSkipDelay, + nextId, + go, + seekTo, + videoRef, + } = opts; + const skipProgressRef = useRef(null); + const cancelAutoSkipRef = useRef<() => void>(() => {}); + + const activeMarker: PlexMarker | undefined = markers?.find( + (m) => abs >= m.start_s && abs < m.end_s - 1, + ); + const activeMarkerRef = useRef(activeMarker); + activeMarkerRef.current = activeMarker; + + const [skipProgress, setSkipProgress] = useState(null); + skipProgressRef.current = skipProgress; + const cancelledMarkerRef = useRef(null); + // Forget a cancelled-skip when the item changes: the ref holds a `${type}-${start_s}` key, and a + // marker at the same offset on the NEXT episode would otherwise be suppressed by a cancel made on + // the previous one (auto-skip silently dead for the rest of a binge). + useEffect(() => { + cancelledMarkerRef.current = null; + }, [id]); + // The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress + // alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway. + const skipTimerRef = useRef(null); + const doSkip = useCallback( + (m: PlexMarker) => { + setSkipProgress(null); + if (m.type === "credits" && nextId) { + // Binge auto-advance: the episode we're leaving was watched through the credits, so mark IT + // (the current id, captured here — not the reactive id, which go() is about to change). + api.plexSetState(id, "watched").catch(() => {}); + go(nextId); + } else seekTo(m.end_s); + }, + [id, go, nextId, seekTo], + ); + const cancelAutoSkip = useCallback(() => { + const m = activeMarkerRef.current; + if (m) cancelledMarkerRef.current = `${m.type}-${m.start_s}`; // don't re-arm this same marker + if (skipTimerRef.current) { + window.clearInterval(skipTimerRef.current); // actually stop the countdown, not just blank it + skipTimerRef.current = null; + } + setSkipProgress(null); + }, []); + cancelAutoSkipRef.current = cancelAutoSkip; + useEffect(() => { + const m = activeMarker; + if (!m) return setSkipProgress(null); + const key = `${m.type}-${m.start_s}`; + const enabled = m.type === "intro" ? autoSkipIntro : autoSkipCredits; + if (!enabled || cancelledMarkerRef.current === key) return setSkipProgress(null); + if (autoSkipDelay <= 0) return doSkip(m); // immediate + 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 / autoSkipDelay); + setSkipProgress(p); + if (p >= 1) { + window.clearInterval(iv); + skipTimerRef.current = null; + doSkip(m); + } + }, 100); + skipTimerRef.current = iv; + return () => { + window.clearInterval(iv); + skipTimerRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + activeMarker?.type, + activeMarker?.start_s, + autoSkipIntro, + autoSkipCredits, + autoSkipDelay, + doSkip, + ]); + + return { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip }; +}