mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
eba68d687e
commit
79f3f6c897
15 files changed
+581
-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. This includes Safari’s legacy picture-in-picture mode.
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+135
@@ -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.
|
||||
}
|
||||
}
|
||||
+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