S4a-4a of the PlexPlayer decomposition. Move audio/subtitle track selection + subtitle rendering into usePlexTracks: the selected-ordinal state, change/cycle audio+subtitle, the debounced sub-offset, the audio A/V-sync offset, and the two subtitle effects (force-showing the mounted track + positioning cues at the user's subPos). The shared media refs (video/hls/audio/aoff/abs/multiAudio) stay component-owned and are passed in, since loadSession + the detail effect write audioRef too; absRef is hoisted above the hook call for that. No behaviour change; verified by self-E2E (subtitle on/off via 'S' mounts/removes the <track>, mode 'showing', VTT 804 cues all positioned to line=subPos, subLang persists; playback uninterrupted). Audio switch is single-track here so cycleAudio correctly no-ops.
491 lines
21 KiB
TypeScript
491 lines
21 KiB
TypeScript
import {
|
||
useCallback,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
type MutableRefObject,
|
||
type RefObject,
|
||
type WheelEvent as ReactWheelEvent,
|
||
} from "react";
|
||
import 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 { clamp01, type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
||
|
||
// 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.
|
||
export function usePlexProgress(opts: {
|
||
id: string;
|
||
qc: QueryClient;
|
||
videoRef: RefObject<HTMLVideoElement | null>;
|
||
absRef: MutableRefObject<number>;
|
||
durationRef: MutableRefObject<number>;
|
||
}): void {
|
||
const { id, qc, videoRef, absRef, durationRef } = opts;
|
||
useEffect(() => {
|
||
// `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 (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.
|
||
const onPauseFlush = () => flush(true);
|
||
const video = videoRef.current;
|
||
video?.addEventListener("pause", onPauseFlush);
|
||
// 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 (absRef.current > 0 && durationRef.current > 0)
|
||
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current, true);
|
||
};
|
||
window.addEventListener("pagehide", onHide);
|
||
return () => {
|
||
window.clearInterval(iv);
|
||
window.removeEventListener("pagehide", onHide);
|
||
video?.removeEventListener("pause", onPauseFlush);
|
||
flush(true);
|
||
// 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).
|
||
// Patch the cached detail here so a reopen resumes from where we left off. (F5 was always fine:
|
||
// a reload starts with an empty cache and fetches the fresh position.) Safe to do only on
|
||
// unmount — patching while mounted would retrigger the loadSession effect and reseek mid-play.
|
||
// Reading the LATEST abs/duration at cleanup is the whole point of the checkpoint (the resume
|
||
// point when leaving), not a stale snapshot — the "ref value may have changed" hint is a false
|
||
// positive here. Scoped disable so the deps array below is still checked.
|
||
/* eslint-disable react-hooks/exhaustive-deps */
|
||
if (absRef.current > 0 && durationRef.current > 0) {
|
||
const pos = Math.floor(absRef.current);
|
||
qc.setQueryData(qk.plexItem(id), (old) => (old ? { ...old, position_seconds: pos } : old));
|
||
}
|
||
/* eslint-enable react-hooks/exhaustive-deps */
|
||
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]);
|
||
}
|
||
|
||
// 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). NOTE: this is the Plex-side model; the YouTube player has
|
||
// its own 0-100 / no-mute-flag arithmetic in playerVolume.ts. Sharing the tested pure core across both
|
||
// is a separate R12 consolidation sprint — this hook is the seam it will rework internally.
|
||
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;
|
||
v.volume = clamp01(vol);
|
||
v.muted = mute;
|
||
},
|
||
[videoRef],
|
||
);
|
||
// Set volume/mute on the <video> AND persist (survives F5).
|
||
const setVol = useCallback(
|
||
(vol: number, mute: boolean) => {
|
||
applyVolume(vol, mute);
|
||
patchPrefs({ volume: clamp01(vol), muted: mute });
|
||
},
|
||
[applyVolume, patchPrefs],
|
||
);
|
||
const nudgeVolume = useCallback(
|
||
(d: number) => setVol(clamp01(volume + d), clamp01(volume + d) === 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;
|
||
go: (id: string | null | undefined) => void;
|
||
seekTo: (t: number) => void;
|
||
videoRef: RefObject<HTMLVideoElement | null>;
|
||
}): {
|
||
activeMarker: PlexMarker | undefined;
|
||
skipProgress: number | null;
|
||
skipProgressRef: MutableRefObject<number | null>;
|
||
cancelAutoSkipRef: MutableRefObject<() => void>;
|
||
doSkip: (m: PlexMarker) => void;
|
||
} {
|
||
const {
|
||
id,
|
||
abs,
|
||
markers,
|
||
autoSkipIntro,
|
||
autoSkipCredits,
|
||
autoSkipDelay,
|
||
nextId,
|
||
go,
|
||
seekTo,
|
||
videoRef,
|
||
} = opts;
|
||
const skipProgressRef = useRef<number | null>(null);
|
||
const cancelAutoSkipRef = useRef<() => void>(() => {});
|
||
|
||
const activeMarker: PlexMarker | undefined = markers?.find(
|
||
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||
);
|
||
const activeMarkerRef = useRef(activeMarker);
|
||
activeMarkerRef.current = activeMarker;
|
||
|
||
const [skipProgress, setSkipProgress] = useState<number | null>(null);
|
||
skipProgressRef.current = skipProgress;
|
||
const cancelledMarkerRef = useRef<string | null>(null);
|
||
// Forget a cancelled-skip when the item changes: the ref holds a `${type}-${start_s}` key, and a
|
||
// marker at the same offset on the NEXT episode would otherwise be suppressed by a cancel made on
|
||
// the previous one (auto-skip silently dead for the rest of a binge).
|
||
useEffect(() => {
|
||
cancelledMarkerRef.current = null;
|
||
}, [id]);
|
||
// The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress
|
||
// alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway.
|
||
const skipTimerRef = useRef<number | null>(null);
|
||
const doSkip = useCallback(
|
||
(m: PlexMarker) => {
|
||
setSkipProgress(null);
|
||
if (m.type === "credits" && nextId) {
|
||
// Binge auto-advance: the episode we're leaving was watched through the credits, so mark IT
|
||
// (the current id, captured here — not the reactive id, which go() is about to change).
|
||
api.plexSetState(id, "watched").catch(() => {});
|
||
go(nextId);
|
||
} else seekTo(m.end_s);
|
||
},
|
||
[id, go, nextId, seekTo],
|
||
);
|
||
const cancelAutoSkip = useCallback(() => {
|
||
const m = activeMarkerRef.current;
|
||
if (m) cancelledMarkerRef.current = `${m.type}-${m.start_s}`; // don't re-arm this same marker
|
||
if (skipTimerRef.current) {
|
||
window.clearInterval(skipTimerRef.current); // actually stop the countdown, not just blank it
|
||
skipTimerRef.current = null;
|
||
}
|
||
setSkipProgress(null);
|
||
}, []);
|
||
cancelAutoSkipRef.current = cancelAutoSkip;
|
||
useEffect(() => {
|
||
const m = activeMarker;
|
||
if (!m) return setSkipProgress(null);
|
||
const key = `${m.type}-${m.start_s}`;
|
||
const enabled = m.type === "intro" ? autoSkipIntro : autoSkipCredits;
|
||
if (!enabled || cancelledMarkerRef.current === key) return setSkipProgress(null);
|
||
if (autoSkipDelay <= 0) return doSkip(m); // immediate
|
||
let elapsed = 0;
|
||
setSkipProgress(0);
|
||
const iv = window.setInterval(() => {
|
||
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
|
||
// deliberately-paused video. It resumes from where it stopped when playback resumes.
|
||
if (videoRef.current?.paused) return;
|
||
elapsed += 0.1;
|
||
const p = Math.min(1, elapsed / autoSkipDelay);
|
||
setSkipProgress(p);
|
||
if (p >= 1) {
|
||
window.clearInterval(iv);
|
||
skipTimerRef.current = null;
|
||
doSkip(m);
|
||
}
|
||
}, 100);
|
||
skipTimerRef.current = iv;
|
||
return () => {
|
||
window.clearInterval(iv);
|
||
skipTimerRef.current = null;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [
|
||
activeMarker?.type,
|
||
activeMarker?.start_s,
|
||
autoSkipIntro,
|
||
autoSkipCredits,
|
||
autoSkipDelay,
|
||
doSkip,
|
||
]);
|
||
|
||
return { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip };
|
||
}
|
||
|
||
// 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.
|
||
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>;
|
||
}): {
|
||
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;
|
||
cycleAudio: () => void;
|
||
cycleSubtitle: () => void;
|
||
changeAudioOffset: (v: number) => void;
|
||
textSubs: PlexItemDetail["subtitle_streams"];
|
||
selectedSub: PlexItemDetail["subtitle_streams"][number] | null;
|
||
subTrackOffset: number;
|
||
} {
|
||
const { id, detail, sessionK, subOffset, subPos, loadSession, patchPrefs } = opts;
|
||
const { videoRef, hlsRef, multiAudioRef, audioRef, aoffRef, absRef } = opts;
|
||
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
|
||
const [audioOrd, setAudioOrd] = useState<number | null>(null);
|
||
const [subOrd, setSubOrd] = useState<number | null>(null);
|
||
// 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 {
|
||
loadSession(absRef.current); // single-audio: re-map the track server-side (session restart)
|
||
}
|
||
},
|
||
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef],
|
||
);
|
||
// 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);
|
||
patchPrefs({
|
||
subLang:
|
||
ord == null ? "" : (detail?.subtitle_streams.find((s) => s.ord === ord)?.language ?? ""),
|
||
});
|
||
},
|
||
[detail, patchPrefs],
|
||
);
|
||
// '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;
|
||
const idx = streams.findIndex((s) => s.ord === (audioRef.current ?? 0));
|
||
const next = streams[(idx + 1) % streams.length]!; // length >= 2 (guarded), so in-bounds
|
||
changeAudio(next.ord === 0 ? null : next.ord);
|
||
}, [detail, changeAudio, audioRef]);
|
||
const cycleSubtitle = useCallback(() => {
|
||
const subs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
|
||
const order: (number | null)[] = [null, ...subs.map((s) => s.ord)];
|
||
const i = order.findIndex((o) => o === subOrd);
|
||
changeSubtitle(order[(i + 1) % order.length]!); // order always has [null, ...] → non-empty
|
||
}, [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(absRef.current), 500);
|
||
},
|
||
[patchPrefs, loadSession, aoffRef, absRef],
|
||
);
|
||
|
||
// 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,
|
||
};
|
||
}
|