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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user