mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat(playback): add shared web picture-in-picture
This commit is contained in:
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,
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
+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();
|
||||
|
||||
+373
@@ -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' }],
|
||||
|
||||
@@ -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