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:
co-authored by
Claude Opus 5
parent
4265254700
commit
46bf5a7870
@@ -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
|
||||
</content>
|
||||
Reference in New Issue
Block a user