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 @@
+
);
@@ -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 */}