@hyperframes/studio 0.4.34 → 0.4.35

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.
@@ -1,6 +1,8 @@
1
1
  import { useRef, useCallback } from "react";
2
2
  import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
+ import { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time";
5
+ import { useCaptionStore } from "../../captions/store";
4
6
 
5
7
  interface PlaybackAdapter {
6
8
  play: () => void;
@@ -105,6 +107,64 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
105
107
  }
106
108
  }
107
109
 
110
+ const SHUTTLE_SPEEDS = [1, 2, 4] as const;
111
+ const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
112
+ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
113
+ "input",
114
+ "textarea",
115
+ "select",
116
+ "button",
117
+ "a[href]",
118
+ "[contenteditable='true']",
119
+ "[role='button']",
120
+ "[role='checkbox']",
121
+ "[role='combobox']",
122
+ "[role='menuitem']",
123
+ "[role='radio']",
124
+ "[role='slider']",
125
+ "[role='spinbutton']",
126
+ "[role='switch']",
127
+ "[role='textbox']",
128
+ ].join(",");
129
+
130
+ export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
131
+ if (!target || typeof target !== "object") return false;
132
+ const candidate = target as { closest?: unknown };
133
+ if (typeof candidate.closest !== "function") return false;
134
+ return (
135
+ (candidate.closest as (selector: string) => Element | null).call(
136
+ target,
137
+ PLAYBACK_SHORTCUT_IGNORED_SELECTOR,
138
+ ) !== null
139
+ );
140
+ }
141
+
142
+ interface PlaybackShortcutCaptionState {
143
+ isCaptionEditMode: boolean;
144
+ selectedCaptionSegmentCount: number;
145
+ }
146
+
147
+ type PlaybackShortcutEvent = Pick<
148
+ KeyboardEvent,
149
+ "altKey" | "ctrlKey" | "metaKey" | "code" | "target"
150
+ >;
151
+
152
+ export function shouldIgnorePlaybackShortcutEvent(
153
+ event: PlaybackShortcutEvent,
154
+ captionState: PlaybackShortcutCaptionState = {
155
+ isCaptionEditMode: false,
156
+ selectedCaptionSegmentCount: 0,
157
+ },
158
+ ): boolean {
159
+ if (event.metaKey || event.ctrlKey || event.altKey) return true;
160
+ if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
161
+ return (
162
+ PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
163
+ captionState.isCaptionEditMode &&
164
+ captionState.selectedCaptionSegmentCount > 0
165
+ );
166
+ }
167
+
108
168
  /**
109
169
  * Parse [data-start] elements from a Document into TimelineElement[].
110
170
  * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
@@ -406,6 +466,13 @@ export function useTimelinePlayer() {
406
466
  const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
407
467
  const pendingSeekRef = useRef<number | null>(null);
408
468
  const isRefreshingRef = useRef(false);
469
+ const reverseRafRef = useRef<number>(0);
470
+ const shuttleDirectionRef = useRef<"forward" | "backward" | null>(null);
471
+ const shuttleSpeedIndexRef = useRef(0);
472
+ const pressedCodesRef = useRef(new Set<string>());
473
+ const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
474
+ const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
475
+ const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
409
476
 
410
477
  // ZERO store subscriptions — this hook never causes re-renders.
411
478
  // All reads use getState() (point-in-time), all writes use the stable setters.
@@ -464,6 +531,10 @@ export function useTimelinePlayer() {
464
531
  }
465
532
  }, []);
466
533
 
534
+ const stopReverseLoop = useCallback(() => {
535
+ cancelAnimationFrame(reverseRafRef.current);
536
+ }, []);
537
+
467
538
  const startRAFLoop = useCallback(() => {
468
539
  const tick = () => {
469
540
  const adapter = getAdapter();
@@ -472,6 +543,14 @@ export function useTimelinePlayer() {
472
543
  const dur = adapter.getDuration();
473
544
  liveTime.notify(time); // direct DOM updates, no React re-render
474
545
  if (time >= dur && !adapter.isPlaying()) {
546
+ if (usePlayerStore.getState().loopEnabled && dur > 0) {
547
+ adapter.seek(0);
548
+ liveTime.notify(0);
549
+ adapter.play();
550
+ setIsPlaying(true);
551
+ rafRef.current = requestAnimationFrame(tick);
552
+ return;
553
+ }
475
554
  setCurrentTime(time); // sync Zustand once at end
476
555
  setIsPlaying(false);
477
556
  cancelAnimationFrame(rafRef.current);
@@ -514,6 +593,8 @@ export function useTimelinePlayer() {
514
593
  }, []);
515
594
 
516
595
  const play = useCallback(() => {
596
+ stopRAFLoop();
597
+ stopReverseLoop();
517
598
  const adapter = getAdapter();
518
599
  if (!adapter) return;
519
600
  if (adapter.getTime() >= adapter.getDuration()) {
@@ -522,18 +603,68 @@ export function useTimelinePlayer() {
522
603
  unmutePreviewMedia(iframeRef.current);
523
604
  applyPlaybackRate(usePlayerStore.getState().playbackRate);
524
605
  adapter.play();
606
+ shuttleDirectionRef.current = "forward";
525
607
  setIsPlaying(true);
526
608
  startRAFLoop();
527
- }, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate]);
609
+ }, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate, stopRAFLoop, stopReverseLoop]);
610
+
611
+ const playBackward = useCallback(
612
+ (rate: number) => {
613
+ stopRAFLoop();
614
+ stopReverseLoop();
615
+ const adapter = getAdapter();
616
+ if (!adapter) return;
617
+ const duration = Math.max(0, adapter.getDuration());
618
+ const initialTime = adapter.getTime() <= 0 && duration > 0 ? duration : adapter.getTime();
619
+ adapter.pause();
620
+ if (initialTime !== adapter.getTime()) adapter.seek(initialTime);
621
+ unmutePreviewMedia(iframeRef.current);
622
+ const speed = Math.max(0.1, Math.min(4, rate));
623
+ let startTime = initialTime;
624
+ let startedAt = performance.now();
625
+
626
+ const tick = (now: number) => {
627
+ const elapsed = ((now - startedAt) / 1000) * speed;
628
+ let nextTime = startTime - elapsed;
629
+ if (nextTime <= 0) {
630
+ if (usePlayerStore.getState().loopEnabled && duration > 0) {
631
+ startTime = duration;
632
+ startedAt = now;
633
+ nextTime = duration;
634
+ } else {
635
+ adapter.seek(0);
636
+ liveTime.notify(0);
637
+ setCurrentTime(0);
638
+ setIsPlaying(false);
639
+ shuttleDirectionRef.current = null;
640
+ reverseRafRef.current = 0;
641
+ return;
642
+ }
643
+ }
644
+ adapter.seek(Math.max(0, nextTime));
645
+ liveTime.notify(Math.max(0, nextTime));
646
+ setIsPlaying(true);
647
+ reverseRafRef.current = requestAnimationFrame(tick);
648
+ };
649
+
650
+ setIsPlaying(true);
651
+ shuttleDirectionRef.current = "backward";
652
+ reverseRafRef.current = requestAnimationFrame(tick);
653
+ },
654
+ [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
655
+ );
528
656
 
529
657
  const pause = useCallback(() => {
658
+ stopReverseLoop();
530
659
  const adapter = getAdapter();
531
660
  if (!adapter) return;
532
661
  adapter.pause();
533
662
  setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
534
663
  setIsPlaying(false);
664
+ shuttleDirectionRef.current = null;
665
+ shuttleSpeedIndexRef.current = 0;
535
666
  stopRAFLoop();
536
- }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop]);
667
+ }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
537
668
 
538
669
  const togglePlay = useCallback(() => {
539
670
  if (usePlayerStore.getState().isPlaying) {
@@ -545,18 +676,136 @@ export function useTimelinePlayer() {
545
676
 
546
677
  const seek = useCallback(
547
678
  (time: number) => {
679
+ stopReverseLoop();
548
680
  const adapter = getAdapter();
549
681
  if (!adapter) return;
550
- adapter.seek(time);
551
- liveTime.notify(time); // Direct DOM updates (playhead, timecode, progress) no re-render
552
- setCurrentTime(time); // sync store so Split/Delete have accurate time
682
+ const duration = Math.max(0, adapter.getDuration());
683
+ const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
684
+ adapter.seek(nextTime);
685
+ liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
686
+ setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
553
687
  stopRAFLoop();
554
688
  // Only update store if state actually changes (avoids unnecessary re-renders)
555
689
  if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
690
+ shuttleDirectionRef.current = null;
691
+ shuttleSpeedIndexRef.current = 0;
692
+ },
693
+ [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
694
+ );
695
+
696
+ const stepFrames = useCallback(
697
+ (deltaFrames: number) => {
698
+ const adapter = getAdapter();
699
+ const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
700
+ seek(currentTime + frameToSeconds(deltaFrames, STUDIO_PREVIEW_FPS));
701
+ },
702
+ [getAdapter, seek],
703
+ );
704
+
705
+ const shuttle = useCallback(
706
+ (direction: "forward" | "backward") => {
707
+ if (shuttleDirectionRef.current === direction) {
708
+ shuttleSpeedIndexRef.current = Math.min(
709
+ shuttleSpeedIndexRef.current + 1,
710
+ SHUTTLE_SPEEDS.length - 1,
711
+ );
712
+ } else {
713
+ shuttleSpeedIndexRef.current = 0;
714
+ }
715
+ const speed = SHUTTLE_SPEEDS[shuttleSpeedIndexRef.current];
716
+ usePlayerStore.getState().setPlaybackRate(speed);
717
+ if (direction === "forward") {
718
+ play();
719
+ } else {
720
+ playBackward(speed);
721
+ }
556
722
  },
557
- [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop],
723
+ [play, playBackward],
558
724
  );
559
725
 
726
+ const handlePlaybackKeyDown = useCallback(
727
+ (e: KeyboardEvent) => {
728
+ if (e.defaultPrevented) return;
729
+ const captionState = useCaptionStore.getState();
730
+ if (
731
+ shouldIgnorePlaybackShortcutEvent(e, {
732
+ isCaptionEditMode: captionState.isEditMode,
733
+ selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
734
+ })
735
+ ) {
736
+ return;
737
+ }
738
+ pressedCodesRef.current.add(e.code);
739
+ if (e.code === "Space") {
740
+ e.preventDefault();
741
+ togglePlay();
742
+ return;
743
+ }
744
+ if (e.code === "ArrowLeft") {
745
+ e.preventDefault();
746
+ stepFrames(e.shiftKey ? -10 : -1);
747
+ return;
748
+ }
749
+ if (e.code === "ArrowRight") {
750
+ e.preventDefault();
751
+ stepFrames(e.shiftKey ? 10 : 1);
752
+ return;
753
+ }
754
+ if (e.repeat) return;
755
+ if (e.code === "KeyK") {
756
+ e.preventDefault();
757
+ pause();
758
+ return;
759
+ }
760
+ if (e.code === "KeyJ") {
761
+ e.preventDefault();
762
+ if (pressedCodesRef.current.has("KeyK")) {
763
+ stepFrames(-1);
764
+ return;
765
+ }
766
+ shuttle("backward");
767
+ return;
768
+ }
769
+ if (e.code === "KeyL") {
770
+ e.preventDefault();
771
+ if (pressedCodesRef.current.has("KeyK")) {
772
+ stepFrames(1);
773
+ return;
774
+ }
775
+ shuttle("forward");
776
+ }
777
+ },
778
+ [pause, shuttle, stepFrames, togglePlay],
779
+ );
780
+
781
+ const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
782
+ pressedCodesRef.current.delete(e.code);
783
+ }, []);
784
+ playbackKeyDownRef.current = handlePlaybackKeyDown;
785
+ playbackKeyUpRef.current = handlePlaybackKeyUp;
786
+
787
+ const attachIframeShortcutListeners = useCallback(() => {
788
+ iframeShortcutCleanupRef.current?.();
789
+ iframeShortcutCleanupRef.current = null;
790
+
791
+ const iframeWin = iframeRef.current?.contentWindow;
792
+ const iframeDoc = iframeRef.current?.contentDocument;
793
+ if (!iframeWin && !iframeDoc) return;
794
+
795
+ const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
796
+ const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
797
+ iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
798
+ iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
799
+ iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
800
+ iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
801
+ iframeShortcutCleanupRef.current = () => {
802
+ iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
803
+ iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
804
+ iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
805
+ iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
806
+ };
807
+ }, []);
808
+
560
809
  // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
561
810
  const processTimelineMessage = useCallback(
562
811
  (data: {
@@ -865,6 +1114,7 @@ export function useTimelinePlayer() {
865
1114
  if (doc && iframeWin) {
866
1115
  normalizePreviewViewport(doc, iframeWin);
867
1116
  autoHealMissingCompositionIds(doc);
1117
+ attachIframeShortcutListeners();
868
1118
  }
869
1119
 
870
1120
  // Try reading __clipManifest if already available (fast path)
@@ -931,6 +1181,7 @@ export function useTimelinePlayer() {
931
1181
  processTimelineMessage,
932
1182
  enrichMissingCompositions,
933
1183
  syncTimelineElements,
1184
+ attachIframeShortcutListeners,
934
1185
  ]);
935
1186
 
936
1187
  /** Save the current playback time so the next onIframeLoad restores it. */
