@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.
@@ -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 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" },
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
- <span
283
- className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px]"
284
- style={{ color: "#A1A1AA" }}
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
- </span>
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-[2] w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 transition-transform group-hover:scale-125"
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-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
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
- 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"}
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
- <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"
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
- {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
- ))}
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
- if (time >= dur && !adapter.isPlaying()) {
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(0);
191
- liveTime.notify(0);
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
- if (nextTime <= 0) {
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 = duration;
285
+ startTime = loopEnd;
275
286
  startedAt = now;
276
- nextTime = duration;
287
+ nextTime = loopEnd;
277
288
  } else {
278
- adapter.seek(0);
279
- liveTime.notify(0);
280
- setCurrentTime(0);
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
  }));