feat(01-15): singleton PiP session with body-level custodial host
All checks were successful
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
parent ceafbcb596
commit 3288a02df8
3 changed files with 209 additions and 0 deletions

View File

@ -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)
})
})

View File

@ -0,0 +1,75 @@
import { ref, readonly } from 'vue'
/** Singleton picture-in-picture session.
*
* A video handed to `adopt()` is moved into a body-level custodial host so
* that it survives the unmount of whatever view rendered it a Teleport
* and a KeepAlive'd view both move their subtree on deactivation, and a
* moved element is a removed element as far as the picture-in-picture spec
* is concerned. Owning the element at the document level sidesteps that
* entirely: nothing about where the video *used* to live can touch it once
* it has been adopted.
*
* Module singleton, no Vue lifecycle hooks a lifecycle hook in a bare
* composable silently no-ops outside a component's setup(). */
const active = ref(false)
const element = ref<HTMLVideoElement | null>(null)
let host: HTMLDivElement | null = null
let leaveHandler: (() => void) | null = null
function ensureHost(): HTMLDivElement {
if (host) return host
const el = document.createElement('div')
el.setAttribute('data-pip-session-host', '')
el.setAttribute('aria-hidden', 'true')
el.style.position = 'fixed'
el.style.top = '0'
el.style.left = '0'
el.style.width = '1px'
el.style.height = '1px'
el.style.overflow = 'hidden'
el.style.opacity = '0'
el.style.pointerEvents = 'none'
el.style.zIndex = '-1'
document.body.appendChild(el)
host = el
return el
}
function release(): void {
const video = element.value
if (video) {
if (leaveHandler) video.removeEventListener('leavepictureinpicture', leaveHandler)
video.pause()
video.removeAttribute('src')
video.src = ''
video.load()
if (video.parentNode) video.parentNode.removeChild(video)
}
leaveHandler = null
element.value = null
active.value = false
}
function adopt(video: HTMLVideoElement): void {
if (active.value && element.value && element.value !== video) {
release()
}
const hostEl = ensureHost()
hostEl.appendChild(video)
element.value = video
active.value = true
leaveHandler = () => release()
video.addEventListener('leavepictureinpicture', leaveHandler)
}
export function usePipSession() {
return {
active: readonly(active),
element: readonly(element),
adopt,
release,
}
}

View File

@ -8,6 +8,17 @@ export const pipSupported =
'pictureInPictureEnabled' in document &&
document.pictureInPictureEnabled
/** Same three checks as `pipSupported`, but evaluated at call time rather
* than import time lets a test stub support before or after this module
* is imported, and lets a component re-check support on demand. */
export function isPipSupported(): boolean {
return (
typeof document !== 'undefined' &&
'pictureInPictureEnabled' in document &&
document.pictureInPictureEnabled
)
}
export async function togglePip(video: HTMLVideoElement | null | undefined): Promise<void> {
if (!video || !pipSupported) return
try {