mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
feat(playback): fullscreen episode panel for series playback (#1620)
Series playing in fullscreen get the same slide-in side panel the live channel list has, with season tabs and the episode list: rest the mouse on the left edge, click it, or press C; pick an episode and it plays inline without leaving fullscreen. - Panel contract: `FullscreenChannelPanelHost` gains optional `panelSearchEnabled` and `panelKind`; the template context gains `open`. Pointer/keyboard rules and the four live providers are unchanged. - Series host: `PortalInlinePlayerComponent` provides the token through `createEpisodePanelHost()` and stamps `app-fullscreen-episode-panel` (SeasonTabsComponent over rows with TMDB still or numeral tile, label, runtime, clamped overview, progress, watched check, now-playing marker; playing row centred on open). Episode clicks reuse the Up Next rail's inline path; season tab clicks reach the hosts' `onSeasonSelected` (Xtream TMDB season enrichment, Stalker lazy VOD load with a Retry row after a failed request). - Gates: `Settings.fullscreenChannelPanel` (label now covers both lists in all locales), episode content only, native-view Embedded MPV withheld by the view, external players excluded. - Inline-series e2e moved to `xtream-series-playback.e2e.ts` with shared Xtream helpers in a fixture; adds a fullscreen episode switch through the panel. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
c110519412
commit
fa8ce26991
55 files changed
+2961
-570
No files matched your search
@@ -0,0 +1,242 @@
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
interceptXtreamRequests,
|
||||
MOCK_SERVER,
|
||||
playFirstSeriesEpisode,
|
||||
rewriteSeriesEpisodesToMp4,
|
||||
routeEpisodeClip,
|
||||
selectWebPlayer,
|
||||
} from './xtream-series-playback.fixture';
|
||||
|
||||
/**
|
||||
* Xtream inline series playback in fullscreen: episode switches, the
|
||||
* fullscreen episode panel and the vendor-chrome shortcuts. Split out of
|
||||
* `xtream.e2e.ts` for size; same mock server and default scenario
|
||||
* (3 seasons × 8 episodes per series).
|
||||
*
|
||||
* Tag: @xtream
|
||||
*/
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await request.post(`${MOCK_SERVER}/reset`);
|
||||
await page.goto('/');
|
||||
await interceptXtreamRequests(page);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen survives an episode switch
|
||||
//
|
||||
// app-web-player-view remounts the engine component for every playback
|
||||
// application (next episode, channel, or alternative source). DOM fullscreen
|
||||
// used to be owned by that engine's shell, so the Fullscreen API exited the
|
||||
// moment the old shell left the document — every "next episode" click and
|
||||
// every autoplay hand-off dropped the viewer back to the page. The owner is
|
||||
// now the app-web-player-view host, which spans all applications of one mount.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('@xtream inline series fullscreen', () => {
|
||||
// No autoplay-policy flag is needed: the episode click is a user
|
||||
// activation and the fixture clip carries no audio track.
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('stays in fullscreen across manual and automatic episode switches', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
|
||||
// Persist the HTML5 engine first: Video.js rejects the mock's
|
||||
// video/matroska source type before any bytes are sniffed.
|
||||
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,
|
||||
});
|
||||
const waitForMetadata = () =>
|
||||
expect
|
||||
.poll(() =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).readyState)
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
await waitForMetadata();
|
||||
|
||||
// The bar auto-hides during playback; a hover reveals it. The
|
||||
// overlay title renders only while the controls consider themselves
|
||||
// fullscreen, so it doubles as the controls-state assertion.
|
||||
// Asserted as "some element is fullscreen" rather than by owner tag,
|
||||
// so the test guards the behavior, not the wiring.
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
const overlayTitle = playerView.locator(
|
||||
'[data-test-id="player-controls-media-title"]'
|
||||
);
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.getByRole('button', { name: 'Enter fullscreen' })
|
||||
.click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await expect(overlayTitle).toContainText('S01E01');
|
||||
|
||||
// Chromium leaves the clicked button focused; the shared controls
|
||||
// release that focus so Space reaches the playback shortcut instead
|
||||
// of activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Manual switch from the shared controls' own next-episode button.
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.locator('[data-test-id="player-controls-next-episode"]')
|
||||
.click();
|
||||
await expect(overlayTitle).toContainText('S01E02', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
|
||||
// Automatic switch: the clip reaching its end autoplays the next
|
||||
// episode with no user activation anywhere near it.
|
||||
await waitForMetadata();
|
||||
await video.evaluate(async (el) => {
|
||||
const media = el as HTMLVideoElement;
|
||||
media.currentTime = Math.max(0, media.duration - 0.2);
|
||||
// Headless autoplay is not guaranteed for a remounted element;
|
||||
// the document already carries user activation from the clicks.
|
||||
await media.play();
|
||||
});
|
||||
await expect(overlayTitle).toContainText('S01E03', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('switches episodes from the fullscreen episode panel without leaving fullscreen', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
await selectWebPlayer(page, 'HTML5 video player');
|
||||
const { playerView } = await playFirstSeriesEpisode(page, request);
|
||||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
const overlayTitle = playerView.locator(
|
||||
'[data-test-id="player-controls-media-title"]'
|
||||
);
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.getByRole('button', { name: 'Enter fullscreen' })
|
||||
.click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await expect(overlayTitle).toContainText('S01E01');
|
||||
|
||||
// C slides in the episode panel: season tabs instead of a search
|
||||
// field, the season's eight rows, the playing one marked.
|
||||
await page.keyboard.press('c');
|
||||
const panel = page.locator('[data-test-id="fullscreen-channel-panel"]');
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(panel).toHaveAttribute('data-panel-kind', 'episodes');
|
||||
await expect(
|
||||
panel.locator('[data-test-id="fullscreen-channel-panel-search"]')
|
||||
).toHaveCount(0);
|
||||
await expect(panel.locator('.season-tabs__pill')).toHaveCount(3);
|
||||
const rows = panel.locator(
|
||||
'[data-test-id="fullscreen-episode-panel-episode"]'
|
||||
);
|
||||
await expect(rows).toHaveCount(8);
|
||||
await expect(rows.first()).toHaveAttribute('aria-current', 'true');
|
||||
|
||||
// Picking an episode plays it inline through the same path as the Up
|
||||
// Next rail: fullscreen survives the engine remount, the panel closes.
|
||||
await rows.nth(2).click();
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'true');
|
||||
await playerView.hover();
|
||||
await expect(overlayTitle).toContainText('S01E03', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
|
||||
// Reopened, the panel marks the new episode; another season's tab
|
||||
// lists that season, offers the way back, and plays from there too.
|
||||
await page.keyboard.press('c');
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(rows.nth(2)).toHaveAttribute('aria-current', 'true');
|
||||
await panel.locator('.season-tabs__pill').nth(1).click();
|
||||
await expect(rows).toHaveCount(8);
|
||||
await expect(panel.locator('[aria-current="true"]')).toHaveCount(0);
|
||||
await expect(
|
||||
panel.locator('[data-testid="back-to-playing"]')
|
||||
).toBeVisible();
|
||||
await rows.first().click();
|
||||
await playerView.hover();
|
||||
await expect(overlayTitle).toContainText('S02E01', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('keep working after a mouse click on a control-bar button', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
// Video.js with the shared controls opted out: the vendor control bar.
|
||||
await selectWebPlayer(page, 'Video.js player', false);
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-vjs-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(playerView.locator('app-player-controls')).toHaveCount(0);
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Chromium leaves the clicked Video.js button focused, and a focused
|
||||
// Video.js component swallows every key; the legacy chrome releases
|
||||
// that focus so Space and M reach the playback shortcuts instead of
|
||||
// Space activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
await playerView.hover();
|
||||
await playerView.locator('.vjs-fullscreen-control').click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('m');
|
||||
await expect
|
||||
.poll(() => video.evaluate((el) => (el as HTMLVideoElement).muted))
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import { expect } from './fixtures';
|
||||
import { setInputValue } from './e2e-helpers';
|
||||
import {
|
||||
getRegisteredProviderUrl,
|
||||
interceptProviderTargetRegistration,
|
||||
} from './provider-target-route';
|
||||
|
||||
/**
|
||||
* Shared Xtream helpers for `xtream.e2e.ts` and `xtream-series-playback.e2e.ts`:
|
||||
* the mock-server address and credentials, the PWA proxy intercept, the
|
||||
* portal import, and the inline series playback setup.
|
||||
*/
|
||||
|
||||
const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211';
|
||||
export const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
|
||||
|
||||
/** Default scenario credentials */
|
||||
export const DEFAULT_USERNAME = 'user1';
|
||||
export const DEFAULT_PASSWORD = 'pass1';
|
||||
|
||||
/**
|
||||
* Intercept calls from the Angular PWA proxy (/xtream) and redirect them
|
||||
* to the mock server. This avoids any real backend requirement.
|
||||
*/
|
||||
export async function interceptXtreamRequests(page: Page): Promise<void> {
|
||||
const providerTargets = await interceptProviderTargetRegistration(page);
|
||||
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const originalUrl = new URL(route.request().url());
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
const providerUrl = getRegisteredProviderUrl(
|
||||
originalUrl,
|
||||
providerTargets
|
||||
);
|
||||
|
||||
if (providerUrl) {
|
||||
mockUrl.searchParams.set('url', providerUrl);
|
||||
}
|
||||
|
||||
originalUrl.searchParams.forEach((value, key) => {
|
||||
if (key === 'targetId') {
|
||||
return;
|
||||
}
|
||||
|
||||
mockUrl.searchParams.set(key, value);
|
||||
});
|
||||
await route.continue({ url: mockUrl.toString() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an Xtream portal via the UI.
|
||||
*/
|
||||
export async function addXtreamPortal(
|
||||
page: Page,
|
||||
options: { name?: string; username?: string; password?: string } = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
name = 'Mock Xtream Portal',
|
||||
username = DEFAULT_USERNAME,
|
||||
password = DEFAULT_PASSWORD,
|
||||
} = options;
|
||||
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
|
||||
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
|
||||
|
||||
await setInputValue(dialog.locator('#title'), name);
|
||||
await setInputValue(dialog.locator('#serverUrl'), MOCK_SERVER);
|
||||
await setInputValue(dialog.locator('#username'), username);
|
||||
await setInputValue(dialog.locator('#password'), password);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
|
||||
await page.waitForURL(/xtreams.*vod/);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline series playback helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serve a tiny VP8 clip for every episode stream. Chromium ships no
|
||||
* proprietary codecs, so the clip stands in for the mock's external HLS
|
||||
* redirect; the browser sniffs the WebM container from the bytes. Registered
|
||||
* after the beforeEach proxy route, so it runs first and fetches from the
|
||||
* mock itself (the mock ignores the url parameter).
|
||||
*/
|
||||
export async function routeEpisodeClip(page: Page): Promise<void> {
|
||||
const episodeClip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === MOCK_SERVER && url.pathname.startsWith('/series/'),
|
||||
async (route) => {
|
||||
// Chromium's media pipeline seeks through byte ranges; a plain
|
||||
// 200 to a Range request clamps every seek to the start, which
|
||||
// would defeat end-of-episode steps.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = episodeClip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, episodeClip.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: {
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
'content-type': 'video/webm',
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${episodeClip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: episodeClip.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mock's episodes are .mkv, which the HTML5 player would hand to hls.js;
|
||||
* get_series_info is rewritten to .mp4 — the extension the player gives to
|
||||
* the native source path.
|
||||
*/
|
||||
export async function rewriteSeriesEpisodesToMp4(page: Page): Promise<void> {
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const original = new URL(route.request().url());
|
||||
if (original.searchParams.get('action') !== 'get_series_info') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
original.searchParams.forEach((value, key) => {
|
||||
if (key !== 'targetId') {
|
||||
mockUrl.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
const response = await route.fetch({ url: mockUrl.toString() });
|
||||
const body = (await response.json()) as {
|
||||
payload: {
|
||||
episodes?: Record<
|
||||
string,
|
||||
Array<{ container_extension: string }>
|
||||
>;
|
||||
};
|
||||
};
|
||||
for (const episodes of Object.values(body.payload.episodes ?? {})) {
|
||||
for (const episode of episodes) {
|
||||
episode.container_extension = 'mp4';
|
||||
}
|
||||
}
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist the web player engine and the shared-controls preference. */
|
||||
export async function selectWebPlayer(
|
||||
page: Page,
|
||||
engine: string,
|
||||
sharedControls = true
|
||||
): Promise<void> {
|
||||
await page.goto('/workspace/settings/playback');
|
||||
await page.locator('[data-test-id="select-video-player"]').click();
|
||||
await page.getByRole('option', { name: engine, exact: true }).click();
|
||||
const toggle = page.locator(
|
||||
'[data-test-id="web-player-shared-controls-toggle"]'
|
||||
);
|
||||
if ((await toggle.locator('input').isChecked()) !== sharedControls) {
|
||||
await toggle.click();
|
||||
}
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
}
|
||||
|
||||
/** Add the mock portal and start the first episode of the first series. */
|
||||
export async function playFirstSeriesEpisode(
|
||||
page: Page,
|
||||
request: APIRequestContext
|
||||
): Promise<{ playerView: Locator; video: Locator }> {
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
const category = categories[0];
|
||||
const seriesItems = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
|
||||
)
|
||||
).json()) as Array<{ name: string; series_id: number }>;
|
||||
const targetSeries = seriesItems[0];
|
||||
|
||||
await page.goto('/');
|
||||
await addXtreamPortal(page);
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
|
||||
const categoryItem = page
|
||||
.locator('.context-panel .category-item')
|
||||
.filter({ hasText: category.category_name })
|
||||
.first();
|
||||
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
|
||||
await categoryItem.click();
|
||||
const seriesCard = page
|
||||
.locator('app-grid-list mat-card')
|
||||
.filter({ hasText: targetSeries.name })
|
||||
.first();
|
||||
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
|
||||
await seriesCard.click();
|
||||
|
||||
const episodeCards = page.locator('.episode-card');
|
||||
await expect(episodeCards).toHaveCount(8, { timeout: 15_000 });
|
||||
await episodeCards.first().click();
|
||||
|
||||
const playerView = page.locator(
|
||||
'app-portal-inline-player app-web-player-view'
|
||||
);
|
||||
return { playerView, video: playerView.locator('video') };
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
rasterizedBorderContrast,
|
||||
@@ -9,9 +7,12 @@ import {
|
||||
waitForScrollIdle,
|
||||
} from './e2e-helpers';
|
||||
import {
|
||||
getRegisteredProviderUrl,
|
||||
interceptProviderTargetRegistration,
|
||||
} from './provider-target-route';
|
||||
addXtreamPortal,
|
||||
DEFAULT_PASSWORD,
|
||||
DEFAULT_USERNAME,
|
||||
interceptXtreamRequests,
|
||||
MOCK_SERVER,
|
||||
} from './xtream-series-playback.fixture';
|
||||
|
||||
/**
|
||||
* Xtream Codes E2E Tests
|
||||
@@ -28,12 +29,6 @@ import {
|
||||
* Tag: @xtream — run only Xtream tests with: nx e2e web-e2e --grep "@xtream"
|
||||
*/
|
||||
|
||||
const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211';
|
||||
const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
|
||||
|
||||
/** Default scenario credentials */
|
||||
const DEFAULT_USERNAME = 'user1';
|
||||
const DEFAULT_PASSWORD = 'pass1';
|
||||
const EPG_USERNAME = 'epg';
|
||||
const EPG_PASSWORD = 'epg';
|
||||
|
||||
@@ -41,65 +36,6 @@ const EPG_PASSWORD = 'epg';
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Intercept calls from the Angular PWA proxy (/xtream) and redirect them
|
||||
* to the mock server. This avoids any real backend requirement.
|
||||
*/
|
||||
async function interceptXtreamRequests(page: Page): Promise<void> {
|
||||
const providerTargets = await interceptProviderTargetRegistration(page);
|
||||
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const originalUrl = new URL(route.request().url());
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
const providerUrl = getRegisteredProviderUrl(
|
||||
originalUrl,
|
||||
providerTargets
|
||||
);
|
||||
|
||||
if (providerUrl) {
|
||||
mockUrl.searchParams.set('url', providerUrl);
|
||||
}
|
||||
|
||||
originalUrl.searchParams.forEach((value, key) => {
|
||||
if (key === 'targetId') {
|
||||
return;
|
||||
}
|
||||
|
||||
mockUrl.searchParams.set(key, value);
|
||||
});
|
||||
await route.continue({ url: mockUrl.toString() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an Xtream portal via the UI.
|
||||
*/
|
||||
async function addXtreamPortal(
|
||||
page: Page,
|
||||
options: { name?: string; username?: string; password?: string } = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
name = 'Mock Xtream Portal',
|
||||
username = DEFAULT_USERNAME,
|
||||
password = DEFAULT_PASSWORD,
|
||||
} = options;
|
||||
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
|
||||
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
|
||||
|
||||
await setInputValue(dialog.locator('#title'), name);
|
||||
await setInputValue(dialog.locator('#serverUrl'), MOCK_SERVER);
|
||||
await setInputValue(dialog.locator('#username'), username);
|
||||
await setInputValue(dialog.locator('#password'), password);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
|
||||
await page.waitForURL(/xtreams.*vod/);
|
||||
}
|
||||
|
||||
async function openPlaylistDetailsDialog(page: Page, title: string) {
|
||||
const playlistSettingsButton = page.getByRole('button', {
|
||||
name: 'Playlist Settings',
|
||||
@@ -965,308 +901,6 @@ function formatXtreamDateTime(timestampSeconds: number): string {
|
||||
.replace('.000Z', '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen survives an episode switch
|
||||
//
|
||||
// app-web-player-view remounts the engine component for every playback
|
||||
// application (next episode, channel, or alternative source). DOM fullscreen
|
||||
// used to be owned by that engine's shell, so the Fullscreen API exited the
|
||||
// moment the old shell left the document — every "next episode" click and
|
||||
// every autoplay hand-off dropped the viewer back to the page. The owner is
|
||||
// now the app-web-player-view host, which spans all applications of one mount.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline series playback helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serve a tiny VP8 clip for every episode stream. Chromium ships no
|
||||
* proprietary codecs, so the clip stands in for the mock's external HLS
|
||||
* redirect; the browser sniffs the WebM container from the bytes. Registered
|
||||
* after the beforeEach proxy route, so it runs first and fetches from the
|
||||
* mock itself (the mock ignores the url parameter).
|
||||
*/
|
||||
async function routeEpisodeClip(page: Page): Promise<void> {
|
||||
const episodeClip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === MOCK_SERVER && url.pathname.startsWith('/series/'),
|
||||
async (route) => {
|
||||
// Chromium's media pipeline seeks through byte ranges; a plain
|
||||
// 200 to a Range request clamps every seek to the start, which
|
||||
// would defeat end-of-episode steps.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = episodeClip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, episodeClip.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: {
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
'content-type': 'video/webm',
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${episodeClip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: episodeClip.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mock's episodes are .mkv, which the HTML5 player would hand to hls.js;
|
||||
* get_series_info is rewritten to .mp4 — the extension the player gives to
|
||||
* the native source path.
|
||||
*/
|
||||
async function rewriteSeriesEpisodesToMp4(page: Page): Promise<void> {
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const original = new URL(route.request().url());
|
||||
if (original.searchParams.get('action') !== 'get_series_info') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
original.searchParams.forEach((value, key) => {
|
||||
if (key !== 'targetId') {
|
||||
mockUrl.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
const response = await route.fetch({ url: mockUrl.toString() });
|
||||
const body = (await response.json()) as {
|
||||
payload: {
|
||||
episodes?: Record<
|
||||
string,
|
||||
Array<{ container_extension: string }>
|
||||
>;
|
||||
};
|
||||
};
|
||||
for (const episodes of Object.values(body.payload.episodes ?? {})) {
|
||||
for (const episode of episodes) {
|
||||
episode.container_extension = 'mp4';
|
||||
}
|
||||
}
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist the web player engine and the shared-controls preference. */
|
||||
async function selectWebPlayer(
|
||||
page: Page,
|
||||
engine: string,
|
||||
sharedControls = true
|
||||
): Promise<void> {
|
||||
await page.goto('/workspace/settings/playback');
|
||||
await page.locator('[data-test-id="select-video-player"]').click();
|
||||
await page.getByRole('option', { name: engine, exact: true }).click();
|
||||
const toggle = page.locator(
|
||||
'[data-test-id="web-player-shared-controls-toggle"]'
|
||||
);
|
||||
if ((await toggle.locator('input').isChecked()) !== sharedControls) {
|
||||
await toggle.click();
|
||||
}
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
}
|
||||
|
||||
/** Add the mock portal and start the first episode of the first series. */
|
||||
async function playFirstSeriesEpisode(
|
||||
page: Page,
|
||||
request: APIRequestContext
|
||||
): Promise<{ playerView: Locator; video: Locator }> {
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
const category = categories[0];
|
||||
const seriesItems = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
|
||||
)
|
||||
).json()) as Array<{ name: string; series_id: number }>;
|
||||
const targetSeries = seriesItems[0];
|
||||
|
||||
await page.goto('/');
|
||||
await addXtreamPortal(page);
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
|
||||
const categoryItem = page
|
||||
.locator('.context-panel .category-item')
|
||||
.filter({ hasText: category.category_name })
|
||||
.first();
|
||||
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
|
||||
await categoryItem.click();
|
||||
const seriesCard = page
|
||||
.locator('app-grid-list mat-card')
|
||||
.filter({ hasText: targetSeries.name })
|
||||
.first();
|
||||
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
|
||||
await seriesCard.click();
|
||||
|
||||
const episodeCards = page.locator('.episode-card');
|
||||
await expect(episodeCards).toHaveCount(8, { timeout: 15_000 });
|
||||
await episodeCards.first().click();
|
||||
|
||||
const playerView = page.locator(
|
||||
'app-portal-inline-player app-web-player-view'
|
||||
);
|
||||
return { playerView, video: playerView.locator('video') };
|
||||
}
|
||||
|
||||
test.describe('@xtream inline series fullscreen', () => {
|
||||
// No autoplay-policy flag is needed: the episode click is a user
|
||||
// activation and the fixture clip carries no audio track.
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('stays in fullscreen across manual and automatic episode switches', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
|
||||
// Persist the HTML5 engine first: Video.js rejects the mock's
|
||||
// video/matroska source type before any bytes are sniffed.
|
||||
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,
|
||||
});
|
||||
const waitForMetadata = () =>
|
||||
expect
|
||||
.poll(() =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).readyState)
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
await waitForMetadata();
|
||||
|
||||
// The bar auto-hides during playback; a hover reveals it. The
|
||||
// overlay title renders only while the controls consider themselves
|
||||
// fullscreen, so it doubles as the controls-state assertion.
|
||||
// Asserted as "some element is fullscreen" rather than by owner tag,
|
||||
// so the test guards the behavior, not the wiring.
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
const overlayTitle = playerView.locator(
|
||||
'[data-test-id="player-controls-media-title"]'
|
||||
);
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.getByRole('button', { name: 'Enter fullscreen' })
|
||||
.click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await expect(overlayTitle).toContainText('S01E01');
|
||||
|
||||
// Chromium leaves the clicked button focused; the shared controls
|
||||
// release that focus so Space reaches the playback shortcut instead
|
||||
// of activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Manual switch from the shared controls' own next-episode button.
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.locator('[data-test-id="player-controls-next-episode"]')
|
||||
.click();
|
||||
await expect(overlayTitle).toContainText('S01E02', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
|
||||
// Automatic switch: the clip reaching its end autoplays the next
|
||||
// episode with no user activation anywhere near it.
|
||||
await waitForMetadata();
|
||||
await video.evaluate(async (el) => {
|
||||
const media = el as HTMLVideoElement;
|
||||
media.currentTime = Math.max(0, media.duration - 0.2);
|
||||
// Headless autoplay is not guaranteed for a remounted element;
|
||||
// the document already carries user activation from the clicks.
|
||||
await media.play();
|
||||
});
|
||||
await expect(overlayTitle).toContainText('S01E03', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('keep working after a mouse click on a control-bar button', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
// Video.js with the shared controls opted out: the vendor control bar.
|
||||
await selectWebPlayer(page, 'Video.js player', false);
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-vjs-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(playerView.locator('app-player-controls')).toHaveCount(0);
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Chromium leaves the clicked Video.js button focused, and a focused
|
||||
// Video.js component swallows every key; the legacy chrome releases
|
||||
// that focus so Space and M reach the playback shortcuts instead of
|
||||
// Space activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
await playerView.hover();
|
||||
await playerView.locator('.vjs-fullscreen-control').click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('m');
|
||||
await expect
|
||||
.poll(() => video.evaluate((el) => (el as HTMLVideoElement).muted))
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
for (const theme of ['light', 'dark']) {
|
||||
test(`@xtream detail surfaces stay visible (${theme})`, async ({
|
||||
page,
|
||||
|
||||
Reference in new issue
Block a user