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,
|
VolumeX,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, HttpError, type PlexMarker } from "../lib/api";
|
import { api, HttpError } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
audioOrdForLang,
|
audioOrdForLang,
|
||||||
clamp01,
|
clamp01,
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
} from "../lib/plexPlayerPrefs";
|
} from "../lib/plexPlayerPrefs";
|
||||||
import { Ctrl, PanelHead, Slider, Toggle } from "./plexPlayerControls";
|
import { Ctrl, PanelHead, Slider, Toggle } from "./plexPlayerControls";
|
||||||
import {
|
import {
|
||||||
|
useAutoSkip,
|
||||||
useControlsVisibility,
|
useControlsVisibility,
|
||||||
useFullscreen,
|
useFullscreen,
|
||||||
usePlayerVolume,
|
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.
|
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
|
||||||
const multiAudioRef = useRef(false);
|
const multiAudioRef = useRef(false);
|
||||||
multiAudioRef.current = (detail?.audio_streams.length ?? 0) > 1;
|
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 [playing, setPlaying] = useState(false);
|
||||||
const [abs, setAbs] = useState(0); // absolute current time
|
const [abs, setAbs] = useState(0); // absolute current time
|
||||||
@@ -417,6 +414,21 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
: null
|
: null
|
||||||
: detail?.next_id;
|
: 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.
|
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
|
||||||
const download = useCallback(() => {
|
const download = useCallback(() => {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -593,7 +605,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleBack();
|
handleBack();
|
||||||
}, [handleBack]);
|
}, [handleBack, skipProgressRef, cancelAutoSkipRef]);
|
||||||
|
|
||||||
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
|
// 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).
|
// 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,
|
onClose,
|
||||||
wake,
|
wake,
|
||||||
handleBack,
|
handleBack,
|
||||||
]);
|
skipProgressRef,
|
||||||
|
cancelAutoSkipRef,
|
||||||
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,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
|
// Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
onFullscreenChange,
|
onFullscreenChange,
|
||||||
requestFullscreen,
|
requestFullscreen,
|
||||||
} from "./fullscreen";
|
} from "./fullscreen";
|
||||||
import { api } from "./api";
|
import { api, type PlexMarker } from "./api";
|
||||||
import { qk } from "./queryKeys";
|
import { qk } from "./queryKeys";
|
||||||
import { clamp01, type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
import { clamp01, type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
||||||
|
|
||||||
@@ -192,3 +192,121 @@ export function usePlayerVolume(opts: {
|
|||||||
);
|
);
|
||||||
return { setVol, nudgeVolume, toggleMute, onWheel };
|
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