mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
* fix(playback): show the Up next card near the real end of an episode The card appeared a fixed 8 minutes before the end: most of a short episode, and far into the story of a long one, with no way to hide it. - Adaptive lead: 4% of the episode, clamped to 40 s … 3 min. - A closing-credits chapter in the last third, when timeline segments carry one, brings the card forward to its start (capped at 5 min). - Close button and Escape dismiss the card for the current next episode. - After 10 s (not while hovered) the card collapses into a one-line pill. - Seconds countdown in the last minute. - New playback setting "Up next card" (default on) turns it off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): address Up next card review feedback - Offer the Up next card setting for Embedded MPV only under the frame-copy engine; native view never mounts the shared controls. - Hovering pauses the collapse delay instead of restarting it. - Document that the card stays visible when the controls auto-hide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): restart the Up next collapse delay for a new episode - A card that stays mounted while its next episode changes gives the new item the full delay instead of the previous item's leftover. - The setting description no longer promises credit-based timing: no current series path supplies chapters yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
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('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 shorter than the card's 40 s minimum
|
||
// lead, 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,
|
||
});
|
||
|
||
// The corner button hides the card for this episode; bringing the
|
||
// controls back does not bring it back.
|
||
await playerView
|
||
.locator('[data-test-id="player-controls-up-next-close"]')
|
||
.click();
|
||
await expect(card).toHaveCount(0);
|
||
await playerView.hover({ position: { x: 40, y: 40 } });
|
||
await expect(
|
||
playerView.locator('[data-test-id="player-controls-next-episode"]')
|
||
).toBeVisible();
|
||
await expect(card).toHaveCount(0);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|