diff --git a/.changes/playback-fullscreen-survives-episode-switch.md b/.changes/playback-fullscreen-survives-episode-switch.md new file mode 100644 index 000000000..861bf88db --- /dev/null +++ b/.changes/playback-fullscreen-survives-episode-switch.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 0cb16e0af..09fa0e868 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 835a98a1b..19a014fc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so ranked recovery actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation — but the playback keyboard shortcuts (Space/K, F, arrow seek/volume, M) still work: each vendor-chrome player attaches `LegacyPlayerShortcuts` (a wrapper over the same `ControlsShortcuts` arbitration/ignore rules) with engine-specific command wiring (`html-video-legacy-shortcuts.ts`, `vjs-legacy-shortcuts.ts`, `art-player-legacy-shortcuts.ts`); seek is gated on authoritative `isLive` plus a finite positive duration, `interactionEnabled` (visible playback diagnostic) disables the keys, and the legacy ArtPlayer chrome passes `hotkey: false` because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would double-handle every key (its lost Escape-exits-`fullscreenWeb` behavior is restored by the wiring). `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.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 — 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 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 consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the shared controls' resolved fullscreen owner (the host-supplied `fullscreenTarget`, i.e. the `app-web-player-view` host, else the engine shell) so ranked recovery actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation — but the playback keyboard shortcuts (Space/K, F, arrow seek/volume, M) still work: each vendor-chrome player attaches `LegacyPlayerShortcuts` (a wrapper over the same `ControlsShortcuts` arbitration/ignore rules) with engine-specific command wiring (`html-video-legacy-shortcuts.ts`, `vjs-legacy-shortcuts.ts`, `art-player-legacy-shortcuts.ts`); seek is gated on authoritative `isLive` plus a finite positive duration, `interactionEnabled` (visible playback diagnostic) disables the keys, and the legacy ArtPlayer chrome passes `hotkey: false` because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would double-handle every key (its lost Escape-exits-`fullscreenWeb` behavior is restored by the wiring). `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.md`. - Shared web picture-in-picture stays inside that default-on rollout. `PlayerController` exposes capability `pictureInPicture`, state `pictureInPictureActive`/`canPictureInPicture`, and command diff --git a/apps/web-e2e/src/fixtures/playback/episode.webm b/apps/web-e2e/src/fixtures/playback/episode.webm new file mode 100644 index 000000000..2c420c6f1 Binary files /dev/null and b/apps/web-e2e/src/fixtures/playback/episode.webm differ diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index fa61707a0..9a549e4bf 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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 { // 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(); + }); +}); diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index fd7615417..a931d6d18 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -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. diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 1c6807501..6085fd153 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -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 + `