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:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
// 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.
|
||||
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.AUDIO_TRACKS_UPDATED, () => handlers.onAudioTracksUpdated());
|
||||
// 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
|
||||
// placed by `line` cannot move out of the way. The track stays loaded, as `hidden`, for PlexSubtitles.
|
||||
describe("usePlexTracks — who draws the cues", () => {
|
||||
it("keeps the mounted track hidden: loaded and tracked, not drawn by the browser", () => {
|
||||
const track = { mode: "disabled" as TextTrackMode };
|
||||
/** A <video> whose text-track list fires `addtrack` / `change` like a browser's. */
|
||||
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");
|
||||
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 }) => (
|
||||
<QueryClientProvider client={client()}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { unmount } = renderHook(
|
||||
return renderHook(
|
||||
() =>
|
||||
usePlexTracks({
|
||||
id: RK,
|
||||
@@ -464,7 +486,22 @@ describe("usePlexTracks — who draws the cues", () => {
|
||||
}),
|
||||
{ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -852,14 +852,25 @@ export function usePlexTracks(opts: {
|
||||
// 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.
|
||||
const subTrackOffset = sessionK - subOffApplied;
|
||||
// Force the single mounted track to "hidden": loaded and its cues tracked (its VTT loads on mount;
|
||||
// `default` alone can be flaky after a remount), but drawn by PlexSubtitles, not by the browser.
|
||||
// Re-runs when the selection or the shifted-track identity changes (fresh <track>).
|
||||
// Keep every track "hidden": loaded and its cues tracked, but drawn by PlexSubtitles, not by the
|
||||
// browser. Not once per selection: the <track> is remounted on its own key (a grown window, a
|
||||
// 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(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
for (const tr of Array.from(video.textTracks)) tr.mode = "hidden";
|
||||
}, [subOrd, id, subTrackOffset, videoRef]);
|
||||
const tracks = videoRef.current?.textTracks;
|
||||
if (!tracks) return;
|
||||
const hide = () => {
|
||||
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 {
|
||||
audioOrd,
|
||||
|
||||
Reference in New Issue
Block a user