refactor(plex): extract PlexPlayer auto-skip into useAutoSkip

S4a-3b of the PlexPlayer decomposition. Move the intro/credits auto-skip
machinery (activeMarker detection, the skipProgress countdown, doSkip,
cancelAutoSkip + the running-interval and reset-on-id effects) into
useAutoSkip. The hook returns skipProgressRef / cancelAutoSkipRef — the
forward-declaration bridge the earlier keyboard/back handlers read to
cancel a running countdown; those two stable refs are now listed in the
backIntent/keyboard dep arrays (they were provably-stable local refs
before, params now). No behaviour change; verified by self-E2E (credits
marker detected -> countdown fill renders + advances while playing ->
auto-skip fires -> item marked Watched + player closes).
This commit is contained in:
2026-08-12 00:46:54 +02:00
parent 68f4834695
commit 521d2aeaa8
2 changed files with 139 additions and 88 deletions
+20 -87
View File
@@ -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<number | null>(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<number | null>(null);
skipProgressRef.current = skipProgress;
const cancelledMarkerRef = useRef<string | null>(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<number | null>(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
+119 -1
View File
@@ -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<HTMLVideoElement | null>;
}): {
activeMarker: PlexMarker | undefined;
skipProgress: number | null;
skipProgressRef: MutableRefObject<number | null>;
cancelAutoSkipRef: MutableRefObject<() => void>;
doSkip: (m: PlexMarker) => void;
} {
const {
id,
abs,
markers,
autoSkipIntro,
autoSkipCredits,
autoSkipDelay,
nextId,
go,
seekTo,
videoRef,
} = opts;
const skipProgressRef = useRef<number | null>(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<number | null>(null);
skipProgressRef.current = skipProgress;
const cancelledMarkerRef = useRef<string | null>(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<number | null>(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 };
}