From 3288a02df89ef7e8136c31c9d7ea78a5a4556e57 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 31 Jul 2026 22:11:56 -0400 Subject: [PATCH] feat(01-15): singleton PiP session with body-level custodial host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../__tests__/usePipSession.test.ts | 123 ++++++++++++++++++ neode-ui/src/composables/usePipSession.ts | 75 +++++++++++ neode-ui/src/utils/pip.ts | 11 ++ 3 files changed, 209 insertions(+) create mode 100644 neode-ui/src/composables/__tests__/usePipSession.test.ts create mode 100644 neode-ui/src/composables/usePipSession.ts diff --git a/neode-ui/src/composables/__tests__/usePipSession.test.ts b/neode-ui/src/composables/__tests__/usePipSession.test.ts new file mode 100644 index 00000000..ea2836fd --- /dev/null +++ b/neode-ui/src/composables/__tests__/usePipSession.test.ts @@ -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) + }) +}) diff --git a/neode-ui/src/composables/usePipSession.ts b/neode-ui/src/composables/usePipSession.ts new file mode 100644 index 00000000..ae0ed29c --- /dev/null +++ b/neode-ui/src/composables/usePipSession.ts @@ -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(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, + } +} diff --git a/neode-ui/src/utils/pip.ts b/neode-ui/src/utils/pip.ts index 9d2da000..7399a980 100644 --- a/neode-ui/src/utils/pip.ts +++ b/neode-ui/src/utils/pip.ts @@ -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 { if (!video || !pipSupported) return try {