mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
- 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>
218 lines
7.9 KiB
TypeScript
218 lines
7.9 KiB
TypeScript
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();
|
||
});
|