feat(plex): multi-rendition audio (client-side switch) + reliable resume-on-F5
- Multi-audio items now ship every audio track as an HLS rendition in one session (stream.py var_stream_map -> master.m3u8), so hls.js switches audio CLIENT-SIDE with no ffmpeg restart, same timeline, no gap/drift. /session gains ?multi=1 (forces HLS even for direct-playable multi-audio files); K is probed from the video variant seg. - Restore the selected audio track on AUDIO_TRACKS_UPDATED, not MANIFEST_PARSED (the renditions aren't parsed yet on MANIFEST_PARSED, so the set was dropped -> after F5 the UI showed the restored track but playback stayed on the default). - Resume position now survives F5: a pagehide keepalive beacon (plexProgressBeacon) saves the current position on reload/close/navigate, since React effect cleanup does not run on a full reload; seekTo writes the target into absRef immediately so a save right after a seek is accurate.
This commit is contained in:
parent
0a0703b769
commit
3c622fd44c
4 changed files with 155 additions and 43 deletions
|
|
@ -133,6 +133,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
|
||||
const durationRef = useRef(0);
|
||||
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
|
||||
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
|
||||
const multiAudioRef = useRef(false);
|
||||
multiAudioRef.current = (detail?.audio_streams.length ?? 0) > 1;
|
||||
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [abs, setAbs] = useState(0); // absolute current time
|
||||
|
|
@ -213,7 +217,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
setLoadError(null);
|
||||
let sess;
|
||||
try {
|
||||
sess = await api.plexSession(id, startAt, audioRef.current, aoffRef.current);
|
||||
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
|
||||
// selected client-side after the manifest loads.
|
||||
const m = multiAudioRef.current;
|
||||
sess = await api.plexSession(id, startAt, m ? null : audioRef.current, aoffRef.current, m);
|
||||
} catch (e: any) {
|
||||
// Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
|
||||
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
|
||||
|
|
@ -247,6 +254,21 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
setReady(true);
|
||||
if (aliveRef.current) video.play().catch(() => {});
|
||||
});
|
||||
// Restore the selected audio track ONCE hls.js has parsed the renditions — doing it on
|
||||
// MANIFEST_PARSED was too early (the track list wasn't populated, so the set was dropped and
|
||||
// playback stayed on the default track while the UI showed the restored selection).
|
||||
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
|
||||
if (multiAudioRef.current) {
|
||||
const want = audioRef.current ?? 0;
|
||||
if (hls.audioTrack !== want) {
|
||||
try {
|
||||
hls.audioTrack = want;
|
||||
} catch {
|
||||
/* out of range — ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// A fatal HLS error (e.g. the remux session died / the file became unreadable) would
|
||||
// otherwise leave the spinner up forever — surface it instead.
|
||||
hls.on(Hls.Events.ERROR, (_e, data) => {
|
||||
|
|
@ -353,8 +375,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
api.plexProgress(id, Math.floor(absRef.current), durationRef.current).catch(() => {});
|
||||
};
|
||||
const iv = window.setInterval(flush, 10000);
|
||||
// Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload,
|
||||
// so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a
|
||||
// keepalive beacon so the POST survives the unload.
|
||||
const onHide = () => {
|
||||
if (absRef.current > 0 && durationRef.current > 0)
|
||||
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current);
|
||||
};
|
||||
window.addEventListener("pagehide", onHide);
|
||||
return () => {
|
||||
window.clearInterval(iv);
|
||||
window.removeEventListener("pagehide", onHide);
|
||||
flush();
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
||||
|
|
@ -368,6 +399,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
if (!video) return;
|
||||
const dur = durationRef.current || target;
|
||||
const T = Math.max(0, Math.min(target, dur - 1));
|
||||
absRef.current = T; // reflect the target immediately so a save/beacon right after a seek is accurate
|
||||
const rel = T - sessionStartRef.current;
|
||||
if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) {
|
||||
video.currentTime = rel;
|
||||
|
|
@ -461,7 +493,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
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 ?? "") });
|
||||
loadSession(absRef.current);
|
||||
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],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1218,10 +1218,11 @@ export const api = {
|
|||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
||||
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0): Promise<PlexPlaySession> => {
|
||||
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {
|
||||
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
|
||||
if (audio != null) p.set("audio", String(audio));
|
||||
if (aoff) p.set("aoff", String(aoff));
|
||||
if (multi) p.set("multi", "1"); // ≥2 audio tracks → multi-rendition HLS (client-side audio switch)
|
||||
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" });
|
||||
},
|
||||
// WebVTT URL for a text subtitle track (used directly as a <track src>). Same-origin → cookie-authed.
|
||||
|
|
@ -1234,6 +1235,27 @@ export const api = {
|
|||
method: "POST",
|
||||
body: JSON.stringify({ position_seconds, duration_seconds }),
|
||||
}),
|
||||
// Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is
|
||||
// cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position
|
||||
// (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we
|
||||
// replicate req()'s credentials + account header (no retry — there's no page left to retry on).
|
||||
plexProgressBeacon: (id: string, position_seconds: number, duration_seconds: number): void => {
|
||||
const active = getActiveAccount();
|
||||
try {
|
||||
void fetch(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||||
},
|
||||
body: JSON.stringify({ position_seconds, duration_seconds }),
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise<unknown> =>
|
||||
req(`/api/plex/item/${encodeURIComponent(id)}/state`, {
|
||||
method: "POST",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue