fix(player): address #298 review — tighter drift, dynamic proxies, ownership event#307
Conversation
Addresses James Russo's review on PR #298. - Drop drift threshold 150ms → 50ms. ITU-R BT.1359 puts A/V offset perceptibility at ±45ms; 150ms risks audible lip-sync drift on talking-head content. Steady-state offset under parent ownership on the factory-series-c-video repro measures 27–37ms. - Add MutationObserver for dynamic sub-composition media so late- attached audio[data-start] / video[data-start] elements get a parent proxy. Without it, parent ownership silences the new element in the iframe via sticky outputMuted but has no parent counterpart to play — silent hole. - Make bridgeMuted sticky in syncRuntimeMedia. Symmetric with mediaOutputMuted so a sub-composition activating after user mute doesn't briefly play at author volume before the next bridge message. Both flags feed a single shouldMute gate per tick. - Reset _audioOwner on iframe load. Latch previously never cleared, leaving stale parent-ownership against a fresh runtime after composition switch — a brief double-voice window until the next NotAllowedError re-promoted. - Dispatch audioownershipchange CustomEvent on every owner change, with reason: 'autoplay-blocked' | 'iframe-reload'. Gives host apps an SLO-ready signal for '% of sessions in parent ownership'. - Surface parent proxy play() rejection as a playbackerror event instead of swallowing silently. Covers the rare case where the parent also lacks activation. - Test gaps filled: userMuted stickiness, OR invariant between flags, contract pin (media.ts fires onAutoplayBlocked every rejection), caller-side latch pattern dedupe, audioownershipchange dispatch, mid-playback promotion path, playbackerror surface. - One-line comment in _promoteToParentProxy on the postMessage async race — defensible because the autoplay gate keeps rejecting until the mute lands. Not addressed here: - Manual verification on a physical iOS / Android device (review item #7). Simulated autoplay block via agent-browser + mobile emulation was done on the original PR; real-device validation is still pending before the next release.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
jrusso1020
left a comment
There was a problem hiding this comment.
Thanks for the thorough response — the 50 ms threshold with the BT.1359 citation, the OR-combined mute invariant, and the ownership-reset on iframe reload all address the #298 points cleanly, and the four new media.test.ts cases (sticky userMuted, OR invariant, fires-every-rejection, caller-latch) are exactly the right pins.
A few concerns worth addressing before merge, flagged inline. The most important is the playbackerror dedup — under parent ownership with parent-also-blocked (the scenario the code comment explicitly anticipates), every paused→playing transition in the iframe message loop will fire a fresh event to the host. Same class of noise problem that mediaAutoplayBlockedPosted already solved on the runtime side.
Secondary: the MutationObserver handles additions but not removals, which means parent-frame proxies accumulate across sub-composition cycles until the next iframe reload. And the _adoptIframeMedia length-delta dance is fine today but one refactor away from a subtle bug.
One broader note, not worth blocking: audioownershipchange and playbackerror are now part of the player's public contract. Worth exporting AudioOwnershipChangeDetail / PlaybackErrorDetail types from @hyperframes/player so host apps can addEventListener<Typed> without casting e.detail as any. Can be a follow-up.
…arentMedia Addresses #307 review feedback from @jrusso1020: - playbackerror dedup: add `_playbackErrorPosted` one-shot latch so under parent ownership with parent-also-blocked, repeated paused→playing transitions don't spam hosts with one event per tick. Mirrors the runtime's `mediaAutoplayBlockedPosted` pattern. Cleared on iframe reload alongside the owner reset. - MutationObserver removedNodes: symmetric detach path so unmounted sub-composition media doesn't leak parent-document `<audio>` nodes or leave orphaned proxies for `_playParentMedia` to iterate. - `_createParentMedia` now returns the created entry (or null on dedup), removing the fragile length-delta + `[length - 1]` inference in the caller — refactor-safe if the creator ever emits >1 element. Adds a regression test pinning the latch: three paused→playing cycles under rejecting `play()` yield exactly one `playbackerror` event. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…arentMedia Addresses #307 review feedback from @jrusso1020: - playbackerror dedup: add `_playbackErrorPosted` one-shot latch so under parent ownership with parent-also-blocked, repeated paused→playing transitions don't spam hosts with one event per tick. Mirrors the runtime's `mediaAutoplayBlockedPosted` pattern. Cleared on iframe reload alongside the owner reset. - MutationObserver removedNodes: symmetric detach path so unmounted sub-composition media doesn't leak parent-document `<audio>` nodes or leave orphaned proxies for `_playParentMedia` to iterate. - `_createParentMedia` now returns the created entry (or null on dedup), removing the fragile length-delta + `[length - 1]` inference in the caller — refactor-safe if the creator ever emits >1 element. Adds a regression test pinning the latch: three paused→playing cycles under rejecting `play()` yield exactly one `playbackerror` event.
56c187a to
9afafc5
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
Walked through 9afafc5. All four blockers resolved cleanly.
Prior concerns — resolved
-
playbackerrordedup._playbackErrorPostedlatch is the right shape — mirrorsmediaAutoplayBlockedPosted, cleared in_onIframeLoadalongside the owner reset, so latch lifetime is correctly bound to the_audioOwner === "parent"session (which is the only thing that demotes back to"runtime"today). Both_playParentMediaand_adoptIframeMediafunnel through_reportPlaybackError, so there's one gate, not two. Regression test pins it. -
_createParentMediatyped return.{ el, start, duration } | nullwith the dedup-returns-null contract in the JSDoc. Caller drops the length-delta dance. Refactor-safe. -
removedNodeshandling. Symmetric_detachIframeMediaresolves vianew URL(rawSrc, iframeEl.ownerDocument.baseURI).href(identical to adoption), pauses, clearssrcto free the decoder, splices out. Walks direct match plus nestedquerySelectorAll, mirroring the adoption walk. -
Hysteresis / typed event details. Deferred with clear rationale; follow-ups noted. Fine.
Nits for a follow-up (non-blocking)
-
No regression test for
_detachIframeMedia. The addition path has tests; the symmetric removal doesn't. Long-running sub-composition cycling is the exact case the detach exists for — a test that adds, removes, and asserts_parentMedia.length === 0pluspause()called would lock in the symmetry. -
The dedup test drives
player.play()/player.pause()directly rather than simulating the iframe state-message loop the code comment describes as the real trigger (_onMessage→_playParentMedia). Close enough in practice, but a test that posts synthetic iframe messages would exercise the real path. -
Processing order within a single
MutationRecord. The loop doesaddedNodesthenremovedNodes. For thechildListedge case where a record carries both (e.g.replaceChildren), you'd adopt then immediately detach. Almost certainly never fires foraudio[data-start]/video[data-start]in practice, and the outcome is correct anyway — theoretical. -
_createParentMedia→nulldedup contract isn't directly tested. Call sites now branch on it; a one-line unit test would pin it.
Approve.
Stacked on top of #307. Fixes three mobile UX bugs that made the studio unusable on iPhone Safari — discovered while testing the audio-ownership work from #307 on a physical device. ## Bugs fixed ### 1. Untappable Play button / bottom controls `#root` was set to `100vh`. iOS Safari reports `100vh` as the **largest** viewport (toolbar hidden) and never shrinks it — so with the toolbar visible, the bottom of the layout sits under it. The Play button + timecode were fully occluded. ### 2. Scrubber not draggable by touch The seek bar had only `onMouseDown`. Mouse events don't fire for touches on iOS Safari, so nothing responded. You could tap to jump but not drag. ### 3. Safari's horizontal swipe hijacked scrubber drags Even when the seek bar caught `pointerdown`, `touch-action: manipulation` still let Safari consume horizontal edge-swipes for back-navigation — dragging the scrubber left was impossible. ## What changed | File | Fix | |---|---| | `packages/studio/src/styles/studio.css` | `#root` → `height: 100dvh` with `100vh` fallback. Dynamic viewport height shrinks when the iOS toolbar is visible, so the bottom of `#root` lines up with the visible area. | | `packages/studio/src/App.tsx` | Two `h-screen` containers → `h-full` so nested children fill the now-dynamic parent instead of asserting `100vh` and overflowing. | | `packages/studio/index.html` | Added `viewport-fit=cover` so iOS exposes real `env(safe-area-inset-bottom)` values. | | `packages/studio/src/player/components/PlayerControls.tsx` | Controls row gets `padding-bottom: calc(0.5rem + env(safe-area-inset-bottom))` so it clears the landscape home indicator. Scrubber replaced `onMouseDown` with `onPointerDown` + `setPointerCapture`, plus `touch-action: none` so Safari doesn't hijack horizontal swipes. Added `pointercancel` + window-level `pointerup` fallbacks. | All desktop code paths are unchanged: `100dvh` falls back to `100vh`, `env(safe-area-inset-bottom)` is `0` off-iOS, Pointer Events subsume Mouse Events on desktop. ## Verified live Via the `cloudflared` tunnel I ran during review on the factory-series-c-video project: - iPhone Safari, portrait: Play button now fully visible and tappable. Bottom controls sit just above the URL bar. - iPhone Safari, landscape: controls clear the home indicator. - Finger-drag the scrubber left and right: tracks the touch smoothly, finger can leave the 6 px bar height without losing the drag. - Desktop click-to-seek and click-drag: still work. - Arrow-key seeking: still works. ## Stacked dependency Base is `fix/player-audio-ownership-review` (PR #307). Once #307 merges, rebase this branch onto `main` — the changes are fully independent; the stacking is just to avoid waiting on the review for #307 before shipping pure UX wins. ## Test plan - [x] `tsc --noEmit` on `packages/studio` — clean - [x] `bun run --filter @hyperframes/studio build` — clean - [x] Live repro on iPhone Safari via cloudflared tunnel: Play button tappable, scrubber drags with touch - [ ] Android Chrome sanity pass before release (same Pointer Events code path, but worth eye-balling)

Follow-up to PR #298 addressing @jrusso1020's review. Each item below maps to a point in his comment.
Significant
1. Drift threshold 150 ms → 50 ms
mirrorParentMediaTime was too loose for lip-synced talking-head content. ITU-R BT.1359 puts A/V perceptibility at ±45 ms; 150 ms sat well inside the "unacceptable" zone. Dropped to 50 ms, extracted as a static constant for clarity.
Verified live on factory-series-c-video (agent-browser): steady-state offset under parent ownership sampled five times over 400 ms =
[35.7, 33.5, 31.2, 27.2, 36.9]ms — below the perceptibility floor. Before this PR the same measurement could drift up to 150 ms before correction.2. Dynamic sub-composition media proxies
Under parent ownership, a sub-composition that attaches a new
<audio data-start>mid-playback was correctly silenced in the iframe (stickyoutputMuted) but had no parent-frame counterpart to play → silent hole in the audio track.Added a
MutationObserveron the iframe body watching foraudio[data-start]/video[data-start]additions. New elements are adopted through the same_adoptIframeMediahelper the initial scan uses, and if parent ownership is already active the new proxy gets itscurrentTimemirrored andplay()called immediately (gated on!this._paused). Observer disconnects on iframe reload + component disconnect.3.
bridgeMutedsticky insyncRuntimeMediaThe asymmetry James flagged:
outputMutedwas sticky per-tick,bridgeMutedwas one-shot viaonSetMuted. A sub-composition activating after a user mute would briefly play at author volume before the next bridge message.syncRuntimeMedianow acceptsuserMutedand the per-clip loop uses a single combinedshouldMutegate. One invariant, two inputs.4. Reset
_audioOwneron iframe reloadThe latch never cleared. On composition switch the player would stay in
parentownership against a fresh runtime that hadn't receivedset-media-output-mutedand whose autoplay-blocked latch was clean — a brief double-audio window until the nextNotAllowedErrorre-promoted (idempotently)._onIframeLoadnow resets_audioOwner = "runtime", pauses any parent proxies, and disconnects the old MutationObserver before a fresh one attaches to the new document. If the player had been inparentownership, a correspondingaudioownershipchangeevent fires withreason: "iframe-reload".Worth addressing
5. Promotion → observable event + reason
Promotion was invisible. Added
CustomEvent("audioownershipchange", { detail: { owner, reason } })fired on every owner transition.reasonis either"autoplay-blocked"(promote → parent) or"iframe-reload"(reset → runtime). Gives host apps an SLO-ready signal for "% of sessions in parent ownership" without exposing internal state.Verified live: dispatching a synthetic
media-autoplay-blockedin the live studio produced{ owner: "parent", reason: "autoplay-blocked" }on the web component exactly once.6. Parent proxy play() rejection →
playbackerroreventPreviously swallowed silently. Now re-emitted as
CustomEvent("playbackerror", { detail: { source: "parent-proxy", error } })so embedding apps can recover or fall back.7. Mobile verification on real hardware
Tested with a tunnel in a real iOS device.
Test gaps (from review)
userMutedstickiness (mirror of the existingoutputMutedtest).outputMutedanduserMuted— explicit test that setting one false while the other is true keepsel.muted === true.syncRuntimeMediafiresonAutoplayBlockedon every rejection (no internal dedupe) — so a future refactor can't quietly move the latch and break the caller's posting logic.audioownershipchangedispatch on promotion + once per transition (no duplicate on idempotent re-promote)._paused = falseat flip time fires_playParentMediaimmediately.playbackerrorsurface on parent proxy rejection with the rightsourcetag.Minor
_promoteToParentProxyexplaining thepostMessageasync race (the mute lands after ~one message-loop tick; the autoplay gate that triggered promotion keeps the iframe rejectingplay()during that window, so the double-play bug doesn't reappear).What's good (from the review)
Kept as-is — noted for posterity:
mutedvsvolumeframing (orthogonal channels).NotAllowedErrorinstead ofmatchMedia('(pointer: coarse)')/ UA sniffing.Test results
packages/core/src/runtime/media.test.ts— 42 tests pass (+4 new:userMutedsticky, OR invariant, fires-every-rejection, caller-latch dedupe)packages/core/src/runtime/bridge.test.ts— 15 tests passpackages/player/src/hyperframes-player.test.ts— 26 tests pass (+3 new:audioownershipchangedispatch, mid-playback promotion,playbackerrorsurface)core+playertsupbuild green oncore/player/clivolumechangethrash, zeroPARENT.play()calls; parent ownership measures 27–37 ms steady-state drift, well inside the 50 ms threshold.Test plan
audioownershipchangeevent fires with correct detail on synthetic autoplay block