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.
170 lines
6.4 KiB
TypeScript
170 lines
6.4 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
|
|
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 VTT fetch need it) but `hidden`: the browser parses the cues and reports the active ones, and
|
|
// this layer shows them. The browser's own rendering could not move out of the way of the control
|
|
// bar — a cue is placed by `line`, which neither WebKit nor Chromium animates, and on an iPad the bar
|
|
// covered the text whenever it was up. Here the text rises above the bar while it shows and slides
|
|
// back when it hides, the same on every engine, and the viewer's size / colour / background / outline
|
|
// settings style it directly.
|
|
|
|
type Cue = TextTrackCue & { text?: string; getCueAsHTML?: () => DocumentFragment };
|
|
|
|
// A cue's default type size, as both engines draw it: 5 % of the video box's height.
|
|
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).
|
|
const CLEARANCE_PX = 8;
|
|
|
|
/** The cues showing now, from the player's own track(s) of `video` that are not switched off. */
|
|
function useActiveCues(video: HTMLVideoElement | null): Cue[] {
|
|
const [cues, setCues] = useState<Cue[]>([]);
|
|
useEffect(() => {
|
|
if (!video) return;
|
|
const tracks = video.textTracks;
|
|
const read = () => {
|
|
const now: Cue[] = [];
|
|
const own = ownTextTracks(video);
|
|
for (const tr of Array.from(tracks)) {
|
|
if (!own.has(tr) || tr.mode === "disabled") continue;
|
|
for (const c of Array.from(tr.activeCues ?? [])) now.push(c as Cue);
|
|
}
|
|
setCues((prev) =>
|
|
prev.length === now.length && prev.every((c, i) => c === now[i]) ? prev : now,
|
|
);
|
|
};
|
|
const bound = new Set<TextTrack>();
|
|
const bind = (tr: TextTrack) => {
|
|
if (bound.has(tr)) return;
|
|
bound.add(tr);
|
|
tr.addEventListener("cuechange", read);
|
|
};
|
|
const onAdd = () => {
|
|
for (const tr of Array.from(tracks)) bind(tr);
|
|
read();
|
|
};
|
|
for (const tr of Array.from(tracks)) bind(tr);
|
|
tracks.addEventListener("addtrack", onAdd);
|
|
tracks.addEventListener("removetrack", read);
|
|
read();
|
|
return () => {
|
|
tracks.removeEventListener("addtrack", onAdd);
|
|
tracks.removeEventListener("removetrack", read);
|
|
for (const tr of bound) tr.removeEventListener("cuechange", read);
|
|
};
|
|
}, [video]);
|
|
return cues;
|
|
}
|
|
|
|
/** The video box's height, followed as the window or fullscreen changes it. */
|
|
function useHeight(el: HTMLElement | null): number {
|
|
const [height, setHeight] = useState(0);
|
|
useEffect(() => {
|
|
if (!el) return;
|
|
const measure = () => setHeight(el.clientHeight);
|
|
measure();
|
|
const ro = new ResizeObserver(measure);
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, [el]);
|
|
return height;
|
|
}
|
|
|
|
/** One cue's text. `getCueAsHTML` keeps its <i>/<b>; it builds DOM nodes from the parsed cue, never
|
|
* markup from a string, so nothing in a subtitle file can run. */
|
|
function CueText({ cue, style }: { cue: Cue; style: React.CSSProperties }) {
|
|
const ref = useRef<HTMLSpanElement>(null);
|
|
useLayoutEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
if (typeof cue.getCueAsHTML === "function") el.replaceChildren(cue.getCueAsHTML());
|
|
else el.textContent = cue.text ?? "";
|
|
}, [cue]);
|
|
return (
|
|
<span
|
|
ref={ref}
|
|
style={style}
|
|
className="whitespace-pre-line px-[0.25em] leading-[1.3] box-decoration-clone"
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default function PlexSubtitles({
|
|
video,
|
|
pos,
|
|
size,
|
|
color,
|
|
background,
|
|
shadowPx,
|
|
shadowColor,
|
|
avoid,
|
|
avoidRef,
|
|
}: {
|
|
video: HTMLVideoElement | null;
|
|
/** Where the text's top sits, in % of the video box's height (the setting's "cue line"). */
|
|
pos: number;
|
|
size: number;
|
|
color: string;
|
|
background: string;
|
|
shadowPx: number;
|
|
shadowColor: string;
|
|
/** True while the control bar shows: the text then keeps above `avoidRef`'s top. */
|
|
avoid: boolean;
|
|
avoidRef: RefObject<HTMLElement | null>;
|
|
}) {
|
|
const cues = useActiveCues(video);
|
|
const height = useHeight(video);
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
// `slide` is false for the first placement of a box that just appeared: it is put where it belongs,
|
|
// 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
|
|
// 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(() => {
|
|
const box = boxRef.current;
|
|
if (!box || !video || cues.length === 0) {
|
|
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 limit = (bar ?? video.getBoundingClientRect().bottom) - CLEARANCE_PX;
|
|
const px = Math.max(0, Math.round((rect.bottom - limit) / scale));
|
|
setLift({ px, slide: shownRef.current });
|
|
shownRef.current = true;
|
|
}, [avoid, avoidRef, cues, height, pos, size, video]);
|
|
|
|
if (cues.length === 0 || height === 0) return null;
|
|
const text: React.CSSProperties = {
|
|
color,
|
|
backgroundColor: background,
|
|
textShadow: subShadow(shadowPx, shadowColor),
|
|
};
|
|
return (
|
|
<div
|
|
ref={boxRef}
|
|
data-testid="plex-subtitles"
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-x-0 px-[5%] text-center"
|
|
style={{ top: `${pos}%`, fontSize: `${(height * CUE_EM_OF_HEIGHT * size) / 100}px` }}
|
|
>
|
|
<div
|
|
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>
|
|
);
|
|
}
|