mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user