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

@ -10,6 +10,7 @@ import {
Pause,
Play,
RotateCcw,
Settings,
SkipBack,
SkipForward,
Volume2,
@ -57,9 +58,25 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
const [ready, setReady] = useState(false);
const [uiVisible, setUiVisible] = useState(true);
const hideTimer = useRef<number | null>(null);
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
const [audioOrd, setAudioOrd] = useState<number | null>(null);
const [subOrd, setSubOrd] = useState<number | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const audioRef = useRef<number | null>(null);
const subRef = useRef<number | null>(null);
const menuOpenRef = useRef(false);
audioRef.current = audioOrd;
subRef.current = subOrd;
menuOpenRef.current = menuOpen;
durationRef.current = duration;
// A new item starts with default tracks (its stream layout differs from the last one's).
useEffect(() => {
setAudioOrd(null);
setSubOrd(null);
}, [id]);
// --- media setup: (re)load a session for `id` at a start offset -------------------------------
const loadSession = useCallback(
async (startAt: number) => {
@ -68,7 +85,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
setReady(false);
let sess;
try {
sess = await api.plexSession(id, startAt);
sess = await api.plexSession(id, startAt, audioRef.current, subRef.current);
} catch {
return;
}
@ -82,7 +99,16 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
hlsRef.current = hls;
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
hls.attachMedia(video);
// Show the (single) muxed subtitle rendition when one was requested, else none. The
// subtitle tracks may not be known yet at MANIFEST_PARSED, so also enable on the dedicated
// event (otherwise hls.js never fetches the WebVTT segments).
const enableSubs = () => {
hls.subtitleDisplay = subRef.current != null;
hls.subtitleTrack = subRef.current != null ? 0 : -1;
};
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, enableSubs);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
enableSubs();
setReady(true);
video.play().catch(() => {});
});
@ -233,6 +259,61 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
a.remove();
}, [id]);
// Switching audio/subtitle re-maps the track server-side, so it restarts the session at the
// current position (like a seek). The refs are updated synchronously so loadSession sees them.
const changeAudio = useCallback(
(ord: number | null) => {
audioRef.current = ord;
setAudioOrd(ord);
setMenuOpen(false);
loadSession(absRef.current);
},
[loadSession],
);
const changeSubtitle = useCallback(
(ord: number | null) => {
subRef.current = ord;
setSubOrd(ord);
setMenuOpen(false);
loadSession(absRef.current);
},
[loadSession],
);
// Lift subtitle cues off the very bottom edge. The video element fills the viewport height, so a
// default (line "auto") cue renders at the extreme bottom and gets clipped — nudge every cue up
// to ~88% so it sits just above the control bar. Re-applied on each cue change (hls.js streams
// cues in as subtitle segments load, and TextTrack has no per-cue "added" event).
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const placeAll = () => {
for (const tr of Array.from(video.textTracks)) {
for (const c of Array.from(tr.cues || [])) {
try {
(c as VTTCue).snapToLines = false;
(c as VTTCue).line = 88;
} catch {
/* not a positionable cue */
}
}
}
};
const bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll);
for (const tr of Array.from(video.textTracks)) bind(tr);
const onAddTrack = (e: TrackEvent) => {
if (e.track) {
bind(e.track as TextTrack);
placeAll();
}
};
video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
return () => {
video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
};
}, [id]);
// Auto-advance to the next episode when one finishes.
useEffect(() => {
const video = videoRef.current;
@ -250,7 +331,8 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
setUiVisible(true);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => {
if (!videoRef.current?.paused) setUiVisible(false);
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false);
}, 3000);
}, []);
@ -446,6 +528,61 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
</div>
<div className="ml-auto flex items-center gap-2">
{detail && (detail.audio_streams.length > 1 || detail.subtitle_streams.length > 0) && (
<div className="relative">
<button
onClick={() => setMenuOpen((o) => !o)}
aria-label={t("plex.player.tracks")}
className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`}
>
<Settings className="w-5 h-5" />
</button>
{menuOpen && (
<div className="absolute bottom-full right-0 mb-2 w-60 max-h-72 overflow-auto rounded-lg border border-white/15 bg-black/95 p-1.5 text-sm">
{detail.audio_streams.length > 1 && (
<>
<div className="px-2 pt-1 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
{t("plex.player.audio")}
</div>
{detail.audio_streams.map((a) => (
<button
key={a.ord}
onClick={() => changeAudio(a.ord)}
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
>
{a.label}
</button>
))}
</>
)}
<div className="px-2 pt-2 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
{t("plex.player.subtitles")}
</div>
<button
onClick={() => changeSubtitle(null)}
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
subOrd == null ? "text-accent" : "text-white"
}`}
>
{t("plex.player.subOff")}
</button>
{detail.subtitle_streams.map((s) => (
<button
key={s.ord}
onClick={() => changeSubtitle(s.ord)}
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
subOrd === s.ord ? "text-accent" : "text-white"
}`}
>
{s.label}
</button>
))}
</div>
)}
</div>
)}
<Ctrl label={t("plex.player.download")} onClick={download}>
<Download className="w-5 h-5" />
</Ctrl>

View file

@ -43,7 +43,11 @@
"next": "Nächste Folge",
"mute": "Stumm (m)",
"fullscreen": "Vollbild (f)",
"download": "Originaldatei herunterladen"
"download": "Originaldatei herunterladen",
"tracks": "Audio & Untertitel",
"audio": "Audio",
"subtitles": "Untertitel",
"subOff": "Aus"
},
"playable": {
"direct": "Spielt direkt im Browser",

View file

@ -43,7 +43,11 @@
"next": "Next episode",
"mute": "Mute (m)",
"fullscreen": "Fullscreen (f)",
"download": "Download original file"
"download": "Download original file",
"tracks": "Audio & subtitles",
"audio": "Audio",
"subtitles": "Subtitles",
"subOff": "Off"
},
"playable": {
"direct": "Plays directly in the browser",

View file

@ -43,7 +43,11 @@
"next": "Következő rész",
"mute": "Némítás (m)",
"fullscreen": "Teljes képernyő (f)",
"download": "Eredeti fájl letöltése"
"download": "Eredeti fájl letöltése",
"tracks": "Audió és felirat",
"audio": "Audió",
"subtitles": "Felirat",
"subOff": "Ki"
},
"playable": {
"direct": "Közvetlenül játszható a böngészőben",

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