Files
siftlode/frontend/src/lib/plexPlayerHooks.ts
T
peter 6a7ff330f6 refactor(frontend): the shared hooks in lib/ stop lying about their deps (E27 S2)
Ten more suppressions, and React 19 turns out to supply the missing primitive.

useEffectEvent (stable since React 19.2, which S1 brought in) is the canonical answer to the
"bind the listener once, but read the latest values" pattern these hooks were disabling the
checker for. Three of playerModalHooks' four escape hatches become honest dep lists: the
wheel-volume handler, the native-controls handover, and the fullscreen-change sync. The
contract is no longer prose — the compiler enforces it.

The fourth stays, deliberately, and now says why at the site: the YouTube player's lifecycle
effect must run once per video (re-running destroys and rebuilds the iframe), and YT.Player
takes its callbacks as an object literal, which an effect event may not be passed into.

Fixed, not suppressed:
- useDismiss captured onClose when the popover opened, so a caller that re-rendered with a
  different handler while it stayed open kept getting the OLD one called. The disabled dep list
  asserted the opposite ("read fresh from this run's closure"). Both the handler and the ref
  list are read through refs now; three tests, the two that matter mutation-checked.
- usePref wrapped its seed in a useCallback whose dep list had to be excused. useState calls a
  lazy initializer exactly once, so the memo was saving nothing; inlined.
- useCardPager clamped an out-of-range page from an effect. Adjusted during render instead —
  React discards that pass rather than committing it, so no out-of-range page is ever painted.
- plexPlayerHooks' auto-skip countdown reads its marker from the ref that already existed, so
  the dep list names its real inputs. That also made the set-state-in-effect finding go away:
  the progress is genuinely timer-driven, and the checker can now see that.
- useUrlMirror and useChipOverflowFit only needed their stable refs listed.

Two test-harness suppressions (a render-phase probe ref, a module-level setState handle) moved
inline with the reason they exist: both exist to observe a hook from outside without changing
what is being measured.

Suppressions 99 -> 89; exhaustive-deps 35 -> 27.
2026-09-21 00:07:12 +02:00

