Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/studio/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
4 changes: 2 additions & 2 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
);
Expand All @@ -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();
Expand Down
87 changes: 75 additions & 12 deletions packages/studio/src/player/components/PlayerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
miguel-heygen marked this conversation as resolved.
// 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;
Comment thread
miguel-heygen marked this conversation as resolved.

// `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);
Comment thread
miguel-heygen marked this conversation as resolved.
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],
);
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/studio/src/styles/studio.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading