diff --git a/.planning/phases/01-federation-mesh-hardening/01-15-SUMMARY.md b/.planning/phases/01-federation-mesh-hardening/01-15-SUMMARY.md new file mode 100644 index 00000000..d638e587 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-15-SUMMARY.md @@ -0,0 +1,126 @@ +--- +phase: 01-federation-mesh-hardening +plan: 15 +subsystem: ui +tags: [vue, picture-in-picture, media, lifecycle, teleport] + +requires: + - phase: 01-federation-mesh-hardening + provides: "MediaLightbox.vue's existing lightbox shell (Teleported backdrop, video element, pip button) and utils/pip's browser-support helper" +provides: + - "usePipSession(): a singleton PiP session that owns a body-level custodial host, so an adopted video survives the unmount of whatever view rendered it" + - "A lightbox→PiP handoff that reads as the video moving into the PiP window rather than the lightbox being dismissed" +affects: [cloud-files, media-viewer] + +tech-stack: + added: [] + patterns: + - "Custodial host pattern: to keep a media element alive across a view teardown, move it into an off-screen div under document.body before the teardown runs. Both Teleport and KeepAlive move their subtree on deactivation, which the picture-in-picture spec treats as removal — so the element must be re-parented above the view tree, not merely referenced." + - "Order-of-operations as a documented invariant: adopt → animate → emit close. Adopting first is what makes the element survive the unmount that the emit triggers; the comment in onEnterPip says so explicitly so a future refactor cannot reorder it innocently." + +key-files: + created: + - neode-ui/src/composables/usePipSession.ts + - neode-ui/src/components/__tests__/MediaLightboxPip.test.ts + modified: + - neode-ui/src/components/cloud/MediaLightbox.vue + +key-decisions: + - "PiP entry is detected via the element's own `enterpictureinpicture` event rather than inferred from the toolbar button, so PiP entered by any route — the browser's native control, a keyboard shortcut — takes exactly the same handoff path." + - "The backdrop's close is driven by `transitionend` with a bounded 350ms fallback timer, covering browsers that skip the transition and the reduced-motion path where the duration is zero and the event never fires." + - "`release()` is idempotent and is called both by the session's own `leavepictureinpicture` listener (the primary path, since the lightbox has usually unmounted by then) and by the component's handler for the rare still-mounted case." + +requirements-completed: [UIFIX-05] + +coverage: + - id: D1 + description: "Entering PiP closes the lightbox once, having adopted the video first so it survives the unmount" + requirement: "UIFIX-05" + verification: + - kind: unit + ref: "neode-ui/src/components/__tests__/MediaLightboxPip.test.ts#entering PiP emits close exactly once and adopts the video before doing so" + status: pass + human_judgment: false + - id: D2 + description: "The close reads as a handoff animation on the PiP path, and as an ordinary dismissal otherwise" + requirement: "UIFIX-05" + verification: + - kind: unit + ref: "…#applies the handoff class on the PiP path; …#applies no handoff class on a button-driven close" + status: pass + human_judgment: false + - id: D3 + description: "Leaving PiP releases the session and tears down playback" + requirement: "UIFIX-05" + verification: + - kind: unit + ref: "…#releases the session when picture-in-picture is left" + status: pass + human_judgment: false + - id: D4 + description: "The component's public contract (props/emits) is unchanged by the rework" + requirement: "UIFIX-05" + verification: + - kind: unit + ref: "…#does not change props or emits declared by the component" + status: pass + human_judgment: false + +duration: 85min +completed: 2026-08-01 +status: complete +--- + +# Phase 1 Plan 15: PiP Handoff That Survives the Lightbox (UIFIX-05) Summary + +**Made picture-in-picture a handoff rather than a dismissal: the video is re-parented to a body-level custodial host before the lightbox closes, so an active PiP session survives the unmount — and the close animates as the video moving out rather than the overlay disappearing.** + +## Performance + +- **Duration:** ~85 min (across two sessions — see Deviations) +- **Completed:** 2026-08-01 +- **Tasks:** 2/2 +- **Files modified:** 3 (1 new composable, 1 component, 1 new test file) + +## Accomplishments + +- `usePipSession()` owns a singleton off-screen host under `document.body`. `adopt(video)` moves the element there so it outlives the view that rendered it; `release()` pauses, detaches and tears down. A `leavepictureinpicture` listener attached at adopt time is the primary release path. +- `MediaLightbox` listens for `enterpictureinpicture`/`leavepictureinpicture` on the video itself, so any route into PiP behaves identically. +- The handoff ordering (adopt → animate → emit close) is enforced and documented in-place; a test asserts adoption happens before the single `close` emit. +- The backdrop gains a `lightbox-pip-handoff` class on the PiP path only, with `transitionend`-driven close and a 350 ms fallback for browsers that skip the transition (including reduced-motion, where the duration is zero). + +## Task Commits + +1. **Task 1: singleton PiP session with body-level custodial host** — `3288a02d` (feat) +2. **Task 2: lightbox handoff wiring, handoff animation, and test suite** — committed with this SUMMARY + +## Files Created/Modified + +- `neode-ui/src/composables/usePipSession.ts` — the singleton session and custodial host (committed in `3288a02d`). +- `neode-ui/src/components/cloud/MediaLightbox.vue` — PiP event handlers, handoff class + CSS, `transitionend`/timer close path, and the `pipSupported` → `isPipSupported()` call-site update. +- `neode-ui/src/components/__tests__/MediaLightboxPip.test.ts` — 5 tests, with jsdom stubs for the picture-in-picture APIs it does not implement. + +## Deviations from Plan + +### Process deviation: executor killed mid-verification by an SSH disconnect + +**Found during:** post-implementation verification +**Issue:** Task 1 had been committed (`3288a02d`); Task 2's component changes and test file were complete on disk but uncommitted when the orchestrating session and its agents died with the operator's SSH connection. +**Resolution:** A follow-on session re-ran the suite (5/5 green), confirmed the full frontend suite was green, wrote this SUMMARY, and committed the remainder. +**Files modified:** none beyond the original work + +## Known Stubs + +None in product code. The test file stubs `pictureInPictureEnabled`, `requestPictureInPicture` and `exitPictureInPicture` because jsdom implements none of them; jsdom also logs "Not implemented: HTMLMediaElement.prototype.pause/load" to stderr during the release test — noise, not failure. + +## Threat Flags + +None — no new endpoint or trust boundary. The one lifecycle risk (an orphaned element left attached to `document.body` after PiP ends) is closed by `release()` being idempotent and wired to both the session's own listener and the component handler. + +## Self-Check: PASSED + +- FOUND: `neode-ui/src/composables/usePipSession.ts` (created, commit `3288a02d`) +- FOUND: `neode-ui/src/components/cloud/MediaLightbox.vue` (modified) +- FOUND: `neode-ui/src/components/__tests__/MediaLightboxPip.test.ts` (created, 5 tests) +- CONFIRMED: `npx vitest run` full frontend suite green + diff --git a/neode-ui/src/components/__tests__/MediaLightboxPip.test.ts b/neode-ui/src/components/__tests__/MediaLightboxPip.test.ts new file mode 100644 index 00000000..45ac2394 --- /dev/null +++ b/neode-ui/src/components/__tests__/MediaLightboxPip.test.ts @@ -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 , 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') + }) +}) diff --git a/neode-ui/src/components/cloud/MediaLightbox.vue b/neode-ui/src/components/cloud/MediaLightbox.vue index 5c282fb7..9adf112b 100644 --- a/neode-ui/src/components/cloud/MediaLightbox.vue +++ b/neode-ui/src/components/cloud/MediaLightbox.vue @@ -4,6 +4,7 @@