Files
iptvnator/apps/web-e2e/src/player-settings-panel.e2e.ts
T
4grayandClaude Opus 5.5 ea25c739f5 fix(playback): address review findings on the Hybrid player controls
- Wide dock overflow: chips and the side settings panel now need 960px of
  player width (ControlsLayout.roomy). Between 720px and 960px the dock
  stays wide but folds the chips into `tune` with state dots and opens
  settings as the bottom sheet. The control row's side columns are
  minmax(min-content, 1fr), so the transport shifts instead of the actions
  overlapping it or leaving the player; chip labels cap at 132px.
- Up next: no countdown once playback has ended (autoplay off schedules
  no switch).
- Up next card: a still that fails to load falls back to the label tile;
  a new URL gets its own attempt.
- Timeline segments are positioned absolutely at their time positions,
  so drawn boundaries match the linear seek input and hover label.
- Type fixes found by a strict type-check: the gap-fill list type, and a
  field initializer that read a constructor parameter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:40:55 +02:00

218 lines
7.9 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.
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);
// Chips and the side panel need a player of at least 960px.
await page.setViewportSize({ width: 1600, height: 1000 });
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();
});