feat(plex): P2 subtitle + audio track selector (closes P2)

Backend: item detail returns audio_streams + subtitle_streams (from Plex Part.Stream
ordinals). The HLS session accepts audio/subtitle stream ordinals — the selected audio
is mapped (transcoded to AAC), a selected subtitle is muxed in as a WebVTT rendition
via a MASTER playlist (-master_pl_name + -var_stream_map sgroup:subs) so ffmpeg keeps
it in sync per-session. Selecting a track forces the HLS path (even for direct files).
Generic /stream/{rk}/hls/{filename} endpoint serves the master/media/vtt/ts artifacts.

Frontend: gear menu lists real audio + subtitle tracks; changing one restarts the
session at the current position (seek-restart mechanism). hls.js subtitle enabled on
SUBTITLE_TRACKS_UPDATED (not just MANIFEST_PARSED, else the VTT is never fetched).
Cues auto-nudged to line 88% (the full-height <video> clips a default bottom-edge cue).
Controls no longer auto-hide while the menu is open. plex.player.* i18n en/hu/de.

Verified in a real browser (2 Broke Girls S1E1, 2 audio + 2 subs): menu shows the real
tracks, enabling English subtitles renders them correctly positioned; backend master
playlist + vtt segments validated over HTTP.
This commit is contained in:
npeter83 2026-07-05 06:08:44 +02:00
parent 29d306441a
commit a06f87f5f6
7 changed files with 259 additions and 65 deletions

View file

@ -689,6 +689,8 @@ export interface PlexItemDetail {
art: string;
cast: string[];
markers: PlexMarker[];
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
subtitle_streams: { ord: number; label: string; language?: string | null }[];
status: string;
position_seconds: number;
show_title?: string | null;
@ -1039,10 +1041,17 @@ 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): Promise<PlexPlaySession> =>
req(`/api/plex/stream/${encodeURIComponent(id)}/session?start=${Math.max(0, Math.floor(start))}`, {
method: "POST",
}),
plexSession: (
id: string,
start = 0,
audio?: number | null,
subtitle?: number | null,
): Promise<PlexPlaySession> => {
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
if (audio != null) p.set("audio", String(audio));
if (subtitle != null) p.set("subtitle", String(subtitle));
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" });
},
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
method: "POST",