mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
refactor(playback): extract picture-in-picture controller
This commit is contained in:
1 parent
df9dda153e
commit
fd4c39a642
6 files changed
+226
-172
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
+11
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user