Files
jellytau/src/lib/components/player/VideoPlayer.menus.test.ts
T
dtourolle 2ff07bfa49 fix(player): one menu at a time, and inside the screen it opens on
Two defects in the video control bar, reported together because they present
together: the menus cover each other, and in portrait they cover the edge of
the screen instead of the video.

DR-256 (a) — audio track, quality and subtitles each owned a `show…` boolean
and no toggle cleared the others. Opening a second menu stacked it over the
first in the same corner: the newer panel hid rows of the older, both stayed
live, and both kept taking clicks. A single `openMenu` value replaces the three
booleans, which makes "at most one menu is open" a property of the state rather
than something every handler has to remember to enforce. The desktop volume
popup was a fourth uncoordinated menu in the same row, so `VolumeControl` grew
optional controlled-open props and joined the group; without them it still
manages itself, which is how MiniPlayer and AudioPlayer keep it.

DR-256 (b) — every panel was `absolute right-0` against *its own icon button*.
Those icons sit mid-row, so a 200-220 px panel extended left from a point well
inside the bar and hung off the left edge of a phone in portrait: half the
tracks could not be read, let alone tapped. One shared panel now anchors to the
control ROW's right edge, clamped to `min(20rem, 100vw - 2rem)` wide and
`min(300px, 45vh)` tall. A full-screen dismiss layer inside the controls subtree
closes it on a tap elsewhere — inside, so the tap never reaches the container's
gesture layer and cannot toggle playback (DR-098).

The volume popup had the same placement bug from the other side: `left-full`
opened it rightward from an icon near the right end of every bar it appears in.
It opens upward, right-aligned, now. And the icon row wraps rather than
overflowing — in portrait the transport controls plus nine icons are wider than
the screen, which pushed fullscreen and close past the edge.

The test renders the real component and drives the toggles, because neither
fault is visible from a helper: both are properties of the composition. It was
written first and failed on both counts — `["Audio Track", "Subtitles"]` open at
once, and no shared panel to anchor.
2026-08-23 17:53:16 +02:00

