diff --git a/.changes/playback-legacy-pip-teardown.md b/.changes/playback-legacy-pip-teardown.md new file mode 100644 index 000000000..8f92b9413 --- /dev/null +++ b/.changes/playback-legacy-pip-teardown.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index dc8d0321c..9ed48199a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 1ddc0c668..321bf2533 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/apps/electron-backend-e2e/src/picture-in-picture.e2e.ts b/apps/electron-backend-e2e/src/picture-in-picture.e2e.ts new file mode 100644 index 000000000..77213a791 --- /dev/null +++ b/apps/electron-backend-e2e/src/picture-in-picture.e2e.ts @@ -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); + } + }); + } +} diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 7af2cb2b2..bf7fb4fdb 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -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 `