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:
parent
335e4d7c76
commit
c8c027d0fc
7 changed files with 206 additions and 89 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue