fix(plex): play subtitles as WebVTT tracks (external sidecar subs no longer crash)

Selecting a subtitle restarted the HLS session with `-map 0:s:{ord}`, assuming an
EMBEDDED stream. Films whose subs are external sidecar .srt files (Plex reports
them, but they aren't in the mkv) matched no stream; the master-playlist's
declared subtitle group then made ffmpeg fail → "Playback couldn't start".

Subtitles now go through a new GET /api/plex/subtitle/{rk}/{ord} → text/vtt
(external subs fetched from Plex via the stream key + SRT→VTT; embedded text subs
extracted with ffmpeg; image subs → 415), served as native <video><track> that
the browser overlays. So choosing/switching a subtitle is instant with NO session
restart, and stream.py drops all subtitle muxing (`-sn`, no master playlist).
Image-based subs (PGS/VobSub) are marked text=false and hidden in the picker.
Verified on prod's Nymphomaniac Vol. II: HU sidecar → 1693 WebVTT cues, no crash.
This commit is contained in:
npeter83 2026-07-06 08:45:30 +02:00
parent 335e4d7c76
commit c8c027d0fc
7 changed files with 206 additions and 89 deletions

View file

@ -75,10 +75,8 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
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;
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
const [helpOpen, setHelpOpen] = useState(false);
@ -111,7 +109,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
setLoadError(null);
let sess;
try {
sess = await api.plexSession(id, startAt, audioRef.current, subRef.current);
sess = await api.plexSession(id, startAt, audioRef.current);
} 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
@ -135,16 +133,8 @@ 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);
// Subtitles are native <track>s (not muxed into the HLS), so nothing subtitle-related here.
hls.on(Hls.Events.MANIFEST_PARSED, () => {
enableSubs();
setReady(true);
if (aliveRef.current) video.play().catch(() => {});
});
@ -322,8 +312,8 @@ 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.
// Switching AUDIO re-maps the track server-side, so it restarts the session at the current
// position (like a seek). The ref is updated synchronously so loadSession sees it.
const changeAudio = useCallback(
(ord: number | null) => {
audioRef.current = ord;
@ -333,15 +323,27 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
},
[loadSession],
);
const changeSubtitle = useCallback(
(ord: number | null) => {
subRef.current = ord;
setSubOrd(ord);
setMenuOpen(false);
loadSession(absRef.current);
},
[loadSession],
);
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
// (applied by the effect below), NO session restart. Off = null.
const changeSubtitle = useCallback((ord: number | null) => {
setSubOrd(ord);
setMenuOpen(false);
}, []);
// Text subtitles offered to the browser as <track>s; image subs (PGS/VobSub) can't be shown as text.
const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
// Apply the selected subtitle: show the matching <track>, disable the rest. The <track> DOM order
// matches `textSubs`, so textTracks[i] ↔ textSubs[i]. Setting mode!="disabled" triggers the VTT fetch.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const tracks = Array.from(video.textTracks);
tracks.forEach((tr, i) => {
const s = textSubs[i];
tr.mode = s && s.ord === subOrd ? "showing" : "disabled";
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subOrd, id, textSubs.length]);
// 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
@ -484,7 +486,19 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
className="max-h-full max-w-full w-auto h-auto"
onClick={togglePlay}
playsInline
/>
>
{/* Text subtitles as native tracks (same-origin sent with the session cookie). The effect
above flips modes; the browser fetches a track's VTT the first time it's shown. */}
{textSubs.map((s) => (
<track
key={s.ord}
kind="subtitles"
src={api.plexSubtitleUrl(id, s.ord)}
srcLang={s.language ?? undefined}
label={s.label}
/>
))}
</video>
{!ready && (
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
@ -621,7 +635,7 @@ 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) && (
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
<div className="relative">
<button
onClick={() => setMenuOpen((o) => !o)}
@ -650,28 +664,32 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
))}
</>
)}
<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>
))}
{textSubs.length > 0 && (
<>
<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>
{textSubs.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>