@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.
Files changed (57) hide show
  1. package/dist/assets/{hyperframes-player-CzwFysqv.js → hyperframes-player-D0Yi3xMP.js} +2 -2
  2. package/dist/assets/index-Ckqo37Co.css +1 -0
  3. package/dist/assets/index-Yvtxngdi.js +116 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +54 -31
  7. package/src/components/StudioGlobalDragOverlay.tsx +26 -0
  8. package/src/components/StudioHeader.tsx +128 -3
  9. package/src/components/StudioRightPanel.tsx +0 -2
  10. package/src/components/editor/DomEditOverlay.test.ts +1 -0
  11. package/src/components/editor/DomEditOverlay.tsx +2 -1
  12. package/src/components/editor/PropertyPanel.tsx +27 -36
  13. package/src/components/editor/domEditingElement.ts +1 -0
  14. package/src/components/editor/manualEdits.test.ts +39 -466
  15. package/src/components/editor/manualEdits.ts +6 -168
  16. package/src/components/editor/manualEditsDom.ts +361 -1
  17. package/src/components/editor/manualEditsParsing.ts +2 -240
  18. package/src/components/editor/manualEditsTypes.ts +1 -40
  19. package/src/components/editor/useDomEditOverlayGestures.ts +25 -8
  20. package/src/components/nle/NLEPreview.tsx +1 -1
  21. package/src/components/sidebar/CompositionsTab.tsx +9 -3
  22. package/src/contexts/DomEditContext.tsx +3 -0
  23. package/src/contexts/FileManagerContext.tsx +3 -0
  24. package/src/hooks/useAppHotkeys.ts +1 -4
  25. package/src/hooks/useDomEditCommits.ts +82 -77
  26. package/src/hooks/useDomEditSession.ts +4 -16
  27. package/src/hooks/useFileManager.ts +10 -1
  28. package/src/hooks/useManifestPersistence.ts +51 -187
  29. package/src/hooks/usePanelLayout.ts +10 -3
  30. package/src/hooks/usePreviewInteraction.ts +0 -1
  31. package/src/hooks/useStudioUrlState.ts +188 -0
  32. package/src/player/components/Player.tsx +15 -1
  33. package/src/player/components/PlayerControls.test.ts +17 -0
  34. package/src/player/components/PlayerControls.tsx +347 -56
  35. package/src/player/hooks/usePlaybackKeyboard.test.ts +174 -0
  36. package/src/player/hooks/usePlaybackKeyboard.ts +37 -10
  37. package/src/player/hooks/useTimelinePlayer.seek.test.ts +329 -0
  38. package/src/player/hooks/useTimelinePlayer.ts +97 -28
  39. package/src/player/hooks/useTimelineSyncCallbacks.ts +10 -4
  40. package/src/player/lib/playbackAdapter.test.ts +50 -0
  41. package/src/player/lib/playbackAdapter.ts +2 -2
  42. package/src/player/lib/playbackTypes.ts +1 -1
  43. package/src/player/lib/timelineDOM.ts +4 -2
  44. package/src/player/lib/timelineIframeHelpers.ts +63 -7
  45. package/src/player/store/playerStore.test.ts +105 -1
  46. package/src/player/store/playerStore.ts +39 -1
  47. package/src/utils/projectRouting.test.ts +15 -0
  48. package/src/utils/projectRouting.ts +46 -9
  49. package/src/utils/sourcePatcher.ts +50 -14
  50. package/src/utils/studioPreviewHelpers.test.ts +56 -0
  51. package/src/utils/studioPreviewHelpers.ts +51 -13
  52. package/src/utils/studioUiPreferences.test.ts +3 -0
  53. package/src/utils/studioUiPreferences.ts +4 -0
  54. package/src/utils/studioUrlState.test.ts +249 -0
  55. package/src/utils/studioUrlState.ts +135 -0
  56. package/dist/assets/index-Bs6NmE0o.js +0 -117
  57. 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 SHORTCUT_HINTS = [
10
- { key: "J", label: "Play backward" },
11
- { key: "K", label: "Stop playback" },
12
- { key: "L", label: "Play forward" },
13
- { key: "←/→", label: "Step one frame backward or forward" },
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
- <span
283
- className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px]"
284
- style={{ color: "#A1A1AA" }}
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
- </span>
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-[2] w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 transition-transform group-hover:scale-125"
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-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
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
- Loop
398
- </button>
399
-
400
- <button
401
- type="button"
402
- onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
403
- disabled={disabled}
404
- className="h-7 w-14 rounded-md border border-neutral-700 px-2 text-[10px] font-mono text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
405
- title="Toggle time/frame display"
406
- aria-label="Toggle time and frame display"
407
- >
408
- {timeDisplayMode === "time" ? "m:ss" : "frames"}
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
- <form
412
- onSubmit={handleJumpSubmit}
413
- className="hidden sm:flex flex-shrink-0 w-[58px] items-center"
414
- >
415
- <input
416
- value={jumpFrame}
417
- onChange={(e) => setJumpFrame(e.target.value)}
418
- disabled={disabled}
419
- inputMode="numeric"
420
- pattern="[0-9]*"
421
- aria-label="Jump to frame"
422
- placeholder="frame"
423
- className="h-7 w-[58px] rounded-md 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"
424
- onKeyDown={handleJumpKeyDown}
425
- onBlur={commitJumpFrame}
426
- />
427
- </form>
428
-
429
- <div
430
- className="hidden lg:flex items-center gap-1 text-[9px] font-mono text-neutral-500"
431
- aria-label="Playback shortcuts: J backward, K stop, L forward, arrows step one frame"
432
- >
433
- {SHORTCUT_HINTS.map((shortcut) => (
434
- <span
435
- key={shortcut.key}
436
- className="group relative rounded border border-neutral-800 px-1 py-0.5"
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
- {shortcut.key}
439
- <span className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 hidden -translate-x-1/2 whitespace-nowrap rounded-md border border-neutral-700 bg-neutral-950 px-2 py-1 font-sans text-[10px] text-neutral-200 shadow-lg group-hover:block">
440
- {shortcut.label}
441
- </span>
442
- </span>
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
+ });