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],
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue