fix(plex): subtitles survive hls.js and a remounted track

hls.js emptied the cues of every text track on the element when a manifest
loaded or the media attached, our own <track> included, so a VTT that loaded
first showed nothing for the session; its subtitle and caption controllers are
no longer built. A remounted default <track> was switched to showing by the
browser after the hide had run, drawing the cues natively under ours; the hide
now follows every added track and mode change.
This commit is contained in:
2026-10-08 19:35:47 +02:00
parent dae0b80f6a
commit 57a81c5643
4 changed files with 115 additions and 13 deletions
+42
View File
@@ -0,0 +1,42 @@
// hls.js empties the cues of every text track on the <video> when a manifest loads or the media
// attaches — the Plex player's own subtitle <track> included, so a VTT that loaded first showed
// nothing for the rest of the session. Its subtitle and caption controllers are not built at all.
import { describe, expect, it, vi } from "vitest";
const built: Record<string, unknown>[] = [];
vi.mock("hls.js", () => {
class FakeHls {
static isSupported = () => true;
static Events = {};
static ErrorTypes = {};
constructor(config: Record<string, unknown>) {
built.push(config);
}
on() {}
}
return { default: FakeHls };
});
const { createHlsPort } = await import("./hlsPort");
describe("createHlsPort", () => {
it("builds hls.js without the controllers that clear every text track's cues", () => {
const noop = () => {};
createHlsPort({
onManifestParsed: noop,
onAudioTracksUpdated: noop,
onFragLoaded: noop,
onError: noop,
});
const config = built[0] ?? {};
for (const key of [
"subtitleTrackController",
"subtitleStreamController",
"timelineController",
]) {
// Set, as undefined: hls.js spreads the config over its defaults, so an absent key keeps them.
expect(key in config).toBe(true);
expect(config[key]).toBeUndefined();
}
});
});
+13 -1
View File
@@ -12,7 +12,19 @@ export function createHlsPort(handlers: HlsHandlers): HlsPort | null {
// up with the live-edge timeline → 0 fragments load → a permanent stall. But the seek-restart model // up with the live-edge timeline → 0 fragments load → a permanent stall. But the seek-restart model
// means fragment 0 IS the requested content position (`?t=` positioned the server ffmpeg there), // means fragment 0 IS the requested content position (`?t=` positioned the server ffmpeg there),
// so it always starts VOD-style at 0 and buffers forward up to maxBufferLength. // so it always starts VOD-style at 0 and buffers forward up to maxBufferLength.
const hls = new Hls({ maxBufferLength: 30, enableWorker: true, startPosition: 0 }); //
// No subtitle or caption controllers (a controller left unset is never built): the subtitles are
// a <track> of ours, out of band, and hls.js empties the cues of EVERY text track on the element
// when a manifest loads or the media attaches/detaches — ours included, so a VTT that loaded before
// the manifest showed nothing for the rest of the session.
const hls = new Hls({
maxBufferLength: 30,
enableWorker: true,
startPosition: 0,
subtitleTrackController: undefined,
subtitleStreamController: undefined,
timelineController: undefined,
});
hls.on(Hls.Events.MANIFEST_PARSED, () => handlers.onManifestParsed()); hls.on(Hls.Events.MANIFEST_PARSED, () => handlers.onManifestParsed());
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => handlers.onAudioTracksUpdated()); hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => handlers.onAudioTracksUpdated());
// The delivered bitrate is measured per fragment: the master playlist's own BANDWIDTH is not a // The delivered bitrate is measured per fragment: the master playlist's own BANDWIDTH is not a
@@ -440,14 +440,36 @@ describe("usePlexTracks — the debounced A/V-offset restart", () => {
// The browser no longer draws the cues: its rendering sat under the control bar on an iPad, and a cue // The browser no longer draws the cues: its rendering sat under the control bar on an iPad, and a cue
// placed by `line` cannot move out of the way. The track stays loaded, as `hidden`, for PlexSubtitles. // placed by `line` cannot move out of the way. The track stays loaded, as `hidden`, for PlexSubtitles.
describe("usePlexTracks — who draws the cues", () => { describe("usePlexTracks — who draws the cues", () => {
it("keeps the mounted track hidden: loaded and tracked, not drawn by the browser", () => { /** A <video> whose text-track list fires `addtrack` / `change` like a browser's. */
const track = { mode: "disabled" as TextTrackMode }; function videoWithTracks(...modes: TextTrackMode[]) {
const list: { mode: TextTrackMode }[] = modes.map((mode) => ({ mode }));
const tracks = Object.assign(new EventTarget(), {
get length() {
return list.length;
},
[Symbol.iterator]: () => list[Symbol.iterator](),
});
const video = document.createElement("video"); const video = document.createElement("video");
Object.defineProperty(video, "textTracks", { value: [track] }); Object.defineProperty(video, "textTracks", { value: tracks });
// What a browser does with a freshly mounted `default` <track>: it switches it on by itself.
const browserAddsShowing = () =>
act(() => {
list.push({ mode: "showing" });
tracks.dispatchEvent(new Event("addtrack"));
});
const browserShows = () =>
act(() => {
list[0]!.mode = "showing";
tracks.dispatchEvent(new Event("change"));
});
return { video, list, browserAddsShowing, browserShows };
}
function mountOn(video: HTMLVideoElement) {
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client()}>{children}</QueryClientProvider> <QueryClientProvider client={client()}>{children}</QueryClientProvider>
); );
const { unmount } = renderHook( return renderHook(
() => () =>
usePlexTracks({ usePlexTracks({
id: RK, id: RK,
@@ -464,7 +486,22 @@ describe("usePlexTracks — who draws the cues", () => {
}), }),
{ wrapper }, { wrapper },
); );
expect(track.mode).toBe("hidden"); }
it("keeps the mounted track hidden: loaded and tracked, not drawn by the browser", () => {
const { video, list } = videoWithTracks("disabled");
const { unmount } = mountOn(video);
expect(list[0]!.mode).toBe("hidden");
unmount();
});
it("hides a remounted track the browser switched on by itself", () => {
const { video, list, browserAddsShowing, browserShows } = videoWithTracks("disabled");
const { unmount } = mountOn(video);
browserAddsShowing();
expect(list.map((t) => t.mode)).toEqual(["hidden", "hidden"]);
browserShows();
expect(list[0]!.mode).toBe("hidden");
unmount(); unmount();
}); });
}); });
+18 -7
View File
@@ -852,14 +852,25 @@ export function usePlexTracks(opts: {
// sessionK is already lag-compensated (see HlsEngine), so it is the true content-zero the VTT // sessionK is already lag-compensated (see HlsEngine), so it is the true content-zero the VTT
// must be shifted by; the user offset nudges on top. // must be shifted by; the user offset nudges on top.
const subTrackOffset = sessionK - subOffApplied; const subTrackOffset = sessionK - subOffApplied;
// Force the single mounted track to "hidden": loaded and its cues tracked (its VTT loads on mount; // Keep every track "hidden": loaded and its cues tracked, but drawn by PlexSubtitles, not by the
// `default` alone can be flaky after a remount), but drawn by PlexSubtitles, not by the browser. // browser. Not once per selection: the <track> is remounted on its own key (a grown window, a
// Re-runs when the selection or the shifted-track identity changes (fresh <track>). // retry), and the browser switches a fresh `default` track to "showing" a task later — which drew
// the cues a second time, natively, under ours. So it is re-applied on every track the list gains
// and on every mode change the browser makes.
useEffect(() => { useEffect(() => {
const video = videoRef.current; const tracks = videoRef.current?.textTracks;
if (!video) return; if (!tracks) return;
for (const tr of Array.from(video.textTracks)) tr.mode = "hidden"; const hide = () => {
}, [subOrd, id, subTrackOffset, videoRef]); for (const tr of Array.from(tracks)) if (tr.mode !== "hidden") tr.mode = "hidden";
};
hide();
tracks.addEventListener("addtrack", hide);
tracks.addEventListener("change", hide);
return () => {
tracks.removeEventListener("addtrack", hide);
tracks.removeEventListener("change", hide);
};
}, [subOrd, id, videoRef]);
return { return {
audioOrd, audioOrd,