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

@ -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",