@@ -941,8 +1192,9 @@ export function useTimelinePlayer() {
941
1192
  : (usePlayerStore.getState().currentTime ?? 0);
942
1193
  isRefreshingRef.current = true;
943
1194
  stopRAFLoop();
1195
+ stopReverseLoop();
944
1196
  setIsPlaying(false);
945
- }, [getAdapter, stopRAFLoop, setIsPlaying]);
1197
+ }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
946
1198
 
947
1199
  const refreshPlayer = useCallback(() => {
948
1200
  const iframe = iframeRef.current;
@@ -956,8 +1208,6 @@ export function useTimelinePlayer() {
956
1208
  iframe.src = url.toString();
957
1209
  }, [saveSeekPosition]);
958
1210
 
959
- const togglePlayRef = useRef(togglePlay);
960
- togglePlayRef.current = togglePlay;
961
1211
  const getAdapterRef = useRef(getAdapter);
962
1212
  getAdapterRef.current = getAdapter;
963
1213
  const processTimelineMessageRef = useRef(processTimelineMessage);
@@ -966,12 +1216,8 @@ export function useTimelinePlayer() {
966
1216
  enrichMissingCompositionsRef.current = enrichMissingCompositions;
967
1217
 
968
1218
  useMountEffect(() => {
969
- const handleKeyDown = (e: KeyboardEvent) => {
970
- if (e.code === "Space" && e.target === document.body) {
971
- e.preventDefault();
972
- togglePlayRef.current();
973
- }
974
- };
1219
+ const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
1220
+ const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
975
1221
 
976
1222
  // Listen for timeline messages from the iframe runtime.
977
1223
  // The runtime sends this AFTER all external compositions load,
@@ -1044,14 +1290,19 @@ export function useTimelinePlayer() {
1044
1290
  }
1045
1291
  };
1046
1292
 
1047
- window.addEventListener("keydown", handleKeyDown);
1293
+ window.addEventListener("keydown", handleWindowKeyDown, true);
1294
+ window.addEventListener("keyup", handleWindowKeyUp, true);
1048
1295
  window.addEventListener("message", handleMessage);
1049
1296
  document.addEventListener("visibilitychange", handleVisibilityChange);
1050
1297
  return () => {
1051
- window.removeEventListener("keydown", handleKeyDown);
1298
+ window.removeEventListener("keydown", handleWindowKeyDown, true);
1299
+ window.removeEventListener("keyup", handleWindowKeyUp, true);
1300
+ iframeShortcutCleanupRef.current?.();
1301
+ iframeShortcutCleanupRef.current = null;
1052
1302
  window.removeEventListener("message", handleMessage);
1053
1303
  document.removeEventListener("visibilitychange", handleVisibilityChange);
1054
1304
  stopRAFLoop();
1305
+ stopReverseLoop();
1055
1306
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1056
1307
  // Don't reset() on cleanup — preserve timeline elements across iframe refreshes
1057
1308
  // to prevent blink. New data will replace old when the iframe reloads.
@@ -1061,9 +1312,10 @@ export function useTimelinePlayer() {
1061
1312
  /** Reset the player store (elements, duration, etc.) — call when switching sessions. */
1062
1313
  const resetPlayer = useCallback(() => {
1063
1314
  stopRAFLoop();
1315
+ stopReverseLoop();
1064
1316
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1065
1317
  usePlayerStore.getState().reset();
1066
- }, [stopRAFLoop]);
1318
+ }, [stopRAFLoop, stopReverseLoop]);
1067
1319
 
1068
1320
  return {
1069
1321
  iframeRef,
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { formatTime } from "./time";
2
+ import { formatFrameTime, frameToSeconds, secondsToFrame, formatTime } from "./time";
3
3
 
4
4
  describe("formatTime", () => {
5
5
  it("formats zero seconds", () => {
@@ -55,3 +55,21 @@ describe("formatTime", () => {
55
55
  expect(formatTime(Infinity)).toBe("0:00");
56
56
  });
57
57
  });
58
+
59
+ describe("frame helpers", () => {
60
+ it("converts seconds to frames at the Studio preview rate", () => {
61
+ expect(secondsToFrame(0)).toBe(0);
62
+ expect(secondsToFrame(1)).toBe(30);
63
+ expect(secondsToFrame(1.5)).toBe(45);
64
+ });
65
+
66
+ it("converts frames to seconds at the Studio preview rate", () => {
67
+ expect(frameToSeconds(0)).toBe(0);
68
+ expect(frameToSeconds(30)).toBe(1);
69
+ expect(frameToSeconds(45)).toBe(1.5);
70
+ });
71
+
72
+ it("formats current and total frame display", () => {
73
+ expect(formatFrameTime(1, 5)).toBe("30f / 150f");
74
+ });
75
+ });
@@ -1,6 +1,26 @@
1
+ export const STUDIO_PREVIEW_FPS = 30;
2
+
1
3
  export function formatTime(time: number): string {
2
4
  if (!Number.isFinite(time) || time < 0) return "0:00";
3
5
  const mins = Math.floor(time / 60);
4
6
  const secs = Math.floor(time % 60);
5
7
  return `${mins}:${secs.toString().padStart(2, "0")}`;
6
8
  }
9
+
10
+ export function secondsToFrame(time: number, fps = STUDIO_PREVIEW_FPS): number {
11
+ if (!Number.isFinite(time) || time <= 0) return 0;
12
+ if (!Number.isFinite(fps) || fps <= 0) return 0;
13
+ return Math.round(time * fps);
14
+ }
15
+
16
+ export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number {
17
+ if (!Number.isFinite(frame) || frame <= 0) return 0;
18
+ if (!Number.isFinite(fps) || fps <= 0) return 0;
19
+ return frame / fps;
20
+ }
21
+
22
+ export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string {
23
+ const currentFrame = secondsToFrame(time, fps);
24
+ const totalFrames = secondsToFrame(duration, fps);
25
+ return `${currentFrame}f / ${totalFrames}f`;
26
+ }
@@ -16,6 +16,7 @@ describe("usePlayerStore", () => {
16
16
  expect(state.elements).toEqual([]);
17
17
  expect(state.selectedElementId).toBeNull();
18
18
  expect(state.playbackRate).toBe(1);
19
+ expect(state.loopEnabled).toBe(false);
19
20
  expect(state.zoomMode).toBe("fit");
20
21
  expect(state.manualZoomPercent).toBe(100);
21
22
  });
@@ -61,6 +62,13 @@ describe("usePlayerStore", () => {
61
62
  });
62
63
  });
63
64
 
65
+ describe("setLoopEnabled", () => {
66
+ it("updates loopEnabled", () => {
67
+ usePlayerStore.getState().setLoopEnabled(true);
68
+ expect(usePlayerStore.getState().loopEnabled).toBe(true);
69
+ });
70
+ });
71
+
64
72
  describe("setTimelineReady", () => {
65
73
  it("updates timelineReady", () => {
66
74
  usePlayerStore.getState().setTimelineReady(true);
@@ -205,9 +213,10 @@ describe("usePlayerStore", () => {
205
213
  expect(state.selectedElementId).toBeNull();
206
214
  });
207
215
 
208
- it("does not reset playbackRate, zoomMode, or manualZoomPercent", () => {
216
+ it("does not reset playbackRate, loopEnabled, zoomMode, or manualZoomPercent", () => {
209
217
  const store = usePlayerStore.getState();
210
218
  store.setPlaybackRate(2);
219
+ store.setLoopEnabled(true);
211
220
  store.setZoomMode("manual");
212
221
  store.setManualZoomPercent(200);
213
222
 
@@ -216,6 +225,7 @@ describe("usePlayerStore", () => {
216
225
  const state = usePlayerStore.getState();
217
226
  // reset() only resets the fields explicitly listed in the reset function
218
227
  expect(state.playbackRate).toBe(2);
228
+ expect(state.loopEnabled).toBe(true);
219
229
  expect(state.zoomMode).toBe("manual");
220
230
  expect(state.manualZoomPercent).toBe(200);
221
231
  });
@@ -34,6 +34,7 @@ interface PlayerState {
34
34
  elements: TimelineElement[];
35
35
  selectedElementId: string | null;
36
36
  playbackRate: number;
37
+ loopEnabled: boolean;
37
38
  /** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
38
39
  zoomMode: ZoomMode;
39
40
  /** Timeline zoom percent relative to the fit width when in manual mode */
@@ -43,6 +44,7 @@ interface PlayerState {
43
44
  setCurrentTime: (time: number) => void;
44
45
  setDuration: (duration: number) => void;
45
46
  setPlaybackRate: (rate: number) => void;
47
+ setLoopEnabled: (enabled: boolean) => void;
46
48
  setTimelineReady: (ready: boolean) => void;
47
49
  setElements: (elements: TimelineElement[]) => void;
48
50
  setSelectedElementId: (id: string | null) => void;
@@ -76,11 +78,13 @@ export const usePlayerStore = create<PlayerState>((set) => ({
76
78
  elements: [],
77
79
  selectedElementId: null,
78
80
  playbackRate: 1,
81
+ loopEnabled: false,
79
82
  zoomMode: "fit",
80
83
  manualZoomPercent: 100,
81
84
 
82
85
  setIsPlaying: (playing) => set({ isPlaying: playing }),
83
86
  setPlaybackRate: (rate) => set({ playbackRate: rate }),
87
+ setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
84
88
  setZoomMode: (mode) => set({ zoomMode: mode }),
85
89
  setManualZoomPercent: (percent) =>
86
90
  set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
@@ -96,7 +100,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
96
100
  ),
97
101
  })),
98
102
  // Resets project-specific state when switching compositions.
99
- // playbackRate, zoomMode, and manualZoomPercent are intentionally preserved
103
+ // playbackRate, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved
100
104
  // because they are user preferences that should survive project switches.
101
105
  reset: () =>
102
106
  set({