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:
2026-08-12 01:21:20 +02:00
parent 521d2aeaa8
commit f86313eaa9
2 changed files with 215 additions and 128 deletions
+36 -127
View File
@@ -43,6 +43,7 @@ import {
useFullscreen,
usePlayerVolume,
usePlexProgress,
usePlexTracks,
useWallClock,
} from "../lib/plexPlayerHooks";
import { fullscreenElement } from "../lib/fullscreen";
@@ -94,13 +95,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const [playing, setPlaying] = useState(false);
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 { fs, toggleFs } = useFullscreen(wrapRef);
const [ready, setReady] = useState(false);
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
// 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.
@@ -121,12 +122,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
fineSeekStepRef.current = prefs.fineSeekStep;
const aoffRef = useRef(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 [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
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 tracksRef = useRef<HTMLDivElement | null>(null);
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
audioRef.current = audioOrd;
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).
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).
);
// 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).
useEffect(() => {
if (!detail) return;
@@ -347,8 +373,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}, [id, patchPrefs]);
// 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 });
// --- seeking (native within the loaded region, else restart the session) ---------------------
@@ -439,80 +463,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
a.remove();
}, [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 = [
{ key: "sync" as const, label: t("plex.player.sync") },
{ 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") },
];
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.
useEffect(() => {
+179 -1
View File
@@ -8,13 +8,14 @@ import {
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 PlexMarker } from "./api";
import { api, type PlexItemDetail, type PlexMarker } from "./api";
import { qk } from "./queryKeys";
import { clamp01, type PlexPlayerPrefs } from "./plexPlayerPrefs";
@@ -310,3 +311,180 @@ export function useAutoSkip(opts: {
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,
};
}