From f86313eaa9e737ab0d1b1907082f8981f06b8d3a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 12 Aug 2026 01:21:20 +0200 Subject: [PATCH] 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 , 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. --- frontend/src/components/PlexPlayer.tsx | 163 +++++----------------- frontend/src/lib/plexPlayerHooks.ts | 180 ++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 128 deletions(-) diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 838d8fa9..c36a7f29 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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(null); - // Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off). - const [audioOrd, setAudioOrd] = useState(null); - const [subOrd, setSubOrd] = useState(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 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(null); const tracksRef = useRef(null); const tracksTriggerRef = useRef(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 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(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 (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 ). - 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(() => { diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index 954068ef..a5a761d7 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -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 . 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; + patchPrefs: (patch: Partial) => void; + videoRef: RefObject; + hlsRef: RefObject; + multiAudioRef: RefObject; + audioRef: MutableRefObject; + aoffRef: MutableRefObject; + absRef: MutableRefObject; +}): { + 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(null); + const [subOrd, setSubOrd] = useState(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 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(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 (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 ). + 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, + }; +}