refactor(playback): extract picture-in-picture controller

This commit is contained in:
4gray committed 2026-07-17 21:32:55 +02:00
1 parent df9dda153e
commit fd4c39a642
6 files changed
+226 -172

No files matched your search

+9 -7
View File
@@ -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.
+9 -7
View File
@@ -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.
@@ -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
```
@@ -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();
@@ -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<void>;
}
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<SeriesPlaybackNavigation | null>;
}
@@ -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 `<video>` element and starts maintaining the state signal. */
@@ -235,10 +219,7 @@ export class WebVideoControlsAdapter implements PlayerController {
this.detachFn = null;
this.video = null;
this.opts = {};
this.pictureInPictureOperation = null;
if (previousVideo) {
this.exitPictureInPictureIfOwned(previousVideo);
}
this.pictureInPicture.release(previousVideo);
this.refresh();
}
@@ -246,128 +227,4 @@ export class WebVideoControlsAdapter implements PlayerController {
setContext(context: WebVideoControlsContext): void {
this.seriesNavigationSource.set(context.seriesNavigation);
}
private togglePictureInPicture(): void {
const video = this.video;
if (!video || this.pictureInPictureOperation) {
return;
}
const snapshot = this.readPictureInPicture(video);
if (!snapshot.canToggle) {
return;
}
if (snapshot.active && snapshot.canExit) {
this.startPictureInPictureOperation(
PICTURE_IN_PICTURE_ACTION.EXIT,
video,
() => video.ownerDocument.exitPictureInPicture()
);
return;
}
if (snapshot.canRequest) {
this.startPictureInPictureOperation(
PICTURE_IN_PICTURE_ACTION.ENTER,
video,
() => video.requestPictureInPicture()
);
}
}
private startPictureInPictureOperation(
action: PictureInPictureAction,
video: HTMLVideoElement,
invoke: () => Promise<unknown>
): void {
const operation: PictureInPictureOperation = {
action,
generation: this.bindingGeneration,
video,
};
this.pictureInPictureOperation = operation;
this.refresh();
let result: Promise<unknown>;
try {
result = invoke();
} catch {
this.settlePictureInPictureOperation(operation, false);
return;
}
void Promise.resolve(result).then(
() => this.settlePictureInPictureOperation(operation, true),
() => this.settlePictureInPictureOperation(operation, false)
);
}
private settlePictureInPictureOperation(
operation: PictureInPictureOperation,
succeeded: boolean
): void {
const isCurrent =
this.pictureInPictureOperation === operation &&
this.bindingGeneration === operation.generation &&
this.video === operation.video;
if (isCurrent) {
this.pictureInPictureOperation = null;
this.refresh();
return;
}
if (succeeded && operation.action === PICTURE_IN_PICTURE_ACTION.ENTER) {
this.exitPictureInPictureIfOwned(operation.video);
}
}
private exitPictureInPictureIfOwned(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.
}
}
private readPictureInPicture(
video: HTMLVideoElement
): PictureInPictureSnapshot {
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.pictureInPictureOperation === null &&
((active && canExit) ||
(canRequest && video.readyState >= HAVE_METADATA)),
supported: canRequest || (active && canExit),
};
} catch {
return {
active: false,
canExit: false,
canRequest: false,
canToggle: false,
supported: false,
};
}
}
}
@@ -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.
}
}
}