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 */}