From 42d39866ffb52c1c377f5f384b760f2de8519982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 18 Apr 2026 01:44:46 +0200 Subject: [PATCH] fix(studio): iPhone Safari layout + touch-drag scrubber (#308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/studio/index.html | 2 +- packages/studio/src/App.tsx | 4 +- .../src/player/components/PlayerControls.tsx | 87 ++++++++++++++++--- packages/studio/src/styles/studio.css | 11 +++ 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/packages/studio/index.html b/packages/studio/index.html index 14a71d575..718c43415 100644 --- a/packages/studio/index.html +++ b/packages/studio/index.html @@ -2,7 +2,7 @@ - + HyperFrames Studio diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 1c632001a..18ad5367d 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -611,7 +611,7 @@ export function StudioApp() { if (resolving || !projectId) { return ( -
+
); @@ -621,7 +621,7 @@ export function StudioApp() { return (
{ if (!e.dataTransfer.types.includes("Files")) return; e.preventDefault(); diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index 684b9de76..b5d59e84f 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -98,23 +98,76 @@ export const PlayerControls = memo(function PlayerControls({ [duration, onSeek], ); - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + // Ignore secondary mouse buttons — only primary (left click / touch / + // pen contact) should start a drag. + if (e.button !== 0) return; e.preventDefault(); + // preventDefault() on pointerdown also suppresses the implicit focus + // transfer that click normally grants a `tabIndex=0` element — which + // matches native `` behavior, but it also means a + // click-then-arrow-key workflow wouldn't work. Restore focus explicitly + // so seeking by click and nudging by arrow keys compose naturally. + e.currentTarget.focus(); isDraggingRef.current = true; + + // `setPointerCapture` routes every subsequent pointermove/up to the + // slider element even when the pointer leaves its bounding box. Without + // it, fast drags on touch would lose events the moment the finger + // slips outside the 6 px-tall hit zone. + const target = e.currentTarget; + const pointerId = e.pointerId; + try { + target.setPointerCapture(pointerId); + } catch { + /* non-supporting browsers fall back to window listeners below */ + } + seekFromClientX(e.clientX); - const onMouseMove = (me: MouseEvent) => { - if (isDraggingRef.current) seekFromClientX(me.clientX); + const onMove = (ev: PointerEvent) => { + if (ev.pointerId !== pointerId) return; + if (isDraggingRef.current) seekFromClientX(ev.clientX); }; - const onMouseUp = () => { + const cleanup = () => { isDraggingRef.current = false; - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); + try { + target.releasePointerCapture(pointerId); + } catch { + /* Already released after the first cleanup — second invocation + via the window-fallback or visibility path is a no-op throw. */ + } + target.removeEventListener("pointermove", onMove); + target.removeEventListener("pointerup", onUp); + target.removeEventListener("pointercancel", onUp); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("blur", cleanup); + }; + const onUp = (ev: PointerEvent) => { + if (ev.pointerId !== pointerId) return; + cleanup(); + }; + // iOS Safari does not reliably fire `pointercancel` when the page is + // backgrounded mid-drag (alt-tab, incoming call, switch apps). Without + // a release path the ref stays `true` until the next pointerdown — a + // stuck-scrubber class bug waiting to happen if anyone later gates + // rendering on `isDragging`. Synthesize the release on hide / blur. + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") cleanup(); }; - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); + target.addEventListener("pointermove", onMove); + target.addEventListener("pointerup", onUp); + target.addEventListener("pointercancel", onUp); + // Window-level fallback in case capture fails and the pointer release + // lands outside the element (rare, but defensive). + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("blur", cleanup); }, [seekFromClientX], ); @@ -137,7 +190,13 @@ export const PlayerControls = memo(function PlayerControls({ return (
{/* Play/Pause button */}