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,
|
||||
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(() => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user