240 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Regression tests for the video player's track / quality / subtitle menus,
* rendered against the REAL component.
*
* TRACES: UR-020, UR-021, UR-066, UR-074 | DR-256 | UT-227, UT-228
*
* Two defects shipped together, and neither is visible from a pure helper:
*
* 1. Each menu owned its own `show…` boolean and no toggle cleared the others,
* so opening the subtitle menu on top of the audio menu left two panels
* overlapping in the same corner — the newer one covering rows of the
* older one, both still live.
*
* 2. Each panel was `absolute right-0` against *its own icon button*, which
* sits mid-row. A 200220 px panel hung off the left edge of a portrait
* phone, so half the tracks could not be read or tapped.
*
* Both are properties of the composition, so these tests drive the real
* markup: click the toggles, then assert what a viewer would see.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, fireEvent } from "@testing-library/svelte";
import { tick } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import VideoPlayer from "./VideoPlayer.svelte";
function testSelection() {
return {
url: "http://x/master.m3u8",
transport: { type: "hls" },
playbackKind: { type: "transcode" },
rendition: null,
available: [
{
quality: "original",
label: "Original",
detail: "Source",
exceedsSource: false,
sourceBitrate: 8_000_000,
},
{
quality: "high",
label: "8 Mbps",
detail: "1080p",
exceedsSource: false,
sourceBitrate: 8_000_000,
},
],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: true,
} as unknown as import("$lib/api/bindings").StreamSelection;
}
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
vi.mock("$lib/player", () => ({
playerController: {
toggle: vi.fn(() => Promise.resolve()),
seekVideo: vi.fn(() => Promise.resolve()),
seek: vi.fn(() => Promise.resolve()),
setActiveAdapter: vi.fn(),
clearActiveAdapter: vi.fn(),
getActiveAdapter: vi.fn(() => null),
switchAudioTrack: vi.fn(() => Promise.resolve()),
setStreamQuality: vi.fn(() => Promise.resolve(null)),
},
}));
vi.mock("$lib/player/adapters/rustReportHost", () => ({
createRustReportHost: () => ({
onState: vi.fn(),
onPosition: vi.fn(),
onMediaLoaded: vi.fn(),
onEnded: vi.fn(),
onError: vi.fn(),
onStreamUrlChanged: vi.fn(),
onBuffering: vi.fn(),
onReady: vi.fn(),
}),
}));
vi.mock("$lib/player/html5Adapter", () => ({
reportState: vi.fn(),
reportPosition: vi.fn(),
reportMediaLoaded: vi.fn(),
resetReporting: vi.fn(),
}));
vi.mock("$lib/utils/pictureInPicture", () => ({
isPipSupported: () => false,
enterPip: vi.fn(),
setAutoEnterEnabled: vi.fn(),
setHtml5VideoState: vi.fn(),
}));
vi.mock("$lib/stores/auth", () => ({
auth: {
getUserId: () => "user-1",
getRepository: () => ({ getHandle: () => "h", jrayActorsAt: async () => [] }),
subscribe: (fn: (v: unknown) => void) => {
fn({ isAuthenticated: true });
return () => {};
},
},
}));
/** Two audio tracks and one subtitle track — enough for all three menus. */
const MEDIA = {
id: "item-1",
name: "Test Episode",
type: "Episode",
runTimeTicks: 6_000_000_000,
durationMs: 600_000,
mediaStreams: [
{ index: 1, kind: "audio", displayTitle: "English AAC", language: "eng", isDefault: true },
{ index: 2, kind: "audio", displayTitle: "Commentary", language: "eng" },
{
index: 3,
kind: "subtitle",
displayTitle: "English SRT",
language: "eng",
codec: "srt",
deliverableAsSidecar: true,
},
],
} as any;
function renderPlayer() {
return render(VideoPlayer, {
props: { media: MEDIA, selection: testSelection(), onClose: vi.fn() },
});
}
/** The menu panels currently on screen, found by their headings. */
function openPanels(container: HTMLElement): string[] {
return ["Audio Track", "Quality", "Subtitles"].filter((heading) =>
[...container.querySelectorAll("div")].some(
(el) => el.children.length === 0 && el.textContent?.trim() === heading,
),
);
}
function clickToggle(container: HTMLElement, label: string) {
const button = container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
expect(button, `expected a "${label}" button in the controls`).toBeTruthy();
return fireEvent.click(button!);
}
describe("VideoPlayer track menus", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
switch (cmd) {
case "player_get_streaming_qualities":
return [];
case "player_get_video_settings":
return { streamingQuality: "original" };
default:
return undefined;
}
});
});
// UT-227
it("opening one menu closes any other — never two panels stacked in the corner", async () => {
const { container } = renderPlayer();
await tick();
await clickToggle(container, "Select audio track");
await tick();
expect(openPanels(container)).toEqual(["Audio Track"]);
await clickToggle(container, "Select subtitles");
await tick();
expect(openPanels(container)).toEqual(["Subtitles"]);
await clickToggle(container, "Select streaming quality");
await tick();
expect(openPanels(container)).toEqual(["Quality"]);
// A second click on the open menu's own toggle closes it.
await clickToggle(container, "Select streaming quality");
await tick();
expect(openPanels(container)).toEqual([]);
});
// UT-227
it("the volume slider is part of the same group — it closes an open track menu", async () => {
const { container } = renderPlayer();
await tick();
await clickToggle(container, "Select subtitles");
await tick();
expect(openPanels(container)).toEqual(["Subtitles"]);
// The volume popup (desktop only) is a menu of this bar too.
const volume = container.querySelector<HTMLButtonElement>('button[title="Volume"]');
expect(volume).toBeTruthy();
await fireEvent.click(volume!);
await tick();
expect(container.querySelector("[aria-label='Volume controls']")).toBeTruthy();
expect(openPanels(container)).toEqual([]);
// …and a track menu closes the volume popup again.
await clickToggle(container, "Select subtitles");
await tick();
expect(container.querySelector("[aria-label='Volume controls']")).toBeNull();
expect(openPanels(container)).toEqual(["Subtitles"]);
});
// UT-228
it("the open panel is anchored to the control bar and clamped to the viewport", async () => {
const { container } = renderPlayer();
await tick();
for (const label of ["Select audio track", "Select subtitles", "Select streaming quality"]) {
await clickToggle(container, label);
await tick();
const panel = container.querySelector<HTMLElement>("[data-testid='player-menu']");
expect(panel, `${label} should open the shared menu panel`).toBeTruthy();
// Anchored to the control row, not to the icon button: a panel anchored
// to a mid-row button runs off the left edge in portrait.
const toggle = container.querySelector<HTMLElement>(`button[aria-label="${label}"]`);
expect(panel!.contains(toggle!)).toBe(false);
expect(toggle!.parentElement!.contains(panel!)).toBe(false);
// …and never wider than the screen it opens on.
expect(panel!.className).toMatch(/max-w-\[|w-\[min\(/);
await clickToggle(container, label);
await tick();
}
});
});