841 lines
44 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 MutableRefObject,
type RefObject,
type WheelEvent as ReactWheelEvent,
} from "react";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
exitFullscreen,
fullscreenElement,
onFullscreenChange,
requestFullscreen,
} from "./fullscreen";
import { api, type PlexItemDetail, type PlexMarker } from "./api";
import { qk } from "./queryKeys";
import { plexLang } from "../i18n";
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 (round 7).
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).
export function useControlsVisibility(opts: {
barAutoHideRef: RefObject<boolean>;
menuOpenRef: RefObject<boolean>;
videoRef: RefObject<HTMLVideoElement | null>;
}): { uiVisible: boolean; wake: () => void } {
const { barAutoHideRef, menuOpenRef, videoRef } = opts;
const [uiVisible, setUiVisible] = useState(true);
const hideTimer = useRef<number | null>(null);
const wake = useCallback(() => {
setUiVisible(true);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
hideTimer.current = window.setTimeout(() => {
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false);
}, 3000);
}, [barAutoHideRef, menuOpenRef, videoRef]);
return { uiVisible, wake };
}
// 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: MutableRefObject<number>;
durationRef: MutableRefObject<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. Round 6 closed only the explicit rollback write;
// this closes the other three (deep-review round 7). 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 (deep-review round 5).
const onPauseFlush = () => {
if (videoRef.current?.ended) return;
flush(true);
};
const video = videoRef.current;
video?.addEventListener("pause", onPauseFlush);
// A seek is a settled position too (S2: 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 (E5 S6) so the optimistic resume patch lands.
const key = qk.plexItem(id, plexLang());
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 (round 5).
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 (round 6). 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()) });
});
};
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() });
};
// 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), []),
};
}
/** Where the viewer is. `abs` only moves once playback has ticked — until then (a session still
* loading, or a resumed one that has stayed paused) it reads 0 while the film is minutes in, so the
* answer is where the session was ASKED to start. Anything that restarts the session "here" — an
* audio switch, an A/V offset, a keyboard seek — must use this: reading `abs` restarted a resume at
* 20:00 from 0:00, and the next checkpoint saved that (deep-review round 4). */
export function playhead(
absRef: MutableRefObject<number>,
loadStartRef: MutableRefObject<number>,
): number {
return absRef.current > 0 ? absRef.current : loadStartRef.current;
}
// 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 — 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;
/** `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;
}): {
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 reportWatchedErrorRef = useRef(opts.reportWatchedError);
reportWatchedErrorRef.current = opts.reportWatchedError;
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). 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 (round 5).
const watchedWrite = api.plexSetState(id, "watched");
watchedWrite.catch(reportWatchedErrorRef.current);
go(nextId, watchedWrite);
} 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;
// 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;
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;
};
}, [
activeMarkerType,
activeMarkerStart,
autoSkipIntro,
autoSkipCredits,
autoSkipDelay,
doSkip,
videoRef,
]);
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 loadSession (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;
subPos: number;
loadSession: (startAt: number, resumePlay?: boolean) => Promise<void>;
patchPrefs: (patch: Partial<PlexPlayerPrefs>) => void;
videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>;
multiAudioRef: RefObject<boolean>;
audioRef: MutableRefObject<number | null>;
aoffRef: MutableRefObject<number>;
absRef: MutableRefObject<number>;
/** The position the current (or still in-flight) session was ASKED to start at — written before
* the session request goes out, unlike `sessionK`, which only lands once it has answered. */
loadStartRef: MutableRefObject<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, subPos, loadSession, patchPrefs } = opts;
const { videoRef, hlsRef, multiAudioRef, audioRef, aoffRef, absRef, loadStartRef } = 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 loadSession 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 ?? ""),
});
if (multiAudioRef.current && hlsRef.current) {
// Multi-rendition: switch the audio track CLIENT-SIDE — instant, same timeline, no restart.
try {
hlsRef.current.audioTrack = ord ?? 0;
} catch {
/* ignore */
}
} else {
// Single audio (or the session has not answered yet): re-map the track server-side.
loadSession(playhead(absRef, loadStartRef));
}
},
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef, loadStartRef],
);
// 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(() => loadSession(playhead(absRef, loadStartRef)), 500);
},
[patchPrefs, loadSession, aoffRef, absRef, loadStartRef],
);
// --- 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(absRef, loadStartRef)),
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 loadSession), so it is the true content-zero the VTT
// must be shifted by; the user offset nudges on top.
const subTrackOffset = sessionK - subOffApplied;
// Force the single mounted track to "showing" (its VTT loads on mount; `default` alone can be flaky
// after a remount). Re-runs when the selection or the shifted-track identity changes (fresh <track>).
useEffect(() => {
const video = videoRef.current;
if (!video) return;
for (const tr of Array.from(video.textTracks)) tr.mode = "showing";
}, [subOrd, id, subTrackOffset, videoRef]);
// Position subtitle cues per the user's vertical setting (subPos). The video fills the viewport
// height, so a default (line "auto") cue renders at the extreme bottom and clips — line% lifts it.
// Re-applied on each cue change (hls.js streams cues in as subtitle segments load; no per-cue event).
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const placeAll = () => {
for (const tr of Array.from(video.textTracks)) {
for (const c of Array.from(tr.cues || [])) {
try {
(c as VTTCue).snapToLines = false;
(c as VTTCue).line = subPos;
} catch {
/* not a positionable cue */
}
}
}
};
const bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll);
for (const tr of Array.from(video.textTracks)) bind(tr);
const onAddTrack = (e: TrackEvent) => {
if (e.track) {
bind(e.track as TextTrack);
placeAll();
}
};
video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
placeAll();
return () => {
video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
};
}, [id, subPos, 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 },
};
}