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.
187 lines
6.8 KiB
TypeScript
187 lines
6.8 KiB
TypeScript
// @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();
|
||
});
|
||
|
||
type FakeTrack = EventTarget & { kind: string; mode: TextTrackMode; activeCues: unknown[] };
|
||
|
||
/** A <video> 1000 px tall with one hidden track of the player's own <track>, whose active cues the
|
||
* test sets; `addTrack` adds another as a browser would — the player's own, or one of the media's. */
|
||
function fakeVideo() {
|
||
const video = document.createElement("video");
|
||
const list: FakeTrack[] = [];
|
||
const tracks = Object.assign(new EventTarget(), {
|
||
get length() {
|
||
return list.length;
|
||
},
|
||
[Symbol.iterator]: () => list[Symbol.iterator](),
|
||
});
|
||
Object.defineProperty(video, "textTracks", { value: tracks });
|
||
Object.defineProperty(video, "clientHeight", { value: 1000 });
|
||
const make = (own: boolean) => {
|
||
const track: FakeTrack = Object.assign(new EventTarget(), {
|
||
kind: "subtitles",
|
||
mode: "hidden" as TextTrackMode,
|
||
activeCues: [] as unknown[],
|
||
});
|
||
if (own) {
|
||
const el = document.createElement("track");
|
||
Object.defineProperty(el, "track", { value: track });
|
||
video.appendChild(el);
|
||
}
|
||
list.push(track);
|
||
return track;
|
||
};
|
||
const cuesOn = (track: FakeTrack, texts: string[]) =>
|
||
act(() => {
|
||
track.activeCues = texts.map((text, i) => ({ startTime: i, text }));
|
||
track.dispatchEvent(new Event("cuechange"));
|
||
});
|
||
const track = make(true);
|
||
const show = (...texts: string[]) => cuesOn(track, texts);
|
||
const addTrack = (own: boolean) => {
|
||
const added = make(own);
|
||
act(() => {
|
||
tracks.dispatchEvent(new Event("addtrack"));
|
||
});
|
||
return (...texts: string[]) => cuesOn(added, texts);
|
||
};
|
||
return { video, show, addTrack };
|
||
}
|
||
|
||
/** Lay the boxes out: the text's box ends at `textBottom`, the bar starts at `barTop`. With a `scale`,
|
||
* the player is drawn that much larger than its own pixels, as the windowed player is. */
|
||
function layout(
|
||
video: HTMLVideoElement,
|
||
bar: HTMLElement,
|
||
textBottom: number,
|
||
barTop: number,
|
||
scale = 1,
|
||
) {
|
||
const rect = (top: number, bottom: number) =>
|
||
({ top, bottom, left: 0, right: 0, width: 0, height: bottom - top }) as DOMRect;
|
||
const isBox = (el: Element) => (el as HTMLElement).dataset?.testid === "plex-subtitles";
|
||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
|
||
if (this === video) return rect(0, 1000);
|
||
if (this === bar) return rect(barTop, 1000);
|
||
// The outer box: the lift moves only the text inside it.
|
||
if (isBox(this)) return rect(textBottom - 80, textBottom);
|
||
return rect(0, 0);
|
||
});
|
||
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (
|
||
this: HTMLElement,
|
||
) {
|
||
return isBox(this) ? 80 / scale : 0;
|
||
});
|
||
}
|
||
|
||
const lifted = () => screen.getByTestId("plex-subtitles-text");
|
||
|
||
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(lifted().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(lifted().style.transform).toBe("translateY(-58px)");
|
||
rerender(false);
|
||
expect(lifted().style.transform).toBe("translateY(0px)");
|
||
});
|
||
|
||
it("lifts in the player's own pixels when the player is drawn scaled", () => {
|
||
const { video, show } = fakeVideo();
|
||
const { bar } = mount(video, true);
|
||
layout(video, bar, 950, 900, 1.25);
|
||
show("Half full");
|
||
// 58 screen pixels are 46.4 of the player's own, which the browser then draws ×1.25.
|
||
expect(lifted().style.transform).toBe("translateY(-46px)");
|
||
});
|
||
|
||
it("places a cue that just appeared, and slides only one already on screen", () => {
|
||
const { video, show } = fakeVideo();
|
||
const { bar, rerender } = mount(video, true);
|
||
layout(video, bar, 950, 900);
|
||
show("Half full");
|
||
expect(lifted().style.transform).toBe("translateY(-58px)");
|
||
expect(lifted().className).not.toContain("transition-transform");
|
||
rerender(false);
|
||
expect(lifted().className).toContain("transition-transform");
|
||
// The bar comes back while no cue is up: the next one is placed above it, not slid there.
|
||
show();
|
||
rerender(true);
|
||
show("or half empty?");
|
||
expect(lifted().style.transform).toBe("translateY(-58px)");
|
||
expect(lifted().className).not.toContain("transition-transform");
|
||
});
|
||
|
||
it("draws the cues of a track the player mounts later — a remounted <track> is a new one", () => {
|
||
const { video, addTrack } = fakeVideo();
|
||
mount(video, false);
|
||
const showOnNew = addTrack(true);
|
||
showOnNew("After the seek");
|
||
expect(screen.getByTestId("plex-subtitles").textContent).toBe("After the seek");
|
||
});
|
||
|
||
it("never draws a track that is not the player's own, such as a direct-played file's", () => {
|
||
const { video, addTrack } = fakeVideo();
|
||
mount(video, false);
|
||
const showInBand = addTrack(false);
|
||
showInBand("Embedded");
|
||
expect(screen.queryByTestId("plex-subtitles")).toBeNull();
|
||
});
|
||
|
||
it("draws nothing while no cue is active", () => {
|
||
const { video } = fakeVideo();
|
||
mount(video, true);
|
||
expect(screen.queryByTestId("plex-subtitles")).toBeNull();
|
||
});
|
||
});
|