From 0a2f6121f8b21115a265d141d07f7c7d7a11501c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:21:37 +0200 Subject: [PATCH] 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 --- ...back-fullscreen-survives-episode-switch.md | 10 + AGENTS.md | 3 +- CLAUDE.md | 2 +- .../src/fixtures/playback/episode.webm | Bin 0 -> 2000 bytes apps/web-e2e/src/xtream.e2e.ts | 220 +++++++++++++++++- docs/architecture/embedded-mpv-native.md | 15 +- docs/architecture/player-controls-contract.md | 70 ++++-- docs/architecture/vod-multi-source.md | 10 +- .../lib/art-player/art-player.component.html | 1 + ...t-player.component.shared-controls.spec.ts | 59 ++++- .../lib/art-player/art-player.component.ts | 4 +- .../embedded-mpv-player.component.html | 1 + ...v-player.component.shared-controls.spec.ts | 65 ++++++ .../embedded-mpv-player.component.ts | 39 +++- .../html-video-player.component.html | 1 + ...o-player.component.shared-controls.spec.ts | 54 +++++ .../html-video-player.component.ts | 4 +- .../player-controls.component.surface.spec.ts | 36 +++ .../player-controls.component.ts | 13 +- .../lib/vjs-player/vjs-player.component.html | 1 + ...s-player.component.shared-controls.spec.ts | 57 +++++ .../lib/vjs-player/vjs-player.component.ts | 4 +- .../web-player-view.component.html | 12 +- ...web-player-view.component.recovery.spec.ts | 34 +-- .../web-player-view.component.scss | 6 + ...yer-view.component.shared-controls.spec.ts | 147 +++++++++++- .../web-player-view.component.spec.ts | 12 +- .../web-player-view.component.ts | 33 ++- .../web-player-view.spec-stubs.ts | 4 + 29 files changed, 830 insertions(+), 87 deletions(-) create mode 100644 .changes/playback-fullscreen-survives-episode-switch.md create mode 100644 apps/web-e2e/src/fixtures/playback/episode.webm 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 0000000000000000000000000000000000000000..2c420c6f15b758836b9b149b8e628ab8b0162ec5 GIT binary patch literal 2000 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1lSh{`pz!d z<-5B(cy)`Y=gPF;HH`})Jh6~<*+AYk-`zbxIiZll>A`E77?mMhnc&?($tL!$HxP3e zBEiPdf&jT{gVyzp&HPRdz70J-iDhYKhI;0Dh6V=VjwoE0&JKsWK43S19DiaR)NS_H z8ySm_c5p12DSd*?dH0+~2BocYnoILiIvN?;TEpVQ0xX&v8I>P5x5%DNWq2@QL!-m4 zMwMBOOraZELU%SYg8crwxOjf@frj)GGhG+AyZSl%ySN6qw4+#GTzoD0KzB02qVy9B zkrht?Iz1WabVEy!+ZA#$lgbJzz-l^NLW3MbJpKJ#+9w+rSOJl-ft~>jFvvS^D|M84 z-pH_cMI!@4!v-dXxr|y43@Qu^?i{VH9jz@LOiT=Heg0>T&;A&}@bCZXfQH4>8X3PZ zTx4Wm6cAvLV33H=|H8o0!@vL(XMTZQob?WNarO(?#W{~)7w6uAO?+x2?;3`UVE2e5 zFeE1Izrdhm0CEoh0&L2sH409_E-u`GMI0PL+|QdtYk(#K6986^kHi4c`Y{?H;DirL xAr2*@0fG`Bp&v#A1RfxNo;PLZ3{-f4>ePw5`!_c-Z0`cr-5;A67k_AE1^~tv53c|K literal 0 HcmV?d00001 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 + `