@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.
- package/dist/assets/{index-CMBmEncK.js → index-C55KfVpx.js} +40 -40
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/components/editor/manualEditsDom.ts +38 -359
- package/src/components/editor/manualEditsDomPatches.ts +237 -0
- package/src/player/components/PlayerControls.tsx +356 -721
- package/src/player/components/ShortcutsPanel.tsx +277 -0
- package/src/player/components/SpeedMenu.tsx +83 -0
- package/src/player/components/useSeekBarDrag.ts +168 -0
- package/src/utils/timelineAssetDrop.test.ts +1 -1
|
@@ -1,52 +1,319 @@
|
|
|
1
|
-
import { useRef,
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
});
|