feat(plex): subtitles rise above the control bar while it shows

On an iPad the bar covered the text whenever it was up: the browser places a
native cue by its line, which nothing animates. The track now stays loaded as
hidden and PlexSubtitles draws the active cues, measured against the bar and
lifted above it in 200 ms, back down when it hides. The viewer's size, colour,
background and outline settings style the text directly. Also fixes a test
typing slip from the search pill change.
This commit is contained in:
2026-10-08 02:59:13 +02:00
parent 5db6dc8ee6
commit 75093458c9
8 changed files with 333 additions and 58 deletions
+1
View File
@@ -713,3 +713,4 @@ of a full-width panel and nothing on the right.
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
+17 -10
View File
@@ -45,7 +45,6 @@ import {
DEFAULT_PREFS,
fmt,
subOrdForLang,
subShadowCss,
type PlexPlayerPrefs,
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead } from "./plexPlayerControls";
@@ -84,6 +83,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { useBackToClose } from "../lib/history";
import PlexSubtitles from "./PlexSubtitles";
import { lockBodyScroll, trapTab } from "./ui/dialogFocus";
import {
pushLayer,
@@ -150,6 +150,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const videoRef = useRef<HTMLVideoElement | null>(null);
const wrapRef = useRef<HTMLDivElement>(null);
// The bottom band, which the subtitles keep above while it shows.
const bottomBarRef = useRef<HTMLDivElement>(null);
// A control that goes while it holds the focus — Next onto the last item or a series finale —
// hands it to its neighbour instead of <body>. The player is portaled outside the
// page scroller, whose keeper does not watch it.
@@ -418,7 +420,6 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
detail,
sessionK,
subOffset: prefs.subOffset,
subPos: prefs.subPos,
restartSession,
setAudioTrack: (track: number) => engine.setAudioTrack(track),
patchPrefs,
@@ -1130,12 +1131,6 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
}),
}}
>
{/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this
player owns the only <video> on screen. */}
<style>{`video::cue{font-size:${prefs.subSize}%;color:${prefs.subColor};background-color:${prefs.subBg};${subShadowCss(
prefs.subShadow,
prefs.subShadowColor,
)}}`}</style>
<video
ref={(el) => {
videoRef.current = el;
@@ -1151,8 +1146,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
onClick={onPictureClick}
playsInline
>
{/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
above flips modes; the browser fetches a track's VTT the first time it's shown. */}
{/* Text subtitles as a native track (same-origin → sent with the session cookie), kept
`hidden`: the browser fetches and parses the VTT, PlexSubtitles below draws the cues. */}
{selectedSub && subShowable && (
<track
// One track = the selected sub only (no multi-track overlap). The key (see usePlexTracks)
@@ -1168,6 +1163,17 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
/>
)}
</video>
<PlexSubtitles
video={videoEl}
pos={prefs.subPos}
size={prefs.subSize}
color={prefs.subColor}
background={prefs.subBg}
shadowPx={prefs.subShadow}
shadowColor={prefs.subShadowColor}
avoid={uiVisible}
avoidRef={bottomBarRef}
/>
{/* Loading / buffering / error overlay. `!ready` = initial session load; `buffering` = a
mid-playback stall or in-flight seek (see the state); `loadError` surfaces a fatal failure so
@@ -1331,6 +1337,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
{/* Bottom controls */}
<div
ref={bottomBarRef}
{...holdWhileHovered}
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-3 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
uiVisible
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
//
// The Plex player draws its subtitles itself. With the browser drawing them, the control bar covered
// the text on an iPad whenever it was up: a native cue is placed by `line`, which nothing animates.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { createRef } from "react";
import PlexSubtitles from "./PlexSubtitles";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
/** A <video> 1000 px tall with one hidden track, whose active cues the test sets. */
function fakeVideo() {
const video = document.createElement("video");
const track = Object.assign(new EventTarget(), {
mode: "hidden" as TextTrackMode,
activeCues: [] as unknown[],
});
const tracks = Object.assign(new EventTarget(), { 0: track, length: 1 });
Object.defineProperty(tracks, Symbol.iterator, {
value: function* () {
yield track;
},
});
Object.defineProperty(video, "textTracks", { value: tracks });
Object.defineProperty(video, "clientHeight", { value: 1000 });
const show = (...texts: string[]) =>
act(() => {
track.activeCues = texts.map((text, i) => ({ startTime: i, text }));
track.dispatchEvent(new Event("cuechange"));
});
return { video, show };
}
/** Lay the boxes out: the text's box ends at `textBottom`, the bar starts at `barTop`. */
function layout(video: HTMLVideoElement, bar: HTMLElement, textBottom: number, barTop: number) {
const rect = (top: number, bottom: number) =>
({ top, bottom, left: 0, right: 0, width: 0, height: bottom - top }) as DOMRect;
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
if (this === video) return rect(0, 1000);
if (this === bar) return rect(barTop, 1000);
if ((this as HTMLElement).dataset?.testid === "plex-subtitles") {
// As a browser reports it: moved by the lift it carries.
const shift = Number(/-?\d+/.exec((this as HTMLElement).style.transform)?.[0] ?? 0);
return rect(textBottom - 80 + shift, textBottom + shift);
}
return rect(0, 0);
});
}
function mount(video: HTMLVideoElement, avoid: boolean) {
const bar = document.createElement("div");
const barRef = createRef<HTMLElement>() as { current: HTMLElement | null };
barRef.current = bar;
const props = {
video,
pos: 88,
size: 100,
color: "#ffffff",
background: "transparent",
shadowPx: 0,
shadowColor: "#000000",
avoidRef: barRef,
};
const view = render(<PlexSubtitles {...props} avoid={avoid} />);
return { bar, rerender: (a: boolean) => view.rerender(<PlexSubtitles {...props} avoid={a} />) };
}
describe("PlexSubtitles", () => {
it("shows the cues the browser reports active, at the cue type size of the video box", () => {
const { video, show } = fakeVideo();
mount(video, false);
show("Half full", "or half empty?");
const box = screen.getByTestId("plex-subtitles");
expect(box.textContent).toBe("Half fullor half empty?");
expect(box.style.fontSize).toBe("50px");
expect(box.style.top).toBe("88%");
});
it("rises above the control bar while it shows, and settles back when it hides", () => {
const { video, show } = fakeVideo();
const { bar, rerender } = mount(video, false);
layout(video, bar, 950, 900);
show("Half full");
expect(screen.getByTestId("plex-subtitles").style.transform).toBe("translateY(0px)");
rerender(true);
// 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)");
rerender(false);
expect(screen.getByTestId("plex-subtitles").style.transform).toBe("translateY(0px)");
});
it("draws nothing while no cue is active", () => {
const { video } = fakeVideo();
mount(video, true);
expect(screen.queryByTestId("plex-subtitles")).toBeNull();
});
});
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
import { subShadow } from "../lib/plexPlayerPrefs";
// 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 every track of `video` that is 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[] = [];
for (const tr of Array.from(tracks)) {
if (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);
};
for (const tr of Array.from(tracks)) bind(tr);
const onAdd = (e: Event) => {
const tr = (e as TrackEvent).track;
if (tr) bind(tr as TextTrack);
read();
};
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);
const [lift, setLift] = useState(0);
const liftRef = useRef(0);
// 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.
useLayoutEffect(() => {
const box = boxRef.current;
if (!box || !video || cues.length === 0) return;
const bottom = box.getBoundingClientRect().bottom + liftRef.current;
const bar = avoid ? avoidRef.current?.getBoundingClientRect().top : undefined;
const limit = (bar ?? video.getBoundingClientRect().bottom) - CLEARANCE_PX;
const next = Math.max(0, Math.round(bottom - limit));
liftRef.current = next;
setLift(next);
}, [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 flex flex-col items-center gap-[0.15em] px-[5%] text-center transition-transform duration-200 ease-out motion-reduce:transition-none"
style={{
top: `${pos}%`,
fontSize: `${(height * CUE_EM_OF_HEIGHT * size) / 100}px`,
transform: `translateY(${-lift}px)`,
}}
>
{cues.map((cue, i) => (
<CueText key={`${cue.startTime}-${i}`} cue={cue} style={text} />
))}
</div>
);
}
@@ -42,7 +42,14 @@ describe("SearchBar on a touch screen", () => {
it("shortens Go to its icon by the column's width, not the screen's", () => {
const { getByText } = render(
<SearchBar value="" onChange={() => {}} onGo={() => {}} goIcon={<svg />} goLabel="Go far" />,
<SearchBar
value=""
onChange={() => {}}
placeholder="Search…"
onGo={() => {}}
goIcon={<svg />}
goLabel="Go far"
/>,
);
const label = getByText("Go far");
expect(label.className).toContain("@min-[900px]/appmain:inline");
@@ -42,7 +42,6 @@ function mount(qc: QueryClient, { sessionK = 0, abs = 3600, loadStart = 0 } = {}
detail: detail(),
sessionK,
subOffset: 0,
subPos: 88,
restartSession: () => {},
setAudioTrack: () => false,
patchPrefs: () => {},
@@ -316,7 +315,6 @@ function TrackHarness() {
detail: detail(),
sessionK: 0,
subOffset: 0,
subPos: 88,
restartSession: () => {},
setAudioTrack: () => false,
patchPrefs: () => {},
@@ -417,7 +415,6 @@ describe("usePlexTracks — the debounced A/V-offset restart", () => {
detail: detail(),
sessionK: 0,
subOffset: 0,
subPos: 88,
restartSession: (at) => restart(id, at),
setAudioTrack: () => false,
patchPrefs: () => {},
@@ -439,3 +436,35 @@ describe("usePlexTracks — the debounced A/V-offset restart", () => {
expect(restart).not.toHaveBeenCalled();
});
});
// 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 };
const video = document.createElement("video");
Object.defineProperty(video, "textTracks", { value: [track] });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client()}>{children}</QueryClientProvider>
);
const { unmount } = renderHook(
() =>
usePlexTracks({
id: RK,
detail: detail(),
sessionK: 0,
subOffset: 0,
restartSession: () => {},
setAudioTrack: () => false,
patchPrefs: () => {},
videoRef: { current: video },
audioRef: { current: 0 } as RefObject<number | null>,
aoffRef: { current: 0 },
playhead: () => 0,
}),
{ wrapper },
);
expect(track.mode).toBe("hidden");
unmount();
});
});
+5 -40
View File
@@ -585,7 +585,6 @@ export function usePlexTracks(opts: {
detail: PlexItemDetail | undefined;
sessionK: number;
subOffset: number;
subPos: number;
/** Restart the item's session at `startAt` (HlsEngine.start). */
restartSession: (startAt: number) => void;
/** Switch audio client-side; false when no multi-rendition session is attached (HlsEngine). */
@@ -633,8 +632,7 @@ export function usePlexTracks(opts: {
* 0 calls, while `onErrorCapture` fires). The capture phase reaches the target regardless. */
subTrackListeners: { onErrorCapture: () => void };
} {
const { id, detail, sessionK, subOffset, subPos, restartSession, setAudioTrack, patchPrefs } =
opts;
const { id, detail, sessionK, subOffset, restartSession, setAudioTrack, patchPrefs } = opts;
const { videoRef, audioRef, aoffRef, playhead } = opts;
const qc = useQueryClient();
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
@@ -854,48 +852,15 @@ 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 "showing" (its VTT loads on mount; `default` alone can be flaky
// after a remount). Re-runs when the selection or the shifted-track identity changes (fresh <track>).
// 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>).
useEffect(() => {
const video = videoRef.current;
if (!video) return;
for (const tr of Array.from(video.textTracks)) tr.mode = "showing";
for (const tr of Array.from(video.textTracks)) tr.mode = "hidden";
}, [subOrd, id, subTrackOffset, videoRef]);
// Position subtitle cues per the user's vertical setting (subPos). The video fills the viewport
// height, so a default (line "auto") cue renders at the extreme bottom and clips — line% lifts it.
// Re-applied on each cue change (hls.js streams cues in as subtitle segments load; no per-cue event).
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const placeAll = () => {
for (const tr of Array.from(video.textTracks)) {
for (const c of Array.from(tr.cues || [])) {
try {
(c as VTTCue).snapToLines = false;
(c as VTTCue).line = subPos;
} catch {
/* not a positionable cue */
}
}
}
};
const bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll);
for (const tr of Array.from(video.textTracks)) bind(tr);
const onAddTrack = (e: TrackEvent) => {
if (e.track) {
bind(e.track as TextTrack);
placeAll();
}
};
video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
placeAll();
return () => {
video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
};
}, [id, subPos, videoRef]);
return {
audioOrd,
setAudioOrd,
+4 -4
View File
@@ -78,9 +78,9 @@ export const DEFAULT_PREFS: PlexPlayerPrefs = {
// A punchy subtitle shadow. The old single 0-offset blur washed out to nothing on bright scenes even
// at the max slider value; instead lay down eight offset copies (a solid dark OUTLINE around the
// glyphs) plus a soft glow, all scaled by the slider — legible over anything, e.g. yellow text on a
// snow-white frame. Empty string when the slider is 0 (shadow off).
export function subShadowCss(px: number, color: string): string {
if (px <= 0) return "";
// snow-white frame. A `text-shadow` value; undefined when the slider is 0 (shadow off).
export function subShadow(px: number, color: string): string | undefined {
if (px <= 0) return undefined;
const o = px;
const b = Math.max(1, Math.round(px / 2));
const dirs: [number, number][] = [
@@ -94,7 +94,7 @@ export function subShadowCss(px: number, color: string): string {
[-o, -o],
];
const outline = dirs.map(([x, y]) => `${x}px ${y}px ${b}px ${color}`).join(",");
return `text-shadow:${outline},0 0 ${o * 2}px ${color};`;
return `${outline},0 0 ${o * 2}px ${color}`;
}
// Top-right wall clock text: time (24h European or 12h am/pm) + optional date in one of two fixed