@hyperframes/studio 0.6.37 → 0.6.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,52 +1,319 @@
1
- import { useRef, useState, useCallback, useEffect, memo } from "react";
2
- import { useMountEffect } from "../../hooks/useMountEffect";
3
- import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
1
+ import { useRef, useCallback, useEffect, memo } from "react";
2
+ import { formatFrameTime, formatTime, stepFrameTime } from "../lib/time";
4
3
  import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
5
- import { usePlayerStore, liveTime } from "../store/playerStore";
4
+ import { usePlayerStore } from "../store/playerStore";
6
5
  import { trackStudioEvent } from "../../utils/studioTelemetry";
7
6
  import { Tooltip } from "../../components/ui";
7
+ import { ShortcutsPanel } from "./ShortcutsPanel";
8
+ import { SpeedMenu } from "./SpeedMenu";
9
+ import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag";
10
+ import { useState } from "react";
8
11
 
9
- const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
10
- const SEEK_EDGE_SNAP_PX = 8;
12
+ export { resolveSeekPercent };
11
13
  type TimeDisplayMode = "time" | "frame";
12
- const SHORTCUT_SECTIONS = [
13
- {
14
- title: "Playback",
15
- hints: [
16
- { key: "Space", label: "Play / Pause" },
17
- { key: "J", label: "Play backward" },
18
- { key: "K", label: "Stop" },
19
- { key: "L", label: "Play forward" },
20
- { key: "M", label: "Toggle mute" },
21
- { key: "⇧L", label: "Toggle loop" },
22
- { key: "←/→", label: "Step 1 frame" },
23
- { key: "⇧←/⇧→", label: "Step 10 frames" },
24
- { key: "F", label: "Toggle fullscreen" },
25
- ],
26
- },
27
- {
28
- title: "Work area",
29
- hints: [
30
- { key: "I", label: "Set in-point" },
31
- { key: "⇧I", label: "Clear in-point" },
32
- { key: "O", label: "Set out-point" },
33
- { key: "⇧O", label: "Clear out-point" },
34
- { key: "A", label: "Jump to in-point" },
35
- { key: "E", label: "Jump to out-point" },
36
- ],
37
- },
38
- ] as const;
39
14
 
