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>

View file

@ -706,7 +706,8 @@ export interface PlexItemDetail {
tagline?: string | null;
markers: PlexMarker[];
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
subtitle_streams: { ord: number; label: string; language?: string | null }[];
// `text`=true → offered as a WebVTT <track>; image subs (PGS/VobSub) are text=false (hidden).
subtitle_streams: { ord: number; label: string; language?: string | null; codec?: string; text: boolean }[];
status: string;
position_seconds: number;
show_title?: string | null;
@ -1152,17 +1153,14 @@ 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,
subtitle?: number | null,
): Promise<PlexPlaySession> => {
plexSession: (id: string, start = 0, audio?: 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" });
},
// WebVTT URL for a text subtitle track (used directly as a <track src>). Same-origin → cookie-authed.
plexSubtitleUrl: (id: string, ord: number): string =>
`/api/plex/subtitle/${encodeURIComponent(id)}/${ord}`,
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
method: "POST",

View file

@ -14,6 +14,14 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.28.3",
date: "2026-07-06",
summary: "Fix: Plex subtitles that wouldn't play — now instant, no restart.",
features: [
"Plex subtitles: fixed playback failing (\"Playback couldn't start\") when picking a subtitle on films whose subtitles are external sidecar files (very common) — those aren't inside the video, so the old approach couldn't mux them. Subtitles are now served as standalone tracks the browser overlays, so turning one on (or switching) is instant and no longer restarts the video. Image-based subtitles (Blu-ray/DVD) are hidden for now (they can't be shown as text yet).",
],
},
{
version: "0.28.2",
date: "2026-07-06",