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:
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user