fix(playback): close legacy picture-in-picture on video replacement (#1538)

* fix(playback): close legacy picture-in-picture on video replacement

* test(playback): wait for the selected video before PiP setup

* test(playback): await changed settings before PiP navigation

* fix(playback): release legacy WebKit picture-in-picture
This commit is contained in:
4gray authored and GitHub committed 2026-09-05 14:02:55 +02:00
1 parent eba68d687e
commit 79f3f6c897
15 files changed
+581 -38

No files matched your search

+6
View File
@@ -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. This includes Safari’s legacy picture-in-picture mode.
+9 -2
View File
@@ -544,7 +544,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
@@ -557,7 +557,14 @@ 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. Legacy
WebKit presentation-mode PiP also returns the retired video to inline; its
presentation-change listener ignores fullscreen/inline events until a late
PiP entry consumes it.
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.
+9 -2
View File
@@ -1155,7 +1155,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
@@ -1168,7 +1168,14 @@ 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. Legacy
WebKit presentation-mode PiP also returns the retired video to inline; its
presentation-change listener ignores fullscreen/inline events until a late
PiP entry consumes it.
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,158 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import {
channelItemByTitle,
closeElectronApp,
expect,
goToDashboard,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openSettings,
openSettingsSection,
saveSettings,
test,
} from './electron-test-fixtures';
const streamHost = 'https://pip-fixture.test';
const media = readFileSync(
join(__dirname, '../../web-e2e/src/fixtures/playback/episode.webm')
);
const playlist = [
'#EXTM3U',
'#EXTINF:-1 tvg-id="pip-one" group-title="News",Channel 1',
`${streamHost}/one.webm`,
'#EXTINF:-1 tvg-id="pip-two" group-title="News",Positive News TV',
`${streamHost}/two.webm`,
].join('\n');
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);
// A fresh profile defaults to Video.js with shared controls.
// For changed settings, await the save control through the
// helper; an immediate isVisible() can miss Angular rendering it.
if (player !== 'videojs' || !sharedControls) {
await saveSettings(page);
}
const playlistPath = join(dataDir, 'pip.m3u');
writeFileSync(playlistPath, playlist);
await page.route(`${streamHost}/**`, (route) =>
route.fulfill({
status: 200,
contentType: 'video/webm',
body: media,
})
);
await goToDashboard(page);
await importM3uPlaylistFromNativeDialog(app, playlistPath);
await page.waitForURL(/\/workspace\/playlists\/.+/);
// Import can auto-select the first channel. Select a distinct
// source and wait for its actual media to load before owning PiP;
// merely finding a video can capture the retiring initial host.
await channelItemByTitle(page, 'Positive News TV')
.first()
.click();
const video = page.locator('app-web-player-view video');
await expect(video).toHaveCount(1);
await expect
.poll(() =>
video.evaluate((element: HTMLVideoElement) => ({
source: element.currentSrc,
loaded:
element.readyState >=
HTMLMediaElement.HAVE_CURRENT_DATA,
}))
)
.toEqual({
source: `${streamHost}/two.webm`,
loaded: true,
});
// Emulate only the OS PiP API, unavailable on headless CI.
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, 'Channel 1').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);
}
});
}
}
+18 -2
View File
@@ -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,21 @@ 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. Legacy Safari/WebKit
presentation-mode PiP is returned to `inline` on that exact video. Its
`webkitpresentationmodechanged` listener ignores fullscreen/inline changes and
is consumed only by the first late PiP entry. 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,77 @@ 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.each(['channel', 'destroy', 'late entry'])(
'closes legacy WebKit PiP on %s',
(transition) => {
createComponent({
url: 'https://example.test/one.mp4',
name: 'One',
});
const video = artPlayerInstances[0].video;
let mode =
transition === 'late entry' ? 'inline' : 'picture-in-picture';
const setMode = jest.fn((next: string) => {
mode = next;
video.dispatchEvent(new Event('webkitpresentationmodechanged'));
});
Object.defineProperties(video, {
webkitPresentationMode: { get: () => mode },
webkitSetPresentationMode: { value: setMode },
});
if (transition === 'channel') {
fixture.componentRef.setInput('channel', {
url: 'https://example.test/two.mp4',
name: 'Two',
});
fixture.detectChanges();
} else {
fixture.destroy();
}
if (transition === 'late entry') {
mode = 'picture-in-picture';
video.dispatchEvent(new Event('webkitpresentationmodechanged'));
}
expect(setMode).toHaveBeenCalledTimes(1);
expect(setMode).toHaveBeenCalledWith('inline');
expect(mode).toBe('inline');
}
);
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();
@@ -0,0 +1,135 @@
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('returns an active WebKit PiP video to inline only once', () => {
const webkit = installWebKitPresentation(video, 'picture-in-picture');
releaseVideoPictureInPicture(video);
releaseVideoPictureInPicture(video);
expect(webkit.setMode).toHaveBeenCalledTimes(1);
expect(webkit.setMode).toHaveBeenCalledWith('inline');
expect(environment.exit).not.toHaveBeenCalled();
});
it('catches late WebKit PiP after unrelated presentation events', () => {
const webkit = installWebKitPresentation(video, 'fullscreen');
releaseVideoPictureInPicture(video);
webkit.change('inline');
expect(webkit.setMode).not.toHaveBeenCalled();
webkit.change('picture-in-picture');
expect(webkit.setMode).toHaveBeenCalledTimes(1);
expect(webkit.setMode).toHaveBeenCalledWith('inline');
// The retired-video listener is consumed by the first late PiP entry.
webkit.change('picture-in-picture');
expect(webkit.setMode).toHaveBeenCalledTimes(1);
});
it('leaves another WebKit video and standard PiP owner untouched', () => {
const webkit = installWebKitPresentation(video, 'inline');
const replacement = document.createElement('video');
const nextWebkit = installWebKitPresentation(
replacement,
'picture-in-picture'
);
environment.setActive(replacement);
releaseVideoPictureInPicture(video);
webkit.change('fullscreen');
expect(webkit.setMode).not.toHaveBeenCalled();
expect(nextWebkit.setMode).not.toHaveBeenCalled();
expect(environment.exit).not.toHaveBeenCalled();
expect(document.pictureInPictureElement).toBe(replacement);
});
it('contains WebKit exit errors while still releasing standard PiP', () => {
const webkit = installWebKitPresentation(video, 'picture-in-picture');
webkit.setMode.mockImplementation(() => {
throw new Error('WebKit exit failed');
});
environment.setActive(video);
expect(() => releaseVideoPictureInPicture(video)).not.toThrow();
expect(webkit.setMode).toHaveBeenCalledWith('inline');
expect(environment.exit).toHaveBeenCalledTimes(1);
});
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();
});
});
function installWebKitPresentation(video: HTMLVideoElement, initial: string) {
let mode = initial;
const change = (next: string) => {
mode = next;
video.dispatchEvent(new Event('webkitpresentationmodechanged'));
};
const setMode = jest.fn(change);
Object.defineProperties(video, {
webkitPresentationMode: { get: () => mode },
webkitSetPresentationMode: { value: setMode },
});
return { change, setMode };
}
@@ -0,0 +1,75 @@
const releasedVideos = new WeakSet<HTMLVideoElement>();
interface WebKitPictureInPictureVideo extends HTMLVideoElement {
readonly webkitPresentationMode?: string;
webkitSetPresentationMode?: (mode: 'inline') => void;
}
/** 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);
releaseWebKitPictureInPicture(video);
}
function releaseWebKitPictureInPicture(
video: WebKitPictureInPictureVideo
): void {
if (typeof video.webkitSetPresentationMode !== 'function') return;
// WebKit uses one event for inline, fullscreen, and PiP. Only consume the
// retired-video listener when a late PiP entry actually arrives.
const onPresentationChange = () => {
if (video.webkitPresentationMode !== 'picture-in-picture') return;
video.removeEventListener(
'webkitpresentationmodechanged',
onPresentationChange
);
exitWebKitPictureInPicture(video);
};
video.addEventListener(
'webkitpresentationmodechanged',
onPresentationChange
);
exitWebKitPictureInPicture(video);
}
function exitWebKitPictureInPicture(video: WebKitPictureInPictureVideo): void {
try {
if (video.webkitPresentationMode === 'picture-in-picture') {
video.webkitSetPresentationMode?.('inline');
}
} catch {
// WebKit's synchronous API can reject presentation changes at teardown.
}
}
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.
}
}
@@ -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