mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
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();
|
||||
});
|
||||
Reference in new issue
Block a user