@hyperframes/studio 0.6.5 → 0.6.7
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/{hyperframes-player-CzwFysqv.js → hyperframes-player-D0Yi3xMP.js} +2 -2
- package/dist/assets/index-Ckqo37Co.css +1 -0
- package/dist/assets/index-Yvtxngdi.js +116 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +54 -31
- package/src/components/StudioGlobalDragOverlay.tsx +26 -0
- package/src/components/StudioHeader.tsx +128 -3
- package/src/components/StudioRightPanel.tsx +0 -2
- package/src/components/editor/DomEditOverlay.test.ts +1 -0
- package/src/components/editor/DomEditOverlay.tsx +2 -1
- package/src/components/editor/PropertyPanel.tsx +27 -36
- package/src/components/editor/domEditingElement.ts +1 -0
- package/src/components/editor/manualEdits.test.ts +39 -466
- package/src/components/editor/manualEdits.ts +6 -168
- package/src/components/editor/manualEditsDom.ts +361 -1
- package/src/components/editor/manualEditsParsing.ts +2 -240
- package/src/components/editor/manualEditsTypes.ts +1 -40
- package/src/components/editor/useDomEditOverlayGestures.ts +25 -8
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/sidebar/CompositionsTab.tsx +9 -3
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/hooks/useAppHotkeys.ts +1 -4
- package/src/hooks/useDomEditCommits.ts +82 -77
- package/src/hooks/useDomEditSession.ts +4 -16
- package/src/hooks/useFileManager.ts +10 -1
- package/src/hooks/useManifestPersistence.ts +51 -187
- package/src/hooks/usePanelLayout.ts +10 -3
- package/src/hooks/usePreviewInteraction.ts +0 -1
- package/src/hooks/useStudioUrlState.ts +188 -0
- package/src/player/components/Player.tsx +15 -1
- package/src/player/components/PlayerControls.test.ts +17 -0
- package/src/player/components/PlayerControls.tsx +347 -56
- package/src/player/hooks/usePlaybackKeyboard.test.ts +174 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +37 -10
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +329 -0
- package/src/player/hooks/useTimelinePlayer.ts +97 -28
- package/src/player/hooks/useTimelineSyncCallbacks.ts +10 -4
- package/src/player/lib/playbackAdapter.test.ts +50 -0
- package/src/player/lib/playbackAdapter.ts +2 -2
- package/src/player/lib/playbackTypes.ts +1 -1
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineIframeHelpers.ts +63 -7
- package/src/player/store/playerStore.test.ts +105 -1
- package/src/player/store/playerStore.ts +39 -1
- package/src/utils/projectRouting.test.ts +15 -0
- package/src/utils/projectRouting.ts +46 -9
- package/src/utils/sourcePatcher.ts +50 -14
- package/src/utils/studioPreviewHelpers.test.ts +56 -0
- package/src/utils/studioPreviewHelpers.ts +51 -13
- package/src/utils/studioUiPreferences.test.ts +3 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/src/utils/studioUrlState.test.ts +249 -0
- package/src/utils/studioUrlState.ts +135 -0
- package/dist/assets/index-Bs6NmE0o.js +0 -117
- package/dist/assets/index-Dswa2GJ2.css +0 -1
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
import { useRef, useState, useCallback, useEffect, memo } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
|
|
4
|
+
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
|
|
4
5
|
import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
5
6
|
|
|
6
7
|
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
|
7
8
|
const SEEK_EDGE_SNAP_PX = 8;
|
|
8
9
|
type TimeDisplayMode = "time" | "frame";
|
|
9
|
-
const
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
const SHORTCUT_SECTIONS = [
|
|
11
|
+
{
|
|
12
|
+
title: "Playback",
|
|
13
|
+
hints: [
|
|
14
|
+
{ key: "Space", label: "Play / Pause" },
|
|
15
|
+
{ key: "J", label: "Play backward" },
|
|
16
|
+
{ key: "K", label: "Stop" },
|
|
17
|
+
{ key: "L", label: "Play forward" },
|
|
18
|
+
{ key: "←/→", label: "Step 1 frame" },
|
|
19
|
+
{ key: "⇧←/⇧→", label: "Step 10 frames" },
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
title: "Work area",
|
|
24
|
+
hints: [
|
|
25
|
+
{ key: "I", label: "Set in-point" },
|
|
26
|
+
{ key: "⇧I", label: "Clear in-point" },
|
|
27
|
+
{ key: "O", label: "Set out-point" },
|
|
28
|
+
{ key: "⇧O", label: "Clear out-point" },
|
|
29
|
+
{ key: "A", label: "Jump to in-point" },
|
|
30
|
+
{ key: "E", label: "Jump to out-point" },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
14
33
|
] as const;
|
|
15
34
|
|
|
16
35
|
export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
|
|
@@ -39,10 +58,17 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
39
58
|
const duration = usePlayerStore((s) => s.duration);
|
|
40
59
|
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
|
41
60
|
const playbackRate = usePlayerStore((s) => s.playbackRate);
|
|
61
|
+
const audioMuted = usePlayerStore((s) => s.audioMuted);
|
|
42
62
|
const loopEnabled = usePlayerStore((s) => s.loopEnabled);
|
|
43
63
|
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
|
|
64
|
+
const setAudioMuted = usePlayerStore.getState().setAudioMuted;
|
|
44
65
|
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
|
|
66
|
+
const inPoint = usePlayerStore((s) => s.inPoint);
|
|
67
|
+
const outPoint = usePlayerStore((s) => s.outPoint);
|
|
68
|
+
const setInPoint = usePlayerStore.getState().setInPoint;
|
|
69
|
+
const setOutPoint = usePlayerStore.getState().setOutPoint;
|
|
45
70
|
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
|
71
|
+
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
46
72
|
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
|
|
47
73
|
const [jumpFrame, setJumpFrame] = useState("");
|
|
48
74
|
|
|
@@ -52,6 +78,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
52
78
|
const seekBarRef = useRef<HTMLDivElement>(null);
|
|
53
79
|
const sliderRef = useRef<HTMLDivElement>(null);
|
|
54
80
|
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
|
|
81
|
+
const shortcutsPanelRef = useRef<HTMLDivElement>(null);
|
|
55
82
|
const isDraggingRef = useRef(false);
|
|
56
83
|
const currentTimeRef = useRef(0);
|
|
57
84
|
const timeDisplayModeRef = useRef(timeDisplayMode);
|
|
@@ -60,6 +87,13 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
60
87
|
const durationRef = useRef(duration);
|
|
61
88
|
durationRef.current = duration;
|
|
62
89
|
const controlsDisabled = disabled || !timelineReady;
|
|
90
|
+
const audioAutoMuted = playbackRate > 1;
|
|
91
|
+
const effectiveAudioMuted = shouldMutePreviewAudio(audioMuted, playbackRate);
|
|
92
|
+
const muteButtonLabel = audioAutoMuted
|
|
93
|
+
? "Audio muted above 1x speed"
|
|
94
|
+
: audioMuted
|
|
95
|
+
? "Unmute audio"
|
|
96
|
+
: "Mute audio";
|
|
63
97
|
useMountEffect(() => {
|
|
64
98
|
const updateProgress = (t: number) => {
|
|
65
99
|
currentTimeRef.current = t;
|
|
@@ -116,6 +150,19 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
116
150
|
};
|
|
117
151
|
}, [showSpeedMenu]);
|
|
118
152
|
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!showShortcuts) return;
|
|
155
|
+
const handleMouseDown = (e: MouseEvent) => {
|
|
156
|
+
if (shortcutsPanelRef.current && !shortcutsPanelRef.current.contains(e.target as Node)) {
|
|
157
|
+
setShowShortcuts(false);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
document.addEventListener("mousedown", handleMouseDown);
|
|
161
|
+
return () => {
|
|
162
|
+
document.removeEventListener("mousedown", handleMouseDown);
|
|
163
|
+
};
|
|
164
|
+
}, [showShortcuts]);
|
|
165
|
+
|
|
119
166
|
const seekFromClientX = useCallback(
|
|
120
167
|
(clientX: number) => {
|
|
121
168
|
if (disabled) return;
|
|
@@ -278,10 +325,14 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
278
325
|
)}
|
|
279
326
|
</button>
|
|
280
327
|
|
|
281
|
-
{/* Time display */}
|
|
282
|
-
<
|
|
283
|
-
|
|
284
|
-
|
|
328
|
+
{/* Time display — click to toggle time/frame mode */}
|
|
329
|
+
<button
|
|
330
|
+
type="button"
|
|
331
|
+
onClick={() => setTimeDisplayMode((m) => (m === "time" ? "frame" : "time"))}
|
|
332
|
+
disabled={disabled}
|
|
333
|
+
title={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
|
|
334
|
+
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80"
|
|
335
|
+
style={{ color: "#A1A1AA", cursor: "pointer" }}
|
|
285
336
|
>
|
|
286
337
|
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
|
287
338
|
{timeDisplayMode === "time" ? (
|
|
@@ -290,7 +341,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
290
341
|
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
|
|
291
342
|
</>
|
|
292
343
|
) : null}
|
|
293
|
-
</
|
|
344
|
+
</button>
|
|
294
345
|
|
|
295
346
|
{/* Seek bar — teal progress fill */}
|
|
296
347
|
<div
|
|
@@ -320,16 +371,57 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
320
371
|
className="w-full rounded-full relative"
|
|
321
372
|
style={{ background: "rgba(255,255,255,0.15)", height: "3px" }}
|
|
322
373
|
>
|
|
374
|
+
{/* Work-area band between in/out points */}
|
|
375
|
+
{(inPoint !== null || outPoint !== null) && duration > 0 && (
|
|
376
|
+
<div
|
|
377
|
+
className="absolute top-0 bottom-0 pointer-events-none"
|
|
378
|
+
style={{
|
|
379
|
+
left: `${inPoint !== null ? Math.min(100, (inPoint / duration) * 100) : 0}%`,
|
|
380
|
+
right: `${outPoint !== null ? 100 - Math.min(100, (outPoint / duration) * 100) : 0}%`,
|
|
381
|
+
background: "rgba(60,230,172,0.15)",
|
|
382
|
+
}}
|
|
383
|
+
/>
|
|
384
|
+
)}
|
|
323
385
|
{/* Progress fill — width is controlled imperatively via ref to avoid React re-render resets */}
|
|
324
386
|
<div
|
|
325
387
|
ref={progressFillRef}
|
|
326
388
|
className="absolute top-0 bottom-0 left-0 z-[1] rounded-full"
|
|
327
389
|
style={{ background: "linear-gradient(90deg, var(--hf-accent, #3CE6AC), #2BBFA0)" }}
|
|
328
390
|
/>
|
|
391
|
+
{/* In-point marker */}
|
|
392
|
+
{inPoint !== null && duration > 0 && (
|
|
393
|
+
<div
|
|
394
|
+
className="absolute z-[3] pointer-events-none"
|
|
395
|
+
style={{
|
|
396
|
+
left: `${Math.min(100, (inPoint / duration) * 100)}%`,
|
|
397
|
+
top: "50%",
|
|
398
|
+
transform: "translate(-50%, -50%)",
|
|
399
|
+
width: "2px",
|
|
400
|
+
height: "10px",
|
|
401
|
+
background: "#3CE6AC",
|
|
402
|
+
borderRadius: "1px",
|
|
403
|
+
}}
|
|
404
|
+
/>
|
|
405
|
+
)}
|
|
406
|
+
{/* Out-point marker */}
|
|
407
|
+
{outPoint !== null && duration > 0 && (
|
|
408
|
+
<div
|
|
409
|
+
className="absolute z-[3] pointer-events-none"
|
|
410
|
+
style={{
|
|
411
|
+
left: `${Math.min(100, (outPoint / duration) * 100)}%`,
|
|
412
|
+
top: "50%",
|
|
413
|
+
transform: "translate(-50%, -50%)",
|
|
414
|
+
width: "2px",
|
|
415
|
+
height: "10px",
|
|
416
|
+
background: "#3CE6AC",
|
|
417
|
+
borderRadius: "1px",
|
|
418
|
+
}}
|
|
419
|
+
/>
|
|
420
|
+
)}
|
|
329
421
|
{/* Playhead thumb — left is controlled imperatively via ref */}
|
|
330
422
|
<div
|
|
331
423
|
ref={progressThumbRef}
|
|
332
|
-
className="absolute top-1/2 z-[
|
|
424
|
+
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"
|
|
333
425
|
style={{
|
|
334
426
|
background: "var(--hf-accent, #3CE6AC)",
|
|
335
427
|
boxShadow: "0 0 6px rgba(60,230,172,0.4), 0 1px 4px rgba(0,0,0,0.4)",
|
|
@@ -338,6 +430,57 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
338
430
|
</div>
|
|
339
431
|
</div>
|
|
340
432
|
|
|
433
|
+
{/* Mute toggle */}
|
|
434
|
+
<button
|
|
435
|
+
type="button"
|
|
436
|
+
onClick={() => {
|
|
437
|
+
if (!audioAutoMuted) setAudioMuted(!audioMuted);
|
|
438
|
+
}}
|
|
439
|
+
disabled={controlsDisabled || audioAutoMuted}
|
|
440
|
+
title={muteButtonLabel}
|
|
441
|
+
aria-label={muteButtonLabel}
|
|
442
|
+
aria-pressed={effectiveAudioMuted}
|
|
443
|
+
className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors disabled:pointer-events-none ${
|
|
444
|
+
effectiveAudioMuted
|
|
445
|
+
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
446
|
+
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
|
447
|
+
} ${audioAutoMuted ? "opacity-70" : ""}`}
|
|
448
|
+
>
|
|
449
|
+
{effectiveAudioMuted ? (
|
|
450
|
+
<svg
|
|
451
|
+
width="13"
|
|
452
|
+
height="13"
|
|
453
|
+
viewBox="0 0 24 24"
|
|
454
|
+
fill="none"
|
|
455
|
+
stroke="currentColor"
|
|
456
|
+
strokeWidth="2"
|
|
457
|
+
strokeLinecap="round"
|
|
458
|
+
strokeLinejoin="round"
|
|
459
|
+
aria-hidden="true"
|
|
460
|
+
>
|
|
461
|
+
<path d="M11 5 6 9H3v6h3l5 4V5Z" />
|
|
462
|
+
<path d="m19 9-6 6" />
|
|
463
|
+
<path d="m13 9 6 6" />
|
|
464
|
+
</svg>
|
|
465
|
+
) : (
|
|
466
|
+
<svg
|
|
467
|
+
width="13"
|
|
468
|
+
height="13"
|
|
469
|
+
viewBox="0 0 24 24"
|
|
470
|
+
fill="none"
|
|
471
|
+
stroke="currentColor"
|
|
472
|
+
strokeWidth="2"
|
|
473
|
+
strokeLinecap="round"
|
|
474
|
+
strokeLinejoin="round"
|
|
475
|
+
aria-hidden="true"
|
|
476
|
+
>
|
|
477
|
+
<path d="M11 5 6 9H3v6h3l5 4V5Z" />
|
|
478
|
+
<path d="M15.5 8.5a5 5 0 0 1 0 7" />
|
|
479
|
+
<path d="M18.5 5.5a9 9 0 0 1 0 13" />
|
|
480
|
+
</svg>
|
|
481
|
+
)}
|
|
482
|
+
</button>
|
|
483
|
+
|
|
341
484
|
{/* Speed control */}
|
|
342
485
|
<div ref={speedMenuContainerRef} className="relative flex-shrink-0">
|
|
343
486
|
<button
|
|
@@ -385,7 +528,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
385
528
|
type="button"
|
|
386
529
|
onClick={() => setLoopEnabled(!loopEnabled)}
|
|
387
530
|
disabled={disabled}
|
|
388
|
-
className={`h-7 w-
|
|
531
|
+
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
389
532
|
loopEnabled
|
|
390
533
|
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
391
534
|
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
|
@@ -394,53 +537,201 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
394
537
|
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
|
|
395
538
|
aria-pressed={loopEnabled}
|
|
396
539
|
>
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
540
|
+
<svg
|
|
541
|
+
width="13"
|
|
542
|
+
height="13"
|
|
543
|
+
viewBox="0 0 24 24"
|
|
544
|
+
fill="none"
|
|
545
|
+
stroke="currentColor"
|
|
546
|
+
strokeWidth="2"
|
|
547
|
+
strokeLinecap="round"
|
|
548
|
+
strokeLinejoin="round"
|
|
549
|
+
aria-hidden="true"
|
|
550
|
+
>
|
|
551
|
+
<path d="M17 2l4 4-4 4" />
|
|
552
|
+
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
|
553
|
+
<path d="M7 22l-4-4 4-4" />
|
|
554
|
+
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
|
555
|
+
</svg>
|
|
409
556
|
</button>
|
|
410
557
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
aria-label="
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
558
|
+
{/* Keyboard shortcuts + frame jump + work area — click to open panel */}
|
|
559
|
+
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
|
|
560
|
+
<button
|
|
561
|
+
type="button"
|
|
562
|
+
onClick={() => setShowShortcuts((v) => !v)}
|
|
563
|
+
className={`w-6 h-6 flex items-center justify-center rounded border transition-colors ${
|
|
564
|
+
showShortcuts
|
|
565
|
+
? "border-neutral-600 text-neutral-200 bg-neutral-800"
|
|
566
|
+
: "border-neutral-800 text-neutral-600 hover:text-neutral-300 hover:border-neutral-600"
|
|
567
|
+
}`}
|
|
568
|
+
aria-label="Shortcuts and tools"
|
|
569
|
+
aria-expanded={showShortcuts}
|
|
570
|
+
>
|
|
571
|
+
<svg
|
|
572
|
+
width="11"
|
|
573
|
+
height="11"
|
|
574
|
+
viewBox="0 0 24 24"
|
|
575
|
+
fill="none"
|
|
576
|
+
stroke="currentColor"
|
|
577
|
+
strokeWidth="1.75"
|
|
578
|
+
strokeLinecap="round"
|
|
579
|
+
strokeLinejoin="round"
|
|
580
|
+
aria-hidden="true"
|
|
581
|
+
>
|
|
582
|
+
<rect x="2" y="4" width="20" height="16" rx="2" />
|
|
583
|
+
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
|
|
584
|
+
</svg>
|
|
585
|
+
</button>
|
|
586
|
+
{showShortcuts && (
|
|
587
|
+
<div
|
|
588
|
+
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
|
|
589
|
+
style={{
|
|
590
|
+
background: "#161618",
|
|
591
|
+
border: "1px solid rgba(255,255,255,0.08)",
|
|
592
|
+
maxHeight: "min(280px, calc(100vh - 80px))",
|
|
593
|
+
}}
|
|
437
594
|
>
|
|
438
|
-
{
|
|
439
|
-
<
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
595
|
+
{/* Frame jump */}
|
|
596
|
+
<div className="px-3 pt-3 pb-2.5">
|
|
597
|
+
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
|
598
|
+
Jump to frame
|
|
599
|
+
</p>
|
|
600
|
+
<form onSubmit={handleJumpSubmit} className="flex items-center gap-1.5">
|
|
601
|
+
<input
|
|
602
|
+
value={jumpFrame}
|
|
603
|
+
onChange={(e) => setJumpFrame(e.target.value)}
|
|
604
|
+
disabled={disabled}
|
|
605
|
+
inputMode="numeric"
|
|
606
|
+
pattern="[0-9]*"
|
|
607
|
+
aria-label="Jump to frame"
|
|
608
|
+
placeholder="frame number"
|
|
609
|
+
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"
|
|
610
|
+
onKeyDown={handleJumpKeyDown}
|
|
611
|
+
onBlur={commitJumpFrame}
|
|
612
|
+
/>
|
|
613
|
+
<button
|
|
614
|
+
type="submit"
|
|
615
|
+
disabled={disabled}
|
|
616
|
+
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"
|
|
617
|
+
>
|
|
618
|
+
Go
|
|
619
|
+
</button>
|
|
620
|
+
</form>
|
|
621
|
+
</div>
|
|
622
|
+
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
|
|
623
|
+
{/* Work area */}
|
|
624
|
+
<div className="px-3 pt-2.5 pb-2">
|
|
625
|
+
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
|
626
|
+
Work area
|
|
627
|
+
</p>
|
|
628
|
+
<div className="flex flex-col gap-1">
|
|
629
|
+
<div className="flex items-center justify-between gap-2">
|
|
630
|
+
<div className="flex items-center gap-2">
|
|
631
|
+
<span
|
|
632
|
+
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
|
|
633
|
+
style={{ background: "rgba(255,255,255,0.05)" }}
|
|
634
|
+
>
|
|
635
|
+
I
|
|
636
|
+
</span>
|
|
637
|
+
<span className="text-[10px] text-neutral-400">In-point</span>
|
|
638
|
+
</div>
|
|
639
|
+
<div className="flex items-center gap-1.5">
|
|
640
|
+
{inPoint !== null ? (
|
|
641
|
+
<>
|
|
642
|
+
<span className="font-mono text-[10px] text-neutral-300">
|
|
643
|
+
{formatTime(inPoint)}
|
|
644
|
+
</span>
|
|
645
|
+
<button
|
|
646
|
+
type="button"
|
|
647
|
+
onClick={() => setInPoint(null)}
|
|
648
|
+
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
|
|
649
|
+
aria-label="Clear in-point"
|
|
650
|
+
>
|
|
651
|
+
<svg
|
|
652
|
+
width="8"
|
|
653
|
+
height="8"
|
|
654
|
+
viewBox="0 0 24 24"
|
|
655
|
+
fill="none"
|
|
656
|
+
stroke="currentColor"
|
|
657
|
+
strokeWidth="2.5"
|
|
658
|
+
>
|
|
659
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
660
|
+
</svg>
|
|
661
|
+
</button>
|
|
662
|
+
</>
|
|
663
|
+
) : (
|
|
664
|
+
<span className="text-[10px] text-neutral-600">—</span>
|
|
665
|
+
)}
|
|
666
|
+
</div>
|
|
667
|
+
</div>
|
|
668
|
+
<div className="flex items-center justify-between gap-2">
|
|
669
|
+
<div className="flex items-center gap-2">
|
|
670
|
+
<span
|
|
671
|
+
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
|
|
672
|
+
style={{ background: "rgba(255,255,255,0.05)" }}
|
|
673
|
+
>
|
|
674
|
+
O
|
|
675
|
+
</span>
|
|
676
|
+
<span className="text-[10px] text-neutral-400">Out-point</span>
|
|
677
|
+
</div>
|
|
678
|
+
<div className="flex items-center gap-1.5">
|
|
679
|
+
{outPoint !== null ? (
|
|
680
|
+
<>
|
|
681
|
+
<span className="font-mono text-[10px] text-neutral-300">
|
|
682
|
+
{formatTime(outPoint)}
|
|
683
|
+
</span>
|
|
684
|
+
<button
|
|
685
|
+
type="button"
|
|
686
|
+
onClick={() => setOutPoint(null)}
|
|
687
|
+
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
|
|
688
|
+
aria-label="Clear out-point"
|
|
689
|
+
>
|
|
690
|
+
<svg
|
|
691
|
+
width="8"
|
|
692
|
+
height="8"
|
|
693
|
+
viewBox="0 0 24 24"
|
|
694
|
+
fill="none"
|
|
695
|
+
stroke="currentColor"
|
|
696
|
+
strokeWidth="2.5"
|
|
697
|
+
>
|
|
698
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
699
|
+
</svg>
|
|
700
|
+
</button>
|
|
701
|
+
</>
|
|
702
|
+
) : (
|
|
703
|
+
<span className="text-[10px] text-neutral-600">—</span>
|
|
704
|
+
)}
|
|
705
|
+
</div>
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
</div>
|
|
709
|
+
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
|
|
710
|
+
{/* Shortcuts */}
|
|
711
|
+
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-3">
|
|
712
|
+
{SHORTCUT_SECTIONS.map((section) => (
|
|
713
|
+
<div key={section.title}>
|
|
714
|
+
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
|
715
|
+
{section.title}
|
|
716
|
+
</p>
|
|
717
|
+
<div className="flex flex-col gap-1">
|
|
718
|
+
{section.hints.map((hint) => (
|
|
719
|
+
<div key={hint.key} className="flex items-center gap-3">
|
|
720
|
+
<span
|
|
721
|
+
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[36px] text-center"
|
|
722
|
+
style={{ background: "rgba(255,255,255,0.05)" }}
|
|
723
|
+
>
|
|
724
|
+
{hint.key}
|
|
725
|
+
</span>
|
|
726
|
+
<span className="text-[10px] text-neutral-400">{hint.label}</span>
|
|
727
|
+
</div>
|
|
728
|
+
))}
|
|
729
|
+
</div>
|
|
730
|
+
</div>
|
|
731
|
+
))}
|
|
732
|
+
</div>
|
|
733
|
+
</div>
|
|
734
|
+
)}
|
|
444
735
|
</div>
|
|
445
736
|
</div>
|
|
446
737
|
);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, useEffect } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
+
|
|
9
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
usePlayerStore.getState().reset();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
interface Spies {
|
|
17
|
+
seek: ReturnType<typeof vi.fn>;
|
|
18
|
+
play: ReturnType<typeof vi.fn>;
|
|
19
|
+
playBackward: ReturnType<typeof vi.fn>;
|
|
20
|
+
pause: ReturnType<typeof vi.fn>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface HookHandle {
|
|
24
|
+
dispatch: (event: KeyboardEvent) => void;
|
|
25
|
+
release: (event: KeyboardEvent) => void;
|
|
26
|
+
spies: Spies;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setupHook(): HookHandle {
|
|
30
|
+
const spies: Spies = {
|
|
31
|
+
seek: vi.fn(),
|
|
32
|
+
play: vi.fn(),
|
|
33
|
+
playBackward: vi.fn(),
|
|
34
|
+
pause: vi.fn(),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let captured: ReturnType<typeof usePlaybackKeyboard> | null = null;
|
|
38
|
+
|
|
39
|
+
function Harness() {
|
|
40
|
+
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
|
41
|
+
const shuttleDirectionRef = React.useRef<"forward" | "backward" | null>(null);
|
|
42
|
+
const shuttleSpeedIndexRef = React.useRef(0);
|
|
43
|
+
const iframeShortcutCleanupRef = React.useRef<(() => void) | null>(null);
|
|
44
|
+
const result = usePlaybackKeyboard({
|
|
45
|
+
iframeRef,
|
|
46
|
+
shuttleDirectionRef,
|
|
47
|
+
shuttleSpeedIndexRef,
|
|
48
|
+
iframeShortcutCleanupRef,
|
|
49
|
+
getAdapter: () => null,
|
|
50
|
+
...spies,
|
|
51
|
+
});
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
captured = result;
|
|
54
|
+
});
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const host = document.createElement("div");
|
|
59
|
+
document.body.append(host);
|
|
60
|
+
const root = createRoot(host);
|
|
61
|
+
act(() => {
|
|
62
|
+
root.render(React.createElement(Harness));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (!captured) throw new Error("usePlaybackKeyboard harness did not capture handlers");
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
dispatch: (event) => captured!.playbackKeyDownRef.current(event),
|
|
69
|
+
release: (event) => captured!.playbackKeyUpRef.current(event),
|
|
70
|
+
spies,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function keydown(init: { code: string; key: string; shiftKey?: boolean }): KeyboardEvent {
|
|
75
|
+
return new KeyboardEvent("keydown", {
|
|
76
|
+
code: init.code,
|
|
77
|
+
key: init.key,
|
|
78
|
+
shiftKey: init.shiftKey ?? false,
|
|
79
|
+
cancelable: true,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function keyup(init: { code: string; key: string }): KeyboardEvent {
|
|
84
|
+
return new KeyboardEvent("keyup", { code: init.code, key: init.key });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => {
|
|
88
|
+
it("'Jump to in-point' fires on physical KeyA in a QWERTY layout", () => {
|
|
89
|
+
const { dispatch, spies } = setupHook();
|
|
90
|
+
usePlayerStore.setState({ inPoint: 1.5 });
|
|
91
|
+
|
|
92
|
+
act(() => {
|
|
93
|
+
dispatch(keydown({ code: "KeyA", key: "a" }));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("'Jump to in-point' fires on AZERTY (physical KeyQ produces e.key='a')", () => {
|
|
100
|
+
const { dispatch, spies } = setupHook();
|
|
101
|
+
usePlayerStore.setState({ inPoint: 2.5 });
|
|
102
|
+
|
|
103
|
+
act(() => {
|
|
104
|
+
dispatch(keydown({ code: "KeyQ", key: "a" }));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(spies.seek).toHaveBeenCalledWith(2.5, { keepPlaying: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("AZERTY 'A' physical key (e.key='q') no longer triggers in-point seek", () => {
|
|
111
|
+
const { dispatch, spies } = setupHook();
|
|
112
|
+
usePlayerStore.setState({ inPoint: 4.0 });
|
|
113
|
+
|
|
114
|
+
act(() => {
|
|
115
|
+
dispatch(keydown({ code: "KeyA", key: "q" }));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(spies.seek).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("Shift+I clears the in-point (e.key='I' is matched after lowercasing)", () => {
|
|
122
|
+
const { dispatch } = setupHook();
|
|
123
|
+
usePlayerStore.setState({ inPoint: 3.0 });
|
|
124
|
+
|
|
125
|
+
act(() => {
|
|
126
|
+
dispatch(keydown({ code: "KeyI", key: "I", shiftKey: true }));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(usePlayerStore.getState().inPoint).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("K-held + L steps forward one frame (combo uses character, not physical position)", () => {
|
|
133
|
+
const { dispatch, spies } = setupHook();
|
|
134
|
+
usePlayerStore.setState({ currentTime: 0 });
|
|
135
|
+
|
|
136
|
+
act(() => {
|
|
137
|
+
dispatch(keydown({ code: "KeyK", key: "k" }));
|
|
138
|
+
});
|
|
139
|
+
act(() => {
|
|
140
|
+
dispatch(keydown({ code: "KeyL", key: "l" }));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(spies.seek).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(spies.play).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("releasing K removes it from the pressed set so subsequent L resumes forward shuttle", () => {
|
|
148
|
+
const { dispatch, release, spies } = setupHook();
|
|
149
|
+
|
|
150
|
+
act(() => {
|
|
151
|
+
dispatch(keydown({ code: "KeyK", key: "k" }));
|
|
152
|
+
});
|
|
153
|
+
act(() => {
|
|
154
|
+
release(keyup({ code: "KeyK", key: "k" }));
|
|
155
|
+
});
|
|
156
|
+
act(() => {
|
|
157
|
+
dispatch(keydown({ code: "KeyL", key: "l" }));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(spies.play).toHaveBeenCalledTimes(1);
|
|
161
|
+
expect(spies.seek).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("Space (universal e.code) still toggles play", () => {
|
|
165
|
+
const { dispatch, spies } = setupHook();
|
|
166
|
+
usePlayerStore.setState({ isPlaying: false });
|
|
167
|
+
|
|
168
|
+
act(() => {
|
|
169
|
+
dispatch(keydown({ code: "Space", key: " " }));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(spies.play).toHaveBeenCalledTimes(1);
|
|
173
|
+
});
|
|
174
|
+
});
|