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:
npeter83 2026-07-08 22:25:03 +02:00
parent 0a0703b769
commit 3c622fd44c
4 changed files with 155 additions and 43 deletions

View file

@ -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],
);