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([]); 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(); 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 /; 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(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 ( ); } 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; }) { const cues = useActiveCues(video); const height = useHeight(video); const boxRef = useRef(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 ( ); }