fix(plex): subtitles stop blocking the request, and stop re-reading the file

Bug hunt 2026-09, items 8 and 12 — the two things the Plex player was measurably
lying about.

Item 8: picking a subtitle track showed nothing for a long time, and the reported
workaround was to stop/start until it suddenly worked. Measured on prod against a
17.06 GB MKV with two embedded SubRip tracks:

  full pass, one track                154 s
  full pass, both tracks in ONE run   111 s
  -c:s copy (no WebVTT conversion)    143 s
  from 1:00:00 to the end of file      66 s
  a 630 s window at 1:00:00, both       8 s

So the conversion was never the cost: ffmpeg streams every byte of the container
between its seek point and its stop point, and the array delivers ~140 MB/s. The
only lever is reading less. Three changes follow from that:

  * a window around the playhead is extracted and published FIRST (seconds), and
    the full pass completes behind it;
  * every embedded text track rides that same single read — picking Hungarian used
    to cost a full pass and then switching to English cost another one;
  * none of it happens inside a request any more. The endpoint answers 202 while a
    background worker extracts, the player polls /status (carrying its position, so
    the window follows the viewer), and one ffmpeg run finally has a timeout.

-copyts is deliberately absent from a windowed pass: with it ffmpeg ignores -t and
reads the whole container anyway (measured — a [0, 600 s] window still took the
full 145 s), so a window comes back on a zero-based clock and is shifted to
absolute time here. Cached tracks written by older builds have no coverage sidecar
and count as complete, so nothing is re-extracted after the upgrade.

The <track> is no longer mounted before the server has something to put in it; a
"Preparing subtitles: …" notice stands in, and a track that can never be shown says
why instead of leaving the viewer to conclude the picker does nothing.

Item 12: "Bitrate" read a constant 211 kbps on every remux, because it was the
master playlist's BANDWIDTH — which ffmpeg derives from the one stream whose rate
it knows, the -b:a 192k AAC track, plus 10%. It is a measurement now: bytes per
second of MEDIA from hls.js FRAG_LOADED, accumulated per fragment type and summed
(a multi-audio session's video and audio renditions cover the same media seconds,
so pooling them would report half the truth), and an honest dash until the first
fragment lands. The panel also finally names the audio and subtitle track in play.