40
- export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
41
- if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
42
- const rawPercent = (clientX - rectLeft) / rectWidth;
43
- const clamped = Math.max(0, Math.min(1, rawPercent));
44
- const snapThreshold = Math.min(0.5, SEEK_EDGE_SNAP_PX / rectWidth);
45
- if (clamped <= snapThreshold) return 0;
46
- if (clamped >= 1 - snapThreshold) return 1;
47
- return clamped;
15
+ /* ── Icon sub-components ─────────────────────────────────────────── */
16
+
17
+ function PlayIcon() {
18
+ return (
19
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
20
+ <polygon points="6,3 20,12 6,21" />
21
+ </svg>
22
+ );
23
+ }
24
+
25
+ function PauseIcon() {
26
+ return (
27
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
28
+ <rect x="6" y="4" width="4" height="16" rx="1" />
29
+ <rect x="14" y="4" width="4" height="16" rx="1" />
30
+ </svg>
31
+ );
32
+ }
33
+
34
+ /* ── Button sub-components ───────────────────────────────────────── */
35
+
36
+ const MuteButton = memo(function MuteButton({
37
+ audioMuted,
38
+ audioAutoMuted,
39
+ effectiveAudioMuted,
40
+ controlsDisabled,
41
+ setAudioMuted,
42
+ }: {
43
+ audioMuted: boolean;
44
+ audioAutoMuted: boolean;
45
+ effectiveAudioMuted: boolean;
46
+ controlsDisabled: boolean;
47
+ setAudioMuted: (v: boolean) => void;
48
+ }) {
49
+ const label = audioAutoMuted
50
+ ? "Audio muted above 1x speed"
51
+ : audioMuted
52
+ ? "Unmute audio"
53
+ : "Mute audio";
54
+ return (
55
+ <Tooltip label={label}>
56
+ <button
57
+ type="button"
58
+ onClick={() => {
59
+ if (!audioAutoMuted) {
60
+ trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
61
+ setAudioMuted(!audioMuted);
62
+ }
63
+ }}
64
+ disabled={controlsDisabled || audioAutoMuted}
65
+ aria-label={label}
66
+ aria-pressed={effectiveAudioMuted}
67
+ className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors disabled:pointer-events-none ${
68
+ effectiveAudioMuted
69
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
70
+ : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
71
+ } ${audioAutoMuted ? "opacity-70" : ""}`}
72
+ >
73
+ <svg
74
+ width="13"
75
+ height="13"
76
+ viewBox="0 0 24 24"
77
+ fill="none"
78
+ stroke="currentColor"
79
+ strokeWidth="2"
80
+ strokeLinecap="round"
81
+ strokeLinejoin="round"
82
+ aria-hidden="true"
83
+ >
84
+ <path d="M11 5 6 9H3v6h3l5 4V5Z" />
85
+ {effectiveAudioMuted ? (
86
+ <>
87
+ <path d="m19 9-6 6" />
88
+ <path d="m13 9 6 6" />
89
+ </>
90
+ ) : (
91
+ <>
92
+ <path d="M15.5 8.5a5 5 0 0 1 0 7" />
93
+ <path d="M18.5 5.5a9 9 0 0 1 0 13" />
94
+ </>
95
+ )}
96
+ </svg>
97
+ </button>
98
+ </Tooltip>
99
+ );
100
+ });
101
+
102
+ const LoopButton = memo(function LoopButton({
103
+ loopEnabled,
104
+ disabled,
105
+ setLoopEnabled,
106
+ }: {
107
+ loopEnabled: boolean;
108
+ disabled: boolean;
109
+ setLoopEnabled: (v: boolean) => void;
110
+ }) {
111
+ return (
112
+ <Tooltip label="Loop playback">
113
+ <button
114
+ type="button"
115
+ onClick={() => {
116
+ trackStudioEvent("playback", { action: "loop_toggle", enabled: !loopEnabled });
117
+ setLoopEnabled(!loopEnabled);
118
+ }}
119
+ disabled={disabled}
120
+ className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
121
+ loopEnabled
122
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
123
+ : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
124
+ }`}
125
+ aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
126
+ aria-pressed={loopEnabled}
127
+ >
128
+ <svg
129
+ width="13"
130
+ height="13"
131
+ viewBox="0 0 24 24"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ strokeWidth="2"
135
+ strokeLinecap="round"
136
+ strokeLinejoin="round"
137
+ aria-hidden="true"
138
+ >
139
+ <path d="M17 2l4 4-4 4" />
140
+ <path d="M3 11V9a4 4 0 0 1 4-4h14" />
141
+ <path d="M7 22l-4-4 4-4" />
142
+ <path d="M21 13v2a4 4 0 0 1-4 4H3" />
143
+ </svg>
144
+ </button>
145
+ </Tooltip>
146
+ );
147
+ });
148
+
149
+ const FullscreenButton = memo(function FullscreenButton({
150
+ isFullscreen,
151
+ onToggleFullscreen,
152
+ }: {
153
+ isFullscreen: boolean;
154
+ onToggleFullscreen: () => void;
155
+ }) {
156
+ return (
157
+ <Tooltip label={isFullscreen ? "Exit fullscreen (F)" : "Enter fullscreen (F)"}>
158
+ <button
159
+ type="button"
160
+ onClick={() => {
161
+ trackStudioEvent("playback", { action: "fullscreen_toggle", active: !isFullscreen });
162
+ onToggleFullscreen();
163
+ }}
164
+ className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors ${
165
+ isFullscreen
166
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
167
+ : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
168
+ }`}
169
+ aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
170
+ >
171
+ <svg
172
+ width="13"
173
+ height="13"
174
+ viewBox="0 0 24 24"
175
+ fill="none"
176
+ stroke="currentColor"
177
+ strokeWidth="2"
178
+ strokeLinecap="round"
179
+ strokeLinejoin="round"
180
+ aria-hidden="true"
181
+ >
182
+ {isFullscreen ? (
183
+ <>
184
+ <path d="M8 3v3a2 2 0 0 1-2 2H3" />
185
+ <path d="M21 8h-3a2 2 0 0 1-2-2V3" />
186
+ <path d="M3 16h3a2 2 0 0 1 2 2v3" />
187
+ <path d="M16 21v-3a2 2 0 0 1 2-2h3" />
188
+ </>
189
+ ) : (
190
+ <>
191
+ <path d="M8 3H5a2 2 0 0 0-2 2v3" />
192
+ <path d="M21 8V5a2 2 0 0 0-2-2h-3" />
193
+ <path d="M3 16v3a2 2 0 0 0 2 2h3" />
194
+ <path d="M16 21h3a2 2 0 0 0 2-2v-3" />
195
+ </>
196
+ )}
197
+ </svg>
198
+ </button>
199
+ </Tooltip>
200
+ );
201
+ });
202
+
203
+ /* ── Seek bar sub-component ──────────────────────────────────────── */
204
+
205
+ function SeekBarMarker({ position, duration }: { position: number; duration: number }) {
206
+ if (duration <= 0) return null;
207
+ return (
208
+ <div
209
+ className="absolute z-[3] pointer-events-none"
210
+ style={{
211
+ left: `${Math.min(100, (position / duration) * 100)}%`,
212
+ top: "50%",
213
+ transform: "translate(-50%, -50%)",
214
+ width: "2px",
215
+ height: "10px",
216
+ background: "#3CE6AC",
217
+ borderRadius: "1px",
218
+ }}
219
+ />
220
+ );
221
+ }
222
+
223
+ function WorkAreaOverlay({
224
+ inPoint,
225
+ outPoint,
226
+ duration,
227
+ }: {
228
+ inPoint: number | null;
229
+ outPoint: number | null;
230
+ duration: number;
231
+ }) {
232
+ if ((inPoint === null && outPoint === null) || duration <= 0) return null;
233
+ return (
234
+ <>
235
+ <div
236
+ className="absolute top-0 bottom-0 pointer-events-none"
237
+ style={{
238
+ left: `${inPoint !== null ? Math.min(100, (inPoint / duration) * 100) : 0}%`,
239
+ right: `${outPoint !== null ? 100 - Math.min(100, (outPoint / duration) * 100) : 0}%`,
240
+ background: "rgba(60,230,172,0.15)",
241
+ }}
242
+ />
243
+ {inPoint !== null && <SeekBarMarker position={inPoint} duration={duration} />}
244
+ {outPoint !== null && <SeekBarMarker position={outPoint} duration={duration} />}
245
+ </>
246
+ );
48
247
  }
49
248
 
249
+ const SeekBar = memo(function SeekBar({
250
+ disabled,
251
+ duration,
252
+ inPoint,
253
+ outPoint,
254
+ progressFillRef,
255
+ progressThumbRef,
256
+ seekBarRef,
257
+ sliderRef,
258
+ onPointerDown,
259
+ onKeyDown,
260
+ }: {
261
+ disabled: boolean;
262
+ duration: number;
263
+ inPoint: number | null;
264
+ outPoint: number | null;
265
+ progressFillRef: React.RefObject<HTMLDivElement | null>;
266
+ progressThumbRef: React.RefObject<HTMLDivElement | null>;
267
+ seekBarRef: React.RefObject<HTMLDivElement | null>;
268
+ sliderRef: React.RefObject<HTMLDivElement | null>;
269
+ onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
270
+ onKeyDown: (e: React.KeyboardEvent) => void;
271
+ }) {
272
+ return (
273
+ <div
274
+ ref={(el) => {
275
+ (seekBarRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
276
+ (sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
277
+ }}
278
+ role="slider"
279
+ tabIndex={disabled ? -1 : 0}
280
+ aria-label="Seek"
281
+ aria-disabled={disabled || undefined}
282
+ aria-valuemin={0}
283
+ aria-valuemax={Math.round(duration)}
284
+ aria-valuenow={0}
285
+ className={`min-w-[96px] flex-1 h-6 flex items-center group ${
286
+ disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
287
+ }`}
288
+ style={{ touchAction: "none" }}
289
+ onPointerDown={onPointerDown}
290
+ onKeyDown={onKeyDown}
291
+ >
292
+ <div
293
+ className="w-full rounded-full relative"
294
+ style={{ background: "rgba(255,255,255,0.15)", height: "3px" }}
295
+ >
296
+ <WorkAreaOverlay inPoint={inPoint} outPoint={outPoint} duration={duration} />
297
+ <div
298
+ ref={progressFillRef}
299
+ className="absolute top-0 bottom-0 left-0 z-[1] rounded-full"
300
+ style={{ background: "linear-gradient(90deg, var(--hf-accent, #3CE6AC), #2BBFA0)" }}
301
+ />
302
+ <div
303
+ ref={progressThumbRef}
304
+ className="absolute top-1/2 z-[4] w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 transition-transform group-hover:scale-125"
305
+ style={{
306
+ background: "var(--hf-accent, #3CE6AC)",
307
+ boxShadow: "0 0 6px rgba(60,230,172,0.4), 0 1px 4px rgba(0,0,0,0.4)",
308
+ }}
309
+ />
310
+ </div>
311
+ </div>
312
+ );
313
+ });
314
+
315
+ /* ── Main component ──────────────────────────────────────────────── */
316
+
50
317
  interface PlayerControlsProps {
51
318
  onTogglePlay: () => void;
52
319
  onSeek: (time: number) => void;
@@ -62,7 +329,6 @@ export const PlayerControls = memo(function PlayerControls({
62
329
  isFullscreen = false,
63
330
  onToggleFullscreen,
64
331
  }: PlayerControlsProps) {
65
- // Subscribe to only the fields we render — each selector prevents cascading re-renders
66
332
  const isPlaying = usePlayerStore((s) => s.isPlaying);
67
333
  const duration = usePlayerStore((s) => s.duration);
68
334
  const timelineReady = usePlayerStore((s) => s.timelineReady);
@@ -76,18 +342,13 @@ export const PlayerControls = memo(function PlayerControls({
76
342
  const outPoint = usePlayerStore((s) => s.outPoint);
77
343
  const setInPoint = usePlayerStore.getState().setInPoint;
78
344
  const setOutPoint = usePlayerStore.getState().setOutPoint;
79
- const [showSpeedMenu, setShowSpeedMenu] = useState(false);
80
- const [showShortcuts, setShowShortcuts] = useState(false);
81
345
  const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
82
- const [jumpFrame, setJumpFrame] = useState("");
83
346
 
84
347
  const progressFillRef = useRef<HTMLDivElement>(null);
85
348
  const progressThumbRef = useRef<HTMLDivElement>(null);
86
349
  const timeDisplayRef = useRef<HTMLSpanElement>(null);
87
350
  const seekBarRef = useRef<HTMLDivElement>(null);
88
351
  const sliderRef = useRef<HTMLDivElement>(null);
89
- const speedMenuContainerRef = useRef<HTMLDivElement>(null);
90
- const shortcutsPanelRef = useRef<HTMLDivElement>(null);
91
352
  const isDraggingRef = useRef(false);
92
353
  const currentTimeRef = useRef(0);
93
354
  const timeDisplayModeRef = useRef(timeDisplayMode);
@@ -98,43 +359,6 @@ export const PlayerControls = memo(function PlayerControls({
98
359
  const controlsDisabled = disabled || !timelineReady;
99
360
  const audioAutoMuted = playbackRate > 1;
100
361
  const effectiveAudioMuted = shouldMutePreviewAudio(audioMuted, playbackRate);
101
- const muteButtonLabel = audioAutoMuted
102
- ? "Audio muted above 1x speed"
103
- : audioMuted
104
- ? "Unmute audio"
105
- : "Mute audio";
106
- useMountEffect(() => {
107
- const updateProgress = (t: number) => {
108
- currentTimeRef.current = t;
109
- const dur = durationRef.current;
110
- const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
111
- if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
112
- if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
113
- if (timeDisplayRef.current) {
114
- timeDisplayRef.current.textContent =
115
- timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t);
116
- }
117
- if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
118
- };
119
- const unsub = liveTime.subscribe(updateProgress);
120
- updateProgress(usePlayerStore.getState().currentTime);
121
-
122
- // Also poll every 500ms as a fallback in case liveTime doesn't fire
123
- const interval = setInterval(() => {
124
- const t = usePlayerStore.getState().currentTime;
125
- const dur = usePlayerStore.getState().duration;
126
- if (dur > 0 && t > 0) {
127
- const pct = Math.min(100, (t / dur) * 100);
128
- if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
129
- if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
130
- }
131
- }, 500);
132
-
133
- return () => {
134
- unsub();
135
- clearInterval(interval);
136
- };
137
- });
138
362
 
139
363
  useEffect(() => {
140
364
  if (!timeDisplayRef.current) return;
@@ -143,150 +367,21 @@ export const PlayerControls = memo(function PlayerControls({
143
367
  timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t);
144
368
  }, [duration, timeDisplayMode]);
145
369
 
146
- useEffect(() => {
147
- if (!showSpeedMenu) return;
148
- const handleMouseDown = (e: MouseEvent) => {
149
- if (
150
- speedMenuContainerRef.current &&
151
- !speedMenuContainerRef.current.contains(e.target as Node)
152
- ) {
153
- setShowSpeedMenu(false);
154
- }
155
- };
156
- document.addEventListener("mousedown", handleMouseDown);
157
- return () => {
158
- document.removeEventListener("mousedown", handleMouseDown);
159
- };
160
- }, [showSpeedMenu]);
161
-
162
- useEffect(() => {
163
- if (!showShortcuts) return;
164
- const handleMouseDown = (e: MouseEvent) => {
165
- if (shortcutsPanelRef.current && !shortcutsPanelRef.current.contains(e.target as Node)) {
166
- setShowShortcuts(false);
167
- }
168
- };
169
- document.addEventListener("mousedown", handleMouseDown);
170
- return () => {
171
- document.removeEventListener("mousedown", handleMouseDown);
172
- };
173
- }, [showShortcuts]);
174
-
175
- const seekFromClientX = useCallback(
176
- (clientX: number) => {
177
- if (disabled) return;
178
- const bar = seekBarRef.current;
179
- if (!bar || duration <= 0) return;
180
- const rect = bar.getBoundingClientRect();
181
- const percent = resolveSeekPercent(clientX, rect.left, rect.width);
182
- // Immediately update progress bar visuals (don't wait for liveTime round-trip)
183
- const pct = percent * 100;
184
- if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
185
- if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
186
- onSeek(percent * duration);
187
- },
188
- [disabled, duration, onSeek],
189
- );
190
-
191
- const handlePointerDown = useCallback(
192
- (e: React.PointerEvent<HTMLDivElement>) => {
193
- // Ignore secondary mouse buttons — only primary (left click / touch /
194
- // pen contact) should start a drag.
195
- if (e.button !== 0) return;
196
- e.preventDefault();
197
- // preventDefault() on pointerdown also suppresses the implicit focus
198
- // transfer that click normally grants a `tabIndex=0` element — which
199
- // matches native `<input type="range">` behavior, but it also means a
200
- // click-then-arrow-key workflow wouldn't work. Restore focus explicitly
201
- // so seeking by click and nudging by arrow keys compose naturally.
202
- e.currentTarget.focus();
203
- isDraggingRef.current = true;
204
-
205
- // `setPointerCapture` routes every subsequent pointermove/up to the
206
- // slider element even when the pointer leaves its bounding box. Without
207
- // it, fast drags on touch would lose events the moment the finger
208
- // slips outside the 6 px-tall hit zone.
209
- const target = e.currentTarget;
210
- const pointerId = e.pointerId;
211
- try {
212
- target.setPointerCapture(pointerId);
213
- } catch {
214
- /* non-supporting browsers fall back to window listeners below */
215
- }
216
-
217
- seekFromClientX(e.clientX);
218
-
219
- // During drag, update the slider visual immediately on every pointer
220
- // event but RAF-throttle the actual onSeek call. The seek path triggers
221
- // adapter.seek + setCurrentTime + React re-renders which can take >16ms
222
- // on complex compositions — keeping visual feedback on the raw event and
223
- // batching the expensive work to one call per frame keeps scrubbing at
224
- // 60 fps.
225
- let seekRafId = 0;
226
- let pendingClientX = e.clientX;
227
- const onMove = (ev: PointerEvent) => {
228
- if (ev.pointerId !== pointerId || !isDraggingRef.current) return;
229
- pendingClientX = ev.clientX;
230
- const bar = seekBarRef.current;
231
- const dur = durationRef.current;
232
- if (bar && dur > 0) {
233
- const rect = bar.getBoundingClientRect();
234
- const pct = resolveSeekPercent(ev.clientX, rect.left, rect.width) * 100;
235
- if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
236
- if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
237
- }
238
- if (!seekRafId) {
239
- seekRafId = requestAnimationFrame(() => {
240
- seekRafId = 0;
241
- if (isDraggingRef.current) seekFromClientX(pendingClientX);
242
- });
243
- }
244
- };
245
- const cleanup = () => {
246
- isDraggingRef.current = false;
247
- if (seekRafId) {
248
- cancelAnimationFrame(seekRafId);
249
- seekRafId = 0;
250
- }
251
- seekFromClientX(pendingClientX);
252
- try {
253
- target.releasePointerCapture(pointerId);
254
- } catch {
255
- /* Already released after the first cleanup — second invocation
256
- via the window-fallback or visibility path is a no-op throw. */
257
- }
258
- target.removeEventListener("pointermove", onMove);
259
- target.removeEventListener("pointerup", onUp);
260
- target.removeEventListener("pointercancel", onUp);
261
- window.removeEventListener("pointerup", onUp);
262
- window.removeEventListener("pointercancel", onUp);
263
- document.removeEventListener("visibilitychange", onVisibilityChange);
264
- window.removeEventListener("blur", cleanup);
265
- };
266
- const onUp = (ev: PointerEvent) => {
267
- if (ev.pointerId !== pointerId) return;
268
- cleanup();
269
- };
270
- // iOS Safari does not reliably fire `pointercancel` when the page is
271
- // backgrounded mid-drag (alt-tab, incoming call, switch apps). Without
272
- // a release path the ref stays `true` until the next pointerdown — a
273
- // stuck-scrubber class bug waiting to happen if anyone later gates
274
- // rendering on `isDragging`. Synthesize the release on hide / blur.
275
- const onVisibilityChange = () => {
276
- if (document.visibilityState === "hidden") cleanup();
277
- };
278
-
279
- target.addEventListener("pointermove", onMove);
280
- target.addEventListener("pointerup", onUp);
281
- target.addEventListener("pointercancel", onUp);
282
- // Window-level fallback in case capture fails and the pointer release
283
- // lands outside the element (rare, but defensive).
284
- window.addEventListener("pointerup", onUp);
285
- window.addEventListener("pointercancel", onUp);
286
- document.addEventListener("visibilitychange", onVisibilityChange);
287
- window.addEventListener("blur", cleanup);
370
+ const { handlePointerDown } = useSeekBarDrag(
371
+ {
372
+ seekBarRef,
373
+ progressFillRef,
374
+ progressThumbRef,
375
+ sliderRef,
376
+ timeDisplayRef,
377
+ isDraggingRef,
378
+ durationRef,
379
+ currentTimeRef,
380
+ timeDisplayModeRef,
288
381
  },
289
- [seekFromClientX],
382
+ onSeek,
383
+ disabled,
384
+ duration,
290
385
  );
291
386
 
292
387
  const handleKeyDown = useCallback(
@@ -304,43 +399,15 @@ export const PlayerControls = memo(function PlayerControls({
304
399
  [disabled, timelineReady, duration, onSeek],
305
400
  );
306
401
 
307
- const commitJumpFrame = useCallback(() => {
308
- if (disabled) return;
309
- const frame = Number.parseInt(jumpFrame, 10);
310
- if (!Number.isFinite(frame) || duration <= 0) return;
311
- onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
312
- }, [disabled, duration, jumpFrame, onSeek]);
313
-
314
- const handleJumpSubmit = useCallback(
315
- (e: React.FormEvent) => {
316
- e.preventDefault();
317
- commitJumpFrame();
318
- },
319
- [commitJumpFrame],
320
- );
321
-
322
- const handleJumpKeyDown = useCallback(
323
- (e: React.KeyboardEvent<HTMLInputElement>) => {
324
- if (e.key !== "Enter") return;
325
- e.preventDefault();
326
- commitJumpFrame();
327
- },
328
- [commitJumpFrame],
329
- );
330
-
331
402
  return (
332
403
  <div
333
404
  className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
334
405
  aria-disabled={disabled || undefined}
335
406
  style={{
336
407
  borderTop: "1px solid rgba(255,255,255,0.04)",
337
- // Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
338
- // the Play button + timecode on iPhone. `env(safe-area-inset-bottom)`
339
- // is 0 everywhere else, so this is a no-op on desktop.
340
408
  paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
341
409
  }}
342
410
  >
343
- {/* Play/Pause button */}
344
411
  <Tooltip label={isPlaying ? "Pause" : "Play"}>
345
412
  <button
346
413
  type="button"
@@ -353,20 +420,10 @@ export const PlayerControls = memo(function PlayerControls({
353
420
  className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
354
421
  style={{ background: "rgba(255,255,255,0.06)" }}
355
422
  >
356
- {isPlaying ? (
357
- <svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
358
- <rect x="6" y="4" width="4" height="16" rx="1" />
359
- <rect x="14" y="4" width="4" height="16" rx="1" />
360
- </svg>
361
- ) : (
362
- <svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
363
- <polygon points="6,3 20,12 6,21" />
364
- </svg>
365
- )}
423
+ {isPlaying ? <PauseIcon /> : <PlayIcon />}
366
424
  </button>
367
425
  </Tooltip>
368
426
 
369
- {/* Time display — click to toggle time/frame mode */}
370
427
  <Tooltip
371
428
  label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
372
429
  >
@@ -387,470 +444,48 @@ export const PlayerControls = memo(function PlayerControls({
387
444
  </button>
388
445
  </Tooltip>
389
446
 
390
- {/* Seek bar — teal progress fill */}
391
- <div
392
- ref={(el) => {
393
- (seekBarRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
394
- (sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
395
- }}
396
- role="slider"
397
- tabIndex={disabled ? -1 : 0}
398
- aria-label="Seek"
399
- aria-disabled={disabled || undefined}
400
- aria-valuemin={0}
401
- aria-valuemax={Math.round(duration)}
402
- aria-valuenow={0}
403
- className={`min-w-[96px] flex-1 h-6 flex items-center group ${
404
- disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
405
- }`}
406
- // `touch-action: none` tells the browser we're handling every
407
- // pointer gesture on this element ourselves. Without it, iOS
408
- // Safari consumes horizontal swipes for its own swipe-back-to-
409
- // previous-page navigation and the scrubber can't drag left.
410
- style={{ touchAction: "none" }}
447
+ <SeekBar
448
+ disabled={disabled}
449
+ duration={duration}
450
+ inPoint={inPoint}
451
+ outPoint={outPoint}
452
+ progressFillRef={progressFillRef}
453
+ progressThumbRef={progressThumbRef}
454
+ seekBarRef={seekBarRef}
455
+ sliderRef={sliderRef}
411
456
  onPointerDown={handlePointerDown}
412
457
  onKeyDown={handleKeyDown}
413
- >
414
- <div
415
- className="w-full rounded-full relative"
416
- style={{ background: "rgba(255,255,255,0.15)", height: "3px" }}
417
- >
418
- {/* Work-area band between in/out points */}
419
- {(inPoint !== null || outPoint !== null) && duration > 0 && (
420
- <div
421
- className="absolute top-0 bottom-0 pointer-events-none"
422
- style={{
423
- left: `${inPoint !== null ? Math.min(100, (inPoint / duration) * 100) : 0}%`,
424
- right: `${outPoint !== null ? 100 - Math.min(100, (outPoint / duration) * 100) : 0}%`,
425
- background: "rgba(60,230,172,0.15)",
426
- }}
427
- />
428
- )}
429
- {/* Progress fill — width is controlled imperatively via ref to avoid React re-render resets */}
430
- <div
431
- ref={progressFillRef}
432
- className="absolute top-0 bottom-0 left-0 z-[1] rounded-full"
433
- style={{ background: "linear-gradient(90deg, var(--hf-accent, #3CE6AC), #2BBFA0)" }}
434
- />
435
- {/* In-point marker */}
436
- {inPoint !== null && duration > 0 && (
437
- <div
438
- className="absolute z-[3] pointer-events-none"
439
- style={{
440
- left: `${Math.min(100, (inPoint / duration) * 100)}%`,
441
- top: "50%",
442
- transform: "translate(-50%, -50%)",
443
- width: "2px",
444
- height: "10px",
445
- background: "#3CE6AC",
446
- borderRadius: "1px",
447
- }}
448
- />
449
- )}
450
- {/* Out-point marker */}
451
- {outPoint !== null && duration > 0 && (
452
- <div
453
- className="absolute z-[3] pointer-events-none"
454
- style={{
455
- left: `${Math.min(100, (outPoint / duration) * 100)}%`,
456
- top: "50%",
457
- transform: "translate(-50%, -50%)",
458
- width: "2px",
459
- height: "10px",
460
- background: "#3CE6AC",
461
- borderRadius: "1px",
462
- }}
463
- />
464
- )}
465
- {/* Playhead thumb — left is controlled imperatively via ref */}
466
- <div
467
- ref={progressThumbRef}
468
- className="absolute top-1/2 z-[4] w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 transition-transform group-hover:scale-125"
469
- style={{
470
- background: "var(--hf-accent, #3CE6AC)",
471
- boxShadow: "0 0 6px rgba(60,230,172,0.4), 0 1px 4px rgba(0,0,0,0.4)",
472
- }}
473
- />
474
- </div>
475
- </div>
458
+ />
476
459
 
477
- {/* Mute toggle */}
478
- <Tooltip label={muteButtonLabel}>
479
- <button
480
- type="button"
481
- onClick={() => {
482
- if (!audioAutoMuted) {
483
- trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
484
- setAudioMuted(!audioMuted);
485
- }
486
- }}
487
- disabled={controlsDisabled || audioAutoMuted}
488
- aria-label={muteButtonLabel}
489
- aria-pressed={effectiveAudioMuted}
490
- className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors disabled:pointer-events-none ${
491
- effectiveAudioMuted
492
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
493
- : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
494
- } ${audioAutoMuted ? "opacity-70" : ""}`}
495
- >
496
- {effectiveAudioMuted ? (
497
- <svg
498
- width="13"
499
- height="13"
500
- viewBox="0 0 24 24"
501
- fill="none"
502
- stroke="currentColor"
503
- strokeWidth="2"
504
- strokeLinecap="round"
505
- strokeLinejoin="round"
506
- aria-hidden="true"
507
- >
508
- <path d="M11 5 6 9H3v6h3l5 4V5Z" />
509
- <path d="m19 9-6 6" />
510
- <path d="m13 9 6 6" />
511
- </svg>
512
- ) : (
513
- <svg
514
- width="13"
515
- height="13"
516
- viewBox="0 0 24 24"
517
- fill="none"
518
- stroke="currentColor"
519
- strokeWidth="2"
520
- strokeLinecap="round"
521
- strokeLinejoin="round"
522
- aria-hidden="true"
523
- >
524
- <path d="M11 5 6 9H3v6h3l5 4V5Z" />
525
- <path d="M15.5 8.5a5 5 0 0 1 0 7" />
526
- <path d="M18.5 5.5a9 9 0 0 1 0 13" />
527
- </svg>
528
- )}
529
- </button>
530
- </Tooltip>
460
+ <MuteButton
461
+ audioMuted={audioMuted}
462
+ audioAutoMuted={audioAutoMuted}
463
+ effectiveAudioMuted={effectiveAudioMuted}
464
+ controlsDisabled={controlsDisabled}
465
+ setAudioMuted={setAudioMuted}
466
+ />
531
467
 
532
- {/* Speed control */}
533
- <div ref={speedMenuContainerRef} className="relative flex-shrink-0">
534
- <Tooltip label="Playback speed">
535
- <button
536
- type="button"
537
- onClick={() => setShowSpeedMenu((v) => !v)}
538
- disabled={disabled}
539
- className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
540
- style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
541
- >
542
- {playbackRate === 1 ? "1x" : `${playbackRate}x`}
543
- </button>
544
- </Tooltip>
545
- {showSpeedMenu && (
546
- <div
547
- className="absolute bottom-full right-0 mb-1.5 rounded-lg shadow-xl z-50 min-w-[56px] overflow-hidden"
548
- style={{ background: "#161618", border: "1px solid rgba(255,255,255,0.08)" }}
549
- >
550
- {SPEED_OPTIONS.map((rate) => (
551
- <button
552
- key={rate}
553
- onClick={() => {
554
- trackStudioEvent("playback", { action: "speed_change", rate });
555
- setPlaybackRate(rate);
556
- setShowSpeedMenu(false);
557
- }}
558
- className="block w-full px-3 py-1.5 text-[11px] text-left font-mono tabular-nums transition-colors"
559
- style={{
560
- color: rate === playbackRate ? "#FAFAFA" : "#71717A",
561
- background: rate === playbackRate ? "rgba(255,255,255,0.06)" : "transparent",
562
- }}
563
- onMouseEnter={(e) => {
564
- if (rate !== playbackRate)
565
- e.currentTarget.style.background = "rgba(255,255,255,0.04)";
566
- }}
567
- onMouseLeave={(e) => {
568
- if (rate !== playbackRate) e.currentTarget.style.background = "transparent";
569
- }}
570
- >
571
- {rate}x
572
- </button>
573
- ))}
574
- </div>
575
- )}
576
- </div>
468
+ <SpeedMenu
469
+ playbackRate={playbackRate}
470
+ setPlaybackRate={setPlaybackRate}
471
+ disabled={disabled}
472
+ />
577
473
 
578
- <Tooltip label="Loop playback">
579
- <button
580
- type="button"
581
- onClick={() => {
582
- trackStudioEvent("playback", { action: "loop_toggle", enabled: !loopEnabled });
583
- setLoopEnabled(!loopEnabled);
584
- }}
585
- disabled={disabled}
586
- className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
587
- loopEnabled
588
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
589
- : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
590
- }`}
591
- aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
592
- aria-pressed={loopEnabled}
593
- >
594
- <svg
595
- width="13"
596
- height="13"
597
- viewBox="0 0 24 24"
598
- fill="none"
599
- stroke="currentColor"
600
- strokeWidth="2"
601
- strokeLinecap="round"
602
- strokeLinejoin="round"
603
- aria-hidden="true"
604
- >
605
- <path d="M17 2l4 4-4 4" />
606
- <path d="M3 11V9a4 4 0 0 1 4-4h14" />
607
- <path d="M7 22l-4-4 4-4" />
608
- <path d="M21 13v2a4 4 0 0 1-4 4H3" />
609
- </svg>
610
- </button>
611
- </Tooltip>
474
+ <LoopButton loopEnabled={loopEnabled} disabled={disabled} setLoopEnabled={setLoopEnabled} />
612
475
 
613
- {/* Fullscreen toggle */}
614
476
  {onToggleFullscreen && (
615
- <Tooltip label={isFullscreen ? "Exit fullscreen (F)" : "Enter fullscreen (F)"}>
616
- <button
617
- type="button"
618
- onClick={() => {
619
- trackStudioEvent("playback", { action: "fullscreen_toggle", active: !isFullscreen });
620
- onToggleFullscreen();
621
- }}
622
- className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors ${
623
- isFullscreen
624
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
625
- : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
626
- }`}
627
- aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
628
- >
629
- {isFullscreen ? (
630
- <svg
631
- width="13"
632
- height="13"
633
- viewBox="0 0 24 24"
634
- fill="none"
635
- stroke="currentColor"
636
- strokeWidth="2"
637
- strokeLinecap="round"
638
- strokeLinejoin="round"
639
- aria-hidden="true"
640
- >
641
- <path d="M8 3v3a2 2 0 0 1-2 2H3" />
642
- <path d="M21 8h-3a2 2 0 0 1-2-2V3" />
643
- <path d="M3 16h3a2 2 0 0 1 2 2v3" />
644
- <path d="M16 21v-3a2 2 0 0 1 2-2h3" />
645
- </svg>
646
- ) : (
647
- <svg
648
- width="13"
649
- height="13"
650
- viewBox="0 0 24 24"
651
- fill="none"
652
- stroke="currentColor"
653
- strokeWidth="2"
654
- strokeLinecap="round"
655
- strokeLinejoin="round"
656
- aria-hidden="true"
657
- >
658
- <path d="M8 3H5a2 2 0 0 0-2 2v3" />
659
- <path d="M21 8V5a2 2 0 0 0-2-2h-3" />
660
- <path d="M3 16v3a2 2 0 0 0 2 2h3" />
661
- <path d="M16 21h3a2 2 0 0 0 2-2v-3" />
662
- </svg>
663
- )}
664
- </button>
665
- </Tooltip>
477
+ <FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
666
478
  )}
