Files
siftlode/frontend/src/lib/plexPlayerHooks.ts
T
peter c489b60c8b fix(clip): clip mode keeps its range, its focus and its keys through the player's own moves
- A mark on the very end seeks a moment short of it; while a clip is marked the end of the item
  neither closes the player nor steps the queue, and Plex's auto-skip waits.
- Plex: Escape and Back peel what is over the picture (info, up-next, the countdown) before the
  clip; Back under a dialog is the dialog's; no range opens while the length is unknown.
- YouTube: the focus goes back to the scissors the user can see, else the player; the panel keeps
  its name, Loop and focus when the view is maximised or restored under it.
- A typed time stays in its field until taken, then shows where it landed; unreadable stays marked
  invalid; a decimal comma is read (ui/TimecodeField, the editor's fields too).
- RangeTrack: a drag is its pointer's, a cancelled drag reports final, an adornment sits beside the
  slider, a caller-chosen focus ring (amber on the clip strip). CropFrame: a drag is its pointer's.
- The editor's remove-cut is a real button at the cut's foot; a segment's keep toggle sits over the
  cut's hit area.
2026-10-10 15:35:21 +02:00

909 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useCallback,
useEffect,
useRef,
useState,
type RefObject,
type WheelEvent as ReactWheelEvent,
} from "react";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
exitFullscreen,
fullscreenElement,
onFullscreenChange,
requestFullscreen,
} from "./fullscreen";
import { api, type PlexItemDetail, type PlexMarker } from "./api";
import { qk } from "./queryKeys";
import { useLatest } from "./useLatest";
import i18n, { plexLang } from "../i18n";
import { markerSkip } from "./plexMarkers";
import { ownTextTracks } from "./plexSubTracks";
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
// How close to the end the backend counts as "finished" (`_FINISH_MARGIN_S` in routes/plex/playback):
// a progress checkpoint at or past `duration - this` marks the item watched and clears the position.
// Mirrored here because `finish`'s rollback must not accidentally drive that branch.
//
// Drift is NOT symmetric, which an earlier version of this comment had backwards. If THIS value
// grows, the rollback is skipped where it need not be — a lost resume point, annoying. If the
// BACKEND's grows (its own comment invites that tuning by contrasting it with the feed's 10 s),
// this guard starts admitting a rollback the backend reads as a finish, which marks the item
// watched, clears the position and scrobbles to Plex — re-performing the very write that was just
// refused, i.e. exactly the defect this constant was introduced to close. So the invariant is
// one-directional and it is PINNED, not described: `plexFinishMargin.contract.test.ts` reads the
// Python and fails if this value ever drops below it.
const FINISH_MARGIN_S = 30;
// A live wall clock for the top bar, re-rendered every 15s (minute resolution is enough for the
// HH:MM display, and a coarse tick keeps the whole player from re-rendering each second).
export function useWallClock(): Date {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const iv = window.setInterval(() => setNow(new Date()), 15000);
return () => window.clearInterval(iv);
}, []);
return now;
}
// Fullscreen toggle for `el` (the player wrapper) + a reactive `fs` flag.
// Via lib/fullscreen, not the raw API: WebKit (Safari/iPadOS) ships only the prefixed spelling, so
// reading `document.fullscreenElement` alone made this player believe it is never fullscreen — the
// button did nothing, `fs` never flipped, and Escape read "not fullscreen" as permission to close the
// whole player instead of just leaving fullscreen.
export function useFullscreen(wrapRef: RefObject<HTMLElement | null>): {
fs: boolean;
toggleFs: () => void;
} {
const [fs, setFs] = useState(false);
const toggleFs = useCallback(() => {
const el = wrapRef.current;
if (!el) return;
if (fullscreenElement()) exitFullscreen();
else requestFullscreen(el);
}, [wrapRef]);
useEffect(() => onFullscreenChange(() => setFs(!!fullscreenElement())), []);
return { fs, toggleFs };
}
// Auto-hiding control bar: `uiVisible` drives the chrome opacity; `wake` reveals it and (re)arms the
// 3s hide timer. Reads the caller's live refs so the timer callback sees fresh state without a
// closure rebuild: barAutoHide (per-user "never auto-hide" setting), whether a menu is open (keep the
// bar up so it can't vanish mid-choice), and the <video> paused state (a paused video keeps the bar).
// `holdOnHover` is the pointer resting on the chrome: the bar stays while it is there (as on YouTube
// and Plex Web — it used to fade out from under a mouse parked on Next), and leaving it
// starts a fresh 3 s. `holdOnFocus` is the same for a KEYBOARD focus in the chrome: the
// bar must not fade from under the control a key user is on — but only for a while after the last
// key (FOCUS_HOLD_MS). The focus never leaves the bands on its own (Escape hands it back to the ⋯), so
// a hold without an end kept them over the whole film; any key wakes them again.
//
// On touch (C3) nothing moves a mouse, so the timer is also armed by PLAYBACK starting — before, the
// bar stayed up until the first tap however long the video played — and a tap on the picture only
// shows or hides the bar (`toggle`), never pauses. `show(false)` hides at once: a double-tap skip
// takes back the show/hide its first tap did.
const HIDE_MS = 3000;
const FOCUS_HOLD_MS = 10_000;
export function useControlsVisibility(opts: {
barAutoHideRef: RefObject<boolean>;
menuOpenRef: RefObject<boolean>;
videoRef: RefObject<HTMLVideoElement | null>;
}): {
uiVisible: boolean;
/** Whether the bands are up right now — for a key handler that runs before the next render. */
controlsShown: () => boolean;
wake: () => void;
holdOnHover: (over: boolean) => void;
holdOnFocus: (on: boolean) => void;
toggle: () => void;
show: (visible: boolean) => void;
} {
const { barAutoHideRef, menuOpenRef, videoRef } = opts;
const [uiVisible, setUiVisible] = useState(true);
const visibleRef = useRef(true);
const hideTimer = useRef<number | null>(null);
const hovered = useRef(false);
const focused = useRef(false);
const setVisible = useCallback((v: boolean) => {
visibleRef.current = v;
setUiVisible(v);
}, []);
const wake = useCallback(() => {
setVisible(true);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
const wokeAt = Date.now();
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
const busy = () => !!videoRef.current?.paused || menuOpenRef.current || hovered.current;
// Not a timer that re-arms itself: a self-calling `arm` read to the compiler's analysis as a
// value from outside this callback (memo-dependencies), and a self-naming function expression
// tripped one of its invariants. The focus hold re-arms at most once — the second timer fires
// when the hold is over, so it has only `busy` left to ask.
hideTimer.current = window.setTimeout(() => {
if (busy()) return;
const held = FOCUS_HOLD_MS - (Date.now() - wokeAt);
if (focused.current && held > 0) {
hideTimer.current = window.setTimeout(() => {
if (!busy()) setVisible(false);
}, held);
return;
}
setVisible(false);
}, HIDE_MS);
}, [barAutoHideRef, menuOpenRef, videoRef, setVisible]);
const show = useCallback(
(visible: boolean) => {
if (visible) return wake();
if (hideTimer.current) window.clearTimeout(hideTimer.current);
// A tap on a picture with a menu open closes nothing and must not strand that menu unseen.
if (!menuOpenRef.current) setVisible(false);
},
[wake, menuOpenRef, setVisible],
);
const toggle = useCallback(() => show(!visibleRef.current), [show]);
const controlsShown = useCallback(() => visibleRef.current, []);
const holdOnHover = useCallback(
(over: boolean) => {
hovered.current = over;
if (!over) wake();
},
[wake],
);
const holdOnFocus = useCallback(
(on: boolean) => {
const was = focused.current;
focused.current = on;
// Only a hold that ENDS restarts the timer. A tap moving the focus out of the bands (a finger
// never holds: its focus is not :focus-visible) re-showed them on its way to the picture, whose
// own toggle then hid them again — the tap did nothing on Android.
if (was && !on) wake();
},
[wake],
);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.addEventListener("play", wake);
return () => video.removeEventListener("play", wake);
}, [videoRef, wake]);
useEffect(
() => () => {
if (hideTimer.current) window.clearTimeout(hideTimer.current);
},
[],
);
return { uiVisible, controlsShown, wake, holdOnHover, holdOnFocus, toggle, show };
}
// Periodic + on-unmount progress checkpoint. Reads live position/duration through refs (owned by the
// component, since seeking/skip/ended all mutate absRef too) so the interval and unload beacon see the
// current spot without rebuilding the effect.
//
// `settle` is what leaving the item does (the final checkpoint + the reopen-cache patch). The player
// calls it itself BEFORE it moves to another item: by the time this effect's cleanup runs for the
// outgoing id, the position has already been reset for the incoming one — so the cleanup found 0 and
// skipped, and every Next dropped the last stretch watched of the episode being left.
//
// `finish` says the item was played THROUGH (it ended, or the credits auto-skip left it) and the
// caller has marked it watched. From then on leaving it records no position: a checkpoint at the
// credits raced the watched mark (landing second, it left a watched row with a resume point and put
// that point back on Plex after the scrobble), and the reopen patch wrote "new, at 49:55" into the
// cache — Prev within the next 30 s reopened the episode at its last seconds (or at the credits,
// which auto-skipped straight back). The server stores a watched item at position 0, so the patch
// says exactly that. Playing on from the finished frame takes the finish back.
export function usePlexProgress(opts: {
id: string;
qc: QueryClient;
videoRef: RefObject<HTMLVideoElement | null>;
absRef: RefObject<number>;
durationRef: RefObject<number>;
}): { settle: () => void; finish: (confirmed?: Promise<unknown>) => void } {
const { id, qc, videoRef, absRef, durationRef } = opts;
const settleRef = useRef<() => void>(() => {});
const finishRef = useRef<(confirmed?: Promise<unknown>) => void>(() => {});
useEffect(() => {
let finished = false;
// Raised when the caller's watched write came back REFUSED. The play-through latch itself stays
// up, because lowering it re-armed three other routes to the same write: the 10s interval, the
// page-hide beacon and `settle` all post `absRef`, which on this path IS the end of the media,
// and the backend reads a position within `_FINISH_MARGIN_S` of the duration as a finish. So the
// branch named "take the finish back" marked the item watched, cleared the position and
// scrobbled to Plex within ten seconds — the refused write performed through a third route,
// under an error toast saying it had failed. An earlier fix closed only the explicit rollback
// write; this closes the other three. What the refusal does take back is the
// CLAIM: the cache must not say watched, and `settle` must not re-assert it on the way out.
let finishRefused = false;
// `final` = a settled checkpoint (pause / page-hide / leaving the player) vs. the periodic tick.
// Only final checkpoints get mirrored to a linked Plex account (backend-side debounce), so Plex
// sees the resume position when the user actually stops — not a timeline call every 10 seconds.
const flush = (final = false) => {
if (finished) return;
if (absRef.current > 0 && durationRef.current > 0)
api
.plexProgress(id, Math.floor(absRef.current), durationRef.current, final)
.catch(() => {});
};
const iv = window.setInterval(flush, 10000);
// Pausing is a settled position — push it to Plex now rather than waiting for unmount/unload.
// Except the pause that ENDS the media: per the HTML spec the browser sets `paused` and fires
// `pause` BEFORE `ended`, so this ran while the finish latch was still down and sent a final
// checkpoint that raced the watched mark the 'ended' handler was about to send. Both requests
// then read "not watched yet" from their own snapshot and both scrobbled — Plex counted one
// watch twice — and where Plex's reported duration runs more than the finish margin past the
// real media, the checkpoint was not near-end either, so it wrote a resume position AFTER the
// scrobble. That race is what `finish` exists to prevent; on a natural play-through it was
// raised a beat too late.
const onPauseFlush = () => {
if (videoRef.current?.ended) return;
flush(true);
};
const video = videoRef.current;
video?.addEventListener("pause", onPauseFlush);
// A seek is a settled position too (Plex is the source of truth, so it must see the new spot
// right away, not up to 10s later). Debounced so scrubbing — many `seeked` events in a row — pushes
// just ONCE, once the user lands. `final=true` bypasses the backend's ≥10s push-throttle, which is
// exactly why the debounce lives here: it bounds a scrub to one immediate Plex timeline call.
let seekTimer: number | undefined;
const onSeekFlush = () => {
window.clearTimeout(seekTimer);
seekTimer = window.setTimeout(() => flush(true), 800);
};
video?.addEventListener("seeked", onSeekFlush);
// Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload,
// so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a
// keepalive beacon so the POST survives the unload (a final checkpoint — the user is leaving).
const onHide = () => {
if (!finished && absRef.current > 0 && durationRef.current > 0)
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current, true);
};
window.addEventListener("pagehide", onHide);
const onPlayAgain = () => {
finished = false;
finishRefused = false;
};
video?.addEventListener("play", onPlayAgain);
// 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). So leaving
// also patches the cached detail, and a reopen resumes from where we left off. (F5 was always fine:
// a reload starts with an empty cache and fetches the fresh position.) Done when leaving, because
// that is when the position is final. Reading the LATEST abs/duration is the whole point of the
// checkpoint (the resume point when leaving).
const settle = () => {
// Match the language-keyed detail query so the optimistic resume patch lands.
const key = qk.plexItem(id, plexLang(i18n.language));
if (finished) {
// A refused finish claims nothing: the server has the item unwatched at the position the
// rollback restored, and the catch below has already dropped this entry, so the next open
// fetches the truth. Re-patching "watched / 0" here would put back the very lie the
// refusal handler removed.
if (!finishRefused)
qc.setQueryData(key, (old) =>
old ? { ...old, status: "watched", position_seconds: 0 } : old,
);
return;
}
flush(true);
if (absRef.current > 0 && durationRef.current > 0) {
const pos = Math.floor(absRef.current);
qc.setQueryData(key, (old) => (old ? { ...old, position_seconds: pos } : old));
}
};
settleRef.current = settle;
finishRef.current = (confirmed?: Promise<unknown>) => {
finished = true;
// The latch has to go up BEFORE the media pauses, so it cannot wait for the server. If the
// watched write is then refused, what is taken back is the CLAIM, not the latch (see
// `finishRefused`): the cached detail said watched / position 0 while the server still had
// the item unwatched at the last checkpoint, and the once-per-id start guard meant the
// corrected detail arriving later was never used — the episode reopened at 0:00 with a
// Watched badge the server disagreed with.
const pos = Math.floor(absRef.current);
const dur = durationRef.current;
// The media's own end, not Plex's reported duration: a file shorter than its metadata (a
// configuration this file documents) ends outside the margin below, and restoring THAT
// position left an unwatched item with a ~99% bar that reopened in its last seconds.
const ended = video?.ended === true;
void confirmed?.catch(() => {
finishRefused = true;
// Restore the resume point the finish replaced — but ONLY where there is one to restore.
// On the play-to-the-end path `pos` IS the end of the media, and the backend reads a
// position within `_FINISH_MARGIN_S` of the duration as a finish: it would set watched,
// clear the position and scrobble to Plex. So the branch named "take the finish back"
// performed, through a second route, exactly the write that had just been refused — while
// the caller's error toast was already on screen saying it had failed. The
// credits-skip caller captures a mid-episode position and is unaffected.
if (!ended && pos > 0 && dur > 0 && pos <= dur - FINISH_MARGIN_S)
api.plexProgress(id, pos, dur, true).catch(() => {});
// `settle` may already have patched the detail to watched / 0 on the way out. Dropping that
// entry rather than invalidating it: an invalidated query with no observer keeps serving its
// stale data, and this one is a lie — no data is better, the next open fetches the truth.
qc.removeQueries({ queryKey: qk.plexItem(id, plexLang(i18n.language)) });
});
};
return () => {
window.clearInterval(iv);
window.clearTimeout(seekTimer);
window.removeEventListener("pagehide", onHide);
video?.removeEventListener("pause", onPauseFlush);
video?.removeEventListener("seeked", onSeekFlush);
video?.removeEventListener("play", onPlayAgain);
settle();
settleRef.current = () => {};
finishRef.current = () => {};
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: qk.plexShow() });
// The Discover rails (Continue watching, the Surprise draw's cards) hold watch state too.
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
};
// absRef/durationRef/videoRef are stable ref objects — listed to satisfy exhaustive-deps without
// changing when the effect re-runs (id/qc only), matching the original in-component effect.
}, [id, qc, absRef, durationRef, videoRef]);
return {
settle: useCallback(() => settleRef.current(), []),
finish: useCallback((confirmed?: Promise<unknown>) => finishRef.current(confirmed), []),
};
}
// Volume/mute for the Plex player's HTML5 <video> (0..1 float, independent .muted flag). Persists to
// the per-account prefs (survives F5) and re-applies on each fresh session (the element resets to its
// default volume after a load/seek-restart). The 0..1 arithmetic is the shared, unit-tested core in
// playerVolume.ts bound to PLEX_SCALE (the YouTube player binds the same core to YT_SCALE, 0–100); the
// separate .muted flag stays here since it's Plex-only. Using normalizeVolumeIn (not a bare clamp)
// means a corrupt persisted volume falls back to the element's CURRENT level (last applied) rather
// than either silently muting (the old bare clamp) or jumping to full — see playerVolume.ts's header.
export function usePlayerVolume(opts: {
videoRef: RefObject<HTMLVideoElement | null>;
volume: number;
muted: boolean;
ready: boolean;
patchPrefs: (patch: Partial<PlexPlayerPrefs>) => void;
infoOpenRef: RefObject<boolean>;
helpOpenRef: RefObject<boolean>;
}): {
setVol: (vol: number, mute: boolean) => void;
nudgeVolume: (d: number) => void;
toggleMute: () => void;
onWheel: (e: ReactWheelEvent) => void;
} {
const { videoRef, volume, muted, ready, patchPrefs, infoOpenRef, helpOpenRef } = opts;
const applyVolume = useCallback(
(vol: number, mute: boolean) => {
const v = videoRef.current;
if (!v) return;
// Fallback = the element's current level, so a corrupt `vol` HOLDS the playing level instead
// of jumping to full (PLEX_SCALE.default). On a fresh element that current level is the
// browser default, which is the sensible "nothing to restore" value anyway.
v.volume = normalizeVolumeIn(vol, v.volume, PLEX_SCALE);
v.muted = mute;
},
[videoRef],
);
// Set volume/mute on the <video> AND persist (survives F5). Normalise ONCE so the element and the
// stored pref can never disagree; a corrupt input holds the element's current level.
const setVol = useCallback(
(vol: number, mute: boolean) => {
const level = normalizeVolumeIn(vol, videoRef.current?.volume, PLEX_SCALE);
applyVolume(level, mute);
patchPrefs({ volume: level, muted: mute });
},
[applyVolume, patchPrefs, videoRef],
);
const nudgeVolume = useCallback(
(d: number) => {
const next = stepVolumeIn(volume, { delta: d, fallback: volume }, PLEX_SCALE);
setVol(next, next === 0);
},
[volume, setVol],
);
const toggleMute = useCallback(() => setVol(volume, !muted), [volume, muted, setVol]);
// Re-apply the persisted volume/mute to the <video> each time a session becomes ready (the element
// is fresh after a load/seek-restart, and starts at its default volume otherwise).
useEffect(() => {
if (ready) applyVolume(volume, muted);
}, [ready, applyVolume, volume, muted]);
// Mouse-wheel over the player adjusts volume (5 units per notch); overlays scroll normally.
const onWheel = useCallback(
(e: ReactWheelEvent) => {
if (infoOpenRef.current || helpOpenRef.current) return;
nudgeVolume(e.deltaY < 0 ? 0.05 : -0.05);
},
[nudgeVolume, infoOpenRef, helpOpenRef],
);
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 — to the marker end, or
// for the credits that end the item to the next item (binge). Which credits those are, and which the
// auto-skip may act on alone, is `lib/plexMarkers`. 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;
/** `watchedWrite` = the in-flight watched mark for the item being left, which played through (see
* usePlexProgress's `finish`); a refused one takes the finish back. */
go: (id: string | null | undefined, watchedWrite?: Promise<unknown>) => void;
seekTo: (t: number) => void;
videoRef: RefObject<HTMLVideoElement | null>;
/** Says a refused watched mark out loud; the translated string belongs to the component layer. */
reportWatchedError: (e: unknown) => void;
/** No countdown while true (a clip being marked); the Skip button still skips. */
suspended?: boolean;
}): {
activeMarker: PlexMarker | undefined;
skipProgress: number | null;
skipProgressRef: RefObject<number | null>;
cancelAutoSkipRef: RefObject<() => void>;
doSkip: (m: PlexMarker) => void;
} {
const {
id,
abs,
markers,
autoSkipIntro,
autoSkipCredits,
autoSkipDelay,
nextId,
go,
seekTo,
videoRef,
suspended = false,
} = opts;
const activeMarker: PlexMarker | undefined = markers?.find(
(m) => abs >= m.start_s && abs < m.end_s - 1,
);
const activeMarkerRef = useLatest(activeMarker);
// A boolean, not the list, goes into the countdown's deps: a refetch hands back an equal-but-new
// list and must not restart a countdown under way (see below); doSkip reads the list from a ref.
const activeAuto = activeMarker != null && markerSkip(activeMarker, markers ?? []).auto;
const markersRef = useLatest(markers);
const [skipProgress, setSkipProgress] = useState<number | null>(null);
const skipProgressRef = useLatest(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 reportWatchedErrorRef = useLatest(opts.reportWatchedError);
const doSkip = useCallback(
(m: PlexMarker) => {
setSkipProgress(null);
if (nextId && markerSkip(m, markersRef.current ?? []).leaves) {
// 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). The
// write is handed to go() so a refusal takes the finish back: this path has no near-end
// checkpoint to corroborate it, so one failed request used to leave the cache claiming
// watched / position 0 while the server still had the credits position.
const watchedWrite = api.plexSetState(id, "watched");
watchedWrite.catch(reportWatchedErrorRef.current);
go(nextId, watchedWrite);
} else seekTo(m.end_s);
},
[id, go, nextId, seekTo, markersRef, reportWatchedErrorRef],
);
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);
}, [activeMarkerRef]);
const cancelAutoSkipRef = useLatest(cancelAutoSkip);
// The marker is identified by type+offset rather than by object identity: `markers?.find` returns
// the same element while the playhead stays inside it, but a refetch of the markers list would
// hand back an equal-but-new object and restart a countdown already under way. The marker itself
// is then read from the ref above, so the dep list names every reactive input it has.
const activeMarkerType = activeMarker?.type;
const activeMarkerStart = activeMarker?.start_s;
useEffect(() => {
const m = activeMarkerRef.current;
if (!m) return setSkipProgress(null);
const key = `${m.type}-${m.start_s}`;
const enabled = (m.type === "intro" ? autoSkipIntro : autoSkipCredits) && activeAuto;
if (!enabled || suspended || 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;
};
}, [
activeMarkerType,
activeMarkerStart,
autoSkipIntro,
autoSkipCredits,
autoSkipDelay,
suspended,
doSkip,
videoRef,
activeMarkerRef,
activeAuto,
]);
return { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip };
}
// Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state and the
// audio/subtitle change/cycle handlers; audio switches re-map server-side via a session restart
// (single stream) or client-side via hls.js (multi-rendition), while subtitle switches just flip the single
// mounted <track>. The shared media refs (video/hls/audio/aoff/abs/multiAudio) are owned by the
// component and passed in so this hook and the media session read/write the same substrate.
/** Why a subtitle track can't be shown, in the three buckets the player has a sentence for. The
* server's codes are finer-grained than that on purpose (they drive the HTTP status too), so the
* fold happens here rather than multiplying near-identical translations. */
export type SubFailReason = "bitmap" | "missing" | "failed";
// The subtitle status poll's cadence after a FAILED request (see usePlexTracks), how many times a
// <track> whose own VTT fetch failed is remounted before the track is reported as unavailable, and
// the first of the (doubling) pauses before each remount.
const SUB_POLL_ERROR_MS = 10_000;
// …and after an `unavailable` answer: nothing is being read for this position right now (a memo the
// server keeps for a minute), so no need for the 1 s cadence — but the poll carries the position, and
// moving into a stretch that IS cached should stop saying "couldn't be loaded" within a few seconds.
const SUB_POLL_UNAVAILABLE_MS = 5_000;
// …and on a `partial` track that is no longer `growing` (its full pass failed and is backed off for
// an hour): it will not fill in on its own, so the 2 s cadence ran for the rest of the film. The poll
// still carries the position — it is what reads the next window ahead of an edge, which the server
// starts within LOOKAHEAD_S (60 s) of it — so it slows down rather than stops.
const SUB_POLL_STALLED_MS = 15_000;
const SUB_TRACK_RETRIES = 2;
const SUB_TRACK_RETRY_MS = 2_000;
function subFailReason(code: string | null | undefined): SubFailReason {
if (code === "bitmap") return "bitmap";
if (code === "no_item" || code === "no_track" || code === "no_file" || code === "not_fetchable")
return "missing";
return "failed";
}
export function usePlexTracks(opts: {
id: string;
detail: PlexItemDetail | undefined;
sessionK: number;
subOffset: number;
/** Restart the item's session at `startAt` (HlsEngine.start). */
restartSession: (startAt: number) => void;
/** Switch audio client-side; false when no multi-rendition session is attached (HlsEngine). */
setAudioTrack: (track: number) => boolean;
patchPrefs: (patch: Partial<PlexPlayerPrefs>) => void;
videoRef: RefObject<HTMLVideoElement | null>;
audioRef: RefObject<number | null>;
aoffRef: RefObject<number>;
/** Where the viewer is — before playback has ticked, where the session was ASKED to start
* (HlsEngine.playhead). Anything that restarts "here", and the subtitle poll, must use it. */
playhead: () => number;
}): {
audioOrd: number | null;
setAudioOrd: (ord: number | null) => void;
subOrd: number | null;
setSubOrd: (ord: number | null) => void;
changeAudio: (ord: number | null) => void;
changeSubtitle: (ord: number | null) => void;
/** Step to the next track and RETURN the ord landed on (null = the default/off entry), or
* `undefined` when there was nothing to cycle. The caller needs the answer to put it on screen:
* a keyboard track switch used to change the audio and tell you nothing. */
cycleAudio: () => number | null | undefined;
cycleSubtitle: () => number | null | undefined;
changeAudioOffset: (v: number) => void;
textSubs: PlexItemDetail["subtitle_streams"];
selectedSub: PlexItemDetail["subtitle_streams"][number] | null;
subTrackOffset: number;
/** The selected track has something to render (a window around the playhead, or the whole thing).
* False means "not yet" — the caller must NOT mount a <track>, it would just sit blank. */
subShowable: boolean;
/** What the player must SAY about the selected track, if anything: still being prepared, or why it
* can't be shown (bucketed for the sentence the player has). Independent of `subShowable` — a
* failing status poll is worth saying even while an earlier window is still mounted. */
subNotice: "preparing" | SubFailReason | null;
/** The cached VTT's generation — it grows as the background extraction widens the track, so it
* belongs in the <track> key/URL or the browser keeps serving the first, short window. */
subGen: number;
/** The <track>'s React key. Everything that must produce a FRESH element — and so a fresh VTT
* fetch — is in it: the item and track, the shifted offset (a seek-restart or a debounced offset
* change), the extraction generation (a widened track replaces the first short window), and the
* failed-load count (a <track> whose fetch failed is replaced rather than left dead). */
subTrackKey: string;
/** Spread onto the <track>. Its `error` event does not bubble, and React 18 attaches no listener of
* its own to a <track> — a plain `onError` there is never called (measured in Chrome and jsdom:
* 0 calls, while `onErrorCapture` fires). The capture phase reaches the target regardless. */
subTrackListeners: { onErrorCapture: () => void };
} {
const { id, detail, sessionK, subOffset, restartSession, setAudioTrack, patchPrefs } = opts;
const { videoRef, audioRef, aoffRef, playhead } = opts;
const qc = useQueryClient();
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
const [audioOrd, setAudioOrd] = useState<number | null>(null);
// The subtitle choice belongs to the ITEM it was made for. Moving to the next episode used to
// carry the old ordinal over until the new item's detail re-derived it, so the status poll asked
// the new item about a track number that may be a different language there, at the old item's
// position. Stored with its id, it simply reads as "off" on any other item.
const [subSel, setSubSel] = useState<{ id: string; ord: number | null }>({ id, ord: null });
const subOrd = subSel.id === id ? subSel.ord : null;
const setSubOrd = useCallback((ord: number | null) => setSubSel({ id, ord }), [id]);
// Failed-<track>-load count for one track generation (see onSubTrackError below).
const [loadFails, setLoadFails] = useState({ key: "", n: 0 });
// Debounced user subtitle offset: avoids re-fetching the VTT on every slider step while dragging.
const [subOffApplied, setSubOffApplied] = useState(subOffset);
useEffect(() => {
const tmr = window.setTimeout(() => setSubOffApplied(subOffset), 250);
return () => window.clearTimeout(tmr);
}, [subOffset]);
// Switching AUDIO re-maps the track server-side, so it restarts the session at the current
// position (like a seek). The ref is updated synchronously so the session request sees it.
const changeAudio = useCallback(
(ord: number | null) => {
audioRef.current = ord;
setAudioOrd(ord);
// Remember the LANGUAGE (not the ord) so F5 / the next item restores this choice. Menu stays open.
patchPrefs({
audioLang:
ord == null ? "" : (detail?.audio_streams.find((s) => s.ord === ord)?.language ?? ""),
});
// Multi-rendition: switch the audio track CLIENT-SIDE — instant, same timeline, no restart.
// Single audio (or the session has not answered yet): re-map the track server-side.
if (!setAudioTrack(ord ?? 0)) restartSession(playhead());
},
[restartSession, setAudioTrack, detail, patchPrefs, audioRef, playhead],
);
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
// (applied by the effect below), NO session restart. Off = null.
const changeSubtitle = useCallback(
(ord: number | null) => {
setSubOrd(ord);
// Picking a track (the same one included) is the viewer asking for it NOW: a track given up on
// after failed loads gets a fresh budget instead of repeating "couldn't be loaded" unasked —
// and the server is asked again at once. Re-picking the highlighted track changes no query key,
// so without the refetch that half of "now" did nothing: a poll backing off after a failed
// request, or one waiting out an `unavailable`, kept waiting.
setLoadFails({ key: "", n: 0 });
if (ord != null && ord === subOrd)
void qc.refetchQueries({ queryKey: qk.plexSubtitle(id, ord), exact: true });
patchPrefs({
subLang:
ord == null ? "" : (detail?.subtitle_streams.find((s) => s.ord === ord)?.language ?? ""),
});
},
[detail, patchPrefs, setSubOrd, subOrd, qc, id],
);
// 'A' cycles audio tracks; 'S' cycles subtitle (off → each text sub → off).
const cycleAudio = useCallback(() => {
const streams = detail?.audio_streams ?? [];
if (streams.length < 2) return undefined;
const idx = streams.findIndex((s) => s.ord === (audioRef.current ?? 0));
const next = streams[(idx + 1) % streams.length]!; // length >= 2 (guarded), so in-bounds
const ord = next.ord === 0 ? null : next.ord;
changeAudio(ord);
return ord;
}, [detail, changeAudio, audioRef]);
const cycleSubtitle = useCallback(() => {
const subs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
if (subs.length === 0) return undefined; // nothing to cycle through but "off"
const order: (number | null)[] = [null, ...subs.map((s) => s.ord)];
const i = order.findIndex((o) => o === subOrd);
const ord = order[(i + 1) % order.length]!; // order always has [null, ...] → non-empty
changeSubtitle(ord);
return ord;
}, [detail, subOrd, changeSubtitle]);
// Audio A/V-sync offset: persist immediately (for the slider) but debounce the session restart so
// dragging doesn't respawn ffmpeg on every step.
const aoffTimer = useRef<number | null>(null);
const changeAudioOffset = useCallback(
(v: number) => {
patchPrefs({ audioOffset: v });
aoffRef.current = v;
if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
aoffTimer.current = window.setTimeout(() => restartSession(playhead()), 500);
},
[patchPrefs, restartSession, aoffRef, playhead],
);
// A restart still waiting belongs to the item it was asked on: fired after a move to the next one,
// it restarted the PREVIOUS item, whose stream then played under the new title. The new item's
// session reads the offset itself (`aoffRef`), so nothing is lost by dropping it.
useEffect(
() => () => {
if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
aoffTimer.current = null;
},
[id],
);
// --- is the selected track actually extractable yet? -------------------------------------------
// Pulling one subtitle track out of a big MKV means ffmpeg streams the whole container: measured on
// prod at 154 s for a 17 GB film (and the same again for the second track). That used to happen
// INSIDE the <track>'s own request, so the browser sat on an empty track with no spinner, no retry
// and no error — the "switch the track, nothing happens, stop/start a few times and eventually it
// works" report (bug hunt 2026-09, item 8). The server now extracts in the background, a window
// around the playhead first, and this poll is what tells us when there is something to mount.
const subStatusQ = useQuery({
queryKey: qk.plexSubtitle(id, subOrd ?? -1),
// The position is only known once playback has ticked: until the first timeupdate `abs` is 0
// even though a resumed session starts minutes in — and every poll said `at=0`, so the first
// window was cut around the head of the film instead of where the viewer is (measured on
// localdev: a resume at 20:00 polled `at=0` for as long as it stayed paused). Until then the
// answer is where the session was asked to start. Not `sessionK`: that only lands once the
// session request has answered, so the first poll of a fresh open read 0 from it, and after a
// move to the next item it still held the PREVIOUS item's start.
queryFn: () => api.plexSubtitleStatus(id, subOrd as number, playhead()),
enabled: subOrd != null,
// Keep polling until the track is COMPLETE, not just showable: each poll carries the current
// position, so seeking past the extracted window is what asks for the next one. A settled
// answer — the whole track, or a reason it can never be shown — stops the poll; without that
// stop, a bitmap track would poll for the length of the film AND keep re-arming the extraction
// every time the server's short failure memo expired.
//
// A FAILED request is neither: it says nothing about the track. It must not keep the 1 s cadence
// (it used to — `data` stays empty on an error, so every failure read as "still preparing" and
// re-armed a second later, for as long as the player was open), but it must not end the poll
// either, or one dropped request would leave "unavailable" on screen for a track that is fine.
// So it backs off to SUB_POLL_ERROR_MS; the player shows the failure inline meanwhile, which is
// also why the request is `quiet` (no global error dialog re-opening under the film).
//
// `unavailable` is not settled either — it is the server's "nothing for this position right now"
// (a Plex hiccup, a stretch ffmpeg could not read), which it forgets within a minute. Treated as
// final it ended the subtitles for the rest of the session, cached stretches included.
refetchInterval: (q) => {
if (q.state.status === "error") return SUB_POLL_ERROR_MS;
const st = q.state.data?.state;
if (st === "ready" || st === "failed") return false;
if (st === "unavailable") return SUB_POLL_UNAVAILABLE_MS;
if (st === "partial") return q.state.data?.growing === false ? SUB_POLL_STALLED_MS : 2000;
return 1000;
},
staleTime: 0,
retry: false,
});
const subStatus = subOrd == null ? null : (subStatusQ.data ?? null);
// A <track> whose own VTT fetch failed never fetches again, and re-asking the status cannot fix
// that on its own: while the answer (and its `gen`) is unchanged the key is unchanged, so React
// keeps the dead element and the subtitles stay silently blank. So a failed load bumps a counter
// that is part of the key — a fresh element, a fresh fetch — and after SUB_TRACK_RETRIES the track
// is reported as unavailable instead of left blank. The count belongs to one track generation: a
// different track, item or `gen` starts from zero, and so does picking the track again.
const loadKey = `${id}:${subOrd}:${subStatus?.gen ?? 0}`;
const subRetry = loadFails.key === loadKey ? loadFails.n : 0;
const subGaveUp = subRetry > SUB_TRACK_RETRIES;
// Mount the <track> for a `partial` track too: a window around the playhead is exactly what the
// viewer needs right now, and the poll widens it underneath them. And for an `unavailable` one that
// has cues somewhere (`gen` > 0): nothing shows at a stretch with no cues, while a seek back into a
// cached one shows them again — unmounting it blanked those too.
const subShowable =
(subStatus?.state === "ready" ||
subStatus?.state === "partial" ||
(subStatus?.state === "unavailable" && subStatus.gen > 0)) &&
!subGaveUp;
// A failing poll is said even while an earlier window stays mounted: react-query keeps the last
// good `data` through a failed refetch, so a `partial` answer kept the track "showable" and the
// failure had nowhere to appear — blank subtitles past the window, no spinner, no message.
const subNotice: "preparing" | SubFailReason | null =
subOrd == null
? null
: subStatusQ.isError || subGaveUp
? "failed"
: subStatus?.state === "failed" || subStatus?.state === "unavailable"
? subFailReason(subStatus.code)
: subShowable
? null
: "preparing";
// A remount fetches at once, so remounting straight away spent the whole budget inside a
// one-second outage (an API restart): three failures in a few milliseconds, then "unavailable"
// for a track that would have loaded a moment later. Each retry waits, doubling; running out is
// reported at once. A pending retry dies with the generation it was for.
const retryTimer = useRef<number | null>(null);
useEffect(
() => () => {
if (retryTimer.current != null) window.clearTimeout(retryTimer.current);
retryTimer.current = null;
},
[loadKey],
);
const onSubTrackError = useCallback(() => {
const bump = () =>
setLoadFails((f) => ({ key: loadKey, n: (f.key === loadKey ? f.n : 0) + 1 }));
if (subRetry >= SUB_TRACK_RETRIES) {
bump();
return;
}
if (retryTimer.current != null) window.clearTimeout(retryTimer.current);
retryTimer.current = window.setTimeout(
() => {
retryTimer.current = null;
bump();
// …and ask again: if the cached VTT was evicted between the poll and the fetch, the answer
// drops back to `preparing` and the poll takes over until it has been re-extracted.
if (subOrd != null) qc.invalidateQueries({ queryKey: qk.plexSubtitle(id, subOrd) });
},
SUB_TRACK_RETRY_MS * 2 ** subRetry,
);
}, [qc, id, subOrd, loadKey, subRetry]);
// Text subtitles offered in the picker; image subs (PGS/VobSub) can't be shown as text.
const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
// Render ONLY the selected sub as a single <track> (below) — rendering all of them and flipping
// modes raced (multiple "showing" at once → overlapping HU+EN) especially across a session restart.
const selectedSub = textSubs.find((s) => s.ord === subOrd) ?? null;
// The backend shifts the absolute-time VTT by (sessionK − userSubOffset): sessionK aligns it to the
// HLS session's zero-based clock; subtracting the user offset makes +offset show the sub LATER. Uses
// the DEBOUNCED user offset so slider drags don't refetch the whole VTT per step.
// sessionK is already lag-compensated (see HlsEngine), so it is the true content-zero the VTT
// must be shifted by; the user offset nudges on top.
const subTrackOffset = sessionK - subOffApplied;
// Keep our own track "hidden": loaded and its cues tracked, but drawn by PlexSubtitles, not by the
// browser. Any other subtitle track (a direct-played file's own, see ownTextTracks) is switched
// off, so neither the browser nor PlexSubtitles draws it. Not once per selection: the <track> is
// remounted on its own key (a grown window, a retry), and the browser switches a fresh `default`
// track to "showing" a task later — which drew the cues a second time, natively, under ours. So it
// is re-applied on every track the list gains and on every mode change the browser makes.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const tracks = video.textTracks;
const settle = () => {
const own = ownTextTracks(video);
for (const tr of Array.from(tracks)) {
const subtitle = tr.kind === "subtitles" || tr.kind === "captions";
const want = own.has(tr) ? "hidden" : subtitle ? "disabled" : tr.mode;
if (tr.mode !== want) tr.mode = want;
}
};
settle();
tracks.addEventListener("addtrack", settle);
tracks.addEventListener("change", settle);
return () => {
tracks.removeEventListener("addtrack", settle);
tracks.removeEventListener("change", settle);
};
}, [subOrd, id, videoRef]);
return {
audioOrd,
setAudioOrd,
subOrd,
setSubOrd,
changeAudio,
changeSubtitle,
cycleAudio,
cycleSubtitle,
changeAudioOffset,
textSubs,
selectedSub,
subTrackOffset,
subShowable,
subNotice,
subGen: subStatus?.gen ?? 0,
subTrackKey: `${id}-${subOrd}-${subTrackOffset.toFixed(2)}-${subStatus?.gen ?? 0}-${subRetry}`,
subTrackListeners: { onErrorCapture: onSubTrackError },
};
}