diff --git a/.changes/playback-legacy-pip-teardown.md b/.changes/playback-legacy-pip-teardown.md new file mode 100644 index 000000000..a0ef28472 --- /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. This includes Safari’s legacy picture-in-picture mode. diff --git a/AGENTS.md b/AGENTS.md index 72a531a67..a59e9b666 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 9b7dace0c..2d054500f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. 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..4b8d83469 --- /dev/null +++ b/apps/electron-backend-e2e/src/picture-in-picture.e2e.ts @@ -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); + } + }); + } +} diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 7af2cb2b2..2fd51281e 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 `