667
479
 
668
- {/* Keyboard shortcuts + frame jump + work area — click to open panel */}
669
- <div ref={shortcutsPanelRef} className="relative flex-shrink-0">
670
- <Tooltip label="Shortcuts and tools">
671
- <button
672
- type="button"
673
- onClick={() => setShowShortcuts((v) => !v)}
674
- className={`w-6 h-6 flex items-center justify-center rounded border transition-colors ${
675
- showShortcuts
676
- ? "border-neutral-600 text-neutral-200 bg-neutral-800"
677
- : "border-neutral-800 text-neutral-600 hover:text-neutral-300 hover:border-neutral-600"
678
- }`}
679
- aria-label="Shortcuts and tools"
680
- aria-expanded={showShortcuts}
681
- >
682
- <svg
683
- width="11"
684
- height="11"
685
- viewBox="0 0 24 24"
686
- fill="none"
687
- stroke="currentColor"
688
- strokeWidth="1.75"
689
- strokeLinecap="round"
690
- strokeLinejoin="round"
691
- aria-hidden="true"
692
- >
693
- <rect x="2" y="4" width="20" height="16" rx="2" />
694
- <path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
695
- </svg>
696
- </button>
697
- </Tooltip>
698
- {showShortcuts && (
699
- <div
700
- className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
701
- style={{
702
- background: "#161618",
703
- border: "1px solid rgba(255,255,255,0.08)",
704
- maxHeight: "min(280px, calc(100vh - 80px))",
705
- }}
706
- >
707
- {/* Frame jump */}
708
- <div className="px-3 pt-3 pb-2.5">
709
- <p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
710
- Jump to frame
711
- </p>
712
- <form onSubmit={handleJumpSubmit} className="flex items-center gap-1.5">
713
- <input
714
- value={jumpFrame}
715
- onChange={(e) => setJumpFrame(e.target.value)}
716
- disabled={disabled}
717
- inputMode="numeric"
718
- pattern="[0-9]*"
719
- aria-label="Jump to frame"
720
- placeholder="frame number"
721
- className="h-6 flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60"
722
- onKeyDown={handleJumpKeyDown}
723
- onBlur={commitJumpFrame}
724
- />
725
- <Tooltip label="Jump to frame">
726
- <button
727
- type="submit"
728
- disabled={disabled}
729
- className="h-6 px-2 rounded border border-neutral-700 text-[10px] text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800 disabled:opacity-40"
730
- >
731
- Go
732
- </button>
733
- </Tooltip>
734
- </form>
735
- </div>
736
- <div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
737
- {/* Work area */}
738
- <div className="px-3 pt-2.5 pb-2">
739
- <p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
740
- Work area
741
- </p>
742
- <div className="flex flex-col gap-1">
743
- <div className="flex items-center justify-between gap-2">
744
- <div className="flex items-center gap-2">
745
- <span
746
- className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
747
- style={{ background: "rgba(255,255,255,0.05)" }}
748
- >
749
- I
750
- </span>
751
- <span className="text-[10px] text-neutral-400">In-point</span>
752
- </div>
753
- <div className="flex items-center gap-1.5">
754
- {inPoint !== null ? (
755
- <>
756
- <span className="font-mono text-[10px] text-neutral-300">
757
- {formatTime(inPoint)}
758
- </span>
759
- <Tooltip label="Clear in-point">
760
- <button
761
- type="button"
762
- onClick={() => setInPoint(null)}
763
- className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
764
- aria-label="Clear in-point"
765
- >
766
- <svg
767
- width="8"
768
- height="8"
769
- viewBox="0 0 24 24"
770
- fill="none"
771
- stroke="currentColor"
772
- strokeWidth="2.5"
773
- >
774
- <path d="M18 6L6 18M6 6l12 12" />
775
- </svg>
776
- </button>
777
- </Tooltip>
778
- </>
779
- ) : (
780
- <span className="text-[10px] text-neutral-600">—</span>
781
- )}
782
- </div>
783
- </div>
784
- <div className="flex items-center justify-between gap-2">
785
- <div className="flex items-center gap-2">
786
- <span
787
- className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
788
- style={{ background: "rgba(255,255,255,0.05)" }}
789
- >
790
- O
791
- </span>
792
- <span className="text-[10px] text-neutral-400">Out-point</span>
793
- </div>
794
- <div className="flex items-center gap-1.5">
795
- {outPoint !== null ? (
796
- <>
797
- <span className="font-mono text-[10px] text-neutral-300">
798
- {formatTime(outPoint)}
799
- </span>
800
- <Tooltip label="Clear out-point">
801
- <button
802
- type="button"
803
- onClick={() => setOutPoint(null)}
804
- className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
805
- aria-label="Clear out-point"
806
- >
807
- <svg
808
- width="8"
809
- height="8"
810
- viewBox="0 0 24 24"
811
- fill="none"
812
- stroke="currentColor"
813
- strokeWidth="2.5"
814
- >
815
- <path d="M18 6L6 18M6 6l12 12" />
816
- </svg>
817
- </button>
818
- </Tooltip>
819
- </>
820
- ) : (
821
- <span className="text-[10px] text-neutral-600">—</span>
822
- )}
823
- </div>
824
- </div>
825
- </div>
826
- </div>
827
- <div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
828
- {/* Shortcuts */}
829
- <div className="px-3 pt-2.5 pb-3 flex flex-col gap-3">
830
- {SHORTCUT_SECTIONS.map((section) => (
831
- <div key={section.title}>
832
- <p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
833
- {section.title}
834
- </p>
835
- <div className="flex flex-col gap-1">
836
- {section.hints.map((hint) => (
837
- <div key={hint.key} className="flex items-center gap-3">
838
- <span
839
- className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[36px] text-center"
840
- style={{ background: "rgba(255,255,255,0.05)" }}
841
- >
842
- {hint.key}
843
- </span>
844
- <span className="text-[10px] text-neutral-400">{hint.label}</span>
845
- </div>
846
- ))}
847
- </div>
848
- </div>
849
- ))}
850
- </div>
851
- </div>
852
- )}
853
- </div>
480
+ <ShortcutsPanel
481
+ disabled={disabled}
482
+ duration={duration}
483
+ inPoint={inPoint}
484
+ outPoint={outPoint}
485
+ setInPoint={setInPoint}
486
+ setOutPoint={setOutPoint}
487
+ onSeek={onSeek}
488
+ />
854
489
  </div>
855
490
  );
856
491
  });