fix(studio): iPhone Safari layout + touch-drag scrubber (#308)

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)
This commit is contained in:
Miguel Ángel
2026-04-18 01:44:46 +02:00
committed by GitHub
parent c49181f1fa
commit 42d39866ff
4 changed files with 89 additions and 15 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>HyperFrames Studio</title>
</head>
<body>
+2 -2
View File
@@ -611,7 +611,7 @@ export function StudioApp() {
if (resolving || !projectId) {
return (
<div className="h-screen w-screen bg-neutral-950 flex items-center justify-center">
<div className="h-full w-full bg-neutral-950 flex items-center justify-center">
<div className="w-4 h-4 rounded-full bg-studio-accent animate-pulse" />
</div>
);
@@ -621,7 +621,7 @@ export function StudioApp() {
return (
<div
className="flex flex-col h-screen w-screen bg-neutral-950 relative"
className="flex flex-col h-full w-full bg-neutral-950 relative"
onDragOver={(e) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
@@ -98,23 +98,76 @@ export const PlayerControls = memo(function PlayerControls({
[duration, onSeek],
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
// 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 `<input type="range">` 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 (
<div
className="px-4 py-2 flex items-center gap-3"
style={{ borderTop: "1px solid rgba(255,255,255,0.04)" }}
style={{
borderTop: "1px solid rgba(255,255,255,0.04)",
// Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
// the Play button + timecode on iPhone. `env(safe-area-inset-bottom)`
// is 0 everywhere else, so this is a no-op on desktop.
paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
}}
>
{/* Play/Pause button */}
<button
@@ -183,8 +242,12 @@ export const PlayerControls = memo(function PlayerControls({
aria-valuemax={Math.round(duration)}
aria-valuenow={0}
className="flex-1 h-6 flex items-center cursor-pointer group"
style={{ touchAction: "manipulation" }}
onMouseDown={handleMouseDown}
// `touch-action: none` tells the browser we're handling every
// pointer gesture on this element ourselves. Without it, iOS
// Safari consumes horizontal swipes for its own swipe-back-to-
// previous-page navigation and the scrubber can't drag left.
style={{ touchAction: "none" }}
onPointerDown={handlePointerDown}
onKeyDown={handleKeyDown}
>
<div
+11
View File
@@ -13,7 +13,18 @@ body {
#root {
width: 100vw;
/*
* 100vh on iOS Safari measures the LARGEST viewport (toolbars hidden) and
* stays fixed at that value, so when the toolbar is visible the bottom of
* the layout sits *under* it and anything at flex-end — the player
* controls row, notably — becomes untappable. `100dvh` follows the
* dynamic viewport, shrinking when the toolbar is shown so the bottom of
* #root lines up with the bottom of the visible area. Fallback to 100vh
* keeps older browsers (pre-Safari 15.4 / Firefox 101 / Chrome 108) on
* the existing behaviour.
*/
height: 100vh;
height: 100dvh;
}
/* CodeMirror overrides */