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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user