@hyperframes/studio 0.6.4 → 0.6.6
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-T-ME1rqL.js} +2 -2
- package/dist/assets/index-Bne9FFeo.css +1 -0
- package/dist/assets/index-DYqqzECY.js +117 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +5 -29
- package/src/components/StudioHeader.tsx +128 -3
- package/src/components/StudioSplash.tsx +17 -0
- package/src/hooks/useManifestPersistence.ts +15 -2
- package/src/hooks/useServerConnection.ts +71 -0
- package/src/player/components/PlayerControls.tsx +286 -56
- package/src/player/hooks/usePlaybackKeyboard.ts +25 -1
- package/src/player/hooks/useTimelinePlayer.ts +21 -10
- package/src/player/store/playerStore.ts +27 -0
- package/dist/assets/index-DMJCfYoN.css +0 -1
- package/dist/assets/index-DsnMQhJc.js +0 -117
|
@@ -6,11 +6,29 @@ import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
|
6
6
|
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
|
7
7
|
const SEEK_EDGE_SNAP_PX = 8;
|
|
8
8
|
type TimeDisplayMode = "time" | "frame";
|
|
9
|
-
const
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
const SHORTCUT_SECTIONS = [
|
|
10
|
+
{
|
|
11
|
+
title: "Playback",
|
|
12
|
+
hints: [
|
|
13
|
+
{ key: "Space", label: "Play / Pause" },
|
|
14
|
+
{ key: "J", label: "Play backward" },
|
|
15
|
+
{ key: "K", label: "Stop" },
|
|
16
|
+
{ key: "L", label: "Play forward" },
|
|
17
|
+
{ key: "←/→", label: "Step 1 frame" },
|
|
18
|
+
{ key: "⇧←/⇧→", label: "Step 10 frames" },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
title: "Work area",
|
|
23
|
+
hints: [
|
|
24
|
+
{ key: "I", label: "Set in-point" },
|
|
25
|
+
{ key: "⇧I", label: "Clear in-point" },
|
|
26
|
+
{ key: "O", label: "Set out-point" },
|
|
27
|
+
{ key: "⇧O", label: "Clear out-point" },
|
|
28
|
+
{ key: "A", label: "Jump to in-point" },
|
|
29
|
+
{ key: "E", label: "Jump to out-point" },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
14
32
|
] as const;
|
|
15
33
|
|
|
16
34
|
export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
|
|
@@ -42,7 +60,12 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
42
60
|
const loopEnabled = usePlayerStore((s) => s.loopEnabled);
|
|
43
61
|
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
|
|
44
62
|
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
|
|
63
|
+
const inPoint = usePlayerStore((s) => s.inPoint);
|
|
64
|
+
const outPoint = usePlayerStore((s) => s.outPoint);
|
|
65
|
+
const setInPoint = usePlayerStore.getState().setInPoint;
|
|
66
|
+
const setOutPoint = usePlayerStore.getState().setOutPoint;
|
|
45
67
|
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
|
68
|
+
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
46
69
|
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
|
|
47
70
|
const [jumpFrame, setJumpFrame] = useState("");
|
|
48
71
|
|
|
@@ -52,6 +75,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
52
75
|
const seekBarRef = useRef<HTMLDivElement>(null);
|
|
53
76
|
const sliderRef = useRef<HTMLDivElement>(null);
|
|
54
77
|
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
|
|
78
|
+
const shortcutsPanelRef = useRef<HTMLDivElement>(null);
|
|
55
79
|
const isDraggingRef = useRef(false);
|
|
56
80
|
const currentTimeRef = useRef(0);
|
|
57
81
|
const timeDisplayModeRef = useRef(timeDisplayMode);
|
|
@@ -116,6 +140,19 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
116
140
|
};
|
|
117
141
|
}, [showSpeedMenu]);
|
|
118
142
|
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (!showShortcuts) return;
|
|
145
|
+
const handleMouseDown = (e: MouseEvent) => {
|
|
146
|
+
if (shortcutsPanelRef.current && !shortcutsPanelRef.current.contains(e.target as Node)) {
|
|
147
|
+
setShowShortcuts(false);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
document.addEventListener("mousedown", handleMouseDown);
|
|
151
|
+
return () => {
|
|
152
|
+
document.removeEventListener("mousedown", handleMouseDown);
|
|
153
|
+
};
|
|
154
|
+
}, [showShortcuts]);
|
|
155
|
+
|
|
119
156
|
const seekFromClientX = useCallback(
|
|
120
157
|
(clientX: number) => {
|
|
121
158
|
if (disabled) return;
|
|
@@ -278,10 +315,14 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
278
315
|
)}
|
|
279
316
|
</button>
|
|
280
317
|
|
|
281
|
-
{/* Time display */}
|
|
282
|
-
<
|
|
283
|
-
|
|
284
|
-
|
|
318
|
+
{/* Time display — click to toggle time/frame mode */}
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
onClick={() => setTimeDisplayMode((m) => (m === "time" ? "frame" : "time"))}
|
|
322
|
+
disabled={disabled}
|
|
323
|
+
title={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
|
|
324
|
+
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80"
|
|
325
|
+
style={{ color: "#A1A1AA", cursor: "pointer" }}
|
|
285
326
|
>
|
|
286
327
|
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
|
287
328
|
{timeDisplayMode === "time" ? (
|
|
@@ -290,7 +331,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
290
331
|
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
|
|
291
332
|
</>
|
|
292
333
|
) : null}
|
|
293
|
-
</
|
|
334
|
+
</button>
|
|
294
335
|
|
|
295
336
|
{/* Seek bar — teal progress fill */}
|
|
296
337
|
<div
|
|
@@ -320,16 +361,57 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
320
361
|
className="w-full rounded-full relative"
|
|
321
362
|
style={{ background: "rgba(255,255,255,0.15)", height: "3px" }}
|
|
322
363
|
>
|
|
364
|
+
{/* Work-area band between in/out points */}
|
|
365
|
+
{(inPoint !== null || outPoint !== null) && duration > 0 && (
|
|
366
|
+
<div
|
|
367
|
+
className="absolute top-0 bottom-0 pointer-events-none"
|
|
368
|
+
style={{
|
|
369
|
+
left: `${inPoint !== null ? Math.min(100, (inPoint / duration) * 100) : 0}%`,
|
|
370
|
+
right: `${outPoint !== null ? 100 - Math.min(100, (outPoint / duration) * 100) : 0}%`,
|
|
371
|
+
background: "rgba(60,230,172,0.15)",
|
|
372
|
+
}}
|
|
373
|
+
/>
|
|
374
|
+
)}
|
|
323
375
|
{/* Progress fill — width is controlled imperatively via ref to avoid React re-render resets */}
|
|
324
376
|
<div
|
|
325
377
|
ref={progressFillRef}
|
|
326
378
|
className="absolute top-0 bottom-0 left-0 z-[1] rounded-full"
|
|
327
379
|
style={{ background: "linear-gradient(90deg, var(--hf-accent, #3CE6AC), #2BBFA0)" }}
|
|
328
380
|
/>
|
|
381
|
+
{/* In-point marker */}
|
|
382
|
+
{inPoint !== null && duration > 0 && (
|
|
383
|
+
<div
|
|
384
|
+
className="absolute z-[3] pointer-events-none"
|
|
385
|
+
style={{
|
|
386
|
+
left: `${Math.min(100, (inPoint / duration) * 100)}%`,
|
|
387
|
+
top: "50%",
|
|
388
|
+
transform: "translate(-50%, -50%)",
|
|
389
|
+
width: "2px",
|
|
390
|
+
height: "10px",
|
|
391
|
+
background: "#3CE6AC",
|
|
392
|
+
borderRadius: "1px",
|
|
393
|
+
}}
|
|
394
|
+
/>
|
|
395
|
+
)}
|
|
396
|
+
{/* Out-point marker */}
|
|
397
|
+
{outPoint !== null && duration > 0 && (
|
|
398
|
+
<div
|
|
399
|
+
className="absolute z-[3] pointer-events-none"
|
|
400
|
+
style={{
|
|
401
|
+
left: `${Math.min(100, (outPoint / duration) * 100)}%`,
|
|
402
|
+
top: "50%",
|
|
403
|
+
transform: "translate(-50%, -50%)",
|
|
404
|
+
width: "2px",
|
|
405
|
+
height: "10px",
|
|
406
|
+
background: "#3CE6AC",
|
|
407
|
+
borderRadius: "1px",
|
|
408
|
+
}}
|
|
409
|
+
/>
|
|
410
|
+
)}
|
|
329
411
|
{/* Playhead thumb — left is controlled imperatively via ref */}
|
|
330
412
|
<div
|
|
331
413
|
ref={progressThumbRef}
|
|
332
|
-
className="absolute top-1/2 z-[
|
|
414
|
+
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
415
|
style={{
|
|
334
416
|
background: "var(--hf-accent, #3CE6AC)",
|
|
335
417
|
boxShadow: "0 0 6px rgba(60,230,172,0.4), 0 1px 4px rgba(0,0,0,0.4)",
|
|
@@ -385,7 +467,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
385
467
|
type="button"
|
|
386
468
|
onClick={() => setLoopEnabled(!loopEnabled)}
|
|
387
469
|
disabled={disabled}
|
|
388
|
-
className={`h-7 w-
|
|
470
|
+
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
389
471
|
loopEnabled
|
|
390
472
|
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
391
473
|
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
|
@@ -394,53 +476,201 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
394
476
|
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
|
|
395
477
|
aria-pressed={loopEnabled}
|
|
396
478
|
>
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
479
|
+
<svg
|
|
480
|
+
width="13"
|
|
481
|
+
height="13"
|
|
482
|
+
viewBox="0 0 24 24"
|
|
483
|
+
fill="none"
|
|
484
|
+
stroke="currentColor"
|
|
485
|
+
strokeWidth="2"
|
|
486
|
+
strokeLinecap="round"
|
|
487
|
+
strokeLinejoin="round"
|
|
488
|
+
aria-hidden="true"
|
|
489
|
+
>
|
|
490
|
+
<path d="M17 2l4 4-4 4" />
|
|
491
|
+
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
|
492
|
+
<path d="M7 22l-4-4 4-4" />
|
|
493
|
+
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
|
494
|
+
</svg>
|
|
409
495
|
</button>
|
|
410
496
|
|
|
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
|
-
<span
|
|
435
|
-
key={shortcut.key}
|
|
436
|
-
className="group relative rounded border border-neutral-800 px-1 py-0.5"
|
|
497
|
+
{/* Keyboard shortcuts + frame jump + work area — click to open panel */}
|
|
498
|
+
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
|
|
499
|
+
<button
|
|
500
|
+
type="button"
|
|
501
|
+
onClick={() => setShowShortcuts((v) => !v)}
|
|
502
|
+
className={`w-6 h-6 flex items-center justify-center rounded border transition-colors ${
|
|
503
|
+
showShortcuts
|
|
504
|
+
? "border-neutral-600 text-neutral-200 bg-neutral-800"
|
|
505
|
+
: "border-neutral-800 text-neutral-600 hover:text-neutral-300 hover:border-neutral-600"
|
|
506
|
+
}`}
|
|
507
|
+
aria-label="Shortcuts and tools"
|
|
508
|
+
aria-expanded={showShortcuts}
|
|
509
|
+
>
|
|
510
|
+
<svg
|
|
511
|
+
width="11"
|
|
512
|
+
height="11"
|
|
513
|
+
viewBox="0 0 24 24"
|
|
514
|
+
fill="none"
|
|
515
|
+
stroke="currentColor"
|
|
516
|
+
strokeWidth="1.75"
|
|
517
|
+
strokeLinecap="round"
|
|
518
|
+
strokeLinejoin="round"
|
|
519
|
+
aria-hidden="true"
|
|
437
520
|
>
|
|
438
|
-
|
|
439
|
-
<
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
521
|
+
<rect x="2" y="4" width="20" height="16" rx="2" />
|
|
522
|
+
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
|
|
523
|
+
</svg>
|
|
524
|
+
</button>
|
|
525
|
+
{showShortcuts && (
|
|
526
|
+
<div
|
|
527
|
+
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
|
|
528
|
+
style={{
|
|
529
|
+
background: "#161618",
|
|
530
|
+
border: "1px solid rgba(255,255,255,0.08)",
|
|
531
|
+
maxHeight: "min(280px, calc(100vh - 80px))",
|
|
532
|
+
}}
|
|
533
|
+
>
|
|
534
|
+
{/* Frame jump */}
|
|
535
|
+
<div className="px-3 pt-3 pb-2.5">
|
|
536
|
+
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
|
537
|
+
Jump to frame
|
|
538
|
+
</p>
|
|
539
|
+
<form onSubmit={handleJumpSubmit} className="flex items-center gap-1.5">
|
|
540
|
+
<input
|
|
541
|
+
value={jumpFrame}
|
|
542
|
+
onChange={(e) => setJumpFrame(e.target.value)}
|
|
543
|
+
disabled={disabled}
|
|
544
|
+
inputMode="numeric"
|
|
545
|
+
pattern="[0-9]*"
|
|
546
|
+
aria-label="Jump to frame"
|
|
547
|
+
placeholder="frame number"
|
|
548
|
+
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"
|
|
549
|
+
onKeyDown={handleJumpKeyDown}
|
|
550
|
+
onBlur={commitJumpFrame}
|
|
551
|
+
/>
|
|
552
|
+
<button
|
|
553
|
+
type="submit"
|
|
554
|
+
disabled={disabled}
|
|
555
|
+
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"
|
|
556
|
+
>
|
|
557
|
+
Go
|
|
558
|
+
</button>
|
|
559
|
+
</form>
|
|
560
|
+
</div>
|
|
561
|
+
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
|
|
562
|
+
{/* Work area */}
|
|
563
|
+
<div className="px-3 pt-2.5 pb-2">
|
|
564
|
+
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
|
565
|
+
Work area
|
|
566
|
+
</p>
|
|
567
|
+
<div className="flex flex-col gap-1">
|
|
568
|
+
<div className="flex items-center justify-between gap-2">
|
|
569
|
+
<div className="flex items-center gap-2">
|
|
570
|
+
<span
|
|
571
|
+
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
|
|
572
|
+
style={{ background: "rgba(255,255,255,0.05)" }}
|
|
573
|
+
>
|
|
574
|
+
I
|
|
575
|
+
</span>
|
|
576
|
+
<span className="text-[10px] text-neutral-400">In-point</span>
|
|
577
|
+
</div>
|
|
578
|
+
<div className="flex items-center gap-1.5">
|
|
579
|
+
{inPoint !== null ? (
|
|
580
|
+
<>
|
|
581
|
+
<span className="font-mono text-[10px] text-neutral-300">
|
|
582
|
+
{formatTime(inPoint)}
|
|
583
|
+
</span>
|
|
584
|
+
<button
|
|
585
|
+
type="button"
|
|
586
|
+
onClick={() => setInPoint(null)}
|
|
587
|
+
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
|
|
588
|
+
aria-label="Clear in-point"
|
|
589
|
+
>
|
|
590
|
+
<svg
|
|
591
|
+
width="8"
|
|
592
|
+
height="8"
|
|
593
|
+
viewBox="0 0 24 24"
|
|
594
|
+
fill="none"
|
|
595
|
+
stroke="currentColor"
|
|
596
|
+
strokeWidth="2.5"
|
|
597
|
+
>
|
|
598
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
599
|
+
</svg>
|
|
600
|
+
</button>
|
|
601
|
+
</>
|
|
602
|
+
) : (
|
|
603
|
+
<span className="text-[10px] text-neutral-600">—</span>
|
|
604
|
+
)}
|
|
605
|
+
</div>
|
|
606
|
+
</div>
|
|
607
|
+
<div className="flex items-center justify-between gap-2">
|
|
608
|
+
<div className="flex items-center gap-2">
|
|
609
|
+
<span
|
|
610
|
+
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
|
|
611
|
+
style={{ background: "rgba(255,255,255,0.05)" }}
|
|
612
|
+
>
|
|
613
|
+
O
|
|
614
|
+
</span>
|
|
615
|
+
<span className="text-[10px] text-neutral-400">Out-point</span>
|
|
616
|
+
</div>
|
|
617
|
+
<div className="flex items-center gap-1.5">
|
|
618
|
+
{outPoint !== null ? (
|
|
619
|
+
<>
|
|
620
|
+
<span className="font-mono text-[10px] text-neutral-300">
|
|
621
|
+
{formatTime(outPoint)}
|
|
622
|
+
</span>
|
|
623
|
+
<button
|
|
624
|
+
type="button"
|
|
625
|
+
onClick={() => setOutPoint(null)}
|
|
626
|
+
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
|
|
627
|
+
aria-label="Clear out-point"
|
|
628
|
+
>
|
|
629
|
+
<svg
|
|
630
|
+
width="8"
|
|
631
|
+
height="8"
|
|
632
|
+
viewBox="0 0 24 24"
|
|
633
|
+
fill="none"
|
|
634
|
+
stroke="currentColor"
|
|
635
|
+
strokeWidth="2.5"
|
|
636
|
+
>
|
|
637
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
638
|
+
</svg>
|
|
639
|
+
</button>
|
|
640
|
+
</>
|
|
641
|
+
) : (
|
|
642
|
+
<span className="text-[10px] text-neutral-600">—</span>
|
|
643
|
+
)}
|
|
644
|
+
</div>
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
|
|
649
|
+
{/* Shortcuts */}
|
|
650
|
+
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-3">
|
|
651
|
+
{SHORTCUT_SECTIONS.map((section) => (
|
|
652
|
+
<div key={section.title}>
|
|
653
|
+
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
|
654
|
+
{section.title}
|
|
655
|
+
</p>
|
|
656
|
+
<div className="flex flex-col gap-1">
|
|
657
|
+
{section.hints.map((hint) => (
|
|
658
|
+
<div key={hint.key} className="flex items-center gap-3">
|
|
659
|
+
<span
|
|
660
|
+
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[36px] text-center"
|
|
661
|
+
style={{ background: "rgba(255,255,255,0.05)" }}
|
|
662
|
+
>
|
|
663
|
+
{hint.key}
|
|
664
|
+
</span>
|
|
665
|
+
<span className="text-[10px] text-neutral-400">{hint.label}</span>
|
|
666
|
+
</div>
|
|
667
|
+
))}
|
|
668
|
+
</div>
|
|
669
|
+
</div>
|
|
670
|
+
))}
|
|
671
|
+
</div>
|
|
672
|
+
</div>
|
|
673
|
+
)}
|
|
444
674
|
</div>
|
|
445
675
|
</div>
|
|
446
676
|
);
|
|
@@ -128,9 +128,33 @@ export function usePlaybackKeyboard({
|
|
|
128
128
|
return;
|
|
129
129
|
}
|
|
130
130
|
shuttle("forward");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (e.code === "KeyI") {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
const t = getAdapter()?.getTime() ?? usePlayerStore.getState().currentTime;
|
|
136
|
+
usePlayerStore.getState().setInPoint(e.shiftKey ? null : t);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (e.code === "KeyO") {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
const t = getAdapter()?.getTime() ?? usePlayerStore.getState().currentTime;
|
|
142
|
+
usePlayerStore.getState().setOutPoint(e.shiftKey ? null : t);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (e.code === "KeyA") {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
seek(usePlayerStore.getState().inPoint ?? 0);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (e.code === "KeyE") {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
const { outPoint } = usePlayerStore.getState();
|
|
153
|
+
seek(outPoint ?? getAdapter()?.getDuration() ?? usePlayerStore.getState().duration);
|
|
154
|
+
return;
|
|
131
155
|
}
|
|
132
156
|
},
|
|
133
|
-
[pause, shuttle, stepFrames, togglePlay],
|
|
157
|
+
[pause, shuttle, stepFrames, togglePlay, getAdapter, seek],
|
|
134
158
|
);
|
|
135
159
|
|
|
136
160
|
const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
|
|
@@ -185,15 +185,21 @@ export function useTimelinePlayer() {
|
|
|
185
185
|
const time = adapter.getTime();
|
|
186
186
|
const dur = adapter.getDuration();
|
|
187
187
|
liveTime.notify(time); // direct DOM updates, no React re-render
|
|
188
|
-
|
|
188
|
+
const { inPoint, outPoint } = usePlayerStore.getState();
|
|
189
|
+
const rawLoopEnd = outPoint !== null ? outPoint : dur;
|
|
190
|
+
const rawLoopStart = inPoint !== null ? inPoint : 0;
|
|
191
|
+
const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur;
|
|
192
|
+
const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
|
|
193
|
+
if (time >= loopEnd) {
|
|
189
194
|
if (usePlayerStore.getState().loopEnabled && dur > 0) {
|
|
190
|
-
adapter.seek(
|
|
191
|
-
liveTime.notify(
|
|
195
|
+
adapter.seek(loopStart);
|
|
196
|
+
liveTime.notify(loopStart);
|
|
192
197
|
adapter.play();
|
|
193
198
|
setIsPlaying(true);
|
|
194
199
|
rafRef.current = requestAnimationFrame(tick);
|
|
195
200
|
return;
|
|
196
201
|
}
|
|
202
|
+
if (adapter.isPlaying()) adapter.pause();
|
|
197
203
|
setCurrentTime(time); // sync Zustand once at end
|
|
198
204
|
setIsPlaying(false);
|
|
199
205
|
cancelAnimationFrame(rafRef.current);
|
|
@@ -241,7 +247,7 @@ export function useTimelinePlayer() {
|
|
|
241
247
|
const adapter = getAdapter();
|
|
242
248
|
if (!adapter) return;
|
|
243
249
|
if (adapter.getTime() >= adapter.getDuration()) {
|
|
244
|
-
adapter.seek(0);
|
|
250
|
+
adapter.seek(usePlayerStore.getState().inPoint ?? 0);
|
|
245
251
|
}
|
|
246
252
|
unmutePreviewMedia(iframeRef.current);
|
|
247
253
|
applyPlaybackRate(usePlayerStore.getState().playbackRate);
|
|
@@ -269,15 +275,20 @@ export function useTimelinePlayer() {
|
|
|
269
275
|
const tick = (now: number) => {
|
|
270
276
|
const elapsed = ((now - startedAt) / 1000) * speed;
|
|
271
277
|
let nextTime = startTime - elapsed;
|
|
272
|
-
|
|
278
|
+
const { inPoint, outPoint } = usePlayerStore.getState();
|
|
279
|
+
const rawLoopEnd = outPoint !== null ? outPoint : duration;
|
|
280
|
+
const rawLoopStart = inPoint !== null ? inPoint : 0;
|
|
281
|
+
const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : duration;
|
|
282
|
+
const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
|
|
283
|
+
if (nextTime <= loopStart) {
|
|
273
284
|
if (usePlayerStore.getState().loopEnabled && duration > 0) {
|
|
274
|
-
startTime =
|
|
285
|
+
startTime = loopEnd;
|
|
275
286
|
startedAt = now;
|
|
276
|
-
nextTime =
|
|
287
|
+
nextTime = loopEnd;
|
|
277
288
|
} else {
|
|
278
|
-
adapter.seek(
|
|
279
|
-
liveTime.notify(
|
|
280
|
-
setCurrentTime(
|
|
289
|
+
adapter.seek(loopStart);
|
|
290
|
+
liveTime.notify(loopStart);
|
|
291
|
+
setCurrentTime(loopStart);
|
|
281
292
|
setIsPlaying(false);
|
|
282
293
|
shuttleDirectionRef.current = null;
|
|
283
294
|
reverseRafRef.current = 0;
|
|
@@ -43,6 +43,10 @@ interface PlayerState {
|
|
|
43
43
|
zoomMode: ZoomMode;
|
|
44
44
|
/** Timeline zoom percent relative to the fit width when in manual mode */
|
|
45
45
|
manualZoomPercent: number;
|
|
46
|
+
/** Work-area in-point (seconds). When set, loop starts here and A jumps here. */
|
|
47
|
+
inPoint: number | null;
|
|
48
|
+
/** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
|
|
49
|
+
outPoint: number | null;
|
|
46
50
|
|
|
47
51
|
setIsPlaying: (playing: boolean) => void;
|
|
48
52
|
setCurrentTime: (time: number) => void;
|
|
@@ -58,6 +62,8 @@ interface PlayerState {
|
|
|
58
62
|
) => void;
|
|
59
63
|
setZoomMode: (mode: ZoomMode) => void;
|
|
60
64
|
setManualZoomPercent: (percent: number) => void;
|
|
65
|
+
setInPoint: (time: number | null) => void;
|
|
66
|
+
setOutPoint: (time: number | null) => void;
|
|
61
67
|
reset: () => void;
|
|
62
68
|
|
|
63
69
|
/**
|
|
@@ -93,6 +99,8 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
93
99
|
loopEnabled: false,
|
|
94
100
|
zoomMode: "fit",
|
|
95
101
|
manualZoomPercent: 100,
|
|
102
|
+
inPoint: null,
|
|
103
|
+
outPoint: null,
|
|
96
104
|
|
|
97
105
|
requestedSeekTime: null,
|
|
98
106
|
requestSeek: (time) => set({ requestedSeekTime: time }),
|
|
@@ -105,6 +113,23 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
105
113
|
},
|
|
106
114
|
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
|
|
107
115
|
setZoomMode: (mode) => set({ zoomMode: mode }),
|
|
116
|
+
setInPoint: (time) =>
|
|
117
|
+
set((state) => {
|
|
118
|
+
const t = time !== null && Number.isFinite(time) ? time : null;
|
|
119
|
+
return {
|
|
120
|
+
inPoint: t,
|
|
121
|
+
outPoint:
|
|
122
|
+
t !== null && state.outPoint !== null && t >= state.outPoint ? null : state.outPoint,
|
|
123
|
+
};
|
|
124
|
+
}),
|
|
125
|
+
setOutPoint: (time) =>
|
|
126
|
+
set((state) => {
|
|
127
|
+
const t = time !== null && Number.isFinite(time) ? time : null;
|
|
128
|
+
return {
|
|
129
|
+
outPoint: t,
|
|
130
|
+
inPoint: t !== null && state.inPoint !== null && t <= state.inPoint ? null : state.inPoint,
|
|
131
|
+
};
|
|
132
|
+
}),
|
|
108
133
|
setManualZoomPercent: (percent) =>
|
|
109
134
|
set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
|
|
110
135
|
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
|
|
@@ -129,5 +154,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
129
154
|
timelineReady: false,
|
|
130
155
|
elements: [],
|
|
131
156
|
selectedElementId: null,
|
|
157
|
+
inPoint: null,
|
|
158
|
+
outPoint: null,
|
|
132
159
|
}),
|
|
133
160
|
}));
|