diff --git a/AGENTS.md b/AGENTS.md index 9119750a3..a8069f79e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,13 +201,15 @@ Key files: 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` reads the attached video and its `ownerDocument`; - browser enter/leave events are authoritative, and exact-owner exit remains - available if request support changes. Request/exit invocation stays - 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. +- `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. diff --git a/CLAUDE.md b/CLAUDE.md index 5f279d84f..dfad56813 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -629,13 +629,15 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use 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` reads the attached video and its `ownerDocument`; - browser enter/leave events are authoritative, and exact-owner exit remains - available if request support changes. Request/exit invocation stays - 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. +- `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. diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index f5b6575e4..d6e1e52ea 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -337,13 +337,14 @@ semantics. When inactive, entry requires remains available regardless of entry readiness or request support, provided the exit API exists. Any pending PiP operation disables the action. -`WebVideoControlsAdapter` reads standard PiP APIs from the actual attached -`HTMLVideoElement` and its `ownerDocument`. Browser +`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 adapter invokes `requestPictureInPicture()` or `exitPictureInPicture()` +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 @@ -552,6 +553,7 @@ libs/ui/playback/src/lib/player-controls/ ├── 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 ``` diff --git a/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.picture-in-picture.spec.ts b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.picture-in-picture.spec.ts index 4d00778bc..c62de8342 100644 --- a/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.picture-in-picture.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.picture-in-picture.spec.ts @@ -78,6 +78,7 @@ describe('WebVideoControlsAdapter Picture-in-Picture', () => { 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); @@ -130,6 +131,7 @@ describe('WebVideoControlsAdapter Picture-in-Picture', () => { 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); @@ -345,6 +347,15 @@ describe('WebVideoControlsAdapter Picture-in-Picture', () => { 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(); diff --git a/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.ts b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.ts index 3e30ffb48..2966027a5 100644 --- a/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.ts +++ b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.ts @@ -29,6 +29,7 @@ import { 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 @@ -41,29 +42,6 @@ export interface WebVideoControlsOptions extends WebVideoMetadataOptions { setSubtitleTrack?: (id: number) => void | Promise; } -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; -} - -interface PictureInPictureSnapshot { - readonly active: boolean; - readonly canExit: boolean; - readonly canRequest: boolean; - readonly canToggle: boolean; - readonly supported: boolean; -} - interface WebVideoControlsContext { seriesNavigation: Signal; } @@ -78,10 +56,16 @@ export class WebVideoControlsAdapter implements PlayerController { private opts: WebVideoControlsOptions = {}; private detachFn: (() => void) | null = null; private bindingGeneration = 0; - private pictureInPictureOperation: PictureInPictureOperation | null = null; /** 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 @@ -107,7 +91,7 @@ export class WebVideoControlsAdapter implements PlayerController { typeof this.opts.setSubtitleTrack === 'function' && (this.opts.getSubtitleTracks?.().length ?? 0) > 0; const isLive = readVideoIsLive(this.video, this.opts); - const pictureInPicture = this.readPictureInPicture(this.video); + const pictureInPicture = this.pictureInPicture.snapshot(); return { ...DEFAULT_PLAYER_CAPABILITIES, seek: !isLive, @@ -140,7 +124,7 @@ export class WebVideoControlsAdapter implements PlayerController { const audioTracks = this.opts.getAudioTracks?.() ?? []; const subtitleTracks = this.opts.getSubtitleTracks?.() ?? []; - const pictureInPicture = this.readPictureInPicture(video); + const pictureInPicture = this.pictureInPicture.snapshot(); return { status: mapVideoStatus(video), @@ -191,7 +175,7 @@ export class WebVideoControlsAdapter implements PlayerController { setPlaybackSpeed: (speed) => applyVideoSpeed(this.video, speed), setAspectRatio: () => undefined, toggleRecording: () => undefined, - togglePictureInPicture: () => this.togglePictureInPicture(), + togglePictureInPicture: () => this.pictureInPicture.toggle(), }; /** Binds to a `