Verified in the browser against the real Plex library, and every new test
mutation-checked.
This commit is contained in:
2026-09-10 19:31:05 +02:00
parent b8bff504d8
commit ed4ab4e13c
13 changed files with 1158 additions and 66 deletions
+58 -4
View File
@@ -41,6 +41,7 @@ import {
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { createFragMeter } from "../lib/plexStats";
import { UpNextCard } from "./UpNextCard";
import {
useAutoSkip,
@@ -114,6 +115,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// stats mode row's "· HDR→SDR" tag; null/false for an 8-bit HW, software, or non-transcode session.
const tonemapRef = useRef<boolean | null>(null);
const durationRef = useRef(0);
// Rolling per-fragment bitrate meter for the stats overlay. Fed from hls.js FRAG_LOADED below and
// reset with each session, because the master playlist's own BANDWIDTH is not a measurement — for a
// remux it is the AAC track's nominal 192k + 10%, i.e. a constant 211 kbps (see plexStats.ts).
const fragMeterRef = useRef(createFragMeter());
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
const multiAudioRef = useRef(false);
@@ -303,6 +308,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// buffer forward up to maxBufferLength. Applies to remux + transcode alike (both use copyts).
const hls = new Hls({ maxBufferLength: 30, enableWorker: true, startPosition: 0 });
hlsRef.current = hls;
// Measure the real delivered bitrate: bytes per second of MEDIA, per fragment. A new session
// is a new stream (different start offset, possibly a different audio rendition), so the
// rolling window starts empty rather than carrying the previous session's numbers over.
fragMeterRef.current.reset();
hls.on(Hls.Events.FRAG_LOADED, (_e, data) => {
fragMeterRef.current.add({
type: data.frag.type,
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
durationS: data.frag.duration,
});
});
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
hls.attachMedia(video);
// Subtitles are native <track>s (not muxed into the HLS), so nothing subtitle-related here.
@@ -361,6 +377,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
textSubs,
selectedSub,
subTrackOffset,
subShowable,
subFail,
subGen,
onSubTrackError,
} = usePlexTracks({
id,
detail,
@@ -851,15 +871,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
>
{/* 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. */}
{selectedSub && (
{selectedSub && subShowable && (
<track
// One track = the selected sub only (no multi-track overlap). Key by the shifted-offset so
// a seek-restart or a (debounced) sub-offset change remounts → fresh VTT fetch.
key={`${id}-${selectedSub.ord}-${subTrackOffset.toFixed(2)}`}
// a seek-restart or a (debounced) sub-offset change remounts → fresh VTT fetch, and by the
// extraction generation so a widened track replaces the first short window.
key={`${id}-${selectedSub.ord}-${subTrackOffset.toFixed(2)}-${subGen}`}
kind="subtitles"
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset)}
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset, subGen)}
srcLang={selectedSub.language ?? undefined}
label={selectedSub.label}
onError={onSubTrackError}
default
/>
)}
@@ -894,6 +916,29 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
)}
{/* Subtitle-extraction notice. Pulling a track out of a big MKV is a full read of the container
(154 s for a 17 GB film, measured), so it happens in the background — around the playhead
first. Until then the <track> is deliberately NOT mounted, and this says why instead of
leaving the viewer to conclude that switching subtitles "does nothing" (bug hunt 2026-09,
item 8). It sits one rung above the track-switch OSD so the two never overlap. */}
<div
className="pointer-events-none absolute inset-x-0 bottom-36 z-menu flex justify-center px-4"
aria-live="polite"
>
{selectedSub && !subShowable && (
<div className="flex max-w-full items-center gap-2 truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
{subFail ? (
<span className="text-danger">{t(`plex.player.subUnavailable.${subFail}`)}</span>
) : (
<>
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
<span>{t("plex.player.subPreparing", { label: selectedSub.label })}</span>
</>
)}
</div>
)}
</div>
{/* Keyboard track-switch OSD. Sits ABOVE the control bar's own space so it reads even while the
bar is showing, and inside the player element so it survives fullscreen. `aria-live` because
the whole point is confirming something you cannot otherwise see.
@@ -1279,6 +1324,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<PlexStatsOverlay
videoRef={videoRef}
hlsRef={hlsRef}
fragMeterRef={fragMeterRef}
audioLabel={
detail?.audio_streams.find((a) => a.ord === (audioOrd ?? 0))?.label ?? undefined
}
subLabel={
subOrd == null
? t("plex.player.subOff")
: (detail?.subtitle_streams.find((x) => x.ord === subOrd)?.label ?? undefined)
}
playable={detail?.playable ?? ""}
hwRef={hwRef}
tonemapRef={tonemapRef}
+25 -2
View File
@@ -2,7 +2,7 @@ import { type RefObject, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type Hls from "hls.js";
import { X } from "lucide-react";
import { buildStatRows, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
// "Stats for nerds" debug overlay (E6 S2). Samples the live HTML5 <video> + hls.js state once a second
// and renders the active mode, resolution, codec/bitrate, dropped frames, buffer, hls level and session
@@ -11,6 +11,9 @@ import { buildStatRows, type HlsLevelInfo, type StatRow } from "../lib/plexStats
export function PlexStatsOverlay({
videoRef,
hlsRef,
fragMeterRef,
audioLabel,
subLabel,
playable,
hwRef,
tonemapRef,
@@ -21,6 +24,11 @@ export function PlexStatsOverlay({
}: {
videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>;
// Rolling FRAG_LOADED meter owned by the player (it lives as long as the hls.js instance, not as
// long as this panel) — the source of the MEASURED bitrate row.
fragMeterRef: RefObject<FragMeter>;
audioLabel?: string; // selected audio track label, as the tracks menu names it
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
playable: string;
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
@@ -88,6 +96,9 @@ export function PlexStatsOverlay({
totalFrames: total,
bufferAheadS: bufferAhead,
hlsLevel: level,
measuredBps: fragMeterRef.current?.bitrate(),
audioLabel,
subLabel,
sessionStartS: sessionStartRef.current ?? 0,
abs: absRef.current ?? 0,
duration: durationRef.current ?? 0,
@@ -97,7 +108,19 @@ export function PlexStatsOverlay({
sample();
const iv = setInterval(sample, 1000);
return () => clearInterval(iv);
}, [videoRef, hlsRef, playable, hwRef, tonemapRef, sessionStartRef, absRef, durationRef]);
}, [
videoRef,
hlsRef,
fragMeterRef,
audioLabel,
subLabel,
playable,
hwRef,
tonemapRef,
sessionStartRef,
absRef,
durationRef,
]);
return (
<div