feat(playback): fold player track and playback options into a settings panel

Phase 2 of the "Hybrid" player controls redesign. Audio, subtitles (tracks,
file loading, delay, size, color), quality, speed and aspect ratio move
from five popovers into one surface behind a `tune` button:

- wide dock: subtitle and speed value chips (cyan = on, violet = changed)
  open the panel focused on their group; right-click / long-press on the
  subtitle chip toggles subtitles; the panel slides in beside the video
  and the dock, title and corner shift left; chips and PiP/record fold away
- compact dock: no chips, state dots on `tune`, and a bottom sheet that
  replaces the dock while open; a tap on the video dismisses it
- a choice applies immediately and keeps the panel open; Escape, the close
  button, `tune`, the video surface and outside pointerdown close it
- ControlsMenuState now knows volume / settings / stats plus the focus
  group; ControlsSettings owns availability, on/modified state and the
  transitions; app-player-settings-panel renders the panel and sheet
- SETTINGS / CLOSE_SETTINGS strings in every language; web E2E for the
  wide panel and the compact sheet; contract doc and release note

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 21:28:24 +02:00
1 parent c5518fa1ff
commit 93c8c4b148
41 files changed
+2634 -1075

No files matched your search

@@ -0,0 +1,215 @@
import type { Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { expect, test } from './fixtures';
/**
* The shared controls' settings panel: on a wide player the speed chip opens
* the panel beside the video and a choice applies in place; on a compact
* player the tune button opens a bottom sheet that a tap on the video
* dismisses. Runs against the built-in HTML5 player and a local clip so no
* remote media is involved.
*/
const FIXTURE_HOST = 'https://player-settings-fixture.local';
const PLAYLIST = [
'#EXTM3U',
'#EXTINF:-1 group-title="Movies",Settings Clip',
`${FIXTURE_HOST}/clip.mp4`,
].join('\n');
test.use({ serviceWorkers: 'block' });
async function serveClip(page: Page): Promise<void> {
const clip = readFileSync(
join(__dirname, 'fixtures/playback/episode.webm')
);
await page.route(`${FIXTURE_HOST}/**`, async (route) => {
const range = /^bytes=(\d*)-(\d*)$/.exec(
route.request().headers()['range'] ?? ''
);
const last = clip.length - 1;
const start = range?.[1]
? Number(range[1])
: range?.[2]
? Math.max(0, clip.length - Number(range[2]))
: 0;
const end =
range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last;
await route.fulfill({
status: range ? 206 : 200,
headers: {
'content-type': 'video/webm',
'accept-ranges': 'bytes',
'content-length': String(end - start + 1),
...(range
? {
'content-range': `bytes ${start}-${end}/${clip.length}`,
}
: {}),
},
body: clip.subarray(start, end + 1),
});
});
}
async function selectHtml5Player(page: Page): Promise<void> {
await page.goto('/workspace/settings/playback');
const select = page.locator('[data-test-id="select-video-player"]');
await expect(select).toBeVisible();
const previous = await select.innerText();
await select.click();
await page
.getByRole('option', { name: 'HTML5 video player', exact: true })
.click();
if (!previous.includes('HTML5 video player')) {
const saveButton = page.getByRole('button', { name: 'Save changes' });
await saveButton.click();
await expect(saveButton).toBeHidden();
}
}
async function importPlaylist(page: Page): Promise<void> {
await page.goto('/');
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
await dialog.getByLabel('Insert m3u(8) playlist as text').fill(PLAYLIST);
await Promise.all([
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
]);
await expect(page.getByText('1 channels')).toBeVisible();
}
async function startClip(page: Page) {
await page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: 'Settings Clip' })
.click();
const view = page.locator('app-web-player-view');
const video = view.locator('video');
await expect
.poll(() =>
video.evaluate(
(el: HTMLVideoElement) =>
Number.isFinite(el.duration) && el.duration > 5
)
)
.toBe(true);
// Reveal the dock and keep it revealed for the assertions below.
await view.hover();
const controls = view.locator('app-player-controls');
await controls.getByRole('button', { name: 'Pause', exact: true }).click();
return { view, video, controls };
}
test('@web @playback settings panel opens from the speed chip and applies in place', async ({
page,
}) => {
test.setTimeout(90_000);
await serveClip(page);
await selectHtml5Player(page);
await importPlaylist(page);
const { video, controls } = await startClip(page);
const speedChip = controls.locator(
'[data-test-id="player-controls-speed-chip"]'
);
await expect(speedChip).toContainText('1×');
await expect(
controls.locator('[data-test-id="player-controls-settings-panel"]')
).toHaveCount(0);
await speedChip.click();
const panel = controls.getByRole('dialog', { name: 'Settings' });
await expect(panel).toBeVisible();
await expect(panel).not.toHaveClass(/player-controls__settings--sheet/);
await expect(controls.locator('.player-controls__bar')).toHaveClass(
/player-controls__bar--panel-open/
);
// Chips fold away while the panel is open; the tune button lights up.
await expect(speedChip).toHaveCount(0);
await expect(
controls.locator('[data-test-id="player-controls-settings-button"]')
).toHaveAttribute('aria-expanded', 'true');
await panel.getByRole('radio', { name: '1.5×', exact: true }).click();
await expect
.poll(() => video.evaluate((el: HTMLVideoElement) => el.playbackRate))
.toBe(1.5);
// The choice keeps the panel open for comparison…
await expect(panel).toBeVisible();
// …and Escape closes it; the chip now reads the modified value.
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(controls.locator('.player-controls__bar')).not.toHaveClass(
/player-controls__bar--panel-open/
);
await expect(
controls.locator('[data-test-id="player-controls-speed-chip"]')
).toContainText('1.5×');
await expect(
controls.locator('[data-test-id="player-controls-speed-chip"]')
).toHaveClass(/player-controls__chip--modified/);
});
test('@web @playback compact player folds the chips into a tune button with a bottom sheet', async ({
page,
}) => {
test.setTimeout(90_000);
await page.setViewportSize({ width: 900, height: 700 });
await serveClip(page);
await selectHtml5Player(page);
await importPlaylist(page);
const { view, controls } = await startClip(page);
await expect(controls.locator('.player-controls__bar')).toHaveClass(
/player-controls__bar--compact/
);
await expect(
controls.locator('[data-test-id="player-controls-speed-chip"]')
).toHaveCount(0);
const tune = controls.locator(
'[data-test-id="player-controls-settings-button"]'
);
await tune.click();
const sheet = controls.getByRole('dialog', { name: 'Settings' });
await expect(sheet).toBeVisible();
await expect(sheet).toHaveClass(/player-controls__settings--sheet/);
await expect(controls.locator('.player-controls__bar')).toHaveClass(
/player-controls__bar--sheet-open/
);
await sheet.getByRole('radio', { name: '2×', exact: true }).click();
await expect
.poll(() =>
view
.locator('video')
.evaluate((el: HTMLVideoElement) => el.playbackRate)
)
.toBe(2);
// A modified value shows as a violet dot once the sheet is closed.
await sheet.locator('[data-test-id="player-settings-close"]').click();
await expect(sheet).toHaveCount(0);
await expect(
controls
.locator('[data-test-id="player-controls-settings-dots"]')
.locator('.player-controls__tune-dot--violet')
).toHaveCount(1);
// Reopen and dismiss by tapping the video: the tap closes the sheet
// instead of toggling playback.
await tune.click();
await expect(sheet).toBeVisible();
await view
.locator('video')
.click({ position: { x: 40, y: 40 }, force: true });
await expect(sheet).toHaveCount(0);
await expect(
controls.getByRole('button', { name: 'Play', exact: true })
).toBeVisible();
});