feat(playback): add shared web picture-in-picture

This commit is contained in:
4gray committed 2026-07-17 19:34:34 +02:00
1 parent 387d02d275
commit 8373f0381a
9 files changed
+1092 -185

No files matched your search

@@ -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,
@@ -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,
@@ -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];
}
}
@@ -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();
@@ -0,0 +1,373 @@
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(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(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);
} 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,73 +10,75 @@ 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';
/**
* 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;
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>;
}
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;
private pictureInPictureOperation: PictureInPictureOperation | null = null;
/** Bumped whenever DOM or engine-specific state must be re-read. */
private readonly tick = signal(0);
@@ -104,7 +106,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.readPictureInPicture(this.video);
return {
...DEFAULT_PLAYER_CAPABILITIES,
seek: !isLive,
@@ -115,7 +118,7 @@ export class WebVideoControlsAdapter implements PlayerController {
subtitles: hasSubtitles,
aspectRatio: false,
recording: false,
pictureInPicture: false,
pictureInPicture: pictureInPicture.supported,
seriesNavigation: !isLive && this.seriesNavigation() !== null,
};
});
@@ -127,23 +130,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.readPictureInPicture(video);
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),
@@ -152,8 +159,8 @@ export class WebVideoControlsAdapter implements PlayerController {
aspectRatio: 'no',
aspectPresets: DEFAULT_ASPECT_PRESETS,
recording: { active: false, elapsedSeconds: 0, message: null },
pictureInPictureActive: false,
canPictureInPicture: false,
pictureInPictureActive: pictureInPicture.active,
canPictureInPicture: pictureInPicture.canToggle,
canPreviousEpisode:
seriesNavCapable && seriesNav?.canPrevious === true,
canNextEpisode: seriesNavCapable && seriesNav?.canNext === true,
@@ -161,33 +168,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: () => undefined,
togglePictureInPicture: () => this.togglePictureInPicture(),
};
/** 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);
}
};
@@ -206,10 +229,16 @@ 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.pictureInPictureOperation = null;
if (previousVideo) {
this.exitPictureInPictureIfOwned(previousVideo);
}
this.refresh();
}
@@ -218,153 +247,127 @@ export class WebVideoControlsAdapter implements PlayerController {
this.seriesNavigationSource.set(context.seriesNavigation);
}
private togglePlay(): void {
private togglePictureInPicture(): void {
const video = this.video;
if (!video) {
if (!video || this.pictureInPictureOperation) {
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)) {
const snapshot = this.readPictureInPicture(video);
if (!snapshot.canToggle) {
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)) {
if (snapshot.active && snapshot.canExit) {
this.startPictureInPictureOperation(
PICTURE_IN_PICTURE_ACTION.EXIT,
video,
() => video.ownerDocument.exitPictureInPicture()
);
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.
if (snapshot.canRequest) {
this.startPictureInPictureOperation(
PICTURE_IN_PICTURE_ACTION.ENTER,
video,
() => video.requestPictureInPicture()
);
}
}
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
private startPictureInPictureOperation(
action: PictureInPictureAction,
video: HTMLVideoElement,
invoke: () => Promise<unknown>
): void {
if (!setter) {
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;
}
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
void Promise.resolve(result).then(
() => this.settlePictureInPictureOperation(operation, true),
() => this.settlePictureInPictureOperation(operation, false)
);
}
private normalizedDuration(isLive: boolean): number | null {
const duration = this.readDuration();
if (isLive || !Number.isFinite(duration) || duration <= 0) {
return null;
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);
}
return duration;
}
private hasSeekable(video: HTMLVideoElement | null): boolean {
private exitPictureInPictureIfOwned(video: HTMLVideoElement): void {
try {
return (video?.seekable?.length ?? 0) > 0;
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 {
return false;
// PiP teardown is best-effort during target replacement.
}
}
private readVolume(video: HTMLVideoElement | null): number {
if (!video) {
return 1;
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,
};
}
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';
}
@@ -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' }],
+2 -1
View File
@@ -11,6 +11,7 @@
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec-fixtures.ts"
"src/**/*.spec-fixtures.ts",
"src/**/*.spec-helpers.ts"
]
}