refactor(plex): extract PlexPlayer track selection into usePlexTracks
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.
This commit is contained in:
@@ -43,6 +43,7 @@ import {
|
|||||||
useFullscreen,
|
useFullscreen,
|
||||||
usePlayerVolume,
|
usePlayerVolume,
|
||||||
usePlexProgress,
|
usePlexProgress,
|
||||||
|
usePlexTracks,
|
||||||
useWallClock,
|
useWallClock,
|
||||||
} from "../lib/plexPlayerHooks";
|
} from "../lib/plexPlayerHooks";
|
||||||
import { fullscreenElement } from "../lib/fullscreen";
|
import { fullscreenElement } from "../lib/fullscreen";
|
||||||
@@ -94,13 +95,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
|
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [abs, setAbs] = useState(0); // absolute current time
|
const [abs, setAbs] = useState(0); // absolute current time
|
||||||
|
// Live mirror of `abs` for stable callbacks + the progress checkpoint (seek/skip/ended write it too).
|
||||||
|
const absRef = useRef(0);
|
||||||
|
absRef.current = abs;
|
||||||
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
|
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
|
||||||
const { fs, toggleFs } = useFullscreen(wrapRef);
|
const { fs, toggleFs } = useFullscreen(wrapRef);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
// 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);
|
|
||||||
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
|
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
|
||||||
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
|
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
|
||||||
// this and re-fetches when a seek-restart changes it.
|
// this and re-fetches when a seek-restart changes it.
|
||||||
@@ -121,12 +122,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
fineSeekStepRef.current = prefs.fineSeekStep;
|
fineSeekStepRef.current = prefs.fineSeekStep;
|
||||||
const aoffRef = useRef(prefs.audioOffset);
|
const aoffRef = useRef(prefs.audioOffset);
|
||||||
aoffRef.current = prefs.audioOffset;
|
aoffRef.current = prefs.audioOffset;
|
||||||
// Debounced user subtitle offset: avoids re-fetching the VTT on every slider step while dragging.
|
|
||||||
const [subOffApplied, setSubOffApplied] = useState(prefs.subOffset);
|
|
||||||
useEffect(() => {
|
|
||||||
const tmr = window.setTimeout(() => setSubOffApplied(prefs.subOffset), 250);
|
|
||||||
return () => window.clearTimeout(tmr);
|
|
||||||
}, [prefs.subOffset]);
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false); // gear = tuning (tabbed)
|
const [menuOpen, setMenuOpen] = useState(false); // gear = tuning (tabbed)
|
||||||
const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
|
const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
|
||||||
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
|
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
|
||||||
@@ -138,7 +133,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
const menuTriggerRef = useRef<HTMLButtonElement>(null);
|
const menuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const tracksRef = useRef<HTMLDivElement | null>(null);
|
const tracksRef = useRef<HTMLDivElement | null>(null);
|
||||||
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
|
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
audioRef.current = audioOrd;
|
|
||||||
menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
|
menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
|
||||||
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
|
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
|
||||||
const { uiVisible, wake } = useControlsVisibility({ barAutoHideRef, menuOpenRef, videoRef });
|
const { uiVisible, wake } = useControlsVisibility({ barAutoHideRef, menuOpenRef, videoRef });
|
||||||
@@ -265,6 +259,38 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
[id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
|
[id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state; the shared
|
||||||
|
// media refs stay component-owned (loadSession above + the detail effect below write audioRef too).
|
||||||
|
const {
|
||||||
|
audioOrd,
|
||||||
|
setAudioOrd,
|
||||||
|
subOrd,
|
||||||
|
setSubOrd,
|
||||||
|
changeAudio,
|
||||||
|
changeSubtitle,
|
||||||
|
cycleAudio,
|
||||||
|
cycleSubtitle,
|
||||||
|
changeAudioOffset,
|
||||||
|
textSubs,
|
||||||
|
selectedSub,
|
||||||
|
subTrackOffset,
|
||||||
|
} = usePlexTracks({
|
||||||
|
id,
|
||||||
|
detail,
|
||||||
|
sessionK,
|
||||||
|
subOffset: prefs.subOffset,
|
||||||
|
subPos: prefs.subPos,
|
||||||
|
loadSession,
|
||||||
|
patchPrefs,
|
||||||
|
videoRef,
|
||||||
|
hlsRef,
|
||||||
|
multiAudioRef,
|
||||||
|
audioRef,
|
||||||
|
aoffRef,
|
||||||
|
absRef,
|
||||||
|
});
|
||||||
|
audioRef.current = audioOrd; // belt-and-suspenders mirror (changeAudio / the detail effect set it too)
|
||||||
|
|
||||||
// Start playback when the detail arrives (resume from the saved position unless already watched).
|
// Start playback when the detail arrives (resume from the saved position unless already watched).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
@@ -347,8 +373,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
}, [id, patchPrefs]);
|
}, [id, patchPrefs]);
|
||||||
|
|
||||||
// Periodic + on-unmount progress checkpoint (interval flush + pause/pagehide + reopen cache patch).
|
// Periodic + on-unmount progress checkpoint (interval flush + pause/pagehide + reopen cache patch).
|
||||||
const absRef = useRef(0);
|
|
||||||
absRef.current = abs;
|
|
||||||
usePlexProgress({ id, qc, videoRef, absRef, durationRef });
|
usePlexProgress({ id, qc, videoRef, absRef, durationRef });
|
||||||
|
|
||||||
// --- seeking (native within the loaded region, else restart the session) ---------------------
|
// --- seeking (native within the loaded region, else restart the session) ---------------------
|
||||||
@@ -439,80 +463,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
a.remove();
|
a.remove();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
// 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],
|
|
||||||
);
|
|
||||||
// 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]);
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
const settingsTabs = [
|
const settingsTabs = [
|
||||||
{ key: "sync" as const, label: t("plex.player.sync") },
|
{ key: "sync" as const, label: t("plex.player.sync") },
|
||||||
{ key: "playback" as const, label: t("plex.player.playback") },
|
{ key: "playback" as const, label: t("plex.player.playback") },
|
||||||
@@ -522,47 +472,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
{ key: "clock" as const, label: t("plex.player.clock") },
|
{ key: "clock" as const, label: t("plex.player.clock") },
|
||||||
];
|
];
|
||||||
const activeTab = settingsTabs.some((x) => x.key === settingsTab) ? settingsTab : "sync";
|
const activeTab = settingsTabs.some((x) => x.key === settingsTab) ? settingsTab : "sync";
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// Position subtitle cues per the user's vertical setting (prefs.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 = prefs.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, prefs.subPos]);
|
|
||||||
|
|
||||||
// Auto-advance to the next episode when one finishes.
|
// Auto-advance to the next episode when one finishes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import {
|
|||||||
type WheelEvent as ReactWheelEvent,
|
type WheelEvent as ReactWheelEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import Hls from "hls.js";
|
||||||
import {
|
import {
|
||||||
exitFullscreen,
|
exitFullscreen,
|
||||||
fullscreenElement,
|
fullscreenElement,
|
||||||
onFullscreenChange,
|
onFullscreenChange,
|
||||||
requestFullscreen,
|
requestFullscreen,
|
||||||
} from "./fullscreen";
|
} from "./fullscreen";
|
||||||
import { api, type PlexMarker } from "./api";
|
import { api, type PlexItemDetail, type PlexMarker } from "./api";
|
||||||
import { qk } from "./queryKeys";
|
import { qk } from "./queryKeys";
|
||||||
import { clamp01, type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
import { clamp01, type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
||||||
|
|
||||||
@@ -310,3 +311,180 @@ export function useAutoSkip(opts: {
|
|||||||
|
|
||||||
return { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user