fix(plex): subtitles draw only the player's own track, lifted in the player's own pixels
A direct-played file's own subtitle stream (WebKit lists an mp4's mov_text) was switched to hidden and drawn under any choice, Off included: only the player's <track> is kept hidden and read, other subtitle tracks are switched off. The lift was measured in screen pixels and applied inside the x1.25 windowed player, and re-measured off a box carrying its own (or a mid-slide) transform: an untransformed outer box is measured, the distance divided by the scale, and a cue that just appeared is placed, not slid from where the last one was left.
This commit is contained in:
@@ -20,45 +20,80 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A <video> 1000 px tall with one hidden track, whose active cues the test sets. */
|
type FakeTrack = EventTarget & { kind: string; mode: TextTrackMode; activeCues: unknown[] };
|
||||||
|
|
||||||
|
/** A <video> 1000 px tall with one hidden track of the player's own <track>, whose active cues the
|
||||||
|
* test sets; `addTrack` adds another as a browser would — the player's own, or one of the media's. */
|
||||||
function fakeVideo() {
|
function fakeVideo() {
|
||||||
const video = document.createElement("video");
|
const video = document.createElement("video");
|
||||||
const track = Object.assign(new EventTarget(), {
|
const list: FakeTrack[] = [];
|
||||||
mode: "hidden" as TextTrackMode,
|
const tracks = Object.assign(new EventTarget(), {
|
||||||
activeCues: [] as unknown[],
|
get length() {
|
||||||
});
|
return list.length;
|
||||||
const tracks = Object.assign(new EventTarget(), { 0: track, length: 1 });
|
|
||||||
Object.defineProperty(tracks, Symbol.iterator, {
|
|
||||||
value: function* () {
|
|
||||||
yield track;
|
|
||||||
},
|
},
|
||||||
|
[Symbol.iterator]: () => list[Symbol.iterator](),
|
||||||
});
|
});
|
||||||
Object.defineProperty(video, "textTracks", { value: tracks });
|
Object.defineProperty(video, "textTracks", { value: tracks });
|
||||||
Object.defineProperty(video, "clientHeight", { value: 1000 });
|
Object.defineProperty(video, "clientHeight", { value: 1000 });
|
||||||
const show = (...texts: string[]) =>
|
const make = (own: boolean) => {
|
||||||
|
const track: FakeTrack = Object.assign(new EventTarget(), {
|
||||||
|
kind: "subtitles",
|
||||||
|
mode: "hidden" as TextTrackMode,
|
||||||
|
activeCues: [] as unknown[],
|
||||||
|
});
|
||||||
|
if (own) {
|
||||||
|
const el = document.createElement("track");
|
||||||
|
Object.defineProperty(el, "track", { value: track });
|
||||||
|
video.appendChild(el);
|
||||||
|
}
|
||||||
|
list.push(track);
|
||||||
|
return track;
|
||||||
|
};
|
||||||
|
const cuesOn = (track: FakeTrack, texts: string[]) =>
|
||||||
act(() => {
|
act(() => {
|
||||||
track.activeCues = texts.map((text, i) => ({ startTime: i, text }));
|
track.activeCues = texts.map((text, i) => ({ startTime: i, text }));
|
||||||
track.dispatchEvent(new Event("cuechange"));
|
track.dispatchEvent(new Event("cuechange"));
|
||||||
});
|
});
|
||||||
return { video, show };
|
const track = make(true);
|
||||||
|
const show = (...texts: string[]) => cuesOn(track, texts);
|
||||||
|
const addTrack = (own: boolean) => {
|
||||||
|
const added = make(own);
|
||||||
|
act(() => {
|
||||||
|
tracks.dispatchEvent(new Event("addtrack"));
|
||||||
|
});
|
||||||
|
return (...texts: string[]) => cuesOn(added, texts);
|
||||||
|
};
|
||||||
|
return { video, show, addTrack };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lay the boxes out: the text's box ends at `textBottom`, the bar starts at `barTop`. */
|
/** Lay the boxes out: the text's box ends at `textBottom`, the bar starts at `barTop`. With a `scale`,
|
||||||
function layout(video: HTMLVideoElement, bar: HTMLElement, textBottom: number, barTop: number) {
|
* the player is drawn that much larger than its own pixels, as the windowed player is. */
|
||||||
|
function layout(
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
bar: HTMLElement,
|
||||||
|
textBottom: number,
|
||||||
|
barTop: number,
|
||||||
|
scale = 1,
|
||||||
|
) {
|
||||||
const rect = (top: number, bottom: number) =>
|
const rect = (top: number, bottom: number) =>
|
||||||
({ top, bottom, left: 0, right: 0, width: 0, height: bottom - top }) as DOMRect;
|
({ top, bottom, left: 0, right: 0, width: 0, height: bottom - top }) as DOMRect;
|
||||||
|
const isBox = (el: Element) => (el as HTMLElement).dataset?.testid === "plex-subtitles";
|
||||||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
|
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
|
||||||
if (this === video) return rect(0, 1000);
|
if (this === video) return rect(0, 1000);
|
||||||
if (this === bar) return rect(barTop, 1000);
|
if (this === bar) return rect(barTop, 1000);
|
||||||
if ((this as HTMLElement).dataset?.testid === "plex-subtitles") {
|
// The outer box: the lift moves only the text inside it.
|
||||||
// As a browser reports it: moved by the lift it carries.
|
if (isBox(this)) return rect(textBottom - 80, textBottom);
|
||||||
const shift = Number(/-?\d+/.exec((this as HTMLElement).style.transform)?.[0] ?? 0);
|
|
||||||
return rect(textBottom - 80 + shift, textBottom + shift);
|
|
||||||
}
|
|
||||||
return rect(0, 0);
|
return rect(0, 0);
|
||||||
});
|
});
|
||||||
|
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (
|
||||||
|
this: HTMLElement,
|
||||||
|
) {
|
||||||
|
return isBox(this) ? 80 / scale : 0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lifted = () => screen.getByTestId("plex-subtitles-text");
|
||||||
|
|
||||||
function mount(video: HTMLVideoElement, avoid: boolean) {
|
function mount(video: HTMLVideoElement, avoid: boolean) {
|
||||||
const bar = document.createElement("div");
|
const bar = document.createElement("div");
|
||||||
const barRef = createRef<HTMLElement>() as { current: HTMLElement | null };
|
const barRef = createRef<HTMLElement>() as { current: HTMLElement | null };
|
||||||
@@ -93,12 +128,54 @@ describe("PlexSubtitles", () => {
|
|||||||
const { bar, rerender } = mount(video, false);
|
const { bar, rerender } = mount(video, false);
|
||||||
layout(video, bar, 950, 900);
|
layout(video, bar, 950, 900);
|
||||||
show("Half full");
|
show("Half full");
|
||||||
expect(screen.getByTestId("plex-subtitles").style.transform).toBe("translateY(0px)");
|
expect(lifted().style.transform).toBe("translateY(0px)");
|
||||||
rerender(true);
|
rerender(true);
|
||||||
// 950 − (900 − 8): the text's bottom clears the bar's top by the 8 px of air.
|
// 950 − (900 − 8): the text's bottom clears the bar's top by the 8 px of air.
|
||||||
expect(screen.getByTestId("plex-subtitles").style.transform).toBe("translateY(-58px)");
|
expect(lifted().style.transform).toBe("translateY(-58px)");
|
||||||
rerender(false);
|
rerender(false);
|
||||||
expect(screen.getByTestId("plex-subtitles").style.transform).toBe("translateY(0px)");
|
expect(lifted().style.transform).toBe("translateY(0px)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lifts in the player's own pixels when the player is drawn scaled", () => {
|
||||||
|
const { video, show } = fakeVideo();
|
||||||
|
const { bar } = mount(video, true);
|
||||||
|
layout(video, bar, 950, 900, 1.25);
|
||||||
|
show("Half full");
|
||||||
|
// 58 screen pixels are 46.4 of the player's own, which the browser then draws ×1.25.
|
||||||
|
expect(lifted().style.transform).toBe("translateY(-46px)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places a cue that just appeared, and slides only one already on screen", () => {
|
||||||
|
const { video, show } = fakeVideo();
|
||||||
|
const { bar, rerender } = mount(video, true);
|
||||||
|
layout(video, bar, 950, 900);
|
||||||
|
show("Half full");
|
||||||
|
expect(lifted().style.transform).toBe("translateY(-58px)");
|
||||||
|
expect(lifted().className).not.toContain("transition-transform");
|
||||||
|
rerender(false);
|
||||||
|
expect(lifted().className).toContain("transition-transform");
|
||||||
|
// The bar comes back while no cue is up: the next one is placed above it, not slid there.
|
||||||
|
show();
|
||||||
|
rerender(true);
|
||||||
|
show("or half empty?");
|
||||||
|
expect(lifted().style.transform).toBe("translateY(-58px)");
|
||||||
|
expect(lifted().className).not.toContain("transition-transform");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws the cues of a track the player mounts later — a remounted <track> is a new one", () => {
|
||||||
|
const { video, addTrack } = fakeVideo();
|
||||||
|
mount(video, false);
|
||||||
|
const showOnNew = addTrack(true);
|
||||||
|
showOnNew("After the seek");
|
||||||
|
expect(screen.getByTestId("plex-subtitles").textContent).toBe("After the seek");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never draws a track that is not the player's own, such as a direct-played file's", () => {
|
||||||
|
const { video, addTrack } = fakeVideo();
|
||||||
|
mount(video, false);
|
||||||
|
const showInBand = addTrack(false);
|
||||||
|
showInBand("Embedded");
|
||||||
|
expect(screen.queryByTestId("plex-subtitles")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("draws nothing while no cue is active", () => {
|
it("draws nothing while no cue is active", () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
|
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||||
import { subShadow } from "../lib/plexPlayerPrefs";
|
import { subShadow } from "../lib/plexPlayerPrefs";
|
||||||
|
import { ownTextTracks } from "../lib/plexSubTracks";
|
||||||
|
|
||||||
// The Plex player's subtitles, drawn by us instead of the browser. The track stays mounted (hls.js and
|
// The Plex player's subtitles, drawn by us instead of the browser. The track stays mounted (hls.js and
|
||||||
// the VTT fetch need it) but `hidden`: the browser parses the cues and reports the active ones, and
|
// the VTT fetch need it) but `hidden`: the browser parses the cues and reports the active ones, and
|
||||||
@@ -16,7 +17,7 @@ const CUE_EM_OF_HEIGHT = 0.05;
|
|||||||
// The air left between the text and what it avoids (the bar's top, or the video's bottom edge).
|
// The air left between the text and what it avoids (the bar's top, or the video's bottom edge).
|
||||||
const CLEARANCE_PX = 8;
|
const CLEARANCE_PX = 8;
|
||||||
|
|
||||||
/** The cues showing now, from every track of `video` that is not switched off. */
|
/** The cues showing now, from the player's own track(s) of `video` that are not switched off. */
|
||||||
function useActiveCues(video: HTMLVideoElement | null): Cue[] {
|
function useActiveCues(video: HTMLVideoElement | null): Cue[] {
|
||||||
const [cues, setCues] = useState<Cue[]>([]);
|
const [cues, setCues] = useState<Cue[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -24,8 +25,9 @@ function useActiveCues(video: HTMLVideoElement | null): Cue[] {
|
|||||||
const tracks = video.textTracks;
|
const tracks = video.textTracks;
|
||||||
const read = () => {
|
const read = () => {
|
||||||
const now: Cue[] = [];
|
const now: Cue[] = [];
|
||||||
|
const own = ownTextTracks(video);
|
||||||
for (const tr of Array.from(tracks)) {
|
for (const tr of Array.from(tracks)) {
|
||||||
if (tr.mode === "disabled") continue;
|
if (!own.has(tr) || tr.mode === "disabled") continue;
|
||||||
for (const c of Array.from(tr.activeCues ?? [])) now.push(c as Cue);
|
for (const c of Array.from(tr.activeCues ?? [])) now.push(c as Cue);
|
||||||
}
|
}
|
||||||
setCues((prev) =>
|
setCues((prev) =>
|
||||||
@@ -38,12 +40,11 @@ function useActiveCues(video: HTMLVideoElement | null): Cue[] {
|
|||||||
bound.add(tr);
|
bound.add(tr);
|
||||||
tr.addEventListener("cuechange", read);
|
tr.addEventListener("cuechange", read);
|
||||||
};
|
};
|
||||||
for (const tr of Array.from(tracks)) bind(tr);
|
const onAdd = () => {
|
||||||
const onAdd = (e: Event) => {
|
for (const tr of Array.from(tracks)) bind(tr);
|
||||||
const tr = (e as TrackEvent).track;
|
|
||||||
if (tr) bind(tr as TextTrack);
|
|
||||||
read();
|
read();
|
||||||
};
|
};
|
||||||
|
for (const tr of Array.from(tracks)) bind(tr);
|
||||||
tracks.addEventListener("addtrack", onAdd);
|
tracks.addEventListener("addtrack", onAdd);
|
||||||
tracks.addEventListener("removetrack", read);
|
tracks.addEventListener("removetrack", read);
|
||||||
read();
|
read();
|
||||||
@@ -115,20 +116,29 @@ export default function PlexSubtitles({
|
|||||||
const cues = useActiveCues(video);
|
const cues = useActiveCues(video);
|
||||||
const height = useHeight(video);
|
const height = useHeight(video);
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
const [lift, setLift] = useState(0);
|
// `slide` is false for the first placement of a box that just appeared: it is put where it belongs,
|
||||||
const liftRef = useRef(0);
|
// not slid there from where the previous cue was left.
|
||||||
|
const [lift, setLift] = useState({ px: 0, slide: false });
|
||||||
|
const shownRef = useRef(false);
|
||||||
|
|
||||||
// How far up the text has to move to clear the bar (or, without it, the video's bottom edge). The
|
// How far up the text has to move to clear the bar (or, without it, the video's bottom edge). The
|
||||||
// box is measured where it would sit with no lift: its current rect plus the lift it carries now.
|
// outer box is never transformed — the lift moves the inner one — so its rect is where the text sits
|
||||||
|
// with no lift, even mid-slide. The rects are screen pixels and the windowed player is scaled
|
||||||
|
// (PlexPlayer's wrapper), while the lift applies in the box's own pixels: the distance is divided
|
||||||
|
// by that scale.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const box = boxRef.current;
|
const box = boxRef.current;
|
||||||
if (!box || !video || cues.length === 0) return;
|
if (!box || !video || cues.length === 0) {
|
||||||
const bottom = box.getBoundingClientRect().bottom + liftRef.current;
|
shownRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = box.getBoundingClientRect();
|
||||||
|
const scale = box.offsetHeight > 0 ? rect.height / box.offsetHeight : 1;
|
||||||
const bar = avoid ? avoidRef.current?.getBoundingClientRect().top : undefined;
|
const bar = avoid ? avoidRef.current?.getBoundingClientRect().top : undefined;
|
||||||
const limit = (bar ?? video.getBoundingClientRect().bottom) - CLEARANCE_PX;
|
const limit = (bar ?? video.getBoundingClientRect().bottom) - CLEARANCE_PX;
|
||||||
const next = Math.max(0, Math.round(bottom - limit));
|
const px = Math.max(0, Math.round((rect.bottom - limit) / scale));
|
||||||
liftRef.current = next;
|
setLift({ px, slide: shownRef.current });
|
||||||
setLift(next);
|
shownRef.current = true;
|
||||||
}, [avoid, avoidRef, cues, height, pos, size, video]);
|
}, [avoid, avoidRef, cues, height, pos, size, video]);
|
||||||
|
|
||||||
if (cues.length === 0 || height === 0) return null;
|
if (cues.length === 0 || height === 0) return null;
|
||||||
@@ -142,16 +152,18 @@ export default function PlexSubtitles({
|
|||||||
ref={boxRef}
|
ref={boxRef}
|
||||||
data-testid="plex-subtitles"
|
data-testid="plex-subtitles"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute inset-x-0 flex flex-col items-center gap-[0.15em] px-[5%] text-center transition-transform duration-200 ease-out motion-reduce:transition-none"
|
className="pointer-events-none absolute inset-x-0 px-[5%] text-center"
|
||||||
style={{
|
style={{ top: `${pos}%`, fontSize: `${(height * CUE_EM_OF_HEIGHT * size) / 100}px` }}
|
||||||
top: `${pos}%`,
|
|
||||||
fontSize: `${(height * CUE_EM_OF_HEIGHT * size) / 100}px`,
|
|
||||||
transform: `translateY(${-lift}px)`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{cues.map((cue, i) => (
|
<div
|
||||||
<CueText key={`${cue.startTime}-${i}`} cue={cue} style={text} />
|
data-testid="plex-subtitles-text"
|
||||||
))}
|
className={`flex flex-col items-center gap-[0.15em] ${lift.slide ? "transition-transform duration-200 ease-out motion-reduce:transition-none" : ""}`}
|
||||||
|
style={{ transform: `translateY(${-lift.px}px)` }}
|
||||||
|
>
|
||||||
|
{cues.map((cue, i) => (
|
||||||
|
<CueText key={`${cue.startTime}-${i}`} cue={cue} style={text} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,9 +440,10 @@ 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", () => {
|
||||||
/** A <video> whose text-track list fires `addtrack` / `change` like a browser's. */
|
/** A <video> whose text-track list fires `addtrack` / `change` like a browser's. Each mode is a
|
||||||
|
* track of the player's own <track>; `mediaAdds` adds one from the media itself instead. */
|
||||||
function videoWithTracks(...modes: TextTrackMode[]) {
|
function videoWithTracks(...modes: TextTrackMode[]) {
|
||||||
const list: { mode: TextTrackMode }[] = modes.map((mode) => ({ mode }));
|
const list: { kind: string; mode: TextTrackMode }[] = [];
|
||||||
const tracks = Object.assign(new EventTarget(), {
|
const tracks = Object.assign(new EventTarget(), {
|
||||||
get length() {
|
get length() {
|
||||||
return list.length;
|
return list.length;
|
||||||
@@ -451,10 +452,20 @@ describe("usePlexTracks — who draws the cues", () => {
|
|||||||
});
|
});
|
||||||
const video = document.createElement("video");
|
const video = document.createElement("video");
|
||||||
Object.defineProperty(video, "textTracks", { value: tracks });
|
Object.defineProperty(video, "textTracks", { value: tracks });
|
||||||
|
const add = (mode: TextTrackMode, own: boolean) => {
|
||||||
|
const track = { kind: "subtitles", mode };
|
||||||
|
if (own) {
|
||||||
|
const el = document.createElement("track");
|
||||||
|
Object.defineProperty(el, "track", { value: track });
|
||||||
|
video.appendChild(el);
|
||||||
|
}
|
||||||
|
list.push(track);
|
||||||
|
};
|
||||||
|
for (const mode of modes) add(mode, true);
|
||||||
// What a browser does with a freshly mounted `default` <track>: it switches it on by itself.
|
// What a browser does with a freshly mounted `default` <track>: it switches it on by itself.
|
||||||
const browserAddsShowing = () =>
|
const browserAddsShowing = () =>
|
||||||
act(() => {
|
act(() => {
|
||||||
list.push({ mode: "showing" });
|
add("showing", true);
|
||||||
tracks.dispatchEvent(new Event("addtrack"));
|
tracks.dispatchEvent(new Event("addtrack"));
|
||||||
});
|
});
|
||||||
const browserShows = () =>
|
const browserShows = () =>
|
||||||
@@ -462,7 +473,13 @@ describe("usePlexTracks — who draws the cues", () => {
|
|||||||
list[0]!.mode = "showing";
|
list[0]!.mode = "showing";
|
||||||
tracks.dispatchEvent(new Event("change"));
|
tracks.dispatchEvent(new Event("change"));
|
||||||
});
|
});
|
||||||
return { video, list, browserAddsShowing, browserShows };
|
// A direct-played file's own subtitle stream, which WebKit lists — and may switch on.
|
||||||
|
const mediaAdds = (mode: TextTrackMode) =>
|
||||||
|
act(() => {
|
||||||
|
add(mode, false);
|
||||||
|
tracks.dispatchEvent(new Event("addtrack"));
|
||||||
|
});
|
||||||
|
return { video, list, browserAddsShowing, browserShows, mediaAdds };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountOn(video: HTMLVideoElement) {
|
function mountOn(video: HTMLVideoElement) {
|
||||||
@@ -504,4 +521,13 @@ describe("usePlexTracks — who draws the cues", () => {
|
|||||||
expect(list[0]!.mode).toBe("hidden");
|
expect(list[0]!.mode).toBe("hidden");
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("switches off a subtitle track that is not the player's own, and leaves its own hidden", () => {
|
||||||
|
const { video, list, mediaAdds } = videoWithTracks("disabled");
|
||||||
|
const { unmount } = mountOn(video);
|
||||||
|
mediaAdds("hidden");
|
||||||
|
mediaAdds("showing");
|
||||||
|
expect(list.map((t) => t.mode)).toEqual(["hidden", "disabled", "disabled"]);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { qk } from "./queryKeys";
|
|||||||
import { useLatest } from "./useLatest";
|
import { useLatest } from "./useLatest";
|
||||||
import i18n, { plexLang } from "../i18n";
|
import i18n, { plexLang } from "../i18n";
|
||||||
import { markerSkip } from "./plexMarkers";
|
import { markerSkip } from "./plexMarkers";
|
||||||
|
import { ownTextTracks } from "./plexSubTracks";
|
||||||
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
||||||
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
|
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
|
||||||
|
|
||||||
@@ -852,23 +853,30 @@ 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;
|
||||||
// Keep every track "hidden": loaded and its cues tracked, but drawn by PlexSubtitles, not by the
|
// Keep our own 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
|
// browser. Any other subtitle track (a direct-played file's own, see ownTextTracks) is switched
|
||||||
// retry), and the browser switches a fresh `default` track to "showing" a task later — which drew
|
// off, so neither the browser nor PlexSubtitles draws it. Not once per selection: the <track> is
|
||||||
// the cues a second time, natively, under ours. So it is re-applied on every track the list gains
|
// remounted on its own key (a grown window, a retry), and the browser switches a fresh `default`
|
||||||
// and on every mode change the browser makes.
|
// 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 tracks = videoRef.current?.textTracks;
|
const video = videoRef.current;
|
||||||
if (!tracks) return;
|
if (!video) return;
|
||||||
const hide = () => {
|
const tracks = video.textTracks;
|
||||||
for (const tr of Array.from(tracks)) if (tr.mode !== "hidden") tr.mode = "hidden";
|
const settle = () => {
|
||||||
|
const own = ownTextTracks(video);
|
||||||
|
for (const tr of Array.from(tracks)) {
|
||||||
|
const subtitle = tr.kind === "subtitles" || tr.kind === "captions";
|
||||||
|
const want = own.has(tr) ? "hidden" : subtitle ? "disabled" : tr.mode;
|
||||||
|
if (tr.mode !== want) tr.mode = want;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
hide();
|
settle();
|
||||||
tracks.addEventListener("addtrack", hide);
|
tracks.addEventListener("addtrack", settle);
|
||||||
tracks.addEventListener("change", hide);
|
tracks.addEventListener("change", settle);
|
||||||
return () => {
|
return () => {
|
||||||
tracks.removeEventListener("addtrack", hide);
|
tracks.removeEventListener("addtrack", settle);
|
||||||
tracks.removeEventListener("change", hide);
|
tracks.removeEventListener("change", settle);
|
||||||
};
|
};
|
||||||
}, [subOrd, id, videoRef]);
|
}, [subOrd, id, videoRef]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** The text tracks of the Plex player's own <track> elements. A <video> can expose others too: the
|
||||||
|
* subtitle stream of a direct-played file (WebKit lists an mp4's mov_text in `textTracks`), or a
|
||||||
|
* metadata track hls.js adds. Those are neither shown nor drawn: the viewer picks the subtitles. */
|
||||||
|
export function ownTextTracks(video: HTMLVideoElement): Set<TextTrack> {
|
||||||
|
return new Set(Array.from(video.querySelectorAll("track"), (el) => el.track));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user