mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playback): Hybrid redesign of the shared player controls (#1709)
This commit is contained in:
1 parent
8b570e3390
commit
be217d5cf5
87 files changed
+6152
-1670
No files matched your search
@@ -0,0 +1,217 @@
|
||||
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();
|
||||
});
|
||||
@@ -123,6 +123,54 @@ test.describe('@xtream inline series fullscreen', () => {
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('shows the Up next card near the end and plays the next episode from it', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
await selectWebPlayer(page, 'HTML5 video player');
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).readyState)
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The 30 s fixture clip is inside the card's threshold from its
|
||||
// first frame, so the card is up as soon as the duration is known.
|
||||
const card = playerView.locator(
|
||||
'[data-test-id="player-controls-up-next"]'
|
||||
);
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText('Up next');
|
||||
await expect(card).toHaveAttribute('aria-label', /S01E02/);
|
||||
|
||||
// The settings panel takes the same corner: the card yields to it.
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.locator('[data-test-id="player-controls-settings-button"]')
|
||||
.click();
|
||||
await expect(card).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
// A click plays the next episode through the host's own path.
|
||||
await card.click();
|
||||
await expect(
|
||||
page.locator('app-portal-inline-player .player-shell__episode-meta')
|
||||
).toContainText('S01E02', { timeout: 15_000 });
|
||||
await expect(card).toHaveAttribute('aria-label', /S01E03/, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('switches episodes from the fullscreen episode panel without leaving fullscreen', async ({
|
||||
page,
|
||||
request,
|
||||
|
||||
Reference in new issue
Block a user