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
@@ -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