Files
iptvnator/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec.ts
T
4gray c6ed504723 feat(playback): add shared picture-in-picture controls (#1199)
* docs(playback): design shared web picture-in-picture

* docs(playback): keep picture-in-picture exit available

* docs(playback): plan shared web picture-in-picture

* feat(playback): add picture-in-picture controls contract

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

* feat(playback): expose shared picture-in-picture action

* docs(playback): document shared web picture-in-picture

* test(playback): cover shared picture-in-picture flow

* test(playback): wait for PiP video in Electron E2E

* refactor(playback): extract picture-in-picture controller
2026-07-17 22:11:29 +02:00

242 lines
8.6 KiB
TypeScript

import { By } from '@angular/platform-browser';
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 {
cleanupSharedControlsTests,
configureSharedControlsTests,
renderSharedControls,
renderSharedControlsDefaults,
type SharedControlsFixture,
} from './html-video-player.component.shared-controls.spec-fixtures';
describe('HtmlVideoPlayerComponent shared controls host', () => {
let HtmlVideoPlayerComponent: typeof import('./html-video-player.component').HtmlVideoPlayerComponent;
const fixtures: SharedControlsFixture[] = [];
beforeAll(async () => {
({ HtmlVideoPlayerComponent } =
await import('./html-video-player.component'));
});
beforeEach(async () => {
await configureSharedControlsTests(HtmlVideoPlayerComponent);
});
afterEach(() => {
cleanupSharedControlsTests(fixtures);
});
it('uses declared shared-control defaults without input overrides', () => {
const { component, controls } = renderSharedControlsDefaults(
HtmlVideoPlayerComponent,
fixtures
);
expect(component.isLive()).toBe(true);
expect(component.interactionEnabled()).toBe(true);
expect(component.showCaptions()).toBe(false);
expect(controls?.showControls()).toBe(true);
expect(controls?.shortcutsEnabled()).toBe(true);
});
it('scopes one adapter and context initialization to each component', () => {
const setContext = jest.spyOn(
WebVideoControlsAdapter.prototype,
'setContext'
);
const first = renderSharedControls(HtmlVideoPlayerComponent, fixtures);
const second = renderSharedControls(HtmlVideoPlayerComponent, fixtures);
expect(first.adapter).not.toBe(second.adapter);
expect(setContext).toHaveBeenCalledTimes(2);
first.fixture.detectChanges();
second.fixture.detectChanges();
expect(setContext).toHaveBeenCalledTimes(2);
});
it('uses one shared controls instance on the actual player shell', () => {
const { fixture, controls } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{
seriesNavigation: {
canPrevious: true,
canNext: true,
autoplayEnabled: true,
},
}
);
const shell = fixture.debugElement.query(
By.css('.html-video-player-shell')
).nativeElement;
const video = fixture.debugElement.query(By.css('video'))
.nativeElement as HTMLVideoElement;
expect(video.controls).toBe(false);
expect(
fixture.debugElement.queryAll(By.directive(PlayerControlsComponent))
).toHaveLength(1);
expect(
fixture.debugElement.query(
By.directive(SeriesPlaybackNavigationControlsComponent)
)
).toBeNull();
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,
fixtures,
{ interactionEnabled: false }
);
expect(controls?.showControls()).toBe(false);
expect(controls?.shortcutsEnabled()).toBe(false);
fixture.componentRef.setInput('interactionEnabled', true);
fixture.detectChanges();
expect(controls?.showControls()).toBe(true);
expect(controls?.shortcutsEnabled()).toBe(true);
});
it('exits only its own fullscreen shell when interactions become unavailable', () => {
const { fixture } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures
);
const shell = fixture.debugElement.query(
By.css('.html-video-player-shell')
).nativeElement as HTMLElement;
const unrelatedSurface = document.createElement('div');
const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor(
document,
'fullscreenElement'
);
const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor(
document,
'exitFullscreen'
);
let fullscreenElement: Element | null = unrelatedSurface;
const exitFullscreen = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
try {
fixture.componentRef.setInput('interactionEnabled', false);
fixture.detectChanges();
expect(exitFullscreen).not.toHaveBeenCalled();
fixture.componentRef.setInput('interactionEnabled', true);
fixture.detectChanges();
fullscreenElement = shell;
fixture.componentRef.setInput('interactionEnabled', false);
fixture.detectChanges();
expect(exitFullscreen).toHaveBeenCalledTimes(1);
} finally {
restoreDocumentProperty(
'fullscreenElement',
fullscreenElementDescriptor
);
restoreDocumentProperty('exitFullscreen', exitFullscreenDescriptor);
}
});
it('keeps series context reactive and forwards navigation outputs', () => {
const setContext = jest.spyOn(
WebVideoControlsAdapter.prototype,
'setContext'
);
const { fixture, component, adapter, controls } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{ isLive: false }
);
const previous = jest.fn();
const next = jest.fn();
component.previousEpisodeRequested.subscribe(previous);
component.nextEpisodeRequested.subscribe(next);
expect(adapter.capabilities().seriesNavigation).toBe(false);
fixture.componentRef.setInput('seriesNavigation', {
canPrevious: true,
canNext: false,
autoplayEnabled: true,
});
fixture.detectChanges();
expect(adapter.capabilities().seriesNavigation).toBe(true);
expect(adapter.state().canPreviousEpisode).toBe(true);
expect(adapter.state().canNextEpisode).toBe(false);
controls?.previousEpisodeRequested.emit();
controls?.nextEpisodeRequested.emit();
expect(previous).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledTimes(1);
expect(setContext).toHaveBeenCalledTimes(1);
});
it('does not run legacy post-play caption suppression', async () => {
const { component } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures
);
const disableCaptions = jest.spyOn(
component as HtmlVideoPlayerComponentInstance,
'disableCaptions'
);
component.handlePlayOperation();
await Promise.resolve();
expect(disableCaptions).not.toHaveBeenCalled();
});
});
function restoreDocumentProperty(
property: 'exitFullscreen' | 'fullscreenElement',
descriptor: PropertyDescriptor | undefined
): void {
if (descriptor) {
Object.defineProperty(document, property, descriptor);
return;
}
delete (document as unknown as Record<string, unknown>)[property];
}