mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(playback): close legacy picture-in-picture on video replacement
This commit is contained in:
1 parent
de81e3b238
commit
5ae7a484c9
15 files changed
+399
-38
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
Switching channels or leaving playback now closes the old picture-in-picture window in HTML5, Video.js, and ArtPlayer even when shared player controls are disabled, preventing frozen or outdated video from remaining on top.
|
||||
@@ -522,7 +522,7 @@ Key files:
|
||||
`pictureInPictureActive`/`canPictureInPicture`, and command
|
||||
`togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard
|
||||
element PiP from the adapter's attached video; shared ArtPlayer keeps vendor
|
||||
`pip: false`, while preference-off native/vendor paths remain unchanged. The
|
||||
`pip: false`, while preference-off native/vendor controls keep their own UI. The
|
||||
capability-gated button sits before fullscreen and uses active enter/exit
|
||||
semantics; entry is disabled until metadata, and the action is disabled while
|
||||
an operation is pending. Embedded MPV reports capability/state false with a
|
||||
@@ -535,7 +535,11 @@ Key files:
|
||||
serialized, and binding generation plus exact video identity protects
|
||||
replacement and teardown from stale completion. Video.js Tech reset and
|
||||
ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a
|
||||
retained target preserve PiP.
|
||||
retained target preserve PiP. Legacy HTML5/ArtPlayer teardown and Video.js
|
||||
Tech replacement also release exact-owned PiP through
|
||||
`web-video-picture-in-picture-lifecycle.ts`, independent of the controls
|
||||
preference. A one-shot listener on the retired video closes late native/vendor
|
||||
entries without retaining the host or touching another video's PiP.
|
||||
Standard PiP shows the browser/OS video surface without Angular control
|
||||
chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP
|
||||
keyboard shortcut, and Embedded MPV popup/native support are out of scope.
|
||||
|
||||
@@ -1147,7 +1147,7 @@ engine` (restart required) or
|
||||
`pictureInPictureActive`/`canPictureInPicture`, and command
|
||||
`togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard
|
||||
element PiP from the adapter's attached video; shared ArtPlayer keeps vendor
|
||||
`pip: false`, while preference-off native/vendor paths remain unchanged. The
|
||||
`pip: false`, while preference-off native/vendor controls keep their own UI. The
|
||||
capability-gated button sits before fullscreen and uses active enter/exit
|
||||
semantics; entry is disabled until metadata, and the action is disabled while
|
||||
an operation is pending. Embedded MPV reports capability/state false with a
|
||||
@@ -1160,7 +1160,11 @@ engine` (restart required) or
|
||||
serialized, and binding generation plus exact video identity protects
|
||||
replacement and teardown from stale completion. Video.js Tech reset and
|
||||
ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a
|
||||
retained target preserve PiP.
|
||||
retained target preserve PiP. Legacy HTML5/ArtPlayer teardown and Video.js
|
||||
Tech replacement also release exact-owned PiP through
|
||||
`web-video-picture-in-picture-lifecycle.ts`, independent of the controls
|
||||
preference. A one-shot listener on the retired video closes late native/vendor
|
||||
entries without retaining the host or touching another video's PiP.
|
||||
Standard PiP shows the browser/OS video surface without Angular control
|
||||
chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP
|
||||
keyboard shortcut, and Embedded MPV popup/native support are out of scope.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
channelItemByTitle,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
m3uFixturePath,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
saveSettings,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
for (const player of ['html5', 'videojs', 'artplayer']) {
|
||||
for (const sharedControls of [false, true]) {
|
||||
test(`@playback @electron closes ${player} PiP on channel change (shared controls: ${sharedControls})`, async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
try {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'playback');
|
||||
await page.getByTestId('select-video-player').click();
|
||||
await page.getByTestId(player).click();
|
||||
await page
|
||||
.getByTestId('web-player-shared-controls-setting')
|
||||
.locator('input[type="checkbox"]')
|
||||
.setChecked(sharedControls);
|
||||
// The default Video.js/shared combination may already be saved.
|
||||
if (await page.getByTestId('save-settings').isVisible()) {
|
||||
await saveSettings(page);
|
||||
}
|
||||
await goToDashboard(page);
|
||||
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
|
||||
await page.waitForURL(/\/workspace\/playlists\/.+/);
|
||||
// The test exercises the real settings/channel/host lifecycle.
|
||||
// Keep synthetic HLS pending and emulate only the OS PiP API,
|
||||
// which is not reliably available on headless CI desktops.
|
||||
await page.route(
|
||||
'https://example.channels/**',
|
||||
() => undefined
|
||||
);
|
||||
await channelItemByTitle(page, 'Channel 1').first().click();
|
||||
const video = page.locator('app-web-player-view video');
|
||||
await expect(video).toHaveCount(1);
|
||||
const oldVideo = await video.elementHandle();
|
||||
if (!oldVideo)
|
||||
throw new Error(
|
||||
'The selected channel has no video element'
|
||||
);
|
||||
await oldVideo.evaluate((element: HTMLVideoElement) => {
|
||||
let owner: Element | null = element;
|
||||
Object.defineProperty(document, 'pictureInPictureElement', {
|
||||
configurable: true,
|
||||
get: () => owner,
|
||||
set: (value: Element | null) => {
|
||||
owner = value;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(document, 'exitPictureInPicture', {
|
||||
configurable: true,
|
||||
value: async () => {
|
||||
const previous = owner;
|
||||
owner = null;
|
||||
previous?.dispatchEvent(
|
||||
new Event('leavepictureinpicture')
|
||||
);
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(new Event('enterpictureinpicture'));
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => !!document.pictureInPictureElement)
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await channelItemByTitle(page, 'Positive News TV')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
oldVideo.evaluate((element) => element.isConnected)
|
||||
)
|
||||
.toBe(false);
|
||||
await expect(video).toHaveCount(1);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() => document.pictureInPictureElement === null
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
// Native/vendor controls can finish a pending entry after the
|
||||
// host is gone. The retired element must close that entry too.
|
||||
if (!sharedControls) {
|
||||
await oldVideo.evaluate((element) => {
|
||||
Reflect.set(
|
||||
document,
|
||||
'pictureInPictureElement',
|
||||
element
|
||||
);
|
||||
element.dispatchEvent(
|
||||
new Event('enterpictureinpicture')
|
||||
);
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() => document.pictureInPictureElement === null
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
await oldVideo.dispose();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,8 @@ ArtPlayer skin, source behavior, and legacy series navigation remain unchanged.
|
||||
With shared controls enabled, HTML5, Video.js, and ArtPlayer expose standard
|
||||
element picture-in-picture through the adapter's attached `<video>`. Shared
|
||||
ArtPlayer keeps its vendor `pip` option disabled so the shared button is the
|
||||
only PiP owner. The preference-off native/vendor paths remain unchanged.
|
||||
only PiP button. Preference-off native/vendor controls keep their own UI;
|
||||
exact-owner PiP teardown also applies in that mode.
|
||||
Embedded MPV advertises no PiP capability and its command is a no-op.
|
||||
|
||||
`Settings.webPlayerSharedControls` is default-ON: an absent stored value means
|
||||
@@ -602,7 +603,7 @@ Picture-in-picture is part of the default-on shared web-controls
|
||||
rollout. It is available through standard element PiP for HTML5, Video.js, and
|
||||
ArtPlayer only when their host snapshot enables `WEB_PLAYER_SHARED_CONTROLS`.
|
||||
The preference-off HTML5 native controls, Video.js skin, and ArtPlayer vendor
|
||||
controls keep their previous behavior. Shared ArtPlayer explicitly keeps vendor
|
||||
controls keep their own PiP actions. Shared ArtPlayer explicitly keeps vendor
|
||||
`pip: false`, leaving the shared action as the single PiP owner.
|
||||
|
||||
The contract exposes:
|
||||
@@ -639,6 +640,18 @@ perform exact-owner cleanup, and bind the replacement video. HTML5 source
|
||||
changes on a retained video target, along with ordinary same-element
|
||||
source/media events, preserve active PiP.
|
||||
|
||||
Teardown safety is independent of the controls preference. Legacy HTML5 and
|
||||
ArtPlayer hosts release their video before destruction; Video.js also releases
|
||||
its previous Tech video when a reset replaces it. The shared
|
||||
`web-video-picture-in-picture-lifecycle.ts` helper checks the video's exact
|
||||
`ownerDocument.pictureInPictureElement` before exiting, contains API failures,
|
||||
and leaves a one-shot listener on the retired video for an in-flight
|
||||
native/vendor entry that completes after teardown. The listener captures only
|
||||
the retired video, with no timer or document listener; a WeakSet makes repeated
|
||||
release idempotent without retaining video elements. Shared controls retain
|
||||
their existing generation-guarded pending-operation cleanup. Neither path
|
||||
transfers PiP to a replacement video or closes another video's PiP.
|
||||
|
||||
Standard element PiP displays the browser/OS video surface, not Angular shared
|
||||
control chrome. Subtitle rendering in that surface is browser-dependent.
|
||||
AirPlay, Cast, Document Picture-in-Picture, a PiP keyboard shortcut, and an
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PictureInPictureTestEnvironment } from '../player-controls/picture-in-picture.spec-helpers';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { Channel } from '@iptvnator/shared/interfaces';
|
||||
@@ -14,8 +15,7 @@ import {
|
||||
resetArtPlayerSpecFixtures,
|
||||
} from './art-player.component.spec-fixtures';
|
||||
|
||||
const actualHlsModule =
|
||||
jest.requireActual<typeof import('hls.js')>('hls.js');
|
||||
const actualHlsModule = jest.requireActual<typeof import('hls.js')>('hls.js');
|
||||
|
||||
jest.unstable_mockModule('artplayer', () => ({
|
||||
default: MockArtplayer,
|
||||
@@ -63,6 +63,38 @@ describe('ArtPlayerComponent', () => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it.each(['channel', 'destroy', 'late entry'])(
|
||||
'closes legacy PiP on %s',
|
||||
(transition) => {
|
||||
const environment = new PictureInPictureTestEnvironment();
|
||||
try {
|
||||
createComponent({
|
||||
url: 'https://example.test/one.mp4',
|
||||
name: 'One',
|
||||
});
|
||||
const video = artPlayerInstances[0].video;
|
||||
environment.installVideo(video);
|
||||
if (transition !== 'late entry') environment.setActive(video);
|
||||
|
||||
if (transition === 'channel') {
|
||||
fixture.componentRef.setInput('channel', {
|
||||
url: 'https://example.test/two.mp4',
|
||||
name: 'Two',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
} else {
|
||||
fixture.destroy();
|
||||
}
|
||||
if (transition === 'late entry') environment.setActive(video);
|
||||
|
||||
expect(environment.exit).toHaveBeenCalledTimes(1);
|
||||
expect(document.pictureInPictureElement).toBeNull();
|
||||
} finally {
|
||||
environment.restore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('emits a playback issue when the native video element reports an unsupported source', () => {
|
||||
createComponent({
|
||||
url: 'https://example.com/archive/movie.mkv',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import Artplayer from 'artplayer';
|
||||
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
|
||||
import { releaseVideoPictureInPicture } from '../player-controls/web-video-picture-in-picture-lifecycle';
|
||||
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
|
||||
import {
|
||||
type LegacyPlayerShortcuts,
|
||||
@@ -199,6 +200,9 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
}
|
||||
|
||||
private destroyPlayer(): void {
|
||||
if (!this.sharedControls) {
|
||||
releaseVideoPictureInPicture(this.player?.video);
|
||||
}
|
||||
const sourceSession = this.sourceSession;
|
||||
this.sourceSession = null;
|
||||
sourceSession?.destroy();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PictureInPictureTestEnvironment } from '../player-controls/picture-in-picture.spec-helpers';
|
||||
import { SimpleChange } from '@angular/core';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
@@ -103,6 +104,26 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
expect(adapterAttach).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'closes legacy PiP on teardown (late entry: %s)',
|
||||
(lateEntry) => {
|
||||
const environment = new PictureInPictureTestEnvironment();
|
||||
try {
|
||||
const video = component.videoPlayer.nativeElement;
|
||||
environment.installVideo(video);
|
||||
if (!lateEntry) environment.setActive(video);
|
||||
|
||||
fixture.destroy();
|
||||
if (lateEntry) environment.setActive(video);
|
||||
|
||||
expect(environment.exit).toHaveBeenCalledTimes(1);
|
||||
expect(document.pictureInPictureElement).toBeNull();
|
||||
} finally {
|
||||
environment.restore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('drives playback keyboard shortcuts against the native video element', () => {
|
||||
const video = component.videoPlayer.nativeElement;
|
||||
expect(video.muted).toBe(false);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
|
||||
import mpegts from 'mpegts.js';
|
||||
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
|
||||
import { releaseVideoPictureInPicture } from '../player-controls/web-video-picture-in-picture-lifecycle';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnostic,
|
||||
@@ -215,11 +216,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
);
|
||||
const session = this.getShakaSession();
|
||||
this.bindControlsSource({ kind: 'shaka', session });
|
||||
session.start(
|
||||
this.videoPlayer.nativeElement,
|
||||
url,
|
||||
channel.drm
|
||||
);
|
||||
session.start(this.videoPlayer.nativeElement, url, channel.drm);
|
||||
if (channel.drm && !channel.drm.supported) {
|
||||
// No source is loaded for unsupported DRM; reset the
|
||||
// element so the previous stream cannot resume playing
|
||||
@@ -359,6 +356,9 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
* Destroy hls instance on component destroy and clean up event listener
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
if (!this.sharedControls) {
|
||||
releaseVideoPictureInPicture(this.videoPlayer?.nativeElement);
|
||||
}
|
||||
this.legacyShortcuts?.detach();
|
||||
this.legacyShortcuts = null;
|
||||
this.controlsBridge?.destroy();
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { PictureInPictureTestEnvironment } from './picture-in-picture.spec-helpers';
|
||||
import { releaseVideoPictureInPicture } from './web-video-picture-in-picture-lifecycle';
|
||||
|
||||
describe('released video PiP lifecycle', () => {
|
||||
let environment: PictureInPictureTestEnvironment;
|
||||
let video: HTMLVideoElement;
|
||||
|
||||
beforeEach(() => {
|
||||
environment = new PictureInPictureTestEnvironment();
|
||||
video = document.createElement('video');
|
||||
environment.installVideo(video);
|
||||
});
|
||||
|
||||
afterEach(() => environment.restore());
|
||||
|
||||
it('releases an active owner only once', () => {
|
||||
environment.setActive(video);
|
||||
releaseVideoPictureInPicture(video);
|
||||
releaseVideoPictureInPicture(video);
|
||||
expect(environment.exit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes a vendor request that enters after its video was released', () => {
|
||||
releaseVideoPictureInPicture(video);
|
||||
expect(environment.exit).not.toHaveBeenCalled();
|
||||
environment.setActive(video);
|
||||
expect(environment.exit).toHaveBeenCalledTimes(1);
|
||||
expect(document.pictureInPictureElement).toBeNull();
|
||||
});
|
||||
|
||||
it('does not close a different video, even on a stale old-video event', () => {
|
||||
releaseVideoPictureInPicture(video);
|
||||
const replacement = document.createElement('video');
|
||||
environment.setActive(replacement);
|
||||
video.dispatchEvent(new Event('enterpictureinpicture'));
|
||||
expect(environment.exit).not.toHaveBeenCalled();
|
||||
expect(document.pictureInPictureElement).toBe(replacement);
|
||||
});
|
||||
|
||||
it('uses the video ownerDocument', () => {
|
||||
const foreignDocument = document.implementation.createHTMLDocument();
|
||||
const foreignEnvironment = new PictureInPictureTestEnvironment(
|
||||
foreignDocument
|
||||
);
|
||||
try {
|
||||
const foreignVideo = foreignDocument.createElement('video');
|
||||
foreignEnvironment.setActive(foreignVideo);
|
||||
releaseVideoPictureInPicture(foreignVideo);
|
||||
expect(foreignEnvironment.exit).toHaveBeenCalledTimes(1);
|
||||
expect(environment.exit).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
foreignEnvironment.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows teardown without a video or an exit API', () => {
|
||||
environment.setActive(video);
|
||||
environment.setExitAvailable(false);
|
||||
expect(() => releaseVideoPictureInPicture(null)).not.toThrow();
|
||||
expect(() => releaseVideoPictureInPicture(video)).not.toThrow();
|
||||
});
|
||||
|
||||
it.each(['throw', 'reject'])('contains an exit API %s', async (failure) => {
|
||||
environment.setActive(video);
|
||||
environment.exit.mockImplementation(() => {
|
||||
if (failure === 'throw') throw new Error('PiP exit failed');
|
||||
return Promise.reject(new Error('PiP exit failed'));
|
||||
});
|
||||
expect(() => releaseVideoPictureInPicture(video)).not.toThrow();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
const releasedVideos = new WeakSet<HTMLVideoElement>();
|
||||
|
||||
/** Release a legacy/native video that will never be used by this host again. */
|
||||
export function releaseVideoPictureInPicture(
|
||||
video: HTMLVideoElement | null | undefined
|
||||
): void {
|
||||
if (!video || releasedVideos.has(video)) {
|
||||
return;
|
||||
}
|
||||
releasedVideos.add(video);
|
||||
// Vendor/native entry requests are not owned by the shared controller.
|
||||
// A pending request can enter after teardown. Keep a one-shot listener on
|
||||
// only the retired video, with no timer, document listener, or host capture.
|
||||
video.addEventListener(
|
||||
'enterpictureinpicture',
|
||||
() => exitOwnedPictureInPicture(video),
|
||||
{ once: true }
|
||||
);
|
||||
exitOwnedPictureInPicture(video);
|
||||
}
|
||||
|
||||
export function exitOwnedPictureInPicture(video: HTMLVideoElement): void {
|
||||
try {
|
||||
const ownerDocument = video.ownerDocument;
|
||||
if (
|
||||
ownerDocument.pictureInPictureElement !== video ||
|
||||
typeof ownerDocument.exitPictureInPicture !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(ownerDocument.exitPictureInPicture()).then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
} catch {
|
||||
// PiP teardown is best-effort during target replacement.
|
||||
}
|
||||
}
|
||||
+4
-21
@@ -1,3 +1,5 @@
|
||||
import { exitOwnedPictureInPicture } from './web-video-picture-in-picture-lifecycle';
|
||||
|
||||
const HAVE_METADATA = 1;
|
||||
const PICTURE_IN_PICTURE_ACTION = {
|
||||
ENTER: 'enter',
|
||||
@@ -45,7 +47,7 @@ export class WebVideoPictureInPictureController {
|
||||
release(previousVideo: HTMLVideoElement | null): void {
|
||||
this.operation = null;
|
||||
if (previousVideo) {
|
||||
this.exitIfOwned(previousVideo);
|
||||
exitOwnedPictureInPicture(previousVideo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,26 +157,7 @@ export class WebVideoPictureInPictureController {
|
||||
return;
|
||||
}
|
||||
if (succeeded && operation.action === PICTURE_IN_PICTURE_ACTION.ENTER) {
|
||||
this.exitIfOwned(operation.video);
|
||||
}
|
||||
}
|
||||
|
||||
private exitIfOwned(video: HTMLVideoElement): void {
|
||||
try {
|
||||
const ownerDocument = video.ownerDocument;
|
||||
if (
|
||||
ownerDocument.pictureInPictureElement !== video ||
|
||||
typeof ownerDocument.exitPictureInPicture !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const result = ownerDocument.exitPictureInPicture();
|
||||
void Promise.resolve(result).then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
} catch {
|
||||
// PiP teardown is best-effort during target replacement.
|
||||
exitOwnedPictureInPicture(operation.video);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,12 @@ jest.unstable_mockModule('mpegts.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe('VjsPlayerComponent reset lifecycle', () => {
|
||||
describe.each([false, true])(
|
||||
'VjsPlayerComponent reset lifecycle (shared controls: %s)',
|
||||
runResetLifecycleSuite
|
||||
);
|
||||
|
||||
function runResetLifecycleSuite(sharedControls: boolean): void {
|
||||
let VjsPlayerComponent: typeof import('./vjs-player.component').VjsPlayerComponent;
|
||||
let fixture: ComponentFixture<VjsPlayerComponentInstance>;
|
||||
let playerHarness: ReturnType<typeof createVideoJsPlayerHarness>;
|
||||
@@ -53,7 +58,10 @@ describe('VjsPlayerComponent reset lifecycle', () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VjsPlayerComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{ provide: WEB_PLAYER_SHARED_CONTROLS, useValue: true },
|
||||
{
|
||||
provide: WEB_PLAYER_SHARED_CONTROLS,
|
||||
useValue: sharedControls,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(VjsPlayerComponent);
|
||||
@@ -148,7 +156,52 @@ describe('VjsPlayerComponent reset lifecycle', () => {
|
||||
expect(
|
||||
fixture.componentInstance.controlsAdapter.capabilities()
|
||||
.pictureInPicture
|
||||
).toBe(true);
|
||||
).toBe(sharedControls);
|
||||
} finally {
|
||||
environment.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes owned PiP on component teardown', () => {
|
||||
const environment = new PictureInPictureTestEnvironment();
|
||||
try {
|
||||
const video = playerHarness.currentVideo;
|
||||
environment.installVideo(video);
|
||||
fixture.componentRef.setInput('options', {
|
||||
sources: [{ src: 'https://example.test/movie.mp4' }],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
playerHarness.ready();
|
||||
environment.setActive(video);
|
||||
|
||||
fixture.destroy();
|
||||
|
||||
expect(environment.exit).toHaveBeenCalledTimes(1);
|
||||
expect(document.pictureInPictureElement).toBeNull();
|
||||
} finally {
|
||||
environment.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves PiP when a source change retains the Tech video', () => {
|
||||
const environment = new PictureInPictureTestEnvironment();
|
||||
try {
|
||||
const video = playerHarness.currentVideo;
|
||||
environment.installVideo(video);
|
||||
fixture.componentRef.setInput('options', {
|
||||
sources: [{ src: 'https://example.test/one.mp4' }],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
playerHarness.ready();
|
||||
environment.setActive(video);
|
||||
|
||||
fixture.componentRef.setInput('options', {
|
||||
sources: [{ src: 'https://example.test/two.mp4' }],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(environment.exit).not.toHaveBeenCalled();
|
||||
expect(document.pictureInPictureElement).toBe(video);
|
||||
} finally {
|
||||
environment.restore();
|
||||
}
|
||||
@@ -316,7 +369,7 @@ describe('VjsPlayerComponent reset lifecycle', () => {
|
||||
});
|
||||
expect(playerHarness.mpegTsPlayers).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createVideoJsPlayerHarness() {
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
|
||||
@@ -100,6 +100,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private readonly seriesNavigationSignal =
|
||||
signal<SeriesPlaybackNavigation | null>(null);
|
||||
private readonly videoSession = new VjsVideoElementSession({
|
||||
releasePictureInPicture: !this.sharedControls,
|
||||
clearPlaybackIssue: () => this.playbackIssue.emit(null),
|
||||
emitPlaybackEnded: () => this.playbackEnded.emit(),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { releaseVideoPictureInPicture } from '../player-controls/web-video-picture-in-picture-lifecycle';
|
||||
|
||||
export interface VjsVideoElementSessionConfig {
|
||||
releasePictureInPicture?: boolean;
|
||||
clearPlaybackIssue: () => void;
|
||||
emitPlaybackEnded: () => void;
|
||||
}
|
||||
@@ -47,6 +50,9 @@ export class VjsVideoElementSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.releasePictureInPicture) {
|
||||
releaseVideoPictureInPicture(this.currentVideo);
|
||||
}
|
||||
this.currentVideo.removeEventListener(
|
||||
'loadeddata',
|
||||
this.clearPlaybackIssue
|
||||
|
||||
Reference in new issue
Block a user