mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playback): add shared picture-in-picture controls (#1199)
* docs(playback): design shared web picture-in-picture * docs(playback): keep picture-in-picture exit available * docs(playback): plan shared web picture-in-picture * feat(playback): add picture-in-picture controls contract * feat(playback): add shared web picture-in-picture * feat(playback): expose shared picture-in-picture action * docs(playback): document shared web picture-in-picture * test(playback): cover shared picture-in-picture flow * test(playback): wait for PiP video in Electron E2E * refactor(playback): extract picture-in-picture controller
This commit is contained in:
1 parent
beb62db314
commit
c6ed504723
48 files changed
+3166
-239
No files matched your search
@@ -191,6 +191,28 @@ Key files:
|
||||
double-click ownership. Diagnostic interaction gating and owned-fullscreen
|
||||
exit match the other web players. The preference-off path keeps the legacy
|
||||
ArtPlayer skin, source behavior, and series navigation unchanged.
|
||||
- Shared web picture-in-picture stays inside that default-off rollout.
|
||||
`PlayerController` exposes capability `pictureInPicture`, state
|
||||
`pictureInPictureActive`/`canPictureInPicture`, and command
|
||||
`togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard
|
||||
element PiP from the adapter's attached video; shared ArtPlayer keeps vendor
|
||||
`pip: false`, while preference-off native/vendor paths remain unchanged. The
|
||||
capability-gated button sits before fullscreen and uses active enter/exit
|
||||
semantics; entry is disabled until metadata, and the action is disabled while
|
||||
an operation is pending. Embedded MPV reports capability/state false with a
|
||||
no-op command and has no popup/mini-window.
|
||||
- `WebVideoControlsAdapter` supplies its current video and binding generation to
|
||||
`WebVideoPictureInPictureController`; the controller reads the video's
|
||||
`ownerDocument`, while browser enter/leave events remain authoritative.
|
||||
Exact-owner exit stays available if request support changes. Request/exit
|
||||
invocation remains synchronous for user activation, one operation is
|
||||
serialized, and binding generation plus exact video identity protects
|
||||
replacement and teardown from stale completion. Video.js Tech reset and
|
||||
ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a
|
||||
retained target preserve PiP.
|
||||
Standard PiP shows the browser/OS video surface without Angular control
|
||||
chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP
|
||||
keyboard shortcut, and Embedded MPV popup/native support are out of scope.
|
||||
- Canonical docs: `docs/architecture/player-controls-contract.md` and
|
||||
`docs/architecture/embedded-mpv-native.md`
|
||||
|
||||
|
||||
@@ -619,6 +619,28 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
|
||||
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video. Frame-copy is the first runtime consumer of shared `app-player-controls`: `PlayerControlsComponent` and its surface/shortcut/fullscreen collaborators own the DOM UI interactions, while the component-scoped `EmbeddedMpvControlsAdapter` maps session state and commands and coordinates correlated recording state; native-view retains the legacy fixed dock. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and package validation requires the exact MPV DLL named in the helper's PE import table beside the executable. Backend process adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; shared-controls adapter: `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
|
||||
- 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. Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `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/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. 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/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/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 retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`.
|
||||
- Shared web picture-in-picture stays inside that default-off rollout.
|
||||
`PlayerController` exposes capability `pictureInPicture`, state
|
||||
`pictureInPictureActive`/`canPictureInPicture`, and command
|
||||
`togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard
|
||||
element PiP from the adapter's attached video; shared ArtPlayer keeps vendor
|
||||
`pip: false`, while preference-off native/vendor paths remain unchanged. The
|
||||
capability-gated button sits before fullscreen and uses active enter/exit
|
||||
semantics; entry is disabled until metadata, and the action is disabled while
|
||||
an operation is pending. Embedded MPV reports capability/state false with a
|
||||
no-op command and has no popup/mini-window.
|
||||
- `WebVideoControlsAdapter` supplies its current video and binding generation to
|
||||
`WebVideoPictureInPictureController`; the controller reads the video's
|
||||
`ownerDocument`, while browser enter/leave events remain authoritative.
|
||||
Exact-owner exit stays available if request support changes. Request/exit
|
||||
invocation remains synchronous for user activation, one operation is
|
||||
serialized, and binding generation plus exact video identity protects
|
||||
replacement and teardown from stale completion. Video.js Tech reset and
|
||||
ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a
|
||||
retained target preserve PiP.
|
||||
Standard PiP shows the browser/OS video surface without Angular control
|
||||
chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP
|
||||
keyboard shortcut, and Embedded MPV popup/native support are out of scope.
|
||||
|
||||
**VOD/Series Detail Pages (two-state layout)**:
|
||||
|
||||
|
||||
@@ -263,6 +263,13 @@ test.describe('Electron Settings', () => {
|
||||
await goToDashboard(app.mainWindow);
|
||||
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
|
||||
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/);
|
||||
await app.mainWindow.route(
|
||||
'https://example.channels/path-to-file/1.m3u8',
|
||||
() => {
|
||||
// Keep the synthetic stream pending so its network failure
|
||||
// cannot replace the shared controls with a diagnostic.
|
||||
}
|
||||
);
|
||||
|
||||
const firstChannel = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
@@ -271,14 +278,129 @@ test.describe('Electron Settings', () => {
|
||||
|
||||
await expect(firstChannel).toBeVisible({ timeout: 20000 });
|
||||
await firstChannel.click();
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'app-html-video-player app-player-controls'
|
||||
)
|
||||
).toBeVisible();
|
||||
const video = app.mainWindow.locator(
|
||||
'app-html-video-player video'
|
||||
);
|
||||
await expect(video).toBeAttached();
|
||||
await video.evaluate<void, HTMLVideoElement>((video) => {
|
||||
const ownerDocument = video.ownerDocument;
|
||||
let activePictureInPictureElement: Element | null = null;
|
||||
video.dataset['pictureInPictureRequestCount'] = '0';
|
||||
video.dataset['pictureInPictureExitCount'] = '0';
|
||||
|
||||
Object.defineProperty(
|
||||
ownerDocument,
|
||||
'pictureInPictureEnabled',
|
||||
{
|
||||
configurable: true,
|
||||
value: true,
|
||||
}
|
||||
);
|
||||
Object.defineProperty(
|
||||
ownerDocument,
|
||||
'pictureInPictureElement',
|
||||
{
|
||||
configurable: true,
|
||||
get: () => activePictureInPictureElement,
|
||||
}
|
||||
);
|
||||
Object.defineProperty(ownerDocument, 'exitPictureInPicture', {
|
||||
configurable: true,
|
||||
value: async (): Promise<void> => {
|
||||
video.dataset['pictureInPictureExitCount'] = String(
|
||||
Number(
|
||||
video.dataset['pictureInPictureExitCount'] ??
|
||||
'0'
|
||||
) + 1
|
||||
);
|
||||
const previousOwner = activePictureInPictureElement;
|
||||
activePictureInPictureElement = null;
|
||||
previousOwner?.dispatchEvent(
|
||||
new Event('leavepictureinpicture')
|
||||
);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(video, 'requestPictureInPicture', {
|
||||
configurable: true,
|
||||
value: async (): Promise<PictureInPictureWindow> => {
|
||||
video.dataset['pictureInPictureRequestCount'] = String(
|
||||
Number(
|
||||
video.dataset['pictureInPictureRequestCount'] ??
|
||||
'0'
|
||||
) + 1
|
||||
);
|
||||
activePictureInPictureElement = video;
|
||||
video.dispatchEvent(new Event('enterpictureinpicture'));
|
||||
|
||||
const pictureInPictureWindow: PictureInPictureWindow =
|
||||
Object.assign(new EventTarget(), {
|
||||
height: video.videoHeight,
|
||||
onresize: null,
|
||||
width: video.videoWidth,
|
||||
});
|
||||
return pictureInPictureWindow;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(video, 'disablePictureInPicture', {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
Object.defineProperty(video, 'readyState', {
|
||||
configurable: true,
|
||||
value: HTMLMediaElement.HAVE_METADATA,
|
||||
});
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
});
|
||||
|
||||
const playerControls = app.mainWindow.locator(
|
||||
'app-html-video-player app-player-controls'
|
||||
);
|
||||
await expect(playerControls).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.locator('app-html-video-player video[controls]')
|
||||
).toHaveCount(0);
|
||||
const enterPictureInPicture = playerControls.getByRole('button', {
|
||||
name: 'Enter picture-in-picture',
|
||||
});
|
||||
|
||||
await expect(enterPictureInPicture).toBeVisible();
|
||||
await expect(enterPictureInPicture).toBeEnabled();
|
||||
await expect(enterPictureInPicture).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false'
|
||||
);
|
||||
await expect(video).toHaveAttribute(
|
||||
'data-picture-in-picture-request-count',
|
||||
'0'
|
||||
);
|
||||
await enterPictureInPicture.click();
|
||||
await expect(video).toHaveAttribute(
|
||||
'data-picture-in-picture-request-count',
|
||||
'1'
|
||||
);
|
||||
|
||||
const exitPictureInPicture = playerControls.getByRole('button', {
|
||||
name: 'Exit picture-in-picture',
|
||||
});
|
||||
await expect(exitPictureInPicture).toBeVisible();
|
||||
await expect(exitPictureInPicture).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await expect(video).toHaveAttribute(
|
||||
'data-picture-in-picture-exit-count',
|
||||
'0'
|
||||
);
|
||||
await exitPictureInPicture.click();
|
||||
await expect(video).toHaveAttribute(
|
||||
'data-picture-in-picture-exit-count',
|
||||
'1'
|
||||
);
|
||||
await expect(enterPictureInPicture).toBeVisible();
|
||||
await expect(enterPictureInPicture).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false'
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "فشل بدء التسجيل.",
|
||||
"RECORDING_FAILED_TO_STOP": "فشل إيقاف التسجيل.",
|
||||
"SAVED_TO": "تم الحفظ في {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "تشغيل صورة داخل صورة",
|
||||
"EXIT_PICTURE_IN_PICTURE": "إنهاء صورة داخل صورة",
|
||||
"ENTER_FULLSCREEN": "ملء الشاشة",
|
||||
"EXIT_FULLSCREEN": "الخروج من ملء الشاشة"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "التسجيل ما قدرش يبدا.",
|
||||
"RECORDING_FAILED_TO_STOP": "التسجيل ما قدرش يتوقف.",
|
||||
"SAVED_TO": "تحفظ في {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "شغّل صورة داخل صورة",
|
||||
"EXIT_PICTURE_IN_PICTURE": "خرج من صورة داخل صورة",
|
||||
"ENTER_FULLSCREEN": "دخل لملء الشاشة",
|
||||
"EXIT_FULLSCREEN": "خرج من ملء الشاشة"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Не ўдалося пачаць запіс.",
|
||||
"RECORDING_FAILED_TO_STOP": "Не ўдалося спыніць запіс.",
|
||||
"SAVED_TO": "Захавана ў {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Уключыць рэжым «выява ў выяве»",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Выйсці з рэжыму «выява ў выяве»",
|
||||
"ENTER_FULLSCREEN": "Увайсці ў поўнаэкранны рэжым",
|
||||
"EXIT_FULLSCREEN": "Выйсці з поўнаэкраннага рэжыму"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Aufnahme konnte nicht gestartet werden.",
|
||||
"RECORDING_FAILED_TO_STOP": "Aufnahme konnte nicht gestoppt werden.",
|
||||
"SAVED_TO": "Gespeichert unter {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Bild-in-Bild starten",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Bild-in-Bild beenden",
|
||||
"ENTER_FULLSCREEN": "Vollbild aktivieren",
|
||||
"EXIT_FULLSCREEN": "Vollbild beenden"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Η έναρξη της εγγραφής απέτυχε.",
|
||||
"RECORDING_FAILED_TO_STOP": "Η διακοπή της εγγραφής απέτυχε.",
|
||||
"SAVED_TO": "Αποθηκεύτηκε στο {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Έναρξη εικόνας εντός εικόνας",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Έξοδος από εικόνα εντός εικόνας",
|
||||
"ENTER_FULLSCREEN": "Μετάβαση σε πλήρη οθόνη",
|
||||
"EXIT_FULLSCREEN": "Έξοδος από πλήρη οθόνη"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Recording failed to start.",
|
||||
"RECORDING_FAILED_TO_STOP": "Recording failed to stop.",
|
||||
"SAVED_TO": "Saved to {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Enter picture-in-picture",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Exit picture-in-picture",
|
||||
"ENTER_FULLSCREEN": "Enter fullscreen",
|
||||
"EXIT_FULLSCREEN": "Exit fullscreen"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "No se pudo iniciar la grabación.",
|
||||
"RECORDING_FAILED_TO_STOP": "No se pudo detener la grabación.",
|
||||
"SAVED_TO": "Guardada en {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Activar imagen en imagen",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Salir de imagen en imagen",
|
||||
"ENTER_FULLSCREEN": "Entrar en pantalla completa",
|
||||
"EXIT_FULLSCREEN": "Salir de pantalla completa"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "L'enregistrement n'a pas pu démarrer.",
|
||||
"RECORDING_FAILED_TO_STOP": "L'enregistrement n'a pas pu s'arrêter.",
|
||||
"SAVED_TO": "Enregistré dans {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Activer le mode image dans l’image",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Quitter le mode image dans l’image",
|
||||
"ENTER_FULLSCREEN": "Passer en plein écran",
|
||||
"EXIT_FULLSCREEN": "Quitter le plein écran"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Impossibile avviare la registrazione.",
|
||||
"RECORDING_FAILED_TO_STOP": "Impossibile interrompere la registrazione.",
|
||||
"SAVED_TO": "Salvato in {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Attiva Picture-in-Picture",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Esci da Picture-in-Picture",
|
||||
"ENTER_FULLSCREEN": "Attiva schermo intero",
|
||||
"EXIT_FULLSCREEN": "Esci dallo schermo intero"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "録画を開始できませんでした。",
|
||||
"RECORDING_FAILED_TO_STOP": "録画を停止できませんでした。",
|
||||
"SAVED_TO": "{{path}}に保存しました",
|
||||
"ENTER_PICTURE_IN_PICTURE": "ピクチャーインピクチャーを開始",
|
||||
"EXIT_PICTURE_IN_PICTURE": "ピクチャーインピクチャーを終了",
|
||||
"ENTER_FULLSCREEN": "全画面表示",
|
||||
"EXIT_FULLSCREEN": "全画面表示を終了"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "녹화를 시작하지 못했습니다.",
|
||||
"RECORDING_FAILED_TO_STOP": "녹화를 중지하지 못했습니다.",
|
||||
"SAVED_TO": "{{path}}에 저장되었습니다",
|
||||
"ENTER_PICTURE_IN_PICTURE": "PIP 모드 시작",
|
||||
"EXIT_PICTURE_IN_PICTURE": "PIP 모드 종료",
|
||||
"ENTER_FULLSCREEN": "전체 화면",
|
||||
"EXIT_FULLSCREEN": "전체 화면 종료"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Opname kon niet worden gestart.",
|
||||
"RECORDING_FAILED_TO_STOP": "Opname kon niet worden gestopt.",
|
||||
"SAVED_TO": "Opgeslagen in {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Beeld-in-beeld starten",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Beeld-in-beeld sluiten",
|
||||
"ENTER_FULLSCREEN": "Volledig scherm openen",
|
||||
"EXIT_FULLSCREEN": "Volledig scherm sluiten"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Nie udało się rozpocząć nagrywania.",
|
||||
"RECORDING_FAILED_TO_STOP": "Nie udało się zatrzymać nagrywania.",
|
||||
"SAVED_TO": "Zapisano w {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Włącz obraz w obrazie",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Wyłącz obraz w obrazie",
|
||||
"ENTER_FULLSCREEN": "Włącz pełny ekran",
|
||||
"EXIT_FULLSCREEN": "Wyłącz pełny ekran"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Falha ao iniciar a gravação.",
|
||||
"RECORDING_FAILED_TO_STOP": "Falha ao parar a gravação.",
|
||||
"SAVED_TO": "Salvo em {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Ativar imagem em imagem",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Sair de imagem em imagem",
|
||||
"ENTER_FULLSCREEN": "Entrar em tela cheia",
|
||||
"EXIT_FULLSCREEN": "Sair da tela cheia"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Не удалось начать запись.",
|
||||
"RECORDING_FAILED_TO_STOP": "Не удалось остановить запись.",
|
||||
"SAVED_TO": "Сохранено в {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Включить режим «картинка в картинке»",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Выйти из режима «картинка в картинке»",
|
||||
"ENTER_FULLSCREEN": "Во весь экран",
|
||||
"EXIT_FULLSCREEN": "Выйти из полноэкранного режима"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Kayıt başlatılamadı.",
|
||||
"RECORDING_FAILED_TO_STOP": "Kayıt durdurulamadı.",
|
||||
"SAVED_TO": "{{path}} konumuna kaydedildi",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Resim içinde resim modunu aç",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Resim içinde resim modundan çık",
|
||||
"ENTER_FULLSCREEN": "Tam ekrana geç",
|
||||
"EXIT_FULLSCREEN": "Tam ekrandan çık"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "录制启动失败。",
|
||||
"RECORDING_FAILED_TO_STOP": "录制停止失败。",
|
||||
"SAVED_TO": "已保存至 {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "进入画中画",
|
||||
"EXIT_PICTURE_IN_PICTURE": "退出画中画",
|
||||
"ENTER_FULLSCREEN": "进入全屏",
|
||||
"EXIT_FULLSCREEN": "退出全屏"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "無法開始錄製。",
|
||||
"RECORDING_FAILED_TO_STOP": "無法停止錄製。",
|
||||
"SAVED_TO": "已儲存至 {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "進入子母畫面",
|
||||
"EXIT_PICTURE_IN_PICTURE": "退出子母畫面",
|
||||
"ENTER_FULLSCREEN": "進入全螢幕",
|
||||
"EXIT_FULLSCREEN": "離開全螢幕"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ consumers and includes:
|
||||
- the standalone `app-player-controls` presentation component and its
|
||||
transient-state collaborators;
|
||||
- a generic `WebVideoControlsAdapter` plus small host helpers;
|
||||
- standard element picture-in-picture through that adapter for the guarded web
|
||||
consumers;
|
||||
- a persisted, default-off web-player preference resolved through an immutable
|
||||
per-host rollout token;
|
||||
- the component-scoped `EmbeddedMpvControlsAdapter`;
|
||||
@@ -60,6 +62,12 @@ 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.
|
||||
|
||||
With shared controls enabled, HTML5, Video.js, and ArtPlayer expose standard
|
||||
element picture-in-picture through the adapter's attached `<video>`. Shared
|
||||
ArtPlayer keeps its vendor `pip` option disabled so the shared button is the
|
||||
only PiP owner. The preference-off native/vendor paths remain unchanged.
|
||||
Embedded MPV advertises no PiP capability and its command is a no-op.
|
||||
|
||||
`Settings.webPlayerSharedControls` remains default-off. `WebPlayerViewComponent`
|
||||
snapshots it into `WEB_PLAYER_SHARED_CONTROLS` when a new player host is
|
||||
created, so HTML5, Video.js, and ArtPlayer switch atomically without an
|
||||
@@ -147,7 +155,7 @@ interface PlayerController {
|
||||
|
||||
`PlayerControlsCapabilities` contains booleans for `seek`, `volume`,
|
||||
`audioTracks`, `subtitles`, `playbackSpeed`, `aspectRatio`, `recording`,
|
||||
`fullscreen`, and `seriesNavigation`.
|
||||
`pictureInPicture`, `fullscreen`, and `seriesNavigation`.
|
||||
|
||||
The default is all-false. An adapter enables only features that its engine and
|
||||
current runtime support. Capability flags primarily control whether optional UI
|
||||
@@ -163,7 +171,8 @@ is rendered; state such as `canSeek`, `canPreviousEpisode`, and
|
||||
- volume;
|
||||
- pre-labelled audio/subtitle tracks and subtitle-enabled state;
|
||||
- playback speed and aspect-ratio selections/presets;
|
||||
- recording state; and
|
||||
- recording state;
|
||||
- picture-in-picture active state and runtime availability; and
|
||||
- previous/next episode availability.
|
||||
|
||||
Adapters translate engine types into this model. The controls component must not
|
||||
@@ -184,6 +193,7 @@ owner.
|
||||
- `setPlaybackSpeed`
|
||||
- `setAspectRatio`
|
||||
- `toggleRecording`
|
||||
- `togglePictureInPicture`
|
||||
|
||||
Episode navigation is deliberately exposed as component outputs
|
||||
(`previousEpisodeRequested` and `nextEpisodeRequested`) because the owning
|
||||
@@ -305,6 +315,54 @@ contract. It uses DOM/media events and accepts optional engine-specific track
|
||||
accessors through `WebVideoControlsOptions`, so the adapter itself stays usable
|
||||
in the PWA and does not import a concrete web engine.
|
||||
|
||||
### Standard element picture-in-picture
|
||||
|
||||
Picture-in-picture is part of the existing default-off shared web-controls
|
||||
rollout. It is available through standard element PiP for HTML5, Video.js, and
|
||||
ArtPlayer only when their host snapshot enables `WEB_PLAYER_SHARED_CONTROLS`.
|
||||
The preference-off HTML5 native controls, Video.js skin, and ArtPlayer vendor
|
||||
controls keep their previous behavior. Shared ArtPlayer explicitly keeps vendor
|
||||
`pip: false`, leaving the shared action as the single PiP owner.
|
||||
|
||||
The contract exposes:
|
||||
|
||||
- capability `pictureInPicture`;
|
||||
- state `pictureInPictureActive` and `canPictureInPicture`; and
|
||||
- command `togglePictureInPicture()`.
|
||||
|
||||
The shared button renders only when the capability is present, immediately
|
||||
before fullscreen. It uses the active state for pressed, icon, and enter/exit
|
||||
semantics. When inactive, entry requires
|
||||
`readyState >= HTMLMediaElement.HAVE_METADATA`; when active, exact-owner exit
|
||||
remains available regardless of entry readiness or request support, provided
|
||||
the exit API exists. Any pending PiP operation disables the action.
|
||||
|
||||
`WebVideoControlsAdapter` delegates standard PiP API access and operation
|
||||
lifecycle to `WebVideoPictureInPictureController`, which reads the adapter's
|
||||
current binding and the attached `HTMLVideoElement`'s `ownerDocument`. Browser
|
||||
`enterpictureinpicture`/`leavepictureinpicture` events and the document's exact
|
||||
`pictureInPictureElement` remain authoritative; command completion never
|
||||
optimistically changes the active state.
|
||||
|
||||
The controller invokes `requestPictureInPicture()` or `exitPictureInPicture()`
|
||||
synchronously from `togglePictureInPicture()` so browser user activation is
|
||||
preserved, then contains asynchronous settlement. Only one enter/exit operation
|
||||
may be pending. A binding generation plus exact video identity prevents a stale
|
||||
completion from clearing or changing the new binding. Replacement or teardown
|
||||
exits PiP only when the old video is the document's exact owner; a stale
|
||||
successful entry receives the same exact-owner cleanup and never exits an
|
||||
unrelated PiP element.
|
||||
|
||||
Video.js Tech reset and ArtPlayer video rebuild paths detach the old binding,
|
||||
perform exact-owner cleanup, and bind the replacement video. HTML5 source
|
||||
changes on a retained video target, along with ordinary same-element
|
||||
source/media events, preserve active PiP.
|
||||
|
||||
Standard element PiP displays the browser/OS video surface, not Angular shared
|
||||
control chrome. Subtitle rendering in that surface is browser-dependent.
|
||||
AirPlay, Cast, Document Picture-in-Picture, a PiP keyboard shortcut, and an
|
||||
Embedded MPV popup or native mini-window are out of scope.
|
||||
|
||||
Native media events refresh the adapter automatically. An engine host must call
|
||||
the public `refresh()` hook after engine-specific getters change
|
||||
without a corresponding media event, including track lists, corrected duration,
|
||||
@@ -414,6 +472,11 @@ The web-player preference does not affect Embedded MPV. Frame-copy always uses
|
||||
the shared DOM controls, while native-view keeps its compositor-safe legacy
|
||||
dock.
|
||||
|
||||
`EmbeddedMpvControlsAdapter` reports `pictureInPicture: false`,
|
||||
`pictureInPictureActive: false`, and `canPictureInPicture: false`;
|
||||
`togglePictureInPicture()` is a no-op. Neither renderer opens an MPV
|
||||
popup/mini-window.
|
||||
|
||||
### Frame-copy engine
|
||||
|
||||
The experimental frame-copy engine uploads helper-produced frames to
|
||||
@@ -489,6 +552,8 @@ libs/ui/playback/src/lib/player-controls/
|
||||
├── web-player-controls.flag.ts
|
||||
├── web-video-controls.adapter.ts
|
||||
├── web-video-controls.host.ts
|
||||
├── web-video-controls.media-helpers.ts
|
||||
├── web-video-picture-in-picture.controller.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,272 @@
|
||||
# Shared Web Picture-in-Picture Design
|
||||
|
||||
## Status
|
||||
|
||||
Selected as the first follow-up to the shared web-player controls rollout.
|
||||
|
||||
## Goal
|
||||
|
||||
Restore Picture-in-Picture (PiP) in IPTVnator's shared controls for the three
|
||||
`<video>`-backed web engines:
|
||||
|
||||
- HTML5/hls.js;
|
||||
- Video.js; and
|
||||
- ArtPlayer.
|
||||
|
||||
The feature must use the same button, accessibility behavior, capability
|
||||
gating, and lifecycle rules in all three engines.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not add PiP, a popup, or an always-on-top mini-player for Embedded MPV.
|
||||
- Do not add AirPlay or Cast.
|
||||
- Do not implement Document Picture-in-Picture.
|
||||
- Do not move IPTVnator's Angular controls into the operating-system PiP
|
||||
window.
|
||||
- Do not add a PiP keyboard shortcut.
|
||||
- Do not change the default-off rollout behavior of shared web controls.
|
||||
- Do not alter the existing vendor/native controls when shared controls are
|
||||
disabled.
|
||||
|
||||
## Architecture decision
|
||||
|
||||
PiP becomes an explicit part of the engine-neutral `PlayerController`
|
||||
contract:
|
||||
|
||||
```ts
|
||||
interface PlayerControlsCapabilities {
|
||||
pictureInPicture: boolean;
|
||||
}
|
||||
|
||||
interface PlayerControlsState {
|
||||
pictureInPictureActive: boolean;
|
||||
canPictureInPicture: boolean;
|
||||
}
|
||||
|
||||
interface PlayerControlsCommands {
|
||||
togglePictureInPicture(): void;
|
||||
}
|
||||
```
|
||||
|
||||
`WebVideoControlsAdapter` owns the standard browser PiP integration because it
|
||||
already owns and rebinds the current `HTMLVideoElement`. This is especially
|
||||
important for Video.js, which can replace its Tech `<video>` after
|
||||
`playerreset`.
|
||||
|
||||
The shared presentation remains independent of HTML5, Video.js, and ArtPlayer:
|
||||
it reads the generic capability/state and invokes the generic command.
|
||||
|
||||
Embedded MPV advertises `pictureInPicture: false`, reports inactive/unavailable
|
||||
state, and implements a no-op command. This is contract compatibility only; no
|
||||
Embedded MPV PiP surface or popup is introduced.
|
||||
|
||||
### Rejected alternatives
|
||||
|
||||
Passing a separate `HTMLVideoElement` input to `app-player-controls` would keep
|
||||
PiP outside the controller, like DOM fullscreen, but would require bespoke
|
||||
target wiring and replacement handling in every web-player host.
|
||||
|
||||
Calling Video.js and ArtPlayer vendor PiP APIs directly would duplicate
|
||||
feature detection, ownership, failure handling, and lifecycle behavior. It
|
||||
would also leave the HTML5 player on a separate implementation.
|
||||
|
||||
## Capability and state semantics
|
||||
|
||||
`pictureInPictureActive` is true only when
|
||||
`document.pictureInPictureElement === attachedVideo`.
|
||||
|
||||
The adapter can request PiP when all of the following hold:
|
||||
|
||||
- an `HTMLVideoElement` is attached;
|
||||
- `document.pictureInPictureEnabled === true`;
|
||||
- `video.requestPictureInPicture` is a function;
|
||||
- `document.exitPictureInPicture` is a function; and
|
||||
- `video.disablePictureInPicture !== true`.
|
||||
|
||||
The `pictureInPicture` capability is true when the adapter can request PiP or
|
||||
when the attached video is already active and the browser still exposes
|
||||
`document.exitPictureInPicture()`. This keeps the exit action available if the
|
||||
element's disable flag or the document support flag changes after entry.
|
||||
|
||||
`canPictureInPicture` is false while an enter/exit operation is pending.
|
||||
Otherwise, it is true when:
|
||||
|
||||
- the attached video is already the active PiP owner and the browser exposes
|
||||
`document.exitPictureInPicture()`, so the user can exit; or
|
||||
- the adapter can request PiP and the video has loaded metadata
|
||||
(`readyState >= 1`, equivalent to `HTMLMediaElement.HAVE_METADATA`).
|
||||
|
||||
Browser `enterpictureinpicture` and `leavepictureinpicture` events are
|
||||
authoritative. The adapter does not optimistically report an active state
|
||||
before the browser confirms entry. Settled promises trigger a reconciliation
|
||||
refresh as a fallback for unusual browser event ordering.
|
||||
|
||||
## Command behavior
|
||||
|
||||
`togglePictureInPicture()` is fire-and-forget, matching the existing command
|
||||
contract:
|
||||
|
||||
- when the attached video owns PiP, call `document.exitPictureInPicture()`;
|
||||
- otherwise, call `video.requestPictureInPicture()`;
|
||||
- ignore the command when capability/readiness gating fails;
|
||||
- ignore an additional toggle while an enter/exit operation is pending;
|
||||
- contain synchronous exceptions and rejected promises; and
|
||||
- never emit an unhandled rejection.
|
||||
|
||||
The request call starts synchronously inside the button click so browser user
|
||||
activation is preserved.
|
||||
|
||||
Fullscreen and PiP remain separate browser-managed presentation modes.
|
||||
IPTVnator does not await one transition before requesting the other, because
|
||||
that can consume the initiating user activation. The adapter and fullscreen
|
||||
helper reconcile the browser's resulting events.
|
||||
|
||||
## Lifecycle and ownership
|
||||
|
||||
The adapter attaches exact `enterpictureinpicture` and
|
||||
`leavepictureinpicture` listeners to its current video alongside the existing
|
||||
media listeners.
|
||||
|
||||
Source replacement on the same video element does not exit PiP. This allows a
|
||||
normal HTML5 or Video.js source change that retains its current video element
|
||||
to continue in the existing PiP window and show the new source. ArtPlayer
|
||||
currently rebuilds its player and video element on channel changes, so that
|
||||
replacement follows the owned-target cleanup below and exits PiP.
|
||||
|
||||
When the adapter detaches or rebinds to a replacement video:
|
||||
|
||||
1. it removes listeners from the old video;
|
||||
2. if and only if the old video currently owns PiP, it best-effort exits PiP;
|
||||
3. it clears local active/pending state;
|
||||
4. it invalidates old asynchronous completions; and
|
||||
5. it binds the replacement video as the only authoritative target.
|
||||
|
||||
An unrelated document PiP element must never be closed by this adapter.
|
||||
Stale events or promise completions from an old target must not mutate the new
|
||||
target's state.
|
||||
|
||||
Video.js Tech replacement therefore exits PiP owned by the old Tech and does
|
||||
not automatically enter PiP on the replacement Tech. Automatic re-entry would
|
||||
require a new user gesture and is intentionally not attempted.
|
||||
|
||||
Destroying HTML5, Video.js, or ArtPlayer performs the same owned-target cleanup.
|
||||
A playback diagnostic hiding shared controls does not itself close PiP; the
|
||||
browser-provided PiP window still has its own close affordance, and target
|
||||
teardown remains the ownership boundary.
|
||||
|
||||
## Shared controls UI
|
||||
|
||||
The PiP action is rendered immediately before fullscreen.
|
||||
|
||||
- It is omitted when `pictureInPicture` is false.
|
||||
- It is disabled while `canPictureInPicture` is false.
|
||||
- `aria-pressed` reflects `pictureInPictureActive`.
|
||||
- Tooltip and accessible text use
|
||||
`EMBEDDED_MPV.PLAYER.ENTER_PICTURE_IN_PICTURE` and
|
||||
`EMBEDDED_MPV.PLAYER.EXIT_PICTURE_IN_PICTURE`.
|
||||
- The inactive and active states use distinct Material icons.
|
||||
- Clicking it reveals the controls and invokes the command exactly once.
|
||||
|
||||
Both translation keys are added to every locale file. No shortcut is added in
|
||||
this change.
|
||||
|
||||
Standard element PiP displays video frames in an OS/browser-managed window. It
|
||||
does not display IPTVnator's Angular control bar or other DOM overlays.
|
||||
Subtitle display inside PiP remains dependent on browser support for the
|
||||
active native text track.
|
||||
|
||||
## Engine behavior
|
||||
|
||||
### HTML5
|
||||
|
||||
The existing shared-controls bridge attaches `WebVideoControlsAdapter` to the
|
||||
component's real `<video>`. No separate PiP host wiring is required.
|
||||
|
||||
### Video.js
|
||||
|
||||
The existing bridge attaches to the current Tech `<video>` and rebinds after
|
||||
`playerreset`. The adapter's owned-target cleanup handles PiP during that
|
||||
replacement. Video.js's vendor PiP button remains available when shared
|
||||
controls are disabled.
|
||||
|
||||
### ArtPlayer
|
||||
|
||||
The existing neutral source bridge attaches to `player.video`. Shared mode
|
||||
keeps ArtPlayer's vendor `pip: false` option so exactly one PiP button is
|
||||
rendered. Legacy mode keeps the existing `pip: true` behavior.
|
||||
|
||||
### Embedded MPV
|
||||
|
||||
Both frame-copy and native-view Embedded MPV remain without PiP. No window,
|
||||
popup, frame-copy routing, or native addon behavior is added.
|
||||
|
||||
## Error handling
|
||||
|
||||
PiP can fail because of missing metadata, browser policy, lost user activation,
|
||||
or an OS-level denial. Such failures:
|
||||
|
||||
- do not change playback state;
|
||||
- do not create persistent error UI;
|
||||
- do not mark PiP active;
|
||||
- clear the pending gate; and
|
||||
- leave the button available for a later user retry when the runtime still
|
||||
reports support.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Contract and shared UI
|
||||
|
||||
- defaults keep PiP capability/state false;
|
||||
- the button is absent without capability;
|
||||
- supported-but-not-ready state renders a disabled action;
|
||||
- inactive and active labels/icons/`aria-pressed` are correct; and
|
||||
- a click invokes `togglePictureInPicture()` once.
|
||||
|
||||
### Web adapter
|
||||
|
||||
- feature-detection matrix for document/video APIs and
|
||||
`disablePictureInPicture`;
|
||||
- metadata readiness gating;
|
||||
- request when inactive and exit when active;
|
||||
- authoritative enter/leave event reconciliation;
|
||||
- pending-toggle serialization;
|
||||
- synchronous throw and rejected-promise containment;
|
||||
- listener cleanup;
|
||||
- same-element source refresh preserving active PiP;
|
||||
- active old video to replacement video exits only the old owner;
|
||||
- stale old-video events/completions cannot mutate the replacement; and
|
||||
- detach never exits an unrelated PiP element.
|
||||
|
||||
### Engine integrations
|
||||
|
||||
- HTML5 shared mode exposes web PiP through its attached video;
|
||||
- Video.js Tech replacement rebinds PiP authority and cleans up the old owner;
|
||||
- ArtPlayer shared mode exposes shared PiP while vendor PiP stays disabled;
|
||||
- ArtPlayer channel rebuild exits PiP owned by the replaced video;
|
||||
- all three flag-off paths retain their existing native/vendor chrome; and
|
||||
- Embedded MPV remains capability-false.
|
||||
|
||||
### Validation
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test ui-playback --skip-nx-cache --runInBand
|
||||
pnpm nx lint ui-playback --skip-nx-cache
|
||||
pnpm nx build web --configuration=development --skip-nx-cache
|
||||
```
|
||||
|
||||
Unit tests cannot prove that an OS-managed PiP window opens. Perform a manual
|
||||
smoke test in a supported Chromium/Electron runtime for each web engine:
|
||||
|
||||
1. open a playable stream;
|
||||
2. enter and exit PiP;
|
||||
3. change source on the same element where applicable;
|
||||
4. switch or destroy the player host; and
|
||||
5. confirm the legacy controls path is unchanged.
|
||||
|
||||
## Documentation impact
|
||||
|
||||
Update `docs/architecture/player-controls-contract.md` with the capability,
|
||||
state, command, lifecycle, and browser limitations. Mirror the concise shared
|
||||
controls summary in `AGENTS.md` and `CLAUDE.md`.
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PlayerControlsComponent,
|
||||
WEB_PLAYER_SHARED_CONTROLS,
|
||||
} from '../player-controls';
|
||||
import { PictureInPictureTestEnvironment } from '../player-controls/picture-in-picture.spec-helpers';
|
||||
import type { ArtPlayerComponent as ArtPlayerComponentInstance } from './art-player.component';
|
||||
|
||||
const artPlayerInstances: MockArtplayer[] = [];
|
||||
@@ -125,6 +126,7 @@ describe('ArtPlayerComponent with shared controls', () => {
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
controls: [],
|
||||
pip: false,
|
||||
autoPlayback: false,
|
||||
autoSize: false,
|
||||
autoMini: false,
|
||||
@@ -241,6 +243,40 @@ describe('ArtPlayerComponent with shared controls', () => {
|
||||
expect(artPlayerInstances[0].destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('exits owned PiP when a channel change rebuilds ArtPlayer', () => {
|
||||
const environment = new PictureInPictureTestEnvironment();
|
||||
try {
|
||||
createComponent({
|
||||
url: 'https://example.test/movie-one.mp4',
|
||||
name: 'Movie one',
|
||||
});
|
||||
const firstVideo = artPlayerInstances[0].video;
|
||||
environment.installVideo(firstVideo);
|
||||
firstVideo.dispatchEvent(new Event('loadedmetadata'));
|
||||
environment.setActive(firstVideo);
|
||||
|
||||
fixture.componentRef.setInput('channel', {
|
||||
url: 'https://example.test/movie-two.mp4',
|
||||
name: 'Movie two',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const replacementVideo = artPlayerInstances[1].video;
|
||||
environment.installVideo(replacementVideo);
|
||||
replacementVideo.dispatchEvent(new Event('loadedmetadata'));
|
||||
|
||||
expect(environment.exit).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
component.controlsAdapter.state().pictureInPictureActive
|
||||
).toBe(false);
|
||||
expect(
|
||||
component.controlsAdapter.capabilities().pictureInPicture
|
||||
).toBe(true);
|
||||
} finally {
|
||||
environment.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('exits only the ArtPlayer shell fullscreen when interaction is disabled', () => {
|
||||
const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor(
|
||||
document,
|
||||
|
||||
@@ -198,6 +198,7 @@ describe('EmbeddedMpvControlsAdapter', () => {
|
||||
playbackSpeed: true,
|
||||
aspectRatio: true,
|
||||
recording: true,
|
||||
pictureInPicture: false,
|
||||
fullscreen: true,
|
||||
seriesNavigation: true,
|
||||
});
|
||||
@@ -222,6 +223,7 @@ describe('EmbeddedMpvControlsAdapter', () => {
|
||||
playbackSpeed: false,
|
||||
aspectRatio: false,
|
||||
recording: false,
|
||||
pictureInPicture: false,
|
||||
fullscreen: true,
|
||||
seriesNavigation: true,
|
||||
});
|
||||
@@ -263,6 +265,8 @@ describe('EmbeddedMpvControlsAdapter', () => {
|
||||
speedPresets: DEFAULT_SPEED_PRESETS,
|
||||
aspectRatio: '16:9',
|
||||
aspectPresets: DEFAULT_ASPECT_PRESETS,
|
||||
pictureInPictureActive: false,
|
||||
canPictureInPicture: false,
|
||||
canPreviousEpisode: true,
|
||||
canNextEpisode: false,
|
||||
});
|
||||
@@ -374,6 +378,7 @@ describe('EmbeddedMpvControlsAdapter', () => {
|
||||
it('delegates every non-recording shared-controls command', () => {
|
||||
configure();
|
||||
|
||||
expect(() => adapter.commands.togglePictureInPicture()).not.toThrow();
|
||||
adapter.commands.togglePlay();
|
||||
adapter.commands.seekTo(45);
|
||||
adapter.commands.seekBy(-10);
|
||||
|
||||
@@ -114,6 +114,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
|
||||
playbackSpeed: optionalCapabilities?.playbackSpeed ?? false,
|
||||
aspectRatio: optionalCapabilities?.aspectOverride ?? false,
|
||||
recording: optionalCapabilities?.recording ?? false,
|
||||
pictureInPicture: false,
|
||||
fullscreen: true,
|
||||
seriesNavigation: !isLive && context.seriesNavigation() !== null,
|
||||
};
|
||||
@@ -190,6 +191,8 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
|
||||
),
|
||||
transitionKey: this.recordingTransitionKey(),
|
||||
},
|
||||
pictureInPictureActive: false,
|
||||
canPictureInPicture: false,
|
||||
canPreviousEpisode:
|
||||
hasSeriesNavigation && seriesNavigation?.canPrevious === true,
|
||||
canNextEpisode:
|
||||
@@ -207,6 +210,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
|
||||
setPlaybackSpeed: (speed) => void this.controller.setSpeed(speed),
|
||||
setAspectRatio: (value) => void this.controller.setAspect(value),
|
||||
toggleRecording: () => void this.toggleRecording(),
|
||||
togglePictureInPicture: () => undefined,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
||||
+22
@@ -3,6 +3,7 @@ import {
|
||||
PlayerControlsComponent,
|
||||
WebVideoControlsAdapter,
|
||||
} from '../player-controls';
|
||||
import { PictureInPictureTestEnvironment } from '../player-controls/picture-in-picture.spec-helpers';
|
||||
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
|
||||
import type { HtmlVideoPlayerComponent as HtmlVideoPlayerComponentInstance } from './html-video-player.component';
|
||||
import {
|
||||
@@ -89,6 +90,27 @@ describe('HtmlVideoPlayerComponent shared controls host', () => {
|
||||
expect(controls?.playerSurface()).toBe(shell);
|
||||
});
|
||||
|
||||
it('attaches PiP support to the actual video after metadata', () => {
|
||||
const environment = new PictureInPictureTestEnvironment();
|
||||
try {
|
||||
const { fixture, adapter } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures
|
||||
);
|
||||
const video = fixture.debugElement.query(By.css('video'))
|
||||
.nativeElement as HTMLVideoElement;
|
||||
environment.installVideo(video, { readyState: 0 });
|
||||
|
||||
environment.setReadyState(video, 1);
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
|
||||
expect(adapter.capabilities().pictureInPicture).toBe(true);
|
||||
expect(adapter.state().canPictureInPicture).toBe(true);
|
||||
} finally {
|
||||
environment.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('gates the shared surface and shortcuts with interaction availability', () => {
|
||||
const { fixture, controls } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('ControlsMenuSelection', () => {
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
togglePictureInPicture: jest.fn(),
|
||||
};
|
||||
menus = new ControlsMenuState();
|
||||
visibility = new ControlsVisibility(() => false);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
export interface Deferred<T> {
|
||||
readonly promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
reject(reason?: unknown): void;
|
||||
}
|
||||
|
||||
export interface PictureInPictureVideoOptions {
|
||||
readonly disablePictureInPicture?: boolean;
|
||||
readonly readyState?: number;
|
||||
readonly request?: PictureInPictureRequest | null;
|
||||
}
|
||||
|
||||
type PictureInPictureRequest = () => Promise<PictureInPictureWindow>;
|
||||
|
||||
interface VideoPropertyDescriptors {
|
||||
readonly disablePictureInPicture: PropertyDescriptor | undefined;
|
||||
readonly readyState: PropertyDescriptor | undefined;
|
||||
readonly requestPictureInPicture: PropertyDescriptor | undefined;
|
||||
}
|
||||
|
||||
export function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
export class PictureInPictureTestEnvironment {
|
||||
private activeElement: Element | null = null;
|
||||
private enabled = true;
|
||||
private readonly enabledDescriptor: PropertyDescriptor | undefined;
|
||||
private readonly elementDescriptor: PropertyDescriptor | undefined;
|
||||
private readonly exitDescriptor: PropertyDescriptor | undefined;
|
||||
private readonly videoDescriptors = new Map<
|
||||
HTMLVideoElement,
|
||||
VideoPropertyDescriptors
|
||||
>();
|
||||
|
||||
readonly exit: jest.MockedFunction<() => Promise<void>> = jest.fn(
|
||||
async () => {
|
||||
this.setActive(null);
|
||||
}
|
||||
);
|
||||
|
||||
constructor(private readonly ownerDocument: Document = document) {
|
||||
this.enabledDescriptor = Object.getOwnPropertyDescriptor(
|
||||
ownerDocument,
|
||||
'pictureInPictureEnabled'
|
||||
);
|
||||
this.elementDescriptor = Object.getOwnPropertyDescriptor(
|
||||
ownerDocument,
|
||||
'pictureInPictureElement'
|
||||
);
|
||||
this.exitDescriptor = Object.getOwnPropertyDescriptor(
|
||||
ownerDocument,
|
||||
'exitPictureInPicture'
|
||||
);
|
||||
Object.defineProperty(ownerDocument, 'pictureInPictureEnabled', {
|
||||
configurable: true,
|
||||
get: () => this.enabled,
|
||||
});
|
||||
Object.defineProperty(ownerDocument, 'pictureInPictureElement', {
|
||||
configurable: true,
|
||||
get: () => this.activeElement,
|
||||
});
|
||||
this.setExitAvailable(true);
|
||||
}
|
||||
|
||||
installVideo(
|
||||
video: HTMLVideoElement,
|
||||
options: PictureInPictureVideoOptions = {}
|
||||
): jest.MockedFunction<PictureInPictureRequest> | null {
|
||||
this.assertOwnerDocument(video);
|
||||
this.captureVideoDescriptors(video);
|
||||
this.setReadyState(video, options.readyState ?? 1);
|
||||
this.setDisablePictureInPicture(
|
||||
video,
|
||||
options.disablePictureInPicture ?? false
|
||||
);
|
||||
|
||||
if (options.request === null) {
|
||||
Object.defineProperty(video, 'requestPictureInPicture', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const request =
|
||||
options.request ??
|
||||
(async () => {
|
||||
this.setActive(video);
|
||||
return {} as PictureInPictureWindow;
|
||||
});
|
||||
const requestMock: jest.MockedFunction<PictureInPictureRequest> =
|
||||
jest.fn(request);
|
||||
Object.defineProperty(video, 'requestPictureInPicture', {
|
||||
configurable: true,
|
||||
value: requestMock,
|
||||
});
|
||||
return requestMock;
|
||||
}
|
||||
|
||||
setActive(element: Element | null, emit = true): void {
|
||||
const previous = this.activeElement;
|
||||
this.activeElement = element;
|
||||
if (!emit || previous === element) {
|
||||
return;
|
||||
}
|
||||
previous?.dispatchEvent(new Event('leavepictureinpicture'));
|
||||
element?.dispatchEvent(new Event('enterpictureinpicture'));
|
||||
}
|
||||
|
||||
setDisablePictureInPicture(video: HTMLVideoElement, value: boolean): void {
|
||||
this.assertOwnerDocument(video);
|
||||
this.captureVideoDescriptors(video);
|
||||
Object.defineProperty(video, 'disablePictureInPicture', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
setEnabled(value: boolean): void {
|
||||
this.enabled = value;
|
||||
}
|
||||
|
||||
setExitAvailable(value: boolean): void {
|
||||
Object.defineProperty(this.ownerDocument, 'exitPictureInPicture', {
|
||||
configurable: true,
|
||||
value: value ? this.exit : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
setReadyState(video: HTMLVideoElement, value: number): void {
|
||||
this.assertOwnerDocument(video);
|
||||
this.captureVideoDescriptors(video);
|
||||
Object.defineProperty(video, 'readyState', {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
restore(): void {
|
||||
for (const [video, descriptors] of this.videoDescriptors) {
|
||||
this.restoreProperty(
|
||||
video,
|
||||
'disablePictureInPicture',
|
||||
descriptors.disablePictureInPicture
|
||||
);
|
||||
this.restoreProperty(video, 'readyState', descriptors.readyState);
|
||||
this.restoreProperty(
|
||||
video,
|
||||
'requestPictureInPicture',
|
||||
descriptors.requestPictureInPicture
|
||||
);
|
||||
}
|
||||
this.videoDescriptors.clear();
|
||||
this.restoreProperty(
|
||||
this.ownerDocument,
|
||||
'pictureInPictureEnabled',
|
||||
this.enabledDescriptor
|
||||
);
|
||||
this.restoreProperty(
|
||||
this.ownerDocument,
|
||||
'pictureInPictureElement',
|
||||
this.elementDescriptor
|
||||
);
|
||||
this.restoreProperty(
|
||||
this.ownerDocument,
|
||||
'exitPictureInPicture',
|
||||
this.exitDescriptor
|
||||
);
|
||||
}
|
||||
|
||||
private assertOwnerDocument(video: HTMLVideoElement): void {
|
||||
if (video.ownerDocument !== this.ownerDocument) {
|
||||
throw new Error('PiP test video belongs to a different document');
|
||||
}
|
||||
}
|
||||
|
||||
private captureVideoDescriptors(video: HTMLVideoElement): void {
|
||||
if (this.videoDescriptors.has(video)) {
|
||||
return;
|
||||
}
|
||||
this.videoDescriptors.set(video, {
|
||||
disablePictureInPicture: Object.getOwnPropertyDescriptor(
|
||||
video,
|
||||
'disablePictureInPicture'
|
||||
),
|
||||
readyState: Object.getOwnPropertyDescriptor(video, 'readyState'),
|
||||
requestPictureInPicture: Object.getOwnPropertyDescriptor(
|
||||
video,
|
||||
'requestPictureInPicture'
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
private restoreProperty(
|
||||
target: object,
|
||||
property: string,
|
||||
descriptor: PropertyDescriptor | undefined
|
||||
): void {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(target, property, descriptor);
|
||||
return;
|
||||
}
|
||||
delete (target as Record<string, unknown>)[property];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
DEFAULT_PLAYER_CAPABILITIES,
|
||||
createEmptyControlsState,
|
||||
} from './player-controls-defaults';
|
||||
|
||||
describe('player-controls defaults', () => {
|
||||
it('defaults Picture-in-Picture to unsupported and inactive', () => {
|
||||
expect(DEFAULT_PLAYER_CAPABILITIES.pictureInPicture).toBe(false);
|
||||
expect(createEmptyControlsState()).toMatchObject({
|
||||
pictureInPictureActive: false,
|
||||
canPictureInPicture: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ export const DEFAULT_PLAYER_CAPABILITIES: PlayerControlsCapabilities = {
|
||||
playbackSpeed: false,
|
||||
aspectRatio: false,
|
||||
recording: false,
|
||||
pictureInPicture: false,
|
||||
fullscreen: false,
|
||||
seriesNavigation: false,
|
||||
};
|
||||
@@ -51,6 +52,8 @@ export function createEmptyControlsState(): PlayerControlsState {
|
||||
aspectRatio: 'no',
|
||||
aspectPresets: DEFAULT_ASPECT_PRESETS,
|
||||
recording: { active: false, elapsedSeconds: 0, message: null },
|
||||
pictureInPictureActive: false,
|
||||
canPictureInPicture: false,
|
||||
canPreviousEpisode: false,
|
||||
canNextEpisode: false,
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ function createFakeController(): FakeController {
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
togglePictureInPicture: jest.fn(),
|
||||
};
|
||||
return {
|
||||
controller: { capabilities, state, commands },
|
||||
|
||||
@@ -568,6 +568,36 @@
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (capabilities().pictureInPicture) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="player-controls__button"
|
||||
[disabled]="!state().canPictureInPicture"
|
||||
(click)="togglePictureInPicture()"
|
||||
[attr.aria-pressed]="state().pictureInPictureActive"
|
||||
[attr.aria-label]="
|
||||
(state().pictureInPictureActive
|
||||
? 'EMBEDDED_MPV.PLAYER.EXIT_PICTURE_IN_PICTURE'
|
||||
: 'EMBEDDED_MPV.PLAYER.ENTER_PICTURE_IN_PICTURE'
|
||||
) | translate
|
||||
"
|
||||
[matTooltip]="
|
||||
(state().pictureInPictureActive
|
||||
? 'EMBEDDED_MPV.PLAYER.EXIT_PICTURE_IN_PICTURE'
|
||||
: 'EMBEDDED_MPV.PLAYER.ENTER_PICTURE_IN_PICTURE'
|
||||
) | translate
|
||||
"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<mat-icon>{{
|
||||
state().pictureInPictureActive
|
||||
? 'picture_in_picture_alt'
|
||||
: 'picture_in_picture'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (capabilities().fullscreen) {
|
||||
<button
|
||||
mat-icon-button
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ function createFakeController() {
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
togglePictureInPicture: jest.fn(),
|
||||
};
|
||||
const controller: PlayerController = { capabilities, state, commands };
|
||||
return { controller, capabilities, state, commands };
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import { signal, type WritableSignal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
DEFAULT_PLAYER_CAPABILITIES,
|
||||
createEmptyControlsState,
|
||||
} from './player-controls-defaults';
|
||||
import { PlayerControlsComponent } from './player-controls.component';
|
||||
import type {
|
||||
PlayerController,
|
||||
PlayerControlsCapabilities,
|
||||
PlayerControlsCommands,
|
||||
PlayerControlsState,
|
||||
} from './player-controls.model';
|
||||
|
||||
function createFakeController() {
|
||||
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
|
||||
...DEFAULT_PLAYER_CAPABILITIES,
|
||||
});
|
||||
const state: WritableSignal<PlayerControlsState> = signal(
|
||||
createEmptyControlsState()
|
||||
);
|
||||
const commands: jest.Mocked<PlayerControlsCommands> = {
|
||||
togglePlay: jest.fn(),
|
||||
seekTo: jest.fn(),
|
||||
seekBy: jest.fn(),
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
togglePictureInPicture: jest.fn(),
|
||||
};
|
||||
const controller: PlayerController = { capabilities, state, commands };
|
||||
return { controller, capabilities, state, commands };
|
||||
}
|
||||
|
||||
describe('PlayerControlsComponent picture-in-picture action', () => {
|
||||
let fixture: ComponentFixture<PlayerControlsComponent>;
|
||||
let component: PlayerControlsComponent;
|
||||
let fake: ReturnType<typeof createFakeController>;
|
||||
|
||||
const setCapabilities = (
|
||||
overrides: Partial<PlayerControlsCapabilities>
|
||||
) => {
|
||||
fake.capabilities.set({
|
||||
...DEFAULT_PLAYER_CAPABILITIES,
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
const setState = (overrides: Partial<PlayerControlsState>) => {
|
||||
fake.state.set({ ...createEmptyControlsState(), ...overrides });
|
||||
};
|
||||
|
||||
const queryButton = (label: string) =>
|
||||
fixture.nativeElement.querySelector(
|
||||
`[aria-label="${label}"]`
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
EMBEDDED_MPV: {
|
||||
PLAYER: {
|
||||
ENTER_PICTURE_IN_PICTURE: 'Enter picture-in-picture',
|
||||
EXIT_PICTURE_IN_PICTURE: 'Exit picture-in-picture',
|
||||
ENTER_FULLSCREEN: 'Enter fullscreen',
|
||||
},
|
||||
},
|
||||
});
|
||||
translate.use('en');
|
||||
|
||||
fake = createFakeController();
|
||||
fixture = TestBed.createComponent(PlayerControlsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('controller', fake.controller);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('omits the action when picture-in-picture is not supported', () => {
|
||||
expect(queryButton('Enter picture-in-picture')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a disabled inactive action when picture-in-picture is unavailable', () => {
|
||||
setCapabilities({ pictureInPicture: true });
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = queryButton('Enter picture-in-picture');
|
||||
expect(button).not.toBeNull();
|
||||
expect(button?.disabled).toBe(true);
|
||||
expect(button?.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('renders the active state with its exit label and alternate icon', () => {
|
||||
setCapabilities({ pictureInPicture: true });
|
||||
setState({
|
||||
canPictureInPicture: true,
|
||||
pictureInPictureActive: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = queryButton('Exit picture-in-picture');
|
||||
expect(button).not.toBeNull();
|
||||
expect(button?.disabled).toBe(false);
|
||||
expect(button?.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(button?.querySelector('mat-icon')?.textContent?.trim()).toBe(
|
||||
'picture_in_picture_alt'
|
||||
);
|
||||
button?.click();
|
||||
expect(fake.commands.togglePictureInPicture).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dispatches one toggle command from an available inactive action', () => {
|
||||
setCapabilities({ pictureInPicture: true });
|
||||
setState({ canPictureInPicture: true });
|
||||
fixture.detectChanges();
|
||||
const reveal = jest.spyOn(component, 'reveal');
|
||||
|
||||
queryButton('Enter picture-in-picture')?.click();
|
||||
|
||||
expect(reveal).toHaveBeenCalledTimes(1);
|
||||
expect(fake.commands.togglePictureInPicture).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('guards the component command when the capability is absent', () => {
|
||||
setState({ canPictureInPicture: true });
|
||||
const reveal = jest.spyOn(component, 'reveal');
|
||||
|
||||
component.togglePictureInPicture();
|
||||
|
||||
expect(reveal).toHaveBeenCalledTimes(1);
|
||||
expect(fake.commands.togglePictureInPicture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('guards the component command when the action is unavailable', () => {
|
||||
setCapabilities({ pictureInPicture: true });
|
||||
const reveal = jest.spyOn(component, 'reveal');
|
||||
|
||||
component.togglePictureInPicture();
|
||||
|
||||
expect(reveal).toHaveBeenCalledTimes(1);
|
||||
expect(fake.commands.togglePictureInPicture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('places picture-in-picture immediately before fullscreen', () => {
|
||||
setCapabilities({ pictureInPicture: true, fullscreen: true });
|
||||
setState({ canPictureInPicture: true });
|
||||
fixture.detectChanges();
|
||||
|
||||
const pictureInPicture = queryButton('Enter picture-in-picture');
|
||||
const fullscreen = queryButton('Enter fullscreen');
|
||||
expect(pictureInPicture).not.toBeNull();
|
||||
expect(fullscreen).not.toBeNull();
|
||||
expect(pictureInPicture?.nextElementSibling).toBe(fullscreen);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ function createFakeController() {
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
togglePictureInPicture: jest.fn(),
|
||||
};
|
||||
const controller: PlayerController = { capabilities, state, commands };
|
||||
return { controller, capabilities, state, commands };
|
||||
|
||||
+11
-16
@@ -30,6 +30,7 @@ function createFakeController() {
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
togglePictureInPicture: jest.fn(),
|
||||
};
|
||||
const controller: PlayerController = { capabilities, state, commands };
|
||||
return { controller, capabilities, state, commands };
|
||||
@@ -298,23 +299,17 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
|
||||
});
|
||||
|
||||
describe('keyboard shortcuts', () => {
|
||||
it('does not consume or toggle playback while loading', () => {
|
||||
setState({ status: 'loading' });
|
||||
fixture.detectChanges();
|
||||
it.each(['loading', 'error'] as const)(
|
||||
'does not consume or toggle playback while status is %s',
|
||||
(status) => {
|
||||
setState({ status });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(pressKey(' ')).toBe(false);
|
||||
expect(pressKey('k')).toBe(false);
|
||||
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not consume or toggle playback after an error', () => {
|
||||
setState({ status: 'error' });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(pressKey(' ')).toBe(false);
|
||||
expect(pressKey('k')).toBe(false);
|
||||
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(pressKey(' ')).toBe(false);
|
||||
expect(pressKey('k')).toBe(false);
|
||||
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('Escape closes an open menu', () => {
|
||||
component.toggleMenu('subtitle');
|
||||
|
||||
@@ -27,6 +27,7 @@ function createFakeController() {
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
togglePictureInPicture: jest.fn(),
|
||||
};
|
||||
const controller: PlayerController = {
|
||||
capabilities,
|
||||
|
||||
@@ -283,11 +283,9 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
event.preventDefault();
|
||||
this.adjustVolume(event.deltaY > 0 ? -0.05 : 0.05);
|
||||
}
|
||||
|
||||
onVolumeHoverEnter(): void {
|
||||
this.volume.hoverEnter();
|
||||
}
|
||||
|
||||
onVolumeHoverLeave(): void {
|
||||
this.volume.hoverLeave();
|
||||
}
|
||||
@@ -304,7 +302,6 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
this.menus.toggle(menu);
|
||||
this.reveal();
|
||||
}
|
||||
|
||||
toggleRecording(): void {
|
||||
if (!this.canRecord()) {
|
||||
return;
|
||||
@@ -313,6 +310,13 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
this.controller().commands.toggleRecording();
|
||||
}
|
||||
|
||||
togglePictureInPicture(): void {
|
||||
this.reveal();
|
||||
const state = this.state();
|
||||
if (this.capabilities().pictureInPicture && state.canPictureInPicture) {
|
||||
this.controller().commands.togglePictureInPicture();
|
||||
}
|
||||
}
|
||||
async toggleFullscreen(): Promise<void> {
|
||||
this.reveal();
|
||||
if (!this.canFullscreen()) {
|
||||
@@ -320,7 +324,6 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
}
|
||||
await this.fullscreen.toggle();
|
||||
}
|
||||
|
||||
private adjustVolume(delta: number): void {
|
||||
if (!this.capabilities().volume) {
|
||||
return;
|
||||
@@ -328,13 +331,11 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
this.volume.adjust(delta);
|
||||
this.reveal();
|
||||
}
|
||||
|
||||
private readTimelineValue(event: Event): number | null {
|
||||
return this.normalizeTimelineValue(
|
||||
Number((event.target as HTMLInputElement).value)
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeTimelineValue(value: number): number | null {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null;
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface PlayerControlsCapabilities {
|
||||
playbackSpeed: boolean;
|
||||
aspectRatio: boolean;
|
||||
recording: boolean;
|
||||
pictureInPicture: boolean;
|
||||
fullscreen: boolean;
|
||||
seriesNavigation: boolean;
|
||||
}
|
||||
@@ -61,6 +62,8 @@ export interface PlayerControlsState {
|
||||
aspectRatio: string;
|
||||
aspectPresets: ReadonlyArray<PlayerPreset<string>>;
|
||||
recording: PlayerRecordingState;
|
||||
pictureInPictureActive: boolean;
|
||||
canPictureInPicture: boolean;
|
||||
canPreviousEpisode: boolean;
|
||||
canNextEpisode: boolean;
|
||||
}
|
||||
@@ -76,6 +79,7 @@ export interface PlayerControlsCommands {
|
||||
setPlaybackSpeed(speed: number): void;
|
||||
setAspectRatio(value: string): void;
|
||||
toggleRecording(): void;
|
||||
togglePictureInPicture(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+38
-1
@@ -154,7 +154,9 @@ describe('WebVideoControlsAdapter (commands & edge branches)', () => {
|
||||
currentTime: 20,
|
||||
volume: 0.5,
|
||||
});
|
||||
adapter.attach(video);
|
||||
const getDuration = jest.fn(() => 100);
|
||||
adapter.attach(video, { getDuration });
|
||||
getDuration.mockClear();
|
||||
|
||||
adapter.commands.seekTo(NaN);
|
||||
adapter.commands.seekTo(Infinity);
|
||||
@@ -164,6 +166,7 @@ describe('WebVideoControlsAdapter (commands & edge branches)', () => {
|
||||
|
||||
expect(video.currentTime).toBe(20);
|
||||
expect(video.volume).toBe(0.5);
|
||||
expect(getDuration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows native media setter exceptions', () => {
|
||||
@@ -311,6 +314,40 @@ describe('WebVideoControlsAdapter (commands & edge branches)', () => {
|
||||
expect(adapter.state().durationSeconds).toBe(42);
|
||||
});
|
||||
|
||||
it('preserves the options receiver when reading corrected duration', () => {
|
||||
const options = {
|
||||
correctedDuration: 84,
|
||||
getDuration() {
|
||||
return this.correctedDuration;
|
||||
},
|
||||
};
|
||||
|
||||
adapter.attach(
|
||||
createVideo({ duration: Infinity, seekableLength: 1 }),
|
||||
options
|
||||
);
|
||||
|
||||
expect(adapter.state().durationSeconds).toBe(84);
|
||||
expect(adapter.state().isLive).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves the options receiver when reading live classification', () => {
|
||||
const options = {
|
||||
live: true,
|
||||
isLive() {
|
||||
return this.live;
|
||||
},
|
||||
};
|
||||
|
||||
adapter.attach(
|
||||
createVideo({ duration: 84, seekableLength: 1 }),
|
||||
options
|
||||
);
|
||||
|
||||
expect(adapter.state().isLive).toBe(true);
|
||||
expect(adapter.capabilities().seek).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes a zero duration to null', () => {
|
||||
adapter.attach(createVideo({ duration: 0 }), { isLive: () => false });
|
||||
expect(adapter.state().durationSeconds).toBeNull();
|
||||
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
import {
|
||||
PictureInPictureTestEnvironment,
|
||||
deferred,
|
||||
} from './picture-in-picture.spec-helpers';
|
||||
import { WebVideoControlsAdapter } from './web-video-controls.adapter';
|
||||
|
||||
const PICTURE_IN_PICTURE_WINDOW = {} as PictureInPictureWindow;
|
||||
|
||||
describe('WebVideoControlsAdapter Picture-in-Picture', () => {
|
||||
let adapter: WebVideoControlsAdapter;
|
||||
let environment: PictureInPictureTestEnvironment;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new WebVideoControlsAdapter();
|
||||
environment = new PictureInPictureTestEnvironment();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
adapter.detach();
|
||||
environment.restore();
|
||||
});
|
||||
|
||||
it('is unavailable when the document disables PiP', () => {
|
||||
environment.setEnabled(false);
|
||||
const video = installVideo();
|
||||
|
||||
adapter.attach(video);
|
||||
|
||||
expectPiP(false, false);
|
||||
});
|
||||
|
||||
it('is unavailable when the element disables PiP', () => {
|
||||
const video = installVideo({ disablePictureInPicture: true });
|
||||
|
||||
adapter.attach(video);
|
||||
|
||||
expectPiP(false, false);
|
||||
});
|
||||
|
||||
it('is unavailable without requestPictureInPicture', () => {
|
||||
const video = installVideo({ request: null });
|
||||
|
||||
adapter.attach(video);
|
||||
|
||||
expectPiP(false, false);
|
||||
});
|
||||
|
||||
it('is unavailable without exitPictureInPicture', () => {
|
||||
environment.setExitAvailable(false);
|
||||
const video = installVideo();
|
||||
|
||||
adapter.attach(video);
|
||||
|
||||
expectPiP(false, false);
|
||||
});
|
||||
|
||||
it('advertises support before metadata and enables entry after metadata', () => {
|
||||
const video = installVideo({ readyState: 0 });
|
||||
adapter.attach(video);
|
||||
|
||||
expectPiP(true, false);
|
||||
|
||||
environment.setReadyState(video, 1);
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
|
||||
expectPiP(true, true);
|
||||
});
|
||||
|
||||
it('requests entry synchronously and serializes the pending request', async () => {
|
||||
const pending = deferred<PictureInPictureWindow>();
|
||||
const video = installVideo({ request: () => pending.promise });
|
||||
const request = video.requestPictureInPicture as jest.MockedFunction<
|
||||
() => Promise<PictureInPictureWindow>
|
||||
>;
|
||||
adapter.attach(video);
|
||||
|
||||
adapter.commands.togglePictureInPicture();
|
||||
adapter.commands.togglePictureInPicture();
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request.mock.contexts[0]).toBe(video);
|
||||
expect(adapter.state().canPictureInPicture).toBe(false);
|
||||
|
||||
pending.resolve(PICTURE_IN_PICTURE_WINDOW);
|
||||
await flushPromises();
|
||||
expect(adapter.state().canPictureInPicture).toBe(true);
|
||||
});
|
||||
|
||||
it('uses browser events, not command optimism, as active authority', async () => {
|
||||
const pending = deferred<PictureInPictureWindow>();
|
||||
const video = installVideo({ request: () => pending.promise });
|
||||
adapter.attach(video);
|
||||
|
||||
adapter.commands.togglePictureInPicture();
|
||||
expect(adapter.state().pictureInPictureActive).toBe(false);
|
||||
|
||||
environment.setActive(video);
|
||||
expect(adapter.state().pictureInPictureActive).toBe(true);
|
||||
environment.setActive(null);
|
||||
expect(adapter.state().pictureInPictureActive).toBe(false);
|
||||
|
||||
pending.resolve(PICTURE_IN_PICTURE_WINDOW);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it('keeps owned exit available after entry support changes', () => {
|
||||
const video = installVideo();
|
||||
adapter.attach(video);
|
||||
environment.setActive(video);
|
||||
environment.setEnabled(false);
|
||||
environment.installVideo(video, {
|
||||
disablePictureInPicture: true,
|
||||
request: null,
|
||||
});
|
||||
adapter.refresh();
|
||||
|
||||
expectPiP(true, true);
|
||||
adapter.commands.togglePictureInPicture();
|
||||
|
||||
expect(environment.exit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('serializes a pending exit', async () => {
|
||||
const pending = deferred<void>();
|
||||
environment.exit.mockImplementation(() => pending.promise);
|
||||
const video = installVideo();
|
||||
adapter.attach(video);
|
||||
environment.setActive(video);
|
||||
|
||||
adapter.commands.togglePictureInPicture();
|
||||
adapter.commands.togglePictureInPicture();
|
||||
|
||||
expect(environment.exit).toHaveBeenCalledTimes(1);
|
||||
expect(environment.exit.mock.contexts[0]).toBe(video.ownerDocument);
|
||||
expect(adapter.state().canPictureInPicture).toBe(false);
|
||||
|
||||
pending.resolve(undefined);
|
||||
await flushPromises();
|
||||
expect(adapter.state().canPictureInPicture).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when a PiP API property throws', () => {
|
||||
const video = installVideo();
|
||||
Object.defineProperty(video, 'disablePictureInPicture', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
throw new Error('PiP state unavailable');
|
||||
},
|
||||
});
|
||||
|
||||
adapter.attach(video);
|
||||
|
||||
expectPiP(false, false);
|
||||
expect(adapter.state().pictureInPictureActive).toBe(false);
|
||||
});
|
||||
|
||||
it('contains a synchronous request throw and recovers the action', () => {
|
||||
const video = installVideo({
|
||||
request: () => {
|
||||
throw new Error('request failed');
|
||||
},
|
||||
});
|
||||
adapter.attach(video);
|
||||
|
||||
expect(() => adapter.commands.togglePictureInPicture()).not.toThrow();
|
||||
expect(adapter.state().canPictureInPicture).toBe(true);
|
||||
});
|
||||
|
||||
it('contains a rejected request and recovers the action', async () => {
|
||||
const video = installVideo({
|
||||
request: () => Promise.reject(new Error('request failed')),
|
||||
});
|
||||
adapter.attach(video);
|
||||
|
||||
adapter.commands.togglePictureInPicture();
|
||||
await flushPromises();
|
||||
|
||||
expect(adapter.state().canPictureInPicture).toBe(true);
|
||||
});
|
||||
|
||||
it('contains a synchronous exit throw and recovers the action', () => {
|
||||
environment.exit.mockImplementation(() => {
|
||||
throw new Error('exit failed');
|
||||
});
|
||||
const video = installVideo();
|
||||
adapter.attach(video);
|
||||
environment.setActive(video);
|
||||
|
||||
expect(() => adapter.commands.togglePictureInPicture()).not.toThrow();
|
||||
expect(adapter.state().canPictureInPicture).toBe(true);
|
||||
});
|
||||
|
||||
it('contains a rejected exit and recovers the action', async () => {
|
||||
environment.exit.mockImplementation(() =>
|
||||
Promise.reject(new Error('exit failed'))
|
||||
);
|
||||
const video = installVideo();
|
||||
adapter.attach(video);
|
||||
environment.setActive(video);
|
||||
|
||||
adapter.commands.togglePictureInPicture();
|
||||
await flushPromises();
|
||||
|
||||
expect(adapter.state().canPictureInPicture).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['loadstart', 'emptied', 'loadedmetadata', 'refresh'] as const)(
|
||||
'preserves owned PiP across same-target %s invalidation',
|
||||
(eventName) => {
|
||||
const video = installVideo();
|
||||
adapter.attach(video);
|
||||
environment.setActive(video);
|
||||
|
||||
if (eventName === 'refresh') {
|
||||
adapter.refresh();
|
||||
} else {
|
||||
video.dispatchEvent(new Event(eventName));
|
||||
}
|
||||
|
||||
expect(environment.exit).not.toHaveBeenCalled();
|
||||
expect(adapter.state().pictureInPictureActive).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('exits exactly the owned old target once on replacement', () => {
|
||||
const owners: Element[] = [];
|
||||
environment.exit.mockImplementation(async () => {
|
||||
const owner = document.pictureInPictureElement;
|
||||
if (owner) {
|
||||
owners.push(owner);
|
||||
}
|
||||
environment.setActive(null);
|
||||
});
|
||||
const oldVideo = installVideo();
|
||||
const replacement = installVideo();
|
||||
adapter.attach(oldVideo);
|
||||
environment.setActive(oldVideo);
|
||||
|
||||
adapter.attach(replacement);
|
||||
|
||||
expect(environment.exit).toHaveBeenCalledTimes(1);
|
||||
expect(owners).toEqual([oldVideo]);
|
||||
expect(adapter.state().pictureInPictureActive).toBe(false);
|
||||
});
|
||||
|
||||
it('never exits an unrelated PiP owner', () => {
|
||||
const video = installVideo();
|
||||
adapter.attach(video);
|
||||
environment.setActive(document.createElement('video'));
|
||||
|
||||
adapter.detach();
|
||||
|
||||
expect(environment.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('makes repeated detach idempotent for owned PiP cleanup', () => {
|
||||
const video = installVideo();
|
||||
adapter.attach(video);
|
||||
environment.setActive(video);
|
||||
|
||||
adapter.detach();
|
||||
adapter.detach();
|
||||
adapter.detach();
|
||||
|
||||
expect(environment.exit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores stale old-target events after replacement', () => {
|
||||
const oldVideo = installVideo();
|
||||
const replacement = installVideo();
|
||||
const refresh = jest.spyOn(adapter, 'refresh');
|
||||
adapter.attach(oldVideo);
|
||||
adapter.attach(replacement);
|
||||
refresh.mockClear();
|
||||
|
||||
oldVideo.dispatchEvent(new Event('enterpictureinpicture'));
|
||||
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(adapter.state().pictureInPictureActive).toBe(false);
|
||||
});
|
||||
|
||||
it('cleans up a stale successful enter only when the old target owns PiP', async () => {
|
||||
const oldPending = deferred<PictureInPictureWindow>();
|
||||
const replacementPending = deferred<PictureInPictureWindow>();
|
||||
const owners: Element[] = [];
|
||||
environment.exit.mockImplementation(async () => {
|
||||
const owner = document.pictureInPictureElement;
|
||||
if (owner) {
|
||||
owners.push(owner);
|
||||
}
|
||||
environment.setActive(null);
|
||||
});
|
||||
const oldVideo = installVideo({ request: () => oldPending.promise });
|
||||
const replacement = installVideo({
|
||||
request: () => replacementPending.promise,
|
||||
});
|
||||
adapter.attach(oldVideo);
|
||||
adapter.commands.togglePictureInPicture();
|
||||
adapter.attach(replacement);
|
||||
adapter.commands.togglePictureInPicture();
|
||||
|
||||
environment.setActive(oldVideo);
|
||||
oldPending.resolve(PICTURE_IN_PICTURE_WINDOW);
|
||||
await flushPromises();
|
||||
|
||||
expect(environment.exit).toHaveBeenCalledTimes(1);
|
||||
expect(owners).toEqual([oldVideo]);
|
||||
expect(adapter.state().canPictureInPicture).toBe(false);
|
||||
|
||||
replacementPending.resolve(PICTURE_IN_PICTURE_WINDOW);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it('does not let a stale failure clear a newer pending operation', async () => {
|
||||
const oldPending = deferred<PictureInPictureWindow>();
|
||||
const replacementPending = deferred<PictureInPictureWindow>();
|
||||
const oldVideo = installVideo({ request: () => oldPending.promise });
|
||||
const replacement = installVideo({
|
||||
request: () => replacementPending.promise,
|
||||
});
|
||||
adapter.attach(oldVideo);
|
||||
adapter.commands.togglePictureInPicture();
|
||||
adapter.attach(replacement);
|
||||
adapter.commands.togglePictureInPicture();
|
||||
|
||||
oldPending.reject(new Error('stale request failed'));
|
||||
await flushPromises();
|
||||
|
||||
expect(environment.exit).not.toHaveBeenCalled();
|
||||
expect(adapter.state().canPictureInPicture).toBe(false);
|
||||
|
||||
replacementPending.resolve(PICTURE_IN_PICTURE_WINDOW);
|
||||
await flushPromises();
|
||||
expect(adapter.state().canPictureInPicture).toBe(true);
|
||||
});
|
||||
|
||||
it('reads PiP APIs from the video ownerDocument', () => {
|
||||
environment.setEnabled(false);
|
||||
const foreignDocument =
|
||||
document.implementation.createHTMLDocument('PiP');
|
||||
const foreignEnvironment = new PictureInPictureTestEnvironment(
|
||||
foreignDocument
|
||||
);
|
||||
const video = foreignDocument.createElement('video');
|
||||
foreignEnvironment.installVideo(video);
|
||||
|
||||
try {
|
||||
adapter.attach(video);
|
||||
expectPiP(true, true);
|
||||
foreignEnvironment.setActive(video);
|
||||
|
||||
adapter.commands.togglePictureInPicture();
|
||||
|
||||
expect(foreignEnvironment.exit).toHaveBeenCalledTimes(1);
|
||||
expect(foreignEnvironment.exit.mock.contexts[0]).toBe(
|
||||
foreignDocument
|
||||
);
|
||||
expect(environment.exit).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
adapter.detach();
|
||||
foreignEnvironment.restore();
|
||||
}
|
||||
});
|
||||
|
||||
function installVideo(
|
||||
options: Parameters<
|
||||
PictureInPictureTestEnvironment['installVideo']
|
||||
>[1] = {}
|
||||
): HTMLVideoElement {
|
||||
const video = document.createElement('video');
|
||||
environment.installVideo(video, options);
|
||||
return video;
|
||||
}
|
||||
|
||||
function expectPiP(supported: boolean, canToggle: boolean): void {
|
||||
expect(adapter.capabilities().pictureInPicture).toBe(supported);
|
||||
expect(adapter.state().canPictureInPicture).toBe(canToggle);
|
||||
}
|
||||
});
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
@@ -10,76 +10,62 @@ import type {
|
||||
PlayerControlsCapabilities,
|
||||
PlayerControlsCommands,
|
||||
PlayerControlsState,
|
||||
PlayerStatus,
|
||||
PlayerTrack,
|
||||
} from './player-controls.model';
|
||||
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
|
||||
import {
|
||||
applyTrackSelection,
|
||||
applyVideoCurrentTime,
|
||||
applyVideoSpeed,
|
||||
applyVideoVolume,
|
||||
hasSeekableRange,
|
||||
isVideoStalled,
|
||||
mapVideoStatus,
|
||||
normalizeVideoDuration,
|
||||
readVideoDuration,
|
||||
readVideoIsLive,
|
||||
readVideoVolume,
|
||||
toggleVideoPlay,
|
||||
type WebVideoMetadataOptions,
|
||||
WEB_VIDEO_EVENTS,
|
||||
} from './web-video-controls.media-helpers';
|
||||
import { WebVideoPictureInPictureController } from './web-video-picture-in-picture.controller';
|
||||
|
||||
/**
|
||||
* Engine-agnostic accessors a web engine injects so the adapter can read/select
|
||||
* audio & subtitle tracks without importing hls.js/videojs/artplayer.
|
||||
*/
|
||||
export interface WebVideoControlsOptions {
|
||||
export interface WebVideoControlsOptions extends WebVideoMetadataOptions {
|
||||
getAudioTracks?: () => PlayerTrack[];
|
||||
setAudioTrack?: (id: number) => void | Promise<void>;
|
||||
getSubtitleTracks?: () => PlayerTrack[];
|
||||
setSubtitleTrack?: (id: number) => void | Promise<void>;
|
||||
isLive?: () => boolean;
|
||||
/**
|
||||
* Optional corrected duration source. Some engines (e.g. Video.js mpegts
|
||||
* raw-TS VOD) report the real duration on the player, not on the `<video>`
|
||||
* element — whose `duration` stays `Infinity` and would be misread as live.
|
||||
* Every returned value except `NaN` is authoritative; `NaN` falls back to
|
||||
* `video.duration`.
|
||||
*/
|
||||
getDuration?: () => number;
|
||||
}
|
||||
|
||||
/** readyState below which an actively-playing video is still buffering. */
|
||||
const HAVE_FUTURE_DATA = 3;
|
||||
const NETWORK_EMPTY = 0;
|
||||
|
||||
interface WebVideoControlsContext {
|
||||
seriesNavigation: Signal<SeriesPlaybackNavigation | null>;
|
||||
}
|
||||
|
||||
const VIDEO_EVENTS = [
|
||||
'loadstart',
|
||||
'emptied',
|
||||
'progress',
|
||||
'stalled',
|
||||
'seeking',
|
||||
'seeked',
|
||||
'play',
|
||||
'pause',
|
||||
'timeupdate',
|
||||
'durationchange',
|
||||
'volumechange',
|
||||
'ratechange',
|
||||
'loadedmetadata',
|
||||
'loadeddata',
|
||||
'canplay',
|
||||
'ended',
|
||||
'waiting',
|
||||
'playing',
|
||||
'error',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Can bridge any `<video>`-backed web engine onto the generic
|
||||
* {@link PlayerController} contract consumed by `app-player-controls`. It binds
|
||||
* purely to DOM/video APIs (works in PWA — no window.electron). Track access is
|
||||
* injected via {@link WebVideoControlsOptions} to keep it engine-agnostic.
|
||||
* Existing web players do not attach this adapter in #1148.
|
||||
* {@link PlayerController} contract using only DOM/video APIs.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WebVideoControlsAdapter implements PlayerController {
|
||||
private video: HTMLVideoElement | null = null;
|
||||
private opts: WebVideoControlsOptions = {};
|
||||
private detachFn: (() => void) | null = null;
|
||||
private bindingGeneration = 0;
|
||||
|
||||
/** Bumped whenever DOM or engine-specific state must be re-read. */
|
||||
private readonly tick = signal(0);
|
||||
private readonly pictureInPicture = new WebVideoPictureInPictureController(
|
||||
() => ({
|
||||
generation: this.bindingGeneration,
|
||||
video: this.video,
|
||||
}),
|
||||
() => this.refresh()
|
||||
);
|
||||
/**
|
||||
* Holds the host-supplied series-navigation signal reactively. Storing the
|
||||
* signal itself (rather than a one-time snapshot of its value) means updates
|
||||
@@ -104,7 +90,8 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
const hasSubtitles =
|
||||
typeof this.opts.setSubtitleTrack === 'function' &&
|
||||
(this.opts.getSubtitleTracks?.().length ?? 0) > 0;
|
||||
const isLive = this.isLive();
|
||||
const isLive = readVideoIsLive(this.video, this.opts);
|
||||
const pictureInPicture = this.pictureInPicture.snapshot();
|
||||
return {
|
||||
...DEFAULT_PLAYER_CAPABILITIES,
|
||||
seek: !isLive,
|
||||
@@ -115,6 +102,7 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
subtitles: hasSubtitles,
|
||||
aspectRatio: false,
|
||||
recording: false,
|
||||
pictureInPicture: pictureInPicture.supported,
|
||||
seriesNavigation: !isLive && this.seriesNavigation() !== null,
|
||||
};
|
||||
});
|
||||
@@ -126,23 +114,27 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
return createEmptyControlsState();
|
||||
}
|
||||
|
||||
const isLive = this.isLive();
|
||||
const duration = this.normalizedDuration(isLive);
|
||||
const isLive = readVideoIsLive(video, this.opts);
|
||||
const duration = normalizeVideoDuration(
|
||||
readVideoDuration(video, this.opts),
|
||||
isLive
|
||||
);
|
||||
const seriesNav = this.seriesNavigation();
|
||||
const seriesNavCapable = !isLive && seriesNav !== null;
|
||||
|
||||
const audioTracks = this.opts.getAudioTracks?.() ?? [];
|
||||
const subtitleTracks = this.opts.getSubtitleTracks?.() ?? [];
|
||||
const pictureInPicture = this.pictureInPicture.snapshot();
|
||||
|
||||
return {
|
||||
status: this.mapStatus(video),
|
||||
status: mapVideoStatus(video),
|
||||
statusMessage: '',
|
||||
stalled: this.isStalled(video),
|
||||
stalled: isVideoStalled(video),
|
||||
positionSeconds: Math.max(0, video?.currentTime ?? 0),
|
||||
durationSeconds: duration,
|
||||
isLive,
|
||||
canSeek: !isLive && (duration ?? 0) > 0 && this.hasSeekable(video),
|
||||
volume: this.readVolume(video),
|
||||
canSeek: !isLive && (duration ?? 0) > 0 && hasSeekableRange(video),
|
||||
volume: readVideoVolume(video),
|
||||
audioTracks,
|
||||
subtitleTracks,
|
||||
subtitlesEnabled: subtitleTracks.some((track) => track.selected),
|
||||
@@ -151,6 +143,8 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
aspectRatio: 'no',
|
||||
aspectPresets: DEFAULT_ASPECT_PRESETS,
|
||||
recording: { active: false, elapsedSeconds: 0, message: null },
|
||||
pictureInPictureActive: pictureInPicture.active,
|
||||
canPictureInPicture: pictureInPicture.canToggle,
|
||||
canPreviousEpisode:
|
||||
seriesNavCapable && seriesNav?.canPrevious === true,
|
||||
canNextEpisode: seriesNavCapable && seriesNav?.canNext === true,
|
||||
@@ -158,32 +152,49 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
});
|
||||
|
||||
readonly commands: PlayerControlsCommands = {
|
||||
togglePlay: () => this.togglePlay(),
|
||||
seekTo: (seconds) => this.applyCurrentTime(seconds),
|
||||
togglePlay: () => toggleVideoPlay(this.video),
|
||||
seekTo: (seconds) =>
|
||||
applyVideoCurrentTime(this.video, seconds, () =>
|
||||
readVideoDuration(this.video, this.opts)
|
||||
),
|
||||
seekBy: (delta) =>
|
||||
this.applyCurrentTime((this.video?.currentTime ?? 0) + delta),
|
||||
setVolume: (value) => this.applyVolume(value),
|
||||
applyVideoCurrentTime(
|
||||
this.video,
|
||||
(this.video?.currentTime ?? 0) + delta,
|
||||
() => readVideoDuration(this.video, this.opts)
|
||||
),
|
||||
setVolume: (value) => applyVideoVolume(this.video, value),
|
||||
setAudioTrack: (id) =>
|
||||
this.applyTrackSelection(this.opts.setAudioTrack, id),
|
||||
applyTrackSelection(this.opts.setAudioTrack, id, () =>
|
||||
this.refresh()
|
||||
),
|
||||
setSubtitleTrack: (id) =>
|
||||
this.applyTrackSelection(this.opts.setSubtitleTrack, id),
|
||||
setPlaybackSpeed: (speed) => this.applySpeed(speed),
|
||||
applyTrackSelection(this.opts.setSubtitleTrack, id, () =>
|
||||
this.refresh()
|
||||
),
|
||||
setPlaybackSpeed: (speed) => applyVideoSpeed(this.video, speed),
|
||||
setAspectRatio: () => undefined,
|
||||
toggleRecording: () => undefined,
|
||||
togglePictureInPicture: () => this.pictureInPicture.toggle(),
|
||||
};
|
||||
|
||||
/** Binds to a `<video>` element and starts maintaining the state signal. */
|
||||
attach(video: HTMLVideoElement, opts: WebVideoControlsOptions = {}): void {
|
||||
this.detach();
|
||||
const generation = this.bindingGeneration;
|
||||
this.video = video;
|
||||
this.opts = opts;
|
||||
|
||||
const onEvent = () => this.refresh();
|
||||
for (const eventName of VIDEO_EVENTS) {
|
||||
const onEvent = () => {
|
||||
if (this.video === video && this.bindingGeneration === generation) {
|
||||
this.refresh();
|
||||
}
|
||||
};
|
||||
for (const eventName of WEB_VIDEO_EVENTS) {
|
||||
video.addEventListener(eventName, onEvent);
|
||||
}
|
||||
this.detachFn = () => {
|
||||
for (const eventName of VIDEO_EVENTS) {
|
||||
for (const eventName of WEB_VIDEO_EVENTS) {
|
||||
video.removeEventListener(eventName, onEvent);
|
||||
}
|
||||
};
|
||||
@@ -202,10 +213,13 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
|
||||
/** Removes listeners and clears the bound element. Idempotent. */
|
||||
detach(): void {
|
||||
const previousVideo = this.video;
|
||||
this.bindingGeneration += 1;
|
||||
this.detachFn?.();
|
||||
this.detachFn = null;
|
||||
this.video = null;
|
||||
this.opts = {};
|
||||
this.pictureInPicture.release(previousVideo);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
@@ -213,154 +227,4 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
setContext(context: WebVideoControlsContext): void {
|
||||
this.seriesNavigationSource.set(context.seriesNavigation);
|
||||
}
|
||||
|
||||
private togglePlay(): void {
|
||||
const video = this.video;
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
if (video.paused || video.ended) {
|
||||
void video.play().catch(() => undefined);
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}
|
||||
|
||||
private applyCurrentTime(seconds: number): void {
|
||||
const video = this.video;
|
||||
if (!video || !Number.isFinite(seconds)) {
|
||||
return;
|
||||
}
|
||||
const duration = this.readDuration();
|
||||
const upperBound = Number.isFinite(duration) ? duration : seconds;
|
||||
try {
|
||||
video.currentTime = Math.max(0, Math.min(seconds, upperBound));
|
||||
} catch {
|
||||
// Some media implementations reject writes while changing source.
|
||||
}
|
||||
}
|
||||
|
||||
private applyVolume(value: number): void {
|
||||
const video = this.video;
|
||||
if (!video || !Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
const clamped = Math.max(0, Math.min(1, value));
|
||||
try {
|
||||
video.volume = clamped;
|
||||
video.muted = clamped <= 0;
|
||||
} catch {
|
||||
// Ignore transient native media setter failures.
|
||||
}
|
||||
}
|
||||
|
||||
private applySpeed(speed: number): void {
|
||||
if (this.video && Number.isFinite(speed) && speed > 0) {
|
||||
try {
|
||||
this.video.playbackRate = speed;
|
||||
} catch {
|
||||
// Ignore transient native media setter failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyTrackSelection(
|
||||
setter: ((id: number) => void | Promise<void>) | undefined,
|
||||
id: number
|
||||
): void {
|
||||
if (!setter) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = setter(id);
|
||||
if (result) {
|
||||
void result.then(
|
||||
() => this.refresh(),
|
||||
() => undefined
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.refresh();
|
||||
} catch {
|
||||
// Engine adapters may reject selection while changing source.
|
||||
}
|
||||
}
|
||||
|
||||
private isLive(): boolean {
|
||||
if (this.opts.isLive) {
|
||||
return this.opts.isLive();
|
||||
}
|
||||
return this.readDuration() === Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the corrected duration via {@link WebVideoControlsOptions.getDuration}
|
||||
* when supplied. `NaN` means "not known yet" and falls back to the
|
||||
* `<video>` element's own `duration`; positive Infinity remains a live
|
||||
* classification signal.
|
||||
*/
|
||||
private readDuration(): number {
|
||||
if (this.opts.getDuration) {
|
||||
const duration = this.opts.getDuration();
|
||||
if (!Number.isNaN(duration)) {
|
||||
return duration;
|
||||
}
|
||||
}
|
||||
return this.video?.duration ?? NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* A video is only "stalled" when it is actively trying to play and lacks
|
||||
* future data; a normally paused VOD must not show a spinner.
|
||||
*/
|
||||
private isStalled(video: HTMLVideoElement | null): boolean {
|
||||
if (!video || video.readyState === undefined) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
!video.paused && !video.ended && video.readyState < HAVE_FUTURE_DATA
|
||||
);
|
||||
}
|
||||
|
||||
private normalizedDuration(isLive: boolean): number | null {
|
||||
const duration = this.readDuration();
|
||||
if (isLive || !Number.isFinite(duration) || duration <= 0) {
|
||||
return null;
|
||||
}
|
||||
return duration;
|
||||
}
|
||||
|
||||
private hasSeekable(video: HTMLVideoElement | null): boolean {
|
||||
try {
|
||||
return (video?.seekable?.length ?? 0) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private readVolume(video: HTMLVideoElement | null): number {
|
||||
if (!video) {
|
||||
return 1;
|
||||
}
|
||||
return video.muted ? 0 : video.volume;
|
||||
}
|
||||
|
||||
private mapStatus(video: HTMLVideoElement | null): PlayerStatus {
|
||||
if (!video) {
|
||||
return 'idle';
|
||||
}
|
||||
if (video.error) {
|
||||
return 'error';
|
||||
}
|
||||
if (video.ended) {
|
||||
return 'ended';
|
||||
}
|
||||
if (video.networkState === NETWORK_EMPTY) {
|
||||
return 'idle';
|
||||
}
|
||||
if (video.paused) {
|
||||
return 'paused';
|
||||
}
|
||||
return video.readyState < 3 ? 'loading' : 'playing';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { PlayerStatus } from './player-controls.model';
|
||||
|
||||
const HAVE_FUTURE_DATA = 3;
|
||||
const NETWORK_EMPTY = 0;
|
||||
|
||||
export interface WebVideoMetadataOptions {
|
||||
isLive?: () => boolean;
|
||||
/**
|
||||
* Optional corrected duration source. Some engines (e.g. Video.js mpegts
|
||||
* raw-TS VOD) report the real duration on the player, not on the `<video>`
|
||||
* element — whose `duration` stays `Infinity` and would be misread as live.
|
||||
* Every returned value except `NaN` is authoritative; `NaN` falls back to
|
||||
* `video.duration`.
|
||||
*/
|
||||
getDuration?: () => number;
|
||||
}
|
||||
|
||||
export const WEB_VIDEO_EVENTS = [
|
||||
'loadstart',
|
||||
'emptied',
|
||||
'progress',
|
||||
'stalled',
|
||||
'seeking',
|
||||
'seeked',
|
||||
'play',
|
||||
'pause',
|
||||
'timeupdate',
|
||||
'durationchange',
|
||||
'volumechange',
|
||||
'ratechange',
|
||||
'loadedmetadata',
|
||||
'loadeddata',
|
||||
'canplay',
|
||||
'ended',
|
||||
'waiting',
|
||||
'playing',
|
||||
'error',
|
||||
'enterpictureinpicture',
|
||||
'leavepictureinpicture',
|
||||
] as const;
|
||||
|
||||
export function toggleVideoPlay(video: HTMLVideoElement | null): void {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
if (video.paused || video.ended) {
|
||||
void video.play().catch(() => undefined);
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}
|
||||
|
||||
export function applyVideoCurrentTime(
|
||||
video: HTMLVideoElement | null,
|
||||
seconds: number,
|
||||
getDuration: () => number
|
||||
): void {
|
||||
if (!video || !Number.isFinite(seconds)) {
|
||||
return;
|
||||
}
|
||||
const duration = getDuration();
|
||||
const upperBound = Number.isFinite(duration) ? duration : seconds;
|
||||
try {
|
||||
video.currentTime = Math.max(0, Math.min(seconds, upperBound));
|
||||
} catch {
|
||||
// Some media implementations reject writes while changing source.
|
||||
}
|
||||
}
|
||||
|
||||
export function applyVideoVolume(
|
||||
video: HTMLVideoElement | null,
|
||||
value: number
|
||||
): void {
|
||||
if (!video || !Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
const clamped = Math.max(0, Math.min(1, value));
|
||||
try {
|
||||
video.volume = clamped;
|
||||
video.muted = clamped <= 0;
|
||||
} catch {
|
||||
// Ignore transient native media setter failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function applyVideoSpeed(
|
||||
video: HTMLVideoElement | null,
|
||||
speed: number
|
||||
): void {
|
||||
if (video && Number.isFinite(speed) && speed > 0) {
|
||||
try {
|
||||
video.playbackRate = speed;
|
||||
} catch {
|
||||
// Ignore transient native media setter failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTrackSelection(
|
||||
setter: ((id: number) => void | Promise<void>) | undefined,
|
||||
id: number,
|
||||
refresh: () => void
|
||||
): void {
|
||||
if (!setter) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = setter(id);
|
||||
if (result) {
|
||||
void result.then(refresh, () => undefined);
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
} catch {
|
||||
// Engine adapters may reject selection while changing source.
|
||||
}
|
||||
}
|
||||
|
||||
export function isVideoStalled(video: HTMLVideoElement | null): boolean {
|
||||
if (!video || video.readyState === undefined) {
|
||||
return false;
|
||||
}
|
||||
return !video.paused && !video.ended && video.readyState < HAVE_FUTURE_DATA;
|
||||
}
|
||||
|
||||
export function normalizeVideoDuration(
|
||||
duration: number,
|
||||
isLive: boolean
|
||||
): number | null {
|
||||
if (isLive || !Number.isFinite(duration) || duration <= 0) {
|
||||
return null;
|
||||
}
|
||||
return duration;
|
||||
}
|
||||
|
||||
export function hasSeekableRange(video: HTMLVideoElement | null): boolean {
|
||||
try {
|
||||
return (video?.seekable?.length ?? 0) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function readVideoVolume(video: HTMLVideoElement | null): number {
|
||||
if (!video) {
|
||||
return 1;
|
||||
}
|
||||
return video.muted ? 0 : video.volume;
|
||||
}
|
||||
|
||||
export function readVideoDuration(
|
||||
video: HTMLVideoElement | null,
|
||||
options: WebVideoMetadataOptions
|
||||
): number {
|
||||
if (options.getDuration) {
|
||||
const duration = options.getDuration();
|
||||
if (!Number.isNaN(duration)) {
|
||||
return duration;
|
||||
}
|
||||
}
|
||||
return video?.duration ?? NaN;
|
||||
}
|
||||
|
||||
export function readVideoIsLive(
|
||||
video: HTMLVideoElement | null,
|
||||
options: WebVideoMetadataOptions
|
||||
): boolean {
|
||||
return options.isLive
|
||||
? options.isLive()
|
||||
: readVideoDuration(video, options) === Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
export function mapVideoStatus(video: HTMLVideoElement | null): PlayerStatus {
|
||||
if (!video) {
|
||||
return 'idle';
|
||||
}
|
||||
if (video.error) {
|
||||
return 'error';
|
||||
}
|
||||
if (video.ended) {
|
||||
return 'ended';
|
||||
}
|
||||
if (video.networkState === NETWORK_EMPTY) {
|
||||
return 'idle';
|
||||
}
|
||||
if (video.paused) {
|
||||
return 'paused';
|
||||
}
|
||||
return video.readyState < HAVE_FUTURE_DATA ? 'loading' : 'playing';
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
const HAVE_METADATA = 1;
|
||||
const PICTURE_IN_PICTURE_ACTION = {
|
||||
ENTER: 'enter',
|
||||
EXIT: 'exit',
|
||||
} as const;
|
||||
|
||||
type PictureInPictureAction =
|
||||
(typeof PICTURE_IN_PICTURE_ACTION)[keyof typeof PICTURE_IN_PICTURE_ACTION];
|
||||
|
||||
interface PictureInPictureOperation {
|
||||
readonly action: PictureInPictureAction;
|
||||
readonly generation: number;
|
||||
readonly video: HTMLVideoElement;
|
||||
}
|
||||
|
||||
export interface WebVideoPictureInPictureBinding {
|
||||
readonly generation: number;
|
||||
readonly video: HTMLVideoElement | null;
|
||||
}
|
||||
|
||||
export interface WebVideoPictureInPictureSnapshot {
|
||||
readonly active: boolean;
|
||||
readonly canExit: boolean;
|
||||
readonly canRequest: boolean;
|
||||
readonly canToggle: boolean;
|
||||
readonly supported: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_PICTURE_IN_PICTURE_SNAPSHOT: WebVideoPictureInPictureSnapshot = {
|
||||
active: false,
|
||||
canExit: false,
|
||||
canRequest: false,
|
||||
canToggle: false,
|
||||
supported: false,
|
||||
};
|
||||
|
||||
export class WebVideoPictureInPictureController {
|
||||
private operation: PictureInPictureOperation | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly readBinding: () => WebVideoPictureInPictureBinding,
|
||||
private readonly refresh: () => void
|
||||
) {}
|
||||
|
||||
release(previousVideo: HTMLVideoElement | null): void {
|
||||
this.operation = null;
|
||||
if (previousVideo) {
|
||||
this.exitIfOwned(previousVideo);
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(): WebVideoPictureInPictureSnapshot {
|
||||
const video = this.readBinding().video;
|
||||
if (!video) {
|
||||
return EMPTY_PICTURE_IN_PICTURE_SNAPSHOT;
|
||||
}
|
||||
|
||||
return this.readSnapshot(video);
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
const binding = this.readBinding();
|
||||
const video = binding.video;
|
||||
if (!video || this.operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = this.readSnapshot(video);
|
||||
if (!snapshot.canToggle) {
|
||||
return;
|
||||
}
|
||||
if (snapshot.active && snapshot.canExit) {
|
||||
this.startOperation(PICTURE_IN_PICTURE_ACTION.EXIT, binding, () =>
|
||||
video.ownerDocument.exitPictureInPicture()
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (snapshot.canRequest) {
|
||||
this.startOperation(PICTURE_IN_PICTURE_ACTION.ENTER, binding, () =>
|
||||
video.requestPictureInPicture()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private readSnapshot(
|
||||
video: HTMLVideoElement
|
||||
): WebVideoPictureInPictureSnapshot {
|
||||
try {
|
||||
const ownerDocument = video.ownerDocument;
|
||||
const active = ownerDocument.pictureInPictureElement === video;
|
||||
const canExit =
|
||||
typeof ownerDocument.exitPictureInPicture === 'function';
|
||||
const canRequest =
|
||||
ownerDocument.pictureInPictureEnabled === true &&
|
||||
typeof video.requestPictureInPicture === 'function' &&
|
||||
canExit &&
|
||||
video.disablePictureInPicture !== true;
|
||||
return {
|
||||
active,
|
||||
canExit,
|
||||
canRequest,
|
||||
canToggle:
|
||||
this.operation === null &&
|
||||
((active && canExit) ||
|
||||
(canRequest && video.readyState >= HAVE_METADATA)),
|
||||
supported: canRequest || (active && canExit),
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_PICTURE_IN_PICTURE_SNAPSHOT;
|
||||
}
|
||||
}
|
||||
|
||||
private startOperation(
|
||||
action: PictureInPictureAction,
|
||||
binding: WebVideoPictureInPictureBinding,
|
||||
invoke: () => Promise<unknown>
|
||||
): void {
|
||||
const video = binding.video;
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
const operation: PictureInPictureOperation = {
|
||||
action,
|
||||
generation: binding.generation,
|
||||
video,
|
||||
};
|
||||
this.operation = operation;
|
||||
this.refresh();
|
||||
|
||||
let result: Promise<unknown>;
|
||||
try {
|
||||
result = invoke();
|
||||
} catch {
|
||||
this.settleOperation(operation, false);
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(result).then(
|
||||
() => this.settleOperation(operation, true),
|
||||
() => this.settleOperation(operation, false)
|
||||
);
|
||||
}
|
||||
|
||||
private settleOperation(
|
||||
operation: PictureInPictureOperation,
|
||||
succeeded: boolean
|
||||
): void {
|
||||
const binding = this.readBinding();
|
||||
const isCurrent =
|
||||
this.operation === operation &&
|
||||
binding.generation === operation.generation &&
|
||||
binding.video === operation.video;
|
||||
if (isCurrent) {
|
||||
this.operation = null;
|
||||
this.refresh();
|
||||
return;
|
||||
}
|
||||
if (succeeded && operation.action === PICTURE_IN_PICTURE_ACTION.ENTER) {
|
||||
this.exitIfOwned(operation.video);
|
||||
}
|
||||
}
|
||||
|
||||
private exitIfOwned(video: HTMLVideoElement): void {
|
||||
try {
|
||||
const ownerDocument = video.ownerDocument;
|
||||
if (
|
||||
ownerDocument.pictureInPictureElement !== video ||
|
||||
typeof ownerDocument.exitPictureInPicture !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const result = ownerDocument.exitPictureInPicture();
|
||||
void Promise.resolve(result).then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
} catch {
|
||||
// PiP teardown is best-effort during target replacement.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { PictureInPictureTestEnvironment } from '../player-controls/picture-in-picture.spec-helpers';
|
||||
import { WEB_PLAYER_SHARED_CONTROLS } from '../player-controls/web-player-controls.flag';
|
||||
import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component';
|
||||
import type { VideoJsPlayer } from './vjs-player.types';
|
||||
@@ -122,6 +123,37 @@ describe('VjsPlayerComponent reset lifecycle', () => {
|
||||
).toHaveBeenCalledWith(latestVideo);
|
||||
});
|
||||
|
||||
it('exits owned PiP when playerreset replaces the Tech video', () => {
|
||||
const environment = new PictureInPictureTestEnvironment();
|
||||
try {
|
||||
const initialVideo = playerHarness.currentVideo;
|
||||
environment.installVideo(initialVideo);
|
||||
fixture.componentRef.setInput('options', {
|
||||
sources: [{ src: 'https://example.test/movie.mp4' }],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
playerHarness.ready();
|
||||
environment.setActive(initialVideo);
|
||||
|
||||
const replacementVideo = document.createElement('video');
|
||||
environment.installVideo(replacementVideo);
|
||||
playerHarness.currentVideo = replacementVideo;
|
||||
playerHarness.emit('playerreset');
|
||||
|
||||
expect(environment.exit).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
fixture.componentInstance.controlsAdapter.state()
|
||||
.pictureInPictureActive
|
||||
).toBe(false);
|
||||
expect(
|
||||
fixture.componentInstance.controlsAdapter.capabilities()
|
||||
.pictureInPicture
|
||||
).toBe(true);
|
||||
} finally {
|
||||
environment.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('never calls Video.js reset until active playback has paused', () => {
|
||||
fixture.componentRef.setInput('options', {
|
||||
sources: [{ src: 'https://example.test/movie.mp4' }],
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec-fixtures.ts"
|
||||
"src/**/*.spec-fixtures.ts",
|
||||
"src/**/*.spec-helpers.ts"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user