From 8c7ed693d561f977fcc31bbc202ec7956c526b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 17 Apr 2026 21:09:34 +0200 Subject: [PATCH 1/2] fix(studio): iPhone Safari layout + touch-drag scrubber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three mobile UX bugs that made the studio unusable on iPhone Safari. Untappable Play button / bottom controls ---------------------------------------- - #root used 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 sat under it. Play button + timecode were occluded. Switched to 100dvh with 100vh fallback; old browsers keep the existing behaviour. - App.tsx's two h-screen containers updated to h-full so nested children fill the now-dynamic #root instead of asserting 100vh and overflowing it. - Added viewport-fit=cover to the viewport meta so iOS exposes real env(safe-area-inset-bottom) values to CSS. - PlayerControls bottom padding now calc(0.5rem + env(safe-area-inset-bottom)) so the controls clear the landscape home indicator / curved bottom edge. No-op on desktop. Scrubber not draggable by touch ------------------------------- - Seek bar only had onMouseDown — iOS Safari fires Pointer Events for touches and nothing responded. Replaced with onPointerDown + setPointerCapture so one handler covers mouse / touch / stylus and drags keep tracking when the finger leaves the 6px hit zone. - Added pointercancel handler so a home-indicator swipe doesn't wedge the drag in progress. - touch-action: none (was 'manipulation') so Safari doesn't grab horizontal swipes for back-navigation while the user scrubs left. - Window-level pointerup fallback defends against older WebKit builds that don't honour setPointerCapture. Verified live on iPhone via cloudflared tunnel on the factory-series-c-video repro: Play button tappable, scrubber drags smoothly left/right with touch, landscape home indicator no longer overlaps the controls. --- packages/studio/index.html | 2 +- packages/studio/src/App.tsx | 4 +- .../src/player/components/PlayerControls.tsx | 65 +++++++++++++++---- packages/studio/src/styles/studio.css | 11 ++++ 4 files changed, 67 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..6633abd3e 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -98,23 +98,54 @@ 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(); 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 onUp = (ev: PointerEvent) => { + if (ev.pointerId !== pointerId) return; isDraggingRef.current = false; - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); + try { + target.releasePointerCapture(pointerId); + } catch { + /* best-effort */ + } + target.removeEventListener("pointermove", onMove); + target.removeEventListener("pointerup", onUp); + target.removeEventListener("pointercancel", onUp); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); }; - 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); }, [seekFromClientX], ); @@ -137,7 +168,13 @@ export const PlayerControls = memo(function PlayerControls({ return (
{/* Play/Pause button */}