fix(01-15): hand the video off to a custodial PiP session (UIFIX-05)

Entering picture-in-picture read as the lightbox being dismissed, and the
session died with it: both Teleport and KeepAlive move their subtree on
deactivation, which the PiP spec treats as removal.

MediaLightbox now listens for the video's own enterpictureinpicture event —
so PiP entered by the browser's native control behaves identically to the
toolbar button — and follows a fixed order: adopt, animate, then emit close.
Adopting first is what makes the element survive the unmount the emit
triggers; the invariant is documented in place so a refactor cannot reorder
it innocently.

The backdrop animates a handoff on the PiP path only, closing on transitionend
with a bounded 350ms fallback for browsers that skip the transition and for
the reduced-motion path where the duration is zero and the event never fires.

Verified: 5 new tests plus the full frontend suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-01 05:53:38 -04:00
co-authored by Claude Opus 5
parent 4265254700
commit 46bf5a7870
3 changed files with 352 additions and 2 deletions
@@ -0,0 +1,145 @@
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import MediaLightbox from '../cloud/MediaLightbox.vue'
import { usePipSession } from '../../composables/usePipSession'
import type { FileBrowserItem } from '../../api/filebrowser-client'
// jsdom has no picture-in-picture implementation — stub the pieces the
// component and the session touch so `enterpictureinpicture` /
// `leavepictureinpicture` can be dispatched like a real browser would.
beforeEach(() => {
// jsdom doesn't implement these either — MediaLightbox's onUnmounted
// revokes every cached blob URL, which throws "not implemented" otherwise.
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:stub')
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn()
Object.defineProperty(document, 'pictureInPictureEnabled', {
value: true,
configurable: 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 () => {}
}
})
afterEach(() => {
usePipSession().release()
document.body.innerHTML = ''
})
const videoItem: FileBrowserItem = {
name: 'clip.mp4',
path: '/clip.mp4',
isDir: false,
} as FileBrowserItem
// MediaLightbox teleports its content to <body>, so its markup lives
// outside the mounted wrapper's own root element — query the document
// directly rather than through `wrapper.find`.
function findVideo(): HTMLVideoElement {
const video = document.body.querySelector('video')
if (!video) throw new Error('video not rendered')
return video as HTMLVideoElement
}
function findBackdrop(): HTMLElement {
const backdrop = document.body.querySelector('.lightbox-backdrop')
if (!backdrop) throw new Error('backdrop not rendered')
return backdrop as HTMLElement
}
async function mountLightbox() {
const wrapper = mount(MediaLightbox, {
props: {
items: [videoItem],
startIndex: 0,
show: true,
fetchBlobUrl: vi.fn().mockResolvedValue('blob:fetch'),
streamUrl: vi.fn().mockResolvedValue('blob:stream'),
},
attachTo: document.body,
})
await flushPromises()
return wrapper
}
describe('MediaLightbox picture-in-picture handoff', () => {
it('entering PiP emits close exactly once and adopts the video before doing so', async () => {
const wrapper = await mountLightbox()
const video = findVideo()
video.dispatchEvent(new Event('enterpictureinpicture'))
// Adopted synchronously, before the animation/emit has finished — the
// video is no longer a descendant of the lightbox's own subtree.
expect(usePipSession().active.value).toBe(true)
expect(usePipSession().element.value).toBe(video)
expect(findBackdrop().contains(video)).toBe(false)
expect(wrapper.emitted('close')).toBeUndefined()
// Bounded fallback fires the close even without a real transitionend
// (jsdom does not run CSS transitions).
await new Promise((resolve) => setTimeout(resolve, 400))
expect(wrapper.emitted('close')).toHaveLength(1)
wrapper.unmount()
// Still connected to the document after the owner unmounts.
expect(document.body.contains(video)).toBe(true)
})
it('applies the handoff class on the PiP path', async () => {
await mountLightbox()
const video = findVideo()
video.dispatchEvent(new Event('enterpictureinpicture'))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(findBackdrop().classList.contains('lightbox-pip-handoff')).toBe(true)
})
it('applies no handoff class on a button-driven close', async () => {
const wrapper = await mountLightbox()
const closeButton = document.body.querySelector(
'.lightbox-topbar .lightbox-btn:last-child'
) as HTMLButtonElement
closeButton.click()
await flushPromises()
expect(wrapper.emitted('close')).toHaveLength(1)
expect(findBackdrop().classList.contains('lightbox-pip-handoff')).toBe(false)
})
it('releases the session when picture-in-picture is left', async () => {
await mountLightbox()
const video = findVideo()
video.dispatchEvent(new Event('enterpictureinpicture'))
expect(usePipSession().active.value).toBe(true)
video.dispatchEvent(new Event('leavepictureinpicture'))
expect(usePipSession().active.value).toBe(false)
})
it('does not change props or emits declared by the component', async () => {
// Contract guard mirrored from the diff-based acceptance criterion:
// this component is used by a second, parallel instance (plan 01-14)
// and must keep working with an unmodified prop/emit set.
const wrapper = await mountLightbox()
expect(wrapper.props('startIndex')).toBe(0)
expect(wrapper.props('show')).toBe(true)
expect(typeof wrapper.props('fetchBlobUrl')).toBe('function')
})
})
@@ -4,6 +4,7 @@
<div
v-if="show"
class="lightbox-backdrop"
:class="{ 'lightbox-pip-handoff': pipHandoff }"
@click.self="close"
@keydown="onKeydown"
tabindex="0"
@@ -19,7 +20,7 @@
</div>
<div class="flex items-center gap-1">
<button
v-if="pipSupported && currentItem && isVideoFile(currentItem)"
v-if="pipAvailable && currentItem && isVideoFile(currentItem)"
class="lightbox-btn"
title="Picture-in-picture"
@click.stop="togglePip(videoEl)"
@@ -84,6 +85,8 @@
autoplay
@dblclick="toggleFullscreen"
@error="onMediaError"
@enterpictureinpicture="onEnterPip"
@leavepictureinpicture="onLeavePip"
/>
<!-- Audio -->
@@ -124,7 +127,8 @@
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { getFileCategory } from '@/composables/useFileType'
import { pipSupported, togglePip } from '@/utils/pip'
import { isPipSupported, togglePip } from '@/utils/pip'
import { usePipSession } from '@/composables/usePipSession'
const props = defineProps<{
items: FileBrowserItem[]
@@ -145,6 +149,16 @@ const currentUrl = ref<string | null>(null)
const backdropEl = ref<HTMLElement | null>(null)
const videoEl = ref<HTMLVideoElement | null>(null)
const pipAvailable = isPipSupported()
const pipSession = usePipSession()
const pipHandoff = ref(false)
// Bounded fallback for the transitionend-driven close below — covers a
// browser that skips the transition entirely (including the reduced-motion
// path, where the CSS transition duration is zero and transitionend never
// fires).
const PIP_HANDOFF_FALLBACK_MS = 350
let handoffTimer: ReturnType<typeof setTimeout> | null = null
const urlCache = new Map<string, string>()
const mediaItems = computed(() =>
@@ -234,6 +248,38 @@ function close() {
emit('close')
}
function finishPipHandoff() {
backdropEl.value?.removeEventListener('transitionend', finishPipHandoff)
if (handoffTimer) {
clearTimeout(handoffTimer)
handoffTimer = null
}
emit('close')
}
// Listens for the enterpictureinpicture event itself, rather than inferring
// from the button click, so PiP entered by any route (the browser's own
// control, a keyboard shortcut) behaves the same. Order matters here and
// must stay exactly this: adopt first, animate second, emit last — adopting
// first is what makes the element survive the unmount the emit triggers.
function onEnterPip() {
const video = videoEl.value
if (!video) return
pipSession.adopt(video)
pipHandoff.value = true
backdropEl.value?.addEventListener('transitionend', finishPipHandoff, { once: true })
handoffTimer = setTimeout(finishPipHandoff, PIP_HANDOFF_FALLBACK_MS)
}
// The session's own leavepictureinpicture listener (attached in
// usePipSession at adopt time) is the primary release path, since the
// lightbox has normally already unmounted by the time PiP is exited. This
// handler only covers the case where the lightbox is somehow still
// mounted; release() is idempotent so calling it twice is harmless.
function onLeavePip() {
pipSession.release()
}
function toggleFullscreen() {
const el = videoEl.value
if (!el) return
@@ -264,6 +310,7 @@ watch(currentItem, (item) => {
watch(() => props.show, async (visible) => {
if (visible) {
pipHandoff.value = false
currentIndex.value = props.startIndex
const item = mediaItems.value[props.startIndex]
if (item) {
@@ -422,6 +469,38 @@ onUnmounted(() => {
opacity: 0;
}
/* Picture-in-picture handoff (UIFIX-05): reads as the video moving into the
PiP window rather than a dismissal. The backdrop's blur/opacity fall away
while the content scales down and drifts toward the bottom-right corner
most browsers default the PiP window to — a best-effort convention, since
the actual corner is browser- and user-controlled. Applied only on the
PiP path; a normal close (button or Escape) never gets this class. */
.lightbox-pip-handoff {
transition: opacity 0.3s ease, backdrop-filter 0.3s ease;
opacity: 0;
backdrop-filter: blur(0px);
-webkit-backdrop-filter: blur(0px);
}
.lightbox-pip-handoff .lightbox-topbar,
.lightbox-pip-handoff .lightbox-nav {
transition: opacity 0.3s ease;
opacity: 0;
}
.lightbox-pip-handoff .lightbox-content {
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
transform: scale(0.4) translate(40vw, 40vh);
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.lightbox-pip-handoff,
.lightbox-pip-handoff .lightbox-topbar,
.lightbox-pip-handoff .lightbox-nav,
.lightbox-pip-handoff .lightbox-content {
transition: none;
}
}
/* Mobile */
@media (max-width: 768px) {
.lightbox-content { padding: 3rem 0; }