refactor(player): the Plex session machine is a tested engine, one request per item in flight

PlexPlayer's session logic (generation, pending play intent, clock ownership, session offset, reclaim
budget, listener teardown) moves into lib/hlsEngine.ts, a plain class with hls.js behind lib/hlsPort.ts
and its own unit tests.

- A seek, track change or reclaim asked for while a session request is out replaces the queued
  target; it is sent once the in-flight answer settles. A burst of 20 presses past the buffer was 20
  requests and 20 ffmpeg spawns; it is now 2-3, and plays again ~2 s sooner.
- The player stops its server session when it leaves the item, closes or the page unloads
  (POST /api/plex/stream/{rk}/session/{sid}/stop, identity-aware). A left transcode used to run to
  the end of the film first.
- A restart asked for after a new session attached but before it played no longer reads the emptied
  media as the viewer's pause.
This commit is contained in:
2026-10-06 22:31:48 +02:00
parent 23f16b69df
commit c1058f1d5a
14 changed files with 1492 additions and 528 deletions
+21 -39
View File
@@ -7,7 +7,6 @@ import {
type WheelEvent as ReactWheelEvent,
} from "react";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
exitFullscreen,
fullscreenElement,
@@ -343,15 +342,6 @@ export function usePlexProgress(opts: {
};
}
/** Where the viewer is. `abs` only moves once playback has ticked — until then (a session still
* loading, or a resumed one that has stayed paused) it reads 0 while the film is minutes in, so the
* answer is where the session was ASKED to start. Anything that restarts the session "here" — an
* audio switch, an A/V offset, a keyboard seek — must use this: reading `abs` restarted a resume at
* 20:00 from 0:00, and the next checkpoint saved that. */
export function playhead(absRef: RefObject<number>, loadStartRef: RefObject<number>): number {
return absRef.current > 0 ? absRef.current : loadStartRef.current;
}
// 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). The 0..1 arithmetic is the shared, unit-tested core in
@@ -551,8 +541,8 @@ export function useAutoSkip(opts: {
}
// 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
// audio/subtitle change/cycle handlers; audio switches re-map server-side via a session restart
// (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.
/** Why a subtitle track can't be shown, in the three buckets the player has a sentence for. The
@@ -589,17 +579,17 @@ export function usePlexTracks(opts: {
sessionK: number;
subOffset: number;
subPos: number;
loadSession: (startAt: number, resumePlay?: boolean) => Promise<void>;
/** Restart the item's session at `startAt` (HlsEngine.start). */
restartSession: (startAt: number) => void;
/** Switch audio client-side; false when no multi-rendition session is attached (HlsEngine). */
setAudioTrack: (track: number) => boolean;
patchPrefs: (patch: Partial<PlexPlayerPrefs>) => void;
videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>;
multiAudioRef: RefObject<boolean>;
audioRef: RefObject<number | null>;
aoffRef: RefObject<number>;
absRef: RefObject<number>;
/** The position the current (or still in-flight) session was ASKED to start at — written before
* the session request goes out, unlike `sessionK`, which only lands once it has answered. */
loadStartRef: RefObject<number>;
/** Where the viewer is — before playback has ticked, where the session was ASKED to start
* (HlsEngine.playhead). Anything that restarts "here", and the subtitle poll, must use it. */
playhead: () => number;
}): {
audioOrd: number | null;
setAudioOrd: (ord: number | null) => void;
@@ -636,8 +626,9 @@ export function usePlexTracks(opts: {
* 0 calls, while `onErrorCapture` fires). The capture phase reaches the target regardless. */
subTrackListeners: { onErrorCapture: () => void };
} {
const { id, detail, sessionK, subOffset, subPos, loadSession, patchPrefs } = opts;
const { videoRef, hlsRef, multiAudioRef, audioRef, aoffRef, absRef, loadStartRef } = opts;
const { id, detail, sessionK, subOffset, subPos, restartSession, setAudioTrack, patchPrefs } =
opts;
const { videoRef, audioRef, aoffRef, playhead } = opts;
const qc = useQueryClient();
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
const [audioOrd, setAudioOrd] = useState<number | null>(null);
@@ -658,7 +649,7 @@ export function usePlexTracks(opts: {
}, [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.
// position (like a seek). The ref is updated synchronously so the session request sees it.
const changeAudio = useCallback(
(ord: number | null) => {
audioRef.current = ord;
@@ -668,20 +659,11 @@ export function usePlexTracks(opts: {
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.
const track = ord ?? 0;
try {
hlsRef.current.audioTrack = track;
} catch {
/* ignore */
}
} else {
// Single audio (or the session has not answered yet): re-map the track server-side.
void loadSession(playhead(absRef, loadStartRef));
}
// Multi-rendition: switch the audio track CLIENT-SIDE — instant, same timeline, no restart.
// Single audio (or the session has not answered yet): re-map the track server-side.
if (!setAudioTrack(ord ?? 0)) restartSession(playhead());
},
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef, loadStartRef],
[restartSession, setAudioTrack, detail, patchPrefs, audioRef, playhead],
);
// 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.
@@ -730,9 +712,9 @@ export function usePlexTracks(opts: {
patchPrefs({ audioOffset: v });
aoffRef.current = v;
if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
aoffTimer.current = window.setTimeout(() => loadSession(playhead(absRef, loadStartRef)), 500);
aoffTimer.current = window.setTimeout(() => restartSession(playhead()), 500);
},
[patchPrefs, loadSession, aoffRef, absRef, loadStartRef],
[patchPrefs, restartSession, aoffRef, playhead],
);
// --- is the selected track actually extractable yet? -------------------------------------------
@@ -751,7 +733,7 @@ export function usePlexTracks(opts: {
// answer is where the session was asked to start. Not `sessionK`: that only lands once the
// session request has answered, so the first poll of a fresh open read 0 from it, and after a
// move to the next item it still held the PREVIOUS item's start.
queryFn: () => api.plexSubtitleStatus(id, subOrd as number, playhead(absRef, loadStartRef)),
queryFn: () => api.plexSubtitleStatus(id, subOrd as number, playhead()),
enabled: subOrd != null,
// Keep polling until the track is COMPLETE, not just showable: each poll carries the current
// position, so seeking past the extracted window is what asks for the next one. A settled
@@ -852,7 +834,7 @@ export function usePlexTracks(opts: {
// 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
// sessionK is already lag-compensated (see HlsEngine), 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