- 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.
909 lines
47 KiB
TypeScript
909 lines
47 KiB
TypeScript
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 },
|
||
};
|
||
}
|