feat(01-15): singleton PiP session with body-level custodial host
Demo images / Build & push demo images (push) Successful in 3m44s

usePipSession() owns an off-screen div under document.body; adopt(video)
moves the element there so it survives the unmount of whatever view
rendered it (a Teleport and a KeepAlive'd view both move their subtree on
deactivation, which the picture-in-picture spec treats as removal).
release() tears down playback and detaches the element; a
leavepictureinpicture listener on the adopted element is the primary
release path so an orphaned owner can never leak it.

Also adds isPipSupported() to pip.ts — a call-time version of the existing
import-time pipSupported const, needed because a test can't restub
document.pictureInPictureEnabled after import. togglePip and pipSupported
are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-31 22:11:56 -04:00
co-authored by Claude Opus 5
parent ceafbcb596
commit 3288a02df8
3 changed files with 209 additions and 0 deletions
@@ -0,0 +1,123 @@
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
import { defineComponent, h } from 'vue'
import { mount } from '@vue/test-utils'
import { usePipSession } from '../usePipSession'
import { isPipSupported } from '../../utils/pip'
// jsdom has no picture-in-picture implementation at all, so these three
// document/element members don't exist until stubbed — matching the plan's
// note that `pipSupported` (module-level) can't be restubbed after import,
// which is exactly why `isPipSupported()` exists as a call-time check.
function definePipStub(enabled: boolean) {
Object.defineProperty(document, 'pictureInPictureEnabled', {
value: enabled,
configurable: true,
})
}
beforeEach(() => {
definePipStub(true)
if (!('pictureInPictureElement' in document)) {
Object.defineProperty(document, 'pictureInPictureElement', {
value: null,
configurable: true,
})
}
if (!HTMLVideoElement.prototype.requestPictureInPicture) {
HTMLVideoElement.prototype.requestPictureInPicture = async function () {
return null as unknown as PictureInPictureWindow
}
}
if (!document.exitPictureInPicture) {
document.exitPictureInPicture = async () => {}
}
})
function hostEl(): HTMLElement | null {
return document.querySelector('[data-pip-session-host]')
}
describe('usePipSession', () => {
afterEach(() => {
usePipSession().release()
})
it('adopts a video into a body-level host that survives the owner unmounting', () => {
const Owner = defineComponent({
setup() {
return () => h('div', [h('video')])
},
})
const wrapper = mount(Owner, { attachTo: document.body })
const video = wrapper.find('video').element as HTMLVideoElement
const session = usePipSession()
session.adopt(video)
expect(session.active.value).toBe(true)
expect(session.element.value).toBe(video)
expect(document.body.contains(video)).toBe(true)
wrapper.unmount()
// The owning component is gone; the video must still be connected.
expect(document.body.contains(video)).toBe(true)
expect(hostEl()?.contains(video)).toBe(true)
})
it('release() removes the adopted element and leaves the host empty', () => {
const video = document.createElement('video')
const session = usePipSession()
session.adopt(video)
expect(hostEl()?.contains(video)).toBe(true)
session.release()
expect(session.active.value).toBe(false)
expect(session.element.value).toBeNull()
expect(hostEl()?.contains(video)).toBe(false)
expect(hostEl()?.childElementCount).toBe(0)
})
it('creates exactly one host node no matter how many times the composable is called', () => {
const video = document.createElement('video')
usePipSession().adopt(video)
usePipSession()
usePipSession()
expect(document.querySelectorAll('[data-pip-session-host]').length).toBe(1)
})
it('a leavepictureinpicture event on the adopted element releases the session', () => {
const video = document.createElement('video')
const session = usePipSession()
session.adopt(video)
video.dispatchEvent(new Event('leavepictureinpicture'))
expect(session.active.value).toBe(false)
expect(session.element.value).toBeNull()
})
it('adopting a second element while one is active releases the first rather than leaking it', () => {
const first = document.createElement('video')
const second = document.createElement('video')
const session = usePipSession()
session.adopt(first)
session.adopt(second)
expect(session.element.value).toBe(second)
expect(hostEl()?.contains(first)).toBe(false)
expect(hostEl()?.contains(second)).toBe(true)
})
})
describe('isPipSupported', () => {
it('reads document state at call time, not at import time', () => {
definePipStub(false)
expect(isPipSupported()).toBe(false)
definePipStub(true)
expect(isPipSupported()).toBe(true)
})
})