Files
iptvnator/apps/web-e2e/src/xtream-series-playback.e2e.ts
T
4grayandClaude Opus 5.5 525ca7bc44 fix(playback): show the Up next card near the real end of an episode (#1768)
* 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>
2026-09-30 21:56:48 +02:00

303 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});