feat(01-15): singleton PiP session with body-level custodial host
Demo images / Build & push demo images (push) Successful in 3m44s
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:
co-authored by
Claude Opus 5
parent
ceafbcb596
commit
3288a02df8
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,17 @@ export const pipSupported =
|
|||||||
'pictureInPictureEnabled' in document &&
|
'pictureInPictureEnabled' in document &&
|
||||||
document.pictureInPictureEnabled
|
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> {
|
export async function togglePip(video: HTMLVideoElement | null | undefined): Promise<void> {
|
||||||
if (!video || !pipSupported) return
|
if (!video || !pipSupported) return
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user