fix(playback): keep fullscreen across episode, channel and source switches (#1509)

WebPlayerViewComponent remounts the engine component for every playback
application, and the DOM Fullscreen API exits the moment its element leaves
the document. The fullscreen element was the engine shell, so every next-
episode click, autoplay hand-off, channel zap and alternative-source switch
dropped the viewer back to the page.

app-player-controls gains a `fullscreenTarget` input; HTML5, Video.js,
ArtPlayer and Embedded MPV forward it, and WebPlayerViewComponent passes its
own host element, which spans all applications of one mount. Keeping
fullscreen exposed a latent bug: the Electron header handoff set plain
fields under OnPush hosts and was only rendered thanks to the fullscreen
exit's stage resize; `channel`/`vjsOptions` are signals now.

Covered by unit regressions (fullscreen target, WebPlayerView remount, OnPush
handoff), a web-e2e run through a manual and an automatic episode switch, and
a manual Electron check. Docs and release note updated.

Closes #1498

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-03 08:21:37 +02:00
1 parent 0adf562177
commit 0a2f6121f8
29 files changed
+830 -87

No files matched your search

@@ -0,0 +1,10 @@
---
type: fix
area: playback
---
With the default shared player controls, the built-in player now stays in
fullscreen when you switch to another episode, when the next episode starts
automatically, and when a live channel or an alternative movie source is
switched — previously every switch dropped back to the page. The legacy
vendor-controls opt-out keeps its previous behavior.
+2 -1
View File
@@ -413,7 +413,8 @@ Key files:
suppression.
`WebPlayerViewComponent.resolvedIsLive` supplies authoritative live/VOD
metadata, while a visible playback diagnostic disables both shared surface
interaction and shortcuts and exits the HTML5 shell's own fullscreen so the
interaction and shortcuts and exits the shared controls' resolved fullscreen
owner (the host-supplied `fullscreenTarget`, else the HTML5 shell) so the
diagnostic actions remain visible. The preference-off path keeps native
controls and legacy series navigation unchanged, while the playback keyboard
shortcuts (Space/K, F, arrow seek/volume, M) attach through
+1 -1
View File
@@ -1122,7 +1122,7 @@ engine` (restart required) or
helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
contracts: `docs/architecture/embedded-mpv-native.md` and
`tools/embedded-mpv/README.md`.
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored values coerce with `!== false` in every normalization site; only an explicit false — the Settings > Playback checkbox — opts out to the legacy vendor chrome), and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. The shared surface has explicit touch semantics (`ControlsSurface.wasTouchInteraction`): viewport taps toggle overlay visibility instead of pausing, the volume popover opens on tap instead of hover, coarse pointers get a taller scrub strip, and at container widths ≤640px the bar reflows to two rows (full-width timeline above transport + an end-aligned, wrapping actions cluster with 40px buttons whose panels remain unclipped). Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer screenshot/AirPlay/web-fullscreen/mini-progress/vendor gestures — listed in the contract doc's "Known differences" section. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded coLine truncated
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored values coerce with `!== false` in every normalization site; only an explicit false — the Settings > Playback checkbox — opts out to the legacy vendor chrome), and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. The shared surface has explicit touch semantics (`ControlsSurface.wasTouchInteraction`): viewport taps toggle overlay visibility instead of pausing, the volume popover opens on tap instead of hover, coarse pointers get a taller scrub strip, and at container widths ≤640px the bar reflows to two rows (full-width timeline above transport + an end-aligned, wrapping actions cluster with 40px buttons whose panels remain unclipped). Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer screenshot/AirPlay/web-fullscreen/mini-progress/vendor gestures — listed in the contract doc's "Known differences" section. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync — its owner is the `app-web-player-view` host (`WebPlayerViewComponent.fullscreenSurface`, passed to every engine as `fullscreenTarget`), not the engine shell, because the view remounts the engine component per playback application and the Fullscreen API exits when its element leaves the document; that is what keeps fullscreen across episode/channel/alternative-source switches (the vendor-chrome opt-out still loses it) — and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guardLine truncated
- Shared web picture-in-picture stays inside that default-on rollout.
`PlayerController` exposes capability `pictureInPicture`, state
`pictureInPictureActive`/`canPictureInPicture`, and command
Binary file not shown.
+208 -12
View File
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { APIRequestContext, Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { setInputValue } from './e2e-helpers';
@@ -549,9 +551,7 @@ async function openLiveChannel(page: Page): Promise<void> {
// On the live root the category click updates store state without
// navigating; the channel sidebar appearing is the completion signal.
const firstCategory = page
.locator('.context-panel .category-item')
.first();
const firstCategory = page.locator('.context-panel .category-item').first();
await expect(firstCategory).toBeVisible();
await firstCategory.click();
@@ -619,9 +619,7 @@ test('@xtream the saved engine mounts the live player first time — no default-
await page.goto('/');
await addXtreamPortal(page);
await page.goto(page.url().replace(/\/vod.*$/, '/live'));
const firstCategory = page
.locator('.context-panel .category-item')
.first();
const firstCategory = page.locator('.context-panel .category-item').first();
await expect(firstCategory).toBeVisible();
await firstCategory.click();
@@ -716,9 +714,7 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
// Serial details: default scenario has 3 seasons × 8 episodes and no
// playback positions yet, so season 1 is auto-selected fully unwatched.
const seasonToggle = page.locator(
'[data-test-id="toggle-season-watched"]'
);
const seasonToggle = page.locator('[data-test-id="toggle-season-watched"]');
await expect(seasonToggle).toBeVisible({ timeout: 15_000 });
await expect(seasonToggle).toContainText('Mark season as watched (8)');
@@ -831,9 +827,7 @@ test('@xtream series watched toggle — marks every season from the header menu,
await expect(seasonTabs).toHaveCount(3);
await menuTrigger.click();
const seriesToggle = page.locator(
'[data-test-id="toggle-series-watched"]'
);
const seriesToggle = page.locator('[data-test-id="toggle-series-watched"]');
await expect(seriesToggle).toBeVisible();
await expect(seriesToggle).toContainText('Mark series as watched (24)');
await seriesToggle.click();
@@ -923,3 +917,205 @@ function formatXtreamDateTime(timestampSeconds: number): string {
.replace('T', ' ')
.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.
// ---------------------------------------------------------------------------
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,
}) => {
// Chromium ships no proprietary codecs, so a tiny VP8 clip stands in
// for the mock's external HLS redirect; the browser sniffs the WebM
// container from the bytes. The mock's episodes are .mkv, which the
// HTML5 player would hand to hls.js, so get_series_info is rewritten
// to .mp4 — the extension the player gives to the native source path.
// Registered after the beforeEach proxy route, so it runs first and
// fetches from the mock itself (the mock ignores the url parameter).
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 the end-of-episode step below.
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),
});
}
);
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 HTML5 engine first: Video.js rejects the mock's
// video/matroska source type before any bytes are sniffed.
await page.goto('/workspace/settings/playback');
await page.locator('[data-test-id="select-video-player"]').click();
await page
.getByRole('option', { name: 'HTML5 video player', exact: true })
.click();
const saveButton = page.getByRole('button', { name: 'Save changes' });
await saveButton.click();
await expect(saveButton).toBeHidden();
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'
);
await expect(playerView.locator('app-html-video-player')).toBeVisible({
timeout: 15_000,
});
const video = playerView.locator('video');
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');
// 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();
});
});
+10 -5
View File
@@ -256,8 +256,10 @@ module's `isFrameCopyRuntimeUsable()` / `getFrameCopyRuntimeAvailability()`):
the new viewport size (device pixels via the display scale factor), including
a forced current-frame render when a paused resize creates a fresh
shared-memory generation. Shared fullscreen uses the DOM Fullscreen API on
the player root, and the component's fullscreen listener still requests
bounds sync.
the host-supplied `fullscreenTarget` (the `app-web-player-view` element,
which outlives the per-application remount; the player root is the
fallback), and the component's fullscreen listener still requests bounds
sync.
Enabling it: the `Settings > Playback > Embedded MPV: frame-copy engine`
checkbox (shown when support reports `frameCopyAvailable` or the option is
@@ -708,9 +710,12 @@ owner, which cancels pending operations, acknowledgement/message timers, and
feedback. This prevents both systems from acting on the same session.
Both engines keep the component's `fullscreenchange` listener because
fullscreen changes require bounds sync. Frame-copy's shared
`ControlsFullscreen` additionally synchronizes when its DOM surface attaches or
changes, including when the root is already fullscreen. Native-view does not
fullscreen changes require bounds sync; both resolve the fullscreen owner as
`fullscreenTarget ?? playerRoot` and read its state once on mount, so a player
remounted for the next episode inside an active fullscreen starts fullscreen
without waiting for an event. Frame-copy's shared `ControlsFullscreen`
additionally synchronizes when its DOM surface attaches or changes, including
when the owner is already fullscreen. Native-view does not
gain a transparent-window, native-fullscreen, or native-surface overlay path
from this integration.
+54 -16
View File
@@ -56,7 +56,8 @@ reapplies the app volume directly to the media element after ArtPlayer restores
its own stored volume, disables vendor chrome/hotkeys, and places a transparent
event-capture layer over ArtPlayer so shared controls exclusively own surface
clicks and double-clicks. Playback diagnostics gate shared interaction and exit
only the ArtPlayer shell's own fullscreen. Source replacement and teardown
only the shared controls' resolved fullscreen owner (the host-supplied
`fullscreenTarget`, else the ArtPlayer shell). Source replacement and teardown
remove exact listeners and engines, and destroyed sessions ignore stale delayed
`customType` callbacks. When the host token resolves to false, the existing
ArtPlayer skin, source behavior, and legacy series navigation remain unchanged.
@@ -102,8 +103,9 @@ added to `PlayerController`.
The only controls-layer participation is interaction gating. While the sibling
diagnostic panel is visible, a web-player host disables shared surface and
keyboard ownership and exits only its own DOM fullscreen so the recovery
actions remain reachable. Clearing the diagnostic restores those paths; it
keyboard ownership and exits only the resolved fullscreen owner's DOM
fullscreen (the host-supplied `fullscreenTarget`, else its own shell) so the
recovery actions remain reachable. Clearing the diagnostic restores those paths; it
does not make the controls contract an owner of the recovery lifecycle.
## Why this exists
@@ -228,13 +230,35 @@ Episode navigation is deliberately exposed as component outputs
playlist/portal feature decides which item to play.
Fullscreen is also outside the engine command contract. The landed component
uses `ControlsFullscreen`, which operates on the supplied DOM player surface
through `requestFullscreen()` / `document.exitFullscreen()`. There is no
fullscreen delegate or native-fullscreen IPC path. `ControlsFullscreen.sync()`
reconciles state when a surface attaches or changes, including when that
surface is already fullscreen. The Embedded MPV host's existing
`fullscreenchange` listener still triggers bounds sync so frame-copy render
size follows the fullscreen DOM surface.
uses `ControlsFullscreen`, which operates on one DOM element through
`requestFullscreen()` / `document.exitFullscreen()`: the optional
`fullscreenTarget` input when the host supplies one, else the `playerSurface`.
There is no fullscreen delegate or native-fullscreen IPC path.
`ControlsFullscreen.sync()` reconciles state when that element attaches or
changes, including when it is already fullscreen. The Embedded MPV host's
existing `fullscreenchange` listener still triggers bounds sync so frame-copy
render size follows the fullscreen DOM surface.
The owner matters because `WebPlayerViewComponent` remounts the engine
component for every playback application (`@for ... track application.token`:
next episode, channel zap, alternative source, retry) and the Fullscreen API
exits the moment its element leaves the document. A shell-owned fullscreen
therefore ended with every switch. `WebPlayerViewComponent` now passes its own
host element (`fullscreenSurface`) as `fullscreenTarget` to HTML5, Video.js,
ArtPlayer, and Embedded MPV; that element spans all applications of one mount,
so a fullscreen entered on episode 1 is still active when episode 2's engine
mounts, and the fresh controls adopt it through `sync()` on attach. The
`playerSurface` (pointer/click/cursor ownership) stays the engine shell. The
vendor-chrome opt-out keeps engine-owned fullscreen and still loses it on a
switch — see "Known differences".
One dependency this uncovered: `WebPlayerViewComponent.channel` and
`vjsOptions` are signals. In Electron the source is handed to the engine inside
the stream-header IPC promise, after the pass that mounted the application,
and the view sits under OnPush hosts (`PortalInlinePlayerComponent`); as plain
fields they were only rendered when something else dirtied the subtree — which
used to be the stage resize caused by the fullscreen exit on every switch. A
remounted engine inside a still-active fullscreen has no such trigger.
## Shared default controls
@@ -332,10 +356,11 @@ The HTML5, Video.js, and ArtPlayer hosts apply the same ownership rule while a
playback diagnostic is visible: `WebPlayerViewComponent` passes
`interactionEnabled = visiblePlaybackDiagnostic() === null`, and all three
components bind that value to `showControls` and
`shortcutsEnabled`. If the active player shell owns DOM fullscreen, its host
exits fullscreen before hiding the controls so the sibling diagnostic banner
and its recovery actions remain visible; fullscreen owned by another
element is left untouched. Retrying playback or clearing the diagnostic
`shortcutsEnabled`. If the shared controls' fullscreen owner (the supplied
`fullscreenTarget`, else the player shell) is in DOM fullscreen, the host
exits fullscreen before hiding the controls so the diagnostic banner and its
recovery actions remain visible; fullscreen owned by another element is left
untouched. Retrying playback or clearing the diagnostic
restores both interaction paths.
Frame-copy recording transitions use the adapter's playback/session identity as
@@ -716,6 +741,14 @@ regressions:
- **Vendor caption menus** behave as before in the opt-out path; shared mode
is authoritative for the session as documented under "Caption preference in
both modes".
- **Fullscreen across a source switch.** Vendor chrome puts its own engine
element into fullscreen (`.video-js`, ArtPlayer's container, the native
`<video>`), and that element is remounted for the next episode, channel, or
alternative source, so the browser exits fullscreen on every switch. Shared
controls fullscreen the host-owned `fullscreenTarget` instead and keep
fullscreen across switches; re-requesting fullscreen for a remounted vendor
engine is not possible for the autoplay hand-off, which has no user
activation.
## Advanced subtitle support
@@ -840,8 +873,13 @@ The shared controls receive the whole player root as their DOM surface. Turning
`showControls` off detaches surface interaction and playback-shortcut
ownership; Escape remains available for generic popover dismissal.
Backdrop-bearing overlays disable playback shortcuts. Fullscreen uses the DOM
Fullscreen API on that root, while the Embedded MPV component continues bounds
sync so the helper renders at the current viewport size.
Fullscreen API on the host-supplied `fullscreenTarget` (the
`app-web-player-view` element, which survives the per-application remount),
falling back to the player root; the component's own `isFullscreen`,
`canFullscreen`, and toggle follow the same owner and re-read it on mount, so
a player remounted inside an active fullscreen starts fullscreen. The Embedded
MPV component continues bounds sync so the helper renders at the current
viewport size.
Recording snapshots arrive independently from command promise settlement. The
adapter therefore treats snapshots as observations rather than acknowledgments
+7 -3
View File
@@ -535,8 +535,11 @@ fanned out on page load.
Every playback host renders `@if (inlinePlayback(); as playback)`, so re-`set()`ing
that signal with a new `{streamUrl, startTime}` swaps the source **in place** —
the player component, `WebPlayerView` and the engine all survive, and
`WebPlayerViewComponent`'s effect rebuilds the source and clears the diagnostic.
the inline player and `WebPlayerView` survive, while `WebPlayerViewComponent`'s
effect starts a new playback application (the engine component is remounted
under a fresh token with the carried `startTime`) and clears the diagnostic.
Because the `WebPlayerView` host, not the engine shell, owns DOM fullscreen, a
fullscreen session survives the swap as well.
Three details make the position survive:
@@ -547,7 +550,8 @@ Three details make the position survive:
`playback_positions` value IS the live one and is reported directly —
seeding it would freeze the resume point where playback started.
- It is a single `.set()`, never `null` then set — a null in between would
destroy the player subtree and lose the engine.
destroy the whole player subtree, including the `WebPlayerView` host that
owns fullscreen.
- `playback_positions` is keyed `(playlistId, contentXtreamId, contentType)`, so
a switch changes the key. The resolved playback carries the **new** source's
`contentInfo`, and that source's row takes over.
@@ -10,6 +10,7 @@
<app-player-controls
[controller]="controlsAdapter"
[playerSurface]="playerRoot"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
@@ -77,8 +77,7 @@ class MockHls {
static isSupported = jest.fn(() => true);
}
const actualHlsModule =
jest.requireActual<typeof import('hls.js')>('hls.js');
const actualHlsModule = jest.requireActual<typeof import('hls.js')>('hls.js');
jest.unstable_mockModule('artplayer', () => ({
default: MockArtplayer,
@@ -327,6 +326,62 @@ describe('ArtPlayerComponent with shared controls', () => {
}
});
it('hands the host fullscreen target to the shared controls and exits only that owner', () => {
const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor(
document,
'fullscreenElement'
);
const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor(
document,
'exitFullscreen'
);
let fullscreenElement: Element | null = null;
const exitFullscreen = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
try {
createComponent({
url: 'https://example.test/movie.mp4',
name: 'Movie',
});
const target = document.createElement('div');
fixture.componentRef.setInput('fullscreenTarget', target);
fixture.detectChanges();
const controls = fixture.debugElement.query(
By.directive(PlayerControlsComponent)
).componentInstance as PlayerControlsComponent;
expect(controls.fullscreenTarget()).toBe(target);
fullscreenElement = fixture.debugElement.query(
By.css('.art-player-shell')
).nativeElement;
fixture.componentRef.setInput('interactionEnabled', false);
fixture.detectChanges();
expect(exitFullscreen).not.toHaveBeenCalled();
fixture.componentRef.setInput('interactionEnabled', true);
fixture.detectChanges();
fullscreenElement = target;
fixture.componentRef.setInput('interactionEnabled', false);
fixture.detectChanges();
expect(exitFullscreen).toHaveBeenCalledTimes(1);
} finally {
restoreDocumentProperty(
'fullscreenElement',
fullscreenElementDescriptor
);
restoreDocumentProperty('exitFullscreen', exitFullscreenDescriptor);
}
});
function createComponent(
channel: Pick<Channel, 'url' | 'name'>,
inputs: { volume?: number; isLive?: boolean } = {}
@@ -56,6 +56,8 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly timeUpdate = output<{
currentTime: number;
@@ -121,7 +123,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
if (changes['interactionEnabled']?.currentValue === false) {
exitOwnedArtPlayerFullscreen(
this.sharedControls,
this.playerRoot()?.nativeElement,
this.fullscreenTarget() ?? this.playerRoot()?.nativeElement,
(error) =>
debugArtPlayer(
'Failed to exit ArtPlayer fullscreen:',
@@ -98,6 +98,7 @@
<app-player-controls
[controller]="sharedControls"
[playerSurface]="playerSurface()"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[showControls]="showControls()"
[shortcutsEnabled]="sharedShortcutsEnabled()"
@@ -244,6 +244,71 @@ describe('EmbeddedMpvPlayerComponent shared controls host', () => {
expect(sharedControls(fixture)?.isFullscreen()).toBe(true);
});
it('owns fullscreen on the host-supplied target instead of its own root', async () => {
const { fixture, component } = render();
const target = document.createElement('div');
const requestFullscreen = jest.fn(async () => {
fullscreenElement = target;
document.dispatchEvent(new Event('fullscreenchange'));
});
(target as HTMLElement & { requestFullscreen: jest.Mock })[
'requestFullscreen'
] = requestFullscreen;
const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor(
document,
'exitFullscreen'
);
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: jest.fn(async () => {
fullscreenElement = null;
document.dispatchEvent(new Event('fullscreenchange'));
}),
});
try {
fixture.componentRef.setInput('fullscreenTarget', target);
fixture.detectChanges();
expect(sharedControls(fixture)?.fullscreenTarget()).toBe(target);
expect(component.canFullscreen()).toBe(true);
await component.toggleFullscreen();
expect(requestFullscreen).toHaveBeenCalledTimes(1);
expect(component.isFullscreen()).toBe(true);
fixture.detectChanges();
expect(
root(fixture).classList.contains(
'embedded-mpv-player--fullscreen'
)
).toBe(true);
await component.toggleFullscreen();
expect(component.isFullscreen()).toBe(false);
} finally {
restoreProperty(
document,
'exitFullscreen',
exitFullscreenDescriptor
);
}
});
it('starts fullscreen when the resolved target is already fullscreen', () => {
// A player remounted for the next episode mounts inside the host's
// still-active fullscreen; no fullscreenchange fires for it.
const target = document.createElement('div');
fullscreenElement = target;
const { fixture, component } = render();
expect(component.isFullscreen()).toBe(false);
fixture.componentRef.setInput('fullscreenTarget', target);
fixture.detectChanges();
expect(component.isFullscreen()).toBe(true);
expect(sharedControls(fixture)?.isFullscreen()).toBe(true);
});
it('never renders both control systems across engine transitions', () => {
const { fixture, controller } = render();
jest.useFakeTimers();
@@ -82,6 +82,8 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly timeUpdate = output<{
currentTime: number;
@@ -124,6 +126,10 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly playerSurface = computed(
() => this.playerRoot()?.nativeElement ?? null
);
/** Element that owns DOM fullscreen: the host's target, else the root. */
readonly fullscreenSurface = computed(
() => this.fullscreenTarget() ?? this.playerSurface()
);
readonly sharedShortcutsEnabled = computed(
() => !this.overlayVisibility.overlayActive()
);
@@ -182,7 +188,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly canFullscreen = computed(
() =>
typeof document !== 'undefined' &&
Boolean(this.playerRoot()?.nativeElement.requestFullscreen) &&
Boolean(this.fullscreenSurface()?.requestFullscreen) &&
Boolean(document.exitFullscreen)
);
readonly statusLabel = computed(() => {
@@ -324,14 +330,22 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
private readonly legacyInteractions: EmbeddedMpvLegacyInteractions;
private readonly onFullscreenChange = () => {
const playerRoot = this.playerRoot()?.nativeElement;
this.isFullscreen.set(
Boolean(playerRoot && document.fullscreenElement === playerRoot)
);
this.syncFullscreenState();
this.legacyInteractions.revealControls();
this.controller.triggerBoundsSync();
};
private syncFullscreenState(): void {
const surface = this.fullscreenSurface();
this.isFullscreen.set(
Boolean(
surface &&
typeof document !== 'undefined' &&
document.fullscreenElement === surface
)
);
}
constructor() {
this.legacyInteractions = new EmbeddedMpvLegacyInteractions({
isAvailable: () => !this.isFrameCopyEngine(),
@@ -433,6 +447,13 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
this.onFullscreenChange
);
}
// A player remounted for the next episode can start inside the
// host-owned fullscreen; no `fullscreenchange` fires for it, so the
// state is read once the fullscreen surface resolves.
effect(() => {
void this.fullscreenSurface();
untracked(() => this.syncFullscreenState());
});
this.controller.setBoundsProvider((host) => {
// The frame-copy engine paints into an ordinary DOM canvas:
@@ -592,15 +613,15 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
async toggleFullscreen(): Promise<void> {
this.legacyInteractions.revealControls();
const playerRoot = this.playerRoot()?.nativeElement;
if (!playerRoot || !this.canFullscreen()) {
const surface = this.fullscreenSurface();
if (!surface || !this.canFullscreen()) {
return;
}
try {
if (document.fullscreenElement === playerRoot) {
if (document.fullscreenElement === surface) {
await document.exitFullscreen();
} else {
await playerRoot.requestFullscreen();
await surface.requestFullscreen();
}
} catch {
return;
@@ -10,6 +10,7 @@
<app-player-controls
[controller]="controlsAdapter"
[playerSurface]="playerRoot"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
@@ -178,6 +178,60 @@ describe('HtmlVideoPlayerComponent shared controls host', () => {
}
});
it('hands the host fullscreen target to the shared controls and exits only that owner', () => {
const { fixture, controls } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures
);
const target = document.createElement('div');
fixture.componentRef.setInput('fullscreenTarget', target);
fixture.detectChanges();
expect(controls?.fullscreenTarget()).toBe(target);
const shell = fixture.debugElement.query(
By.css('.html-video-player-shell')
).nativeElement as HTMLElement;
const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor(
document,
'fullscreenElement'
);
const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor(
document,
'exitFullscreen'
);
let fullscreenElement: Element | null = shell;
const exitFullscreen = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
try {
// The shell is no longer the owner once a target is supplied.
fixture.componentRef.setInput('interactionEnabled', false);
fixture.detectChanges();
expect(exitFullscreen).not.toHaveBeenCalled();
fixture.componentRef.setInput('interactionEnabled', true);
fixture.detectChanges();
fullscreenElement = target;
fixture.componentRef.setInput('interactionEnabled', false);
fixture.detectChanges();
expect(exitFullscreen).toHaveBeenCalledTimes(1);
} finally {
restoreDocumentProperty(
'fullscreenElement',
fullscreenElementDescriptor
);
restoreDocumentProperty('exitFullscreen', exitFullscreenDescriptor);
}
});
it('keeps series context reactive and forwards navigation outputs', () => {
const setContext = jest.spyOn(
WebVideoControlsAdapter.prototype,
@@ -76,6 +76,8 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
@@ -164,7 +166,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
if (changes['interactionEnabled']?.currentValue === false) {
exitOwnedFullscreen(
this.sharedControls,
this.playerRoot()?.nativeElement,
this.fullscreenTarget() ?? this.playerRoot()?.nativeElement,
(error) =>
debugHtmlPlayer(
'Failed to exit HTML5 player fullscreen:',
@@ -272,6 +272,42 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
expect(component.isFullscreen()).toBe(false);
});
it('puts a host-supplied fullscreen target into fullscreen instead of the surface', async () => {
const target = document.createElement('div');
const requestTargetFullscreen = jest.fn(async () => {
fullscreenElement = target;
document.dispatchEvent(new Event('fullscreenchange'));
});
(target as HTMLElement & { requestFullscreen: jest.Mock })[
'requestFullscreen'
] = requestTargetFullscreen;
fixture.componentRef.setInput('fullscreenTarget', target);
fixture.detectChanges();
await component.toggleFullscreen();
expect(requestTargetFullscreen).toHaveBeenCalledTimes(1);
expect(requestFullscreen).not.toHaveBeenCalled();
expect(component.isFullscreen()).toBe(true);
await component.toggleFullscreen();
expect(exitFullscreen).toHaveBeenCalledTimes(1);
expect(component.isFullscreen()).toBe(false);
});
it('adopts a fullscreen target that is already fullscreen when it attaches', () => {
// Controls remounted for the next episode attach inside the
// host's still-active fullscreen; no fullscreenchange fires for
// that, so the state has to come from the sync on attach.
const target = document.createElement('div');
fullscreenElement = target;
expect(component.isFullscreen()).toBe(false);
fixture.componentRef.setInput('fullscreenTarget', target);
fixture.detectChanges();
expect(component.isFullscreen()).toBe(true);
});
it('does nothing when the engine does not support fullscreen', async () => {
setCapabilities({ fullscreen: false });
fixture.detectChanges();
@@ -55,6 +55,15 @@ export class PlayerControlsComponent implements OnDestroy {
private readonly translate = inject(TranslateService);
readonly controller = input.required<PlayerController>();
readonly playerSurface = input<HTMLElement | null>(null);
/**
* Element put into DOM fullscreen; defaults to `playerSurface`. Hosts
* that remount their player shell per source (`WebPlayerViewComponent`
* renders one component per playback application) pass an ancestor that
* outlives the remount: the Fullscreen API exits the moment its element
* leaves the document, so a shell-owned fullscreen would end with every
* episode, channel, or alternative-source switch.
*/
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly showControls = input(true);
readonly shortcutsEnabled = input(true);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
@@ -68,7 +77,7 @@ export class PlayerControlsComponent implements OnDestroy {
private readonly shortcuts = new ControlsShortcuts();
private readonly visibility = new ControlsVisibility(() => this.canHide());
private readonly fullscreen = new ControlsFullscreen(
() => this.playerSurface(),
() => this.fullscreenTarget() ?? this.playerSurface(),
() => this.reveal()
);
private readonly volume = new ControlsVolume({
@@ -177,6 +186,8 @@ export class PlayerControlsComponent implements OnDestroy {
effect((onCleanup) => {
const playerSurface = this.playerSurface();
const surface = this.showControls() ? playerSurface : null;
// A replaced fullscreen owner re-syncs the state as well.
void this.fullscreenTarget();
this.fullscreen.sync();
onCleanup(this.surface.attachSurface(surface));
});
@@ -12,6 +12,7 @@
<app-player-controls
[controller]="controlsAdapter"
[playerSurface]="playerRoot"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
@@ -178,6 +178,63 @@ describe('VjsPlayerComponent shared controls', () => {
restoreDocumentProperty('exitFullscreen', exitFullscreenDescriptor);
}
});
it('hands the host fullscreen target to the shared controls and exits only that owner', () => {
fixture.componentRef.setInput('options', {
sources: [{ src: 'https://example.test/movie.mp4' }],
});
const target = document.createElement('div');
fixture.componentRef.setInput('fullscreenTarget', target);
fixture.detectChanges();
playerHarness.ready();
fixture.detectChanges();
const controls = fixture.debugElement.query(
By.directive(PlayerControlsComponent)
).componentInstance as PlayerControlsComponent;
expect(controls.fullscreenTarget()).toBe(target);
const shell = fixture.debugElement.query(By.css('.vjs-player-shell'))
.nativeElement as HTMLElement;
const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor(
document,
'fullscreenElement'
);
const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor(
document,
'exitFullscreen'
);
let fullscreenElement: Element | null = shell;
const exitFullscreen = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
try {
fixture.componentRef.setInput('interactionEnabled', false);
fixture.detectChanges();
expect(exitFullscreen).not.toHaveBeenCalled();
fixture.componentRef.setInput('interactionEnabled', true);
fixture.detectChanges();
fullscreenElement = target;
fixture.componentRef.setInput('interactionEnabled', false);
fixture.detectChanges();
expect(exitFullscreen).toHaveBeenCalledTimes(1);
} finally {
restoreDocumentProperty(
'fullscreenElement',
fullscreenElementDescriptor
);
restoreDocumentProperty('exitFullscreen', exitFullscreenDescriptor);
}
});
});
function restoreDocumentProperty(
@@ -80,6 +80,8 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly timeUpdate = output<{
currentTime: number;
@@ -178,7 +180,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
if (changes['interactionEnabled']?.currentValue === false) {
exitOwnedVjsFullscreen(
this.sharedControls,
this.playerRoot()?.nativeElement,
this.fullscreenTarget() ?? this.playerRoot()?.nativeElement,
(error) =>
debugVjsPlayer(
'Failed to exit Video.js player fullscreen:',
@@ -2,9 +2,10 @@
@if (application.binding; as binding) {
@if (binding.target === 'videojs') {
@defer (on immediate) {
@if (vjsOptions) {
@if (vjsOptions(); as options) {
<app-vjs-player
[options]="vjsOptions"
[options]="options"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
@@ -28,9 +29,10 @@
}
} @else if (binding.target === 'html5') {
@defer (on immediate) {
@if (channel) {
@if (channel(); as channel) {
<app-html-video-player
[channel]="$any(channel)"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
@@ -55,9 +57,10 @@
}
} @else if (binding.target === 'artplayer') {
@defer (on immediate) {
@if (channel) {
@if (channel(); as channel) {
<app-art-player
[channel]="$any(channel)"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
@@ -84,6 +87,7 @@
} @else if (application.embeddedMpv) {
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[recordingFolder]="recordingFolder()"
[recordingMetadata]="recordingMetadata()"
@@ -352,7 +352,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(streamHeaders.apply).toHaveBeenCalledTimes(1);
expect(component.selectedPlayer()).toBe(VideoPlayer.VideoJs);
expect(component.channel?.url).toBe('https://example.com/next.m3u8');
expect(component.channel()?.url).toBe('https://example.com/next.m3u8');
expect(component.playbackDiagnostic()).toBeNull();
});
@@ -897,7 +897,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(component.playbackDiagnostic()).toBeNull();
expect(component.visiblePlaybackDiagnostic()).toBeNull();
expect(query('playback-diagnostic-banner')).toBeNull();
expect(component.channel).toBeUndefined();
expect(component.channel()).toBeUndefined();
}
);
@@ -932,7 +932,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(component.activeBinding()).toBe(currentBinding);
expect(component.playbackDiagnostic()).toBe(currentIssue);
expect(component.visiblePlaybackDiagnostic()).toBe(currentIssue);
expect(component.channel?.url).toBe(
expect(component.channel()?.url).toBe(
'https://example.com/current.m3u8'
);
}
@@ -968,7 +968,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(component.recoveryPending()).toBe(true);
expect(component.visiblePlaybackDiagnostic()).toBeNull();
expect(component.channel).toBeUndefined();
expect(component.channel()).toBeUndefined();
headerResolvers[1](false);
await fixture.whenStable();
@@ -988,7 +988,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
const sourceABinding = component.activeBinding();
expect(sourceABinding).not.toBeNull();
expect(headerResolvers).toHaveLength(1);
expect(component.channel).toBeUndefined();
expect(component.channel()).toBeUndefined();
expect(component.visiblePlaybackDiagnostic()).toBeNull();
setPlayback({ streamUrl: 'https://example.com/source-b.m3u8' });
@@ -996,8 +996,8 @@ describe('WebPlayerViewComponent recovery integration', () => {
binding: component.activeBinding(),
pending: component.recoveryPending(),
diagnostic: component.playbackDiagnostic(),
channel: component.channel,
vjsOptions: component.vjsOptions,
channel: component.channel(),
vjsOptions: component.vjsOptions(),
failures: [...failures],
};
@@ -1008,8 +1008,8 @@ describe('WebPlayerViewComponent recovery integration', () => {
binding: component.activeBinding(),
pending: component.recoveryPending(),
diagnostic: component.playbackDiagnostic(),
channel: component.channel,
vjsOptions: component.vjsOptions,
channel: component.channel(),
vjsOptions: component.vjsOptions(),
failures,
}).toEqual(pendingState);
expect(component.visiblePlaybackDiagnostic()).toBeNull();
@@ -1021,10 +1021,10 @@ describe('WebPlayerViewComponent recovery integration', () => {
headerResolvers[1](true);
await fixture.whenStable();
expect(component.channel?.url).toBe(
expect(component.channel()?.url).toBe(
'https://example.com/source-b.m3u8'
);
expect(component.vjsOptions?.sources).toEqual([
expect(component.vjsOptions()?.sources).toEqual([
expect.objectContaining({
src: 'https://example.com/source-b.m3u8',
}),
@@ -1062,7 +1062,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(component.activeBinding()).not.toBeNull();
expect(component.recoveryPending()).toBe(true);
expect(component.channel).toBeUndefined();
expect(component.channel()).toBeUndefined();
fixture.destroy();
expect(component.activeBinding()).toBeNull();
@@ -1071,8 +1071,8 @@ describe('WebPlayerViewComponent recovery integration', () => {
binding: component.activeBinding(),
pending: component.recoveryPending(),
diagnostic: component.playbackDiagnostic(),
channel: component.channel,
vjsOptions: component.vjsOptions,
channel: component.channel(),
vjsOptions: component.vjsOptions(),
failures: [...failures],
};
@@ -1083,8 +1083,8 @@ describe('WebPlayerViewComponent recovery integration', () => {
binding: component.activeBinding(),
pending: component.recoveryPending(),
diagnostic: component.playbackDiagnostic(),
channel: component.channel,
vjsOptions: component.vjsOptions,
channel: component.channel(),
vjsOptions: component.vjsOptions(),
failures,
}).toEqual(destroyedState);
expect(component.playbackDiagnostic()).toBe(diagnostic);
@@ -1167,7 +1167,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
headerResolvers[2](true);
await fixture.whenStable();
expect(component.visiblePlaybackDiagnostic()).toBeNull();
expect(component.vjsOptions).toEqual(
expect(component.vjsOptions()).toEqual(
expect.objectContaining({
sources: [
expect.objectContaining({
@@ -6,6 +6,12 @@
overflow: hidden;
}
// The host owns DOM fullscreen (see `fullscreenSurface` in the component),
// so the letterbox around a remounted player must be black, not app surface.
.web-player-view:fullscreen {
background: #000;
}
app-art-player,
app-embedded-mpv-player,
app-vjs-player,
@@ -1,5 +1,10 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { signal } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
signal,
type Type,
} from '@angular/core';
import {
ComponentFixture,
DeferBlockBehavior,
@@ -277,6 +282,109 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
expect(retriedVjsPlayer.interactionEnabled()).toBe(true);
});
it('renders the engine when the Electron header handoff lands after the mounting pass under an OnPush host', async () => {
// Electron hands the source over inside the setUserAgent promise,
// i.e. after the change-detection pass that mounted the application.
// Real hosts (PortalInlinePlayerComponent) are OnPush, so nothing
// re-checks this view on its own — the handoff has to mark it. It
// used to ride on the fullscreen exit's stage resize on every episode
// switch, which the host-owned fullscreen removed.
// Every apply gets its own pending promise: a superseded apply (the
// view can restart the application while settling) must resolve too,
// and only the newest one hands the source over.
const resolvers: Array<(value: boolean) => void> = [];
const setUserAgent = jest.fn(
() =>
new Promise<boolean>((resolve) => {
resolvers.push(resolve);
})
);
(window as unknown as { electron?: unknown }).electron = {
setUserAgent,
};
const RootHostComponent = createOnPushHostTree(WebPlayerViewComponent, {
streamUrl: 'https://example.com/episode-1.mp4',
title: 'Episode 1',
contentInfo: createVodContentInfo(),
});
const hostFixture = TestBed.createComponent(RootHostComponent);
try {
// Let the @defer block settle first: its completion marks the
// tree for refresh on its own, which would mask the bug if the
// header promise resolved in the same microtask flush.
hostFixture.detectChanges();
await hostFixture.whenStable();
hostFixture.detectChanges();
expect(setUserAgent).toHaveBeenCalled();
expect(
hostFixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
)
).toBeNull();
for (const resolve of resolvers.splice(0)) {
resolve(true);
}
await hostFixture.whenStable();
hostFixture.detectChanges();
expect(
hostFixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
)
).not.toBeNull();
} finally {
hostFixture.destroy();
delete (window as unknown as { electron?: unknown }).electron;
}
});
it.each([
[
'HTML5',
() => renderHtmlPlayer({ contentInfo: createVodContentInfo() }),
],
[
'Video.js',
() => renderVjsPlayer({ contentInfo: createVodContentInfo() }),
],
[
'ArtPlayer',
() => renderArtPlayer({ contentInfo: createVodContentInfo() }),
],
])(
'keeps the host as the one fullscreen owner across a %s application remount',
async (_engine, renderPlayer) => {
const player = await renderPlayer();
const host = fixture.nativeElement as HTMLElement;
expect(player.fullscreenTarget()).toBe(host);
// The next episode is a new playback object, which remounts the
// engine component (new application token). The fullscreen owner
// must not be that component, or the Fullscreen API would exit
// the moment the old shell leaves the document.
setPlayback(
{
contentInfo: {
...createVodContentInfo(),
contentXtreamId: 124,
},
},
'https://example.com/episode-2.mp4'
);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const remounted = fixture.debugElement.query(
By.directive(player.constructor as never)
).componentInstance as { fullscreenTarget(): HTMLElement | null };
expect(remounted).not.toBe(player);
expect(remounted.fullscreenTarget()).toBe(host);
expect(host.isConnected).toBe(true);
}
);
it.each([
['inferred VOD', { contentInfo: createVodContentInfo() }, false],
[
@@ -292,14 +400,14 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
fixture.detectChanges();
expect(component.vjsOptions).toEqual(
expect(component.vjsOptions()).toEqual(
expect.objectContaining({ isLive: expected, reloadToken: 0 })
);
component.retryPlayback();
fixture.detectChanges();
expect(component.vjsOptions).toEqual(
expect(component.vjsOptions()).toEqual(
expect.objectContaining({ isLive: expected, reloadToken: 1 })
);
}
@@ -390,3 +498,36 @@ function createNetworkDiagnostic(): PlaybackDiagnostic {
videoCodecs: [],
};
}
/**
* Root (default CD) → OnPush middle → app-web-player-view. `detectChanges()`
* on the root refreshes the root itself unconditionally, so the OnPush layer
* has to sit in between for the "not dirty, skipped" case to exist.
*/
function createOnPushHostTree(
WebPlayerView: Type<unknown>,
playback: ResolvedPortalPlayback
): Type<unknown> {
@Component({
selector: 'app-onpush-player-host',
imports: [WebPlayerView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<app-web-player-view
playbackSessionKey="onpush-host"
[streamUrl]="playback.streamUrl"
[playback]="playback"
/>`,
})
class OnPushPlayerHostComponent {
readonly playback = playback;
}
@Component({
selector: 'app-root-player-host',
imports: [OnPushPlayerHostComponent],
template: '<app-onpush-player-host />',
})
class RootPlayerHostComponent {}
return RootPlayerHostComponent;
}
@@ -49,6 +49,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
})
class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
@@ -68,6 +69,7 @@ class StubVjsPlayerComponent {
})
class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
@@ -88,6 +90,7 @@ class StubHtmlVideoPlayerComponent {
})
class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
@@ -108,6 +111,7 @@ class StubArtPlayerComponent {
})
class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
@@ -1075,12 +1079,12 @@ describe('WebPlayerViewComponent', () => {
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
}
);
expect(component.channel).toBeUndefined();
expect(component.channel()).toBeUndefined();
await fixture.whenStable();
fixture.detectChanges();
expect(component.channel.url).toBe(GATED_STREAM_URL);
expect(component.channel()?.url).toBe(GATED_STREAM_URL);
});
it('omits the credentials object when the playback carries none', async () => {
@@ -1123,11 +1127,11 @@ describe('WebPlayerViewComponent', () => {
// The stale IPC completion must not hand the old source over.
resolvers[0]();
await fixture.whenStable();
expect(component.channel).toBeUndefined();
expect(component.channel()).toBeUndefined();
resolvers[1]();
await fixture.whenStable();
expect(component.channel.url).toBe(
expect(component.channel()?.url).toBe(
'http://portal.example:8080/live/ch2.ts'
);
});
@@ -1,5 +1,6 @@
import {
Component,
ElementRef,
OnDestroy,
ViewEncapsulation,
computed,
@@ -87,6 +88,18 @@ function resolveWebPlayerSharedControls(): boolean {
encapsulation: ViewEncapsulation.None,
})
export class WebPlayerViewComponent implements OnDestroy {
/**
* DOM fullscreen owner handed to every rendered player. Applications are
* remounted per token (`@for ... track application.token` below), and
* the Fullscreen API exits the moment its element leaves the document,
* so a fullscreen owned by the player shell would end with every
* episode, channel, or alternative-source switch. This host element
* spans all applications of one mount: fullscreen entered on episode 1
* is still in place when episode 2's engine mounts, and the fresh
* controls pick it up through `ControlsFullscreen.sync()`.
*/
readonly fullscreenSurface: HTMLElement = inject(ElementRef<HTMLElement>)
.nativeElement;
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK, {
@@ -142,8 +155,16 @@ export class WebPlayerViewComponent implements OnDestroy {
);
readonly activeBinding = this.recoverySession.activeBinding;
channel: Channel | undefined;
vjsOptions: VideoPlayerOptions | undefined;
/**
* Source handed to the rendered engine. Signals, not plain fields: in
* Electron the handoff lands in the stream-header IPC promise, after the
* change-detection pass that mounted the application, and this view sits
* under OnPush hosts (`PortalInlinePlayerComponent`) — a plain field set
* there was only rendered when something else happened to dirty the
* subtree, which used to be the fullscreen exit on every episode switch.
*/
readonly channel = signal<Channel | undefined>(undefined);
readonly vjsOptions = signal<VideoPlayerOptions | undefined>(undefined);
// Resolved from the live SettingsStore signal, not a mount-time storage
// snapshot: a saved player change (settings page, command palette) must
@@ -262,8 +283,8 @@ export class WebPlayerViewComponent implements OnDestroy {
const selectedPlayer = untracked(this.selectedPlayer);
const reloadToken = untracked(this.reloadToken);
const target = toInlinePlaybackPlayer(selectedPlayer);
this.channel = undefined;
this.vjsOptions = undefined;
this.channel.set(undefined);
this.vjsOptions.set(undefined);
this.recovery.clearDiagnostic();
if (target === null) {
this.applicationHandoff.release();
@@ -281,8 +302,8 @@ export class WebPlayerViewComponent implements OnDestroy {
token,
() => this.playbackApplicationToken(),
(handoff) => {
this.channel = handoff.channel;
this.vjsOptions = handoff.vjsOptions;
this.channel.set(handoff.channel);
this.vjsOptions.set(handoff.vjsOptions);
}
);
});
@@ -17,6 +17,7 @@ import type {
})
export class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
@@ -36,6 +37,7 @@ export class StubVjsPlayerComponent {
})
export class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
@@ -56,6 +58,7 @@ export class StubHtmlVideoPlayerComponent {
})
export class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
@@ -76,6 +79,7 @@ export class StubArtPlayerComponent {
})
export class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);