diff --git a/.changes/playback-legacy-pip-teardown.md b/.changes/playback-legacy-pip-teardown.md index 8f92b9413..a0ef28472 100644 --- a/.changes/playback-legacy-pip-teardown.md +++ b/.changes/playback-legacy-pip-teardown.md @@ -3,4 +3,4 @@ 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. +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 9ed48199a..dc1824c56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -539,7 +539,10 @@ Key files: 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. + 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 321bf2533..bc85ec1a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1164,7 +1164,10 @@ engine` (restart required) or 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. + 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/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index bf7fb4fdb..2fd51281e 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -648,7 +648,10 @@ its previous Tech video when a reset replaces it. The shared 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 +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. diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts index f9ddbd283..58a724723 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts @@ -95,6 +95,45 @@ describe('ArtPlayerComponent', () => { } ); + 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', diff --git a/libs/ui/playback/src/lib/player-controls/web-video-picture-in-picture-lifecycle.spec.ts b/libs/ui/playback/src/lib/player-controls/web-video-picture-in-picture-lifecycle.spec.ts index d92292b3b..4be607472 100644 --- a/libs/ui/playback/src/lib/player-controls/web-video-picture-in-picture-lifecycle.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/web-video-picture-in-picture-lifecycle.spec.ts @@ -53,6 +53,55 @@ describe('released video PiP lifecycle', () => { } }); + 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); @@ -70,3 +119,17 @@ describe('released video PiP lifecycle', () => { 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 }; +} diff --git a/libs/ui/playback/src/lib/player-controls/web-video-picture-in-picture-lifecycle.ts b/libs/ui/playback/src/lib/player-controls/web-video-picture-in-picture-lifecycle.ts index c4d457665..624c67438 100644 --- a/libs/ui/playback/src/lib/player-controls/web-video-picture-in-picture-lifecycle.ts +++ b/libs/ui/playback/src/lib/player-controls/web-video-picture-in-picture-lifecycle.ts @@ -1,5 +1,10 @@ const releasedVideos = new WeakSet(); +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 @@ -17,6 +22,38 @@ export function releaseVideoPictureInPicture( { 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 {