@hyperframes/studio 0.5.0-alpha.7 → 0.5.0-alpha.9

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.
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  clampTimelineZoomPercent,
4
4
  getNextTimelineZoomPercent,
5
+ getPinchTimelineZoomPercent,
5
6
  getTimelinePixelsPerSecond,
6
7
  getTimelineZoomPercent,
7
8
  MAX_TIMELINE_ZOOM_PERCENT,
@@ -60,3 +61,23 @@ describe("getNextTimelineZoomPercent", () => {
60
61
  );
61
62
  });
62
63
  });
64
+
65
+ describe("getPinchTimelineZoomPercent", () => {
66
+ it("zooms in for upward pinch wheel deltas", () => {
67
+ expect(getPinchTimelineZoomPercent(-80, "fit", 100)).toBeGreaterThan(100);
68
+ });
69
+
70
+ it("zooms out for downward pinch wheel deltas", () => {
71
+ expect(getPinchTimelineZoomPercent(80, "manual", 200)).toBeLessThan(200);
72
+ });
73
+
74
+ it("keeps the current zoom for zero or invalid deltas", () => {
75
+ expect(getPinchTimelineZoomPercent(0, "manual", 180)).toBe(180);
76
+ expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180)).toBe(180);
77
+ });
78
+
79
+ it("clamps pinch zoom to the supported range", () => {
80
+ expect(getPinchTimelineZoomPercent(10000, "manual", 100)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
81
+ expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
82
+ });
83
+ });
@@ -4,6 +4,7 @@ export const MIN_TIMELINE_ZOOM_PERCENT = 10;
4
4
  export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
5
5
  const ZOOM_OUT_FACTOR = 0.8;
6
6
  const ZOOM_IN_FACTOR = 1.25;
7
+ const PINCH_ZOOM_SENSITIVITY = 0.0035;
7
8
 
8
9
  export function clampTimelineZoomPercent(percent: number): number {
9
10
  if (!Number.isFinite(percent)) return 100;
@@ -36,3 +37,13 @@ export function getNextTimelineZoomPercent(
36
37
  const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR;
37
38
  return clampTimelineZoomPercent(next);
38
39
  }
40
+
41
+ export function getPinchTimelineZoomPercent(
42
+ deltaY: number,
43
+ zoomMode: ZoomMode,
44
+ manualZoomPercent: number,
45
+ ): number {
46
+ const current = getTimelineZoomPercent(zoomMode, manualZoomPercent);
47
+ if (!Number.isFinite(deltaY) || deltaY === 0) return current;
48
+ return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
49
+ }
@@ -7,6 +7,8 @@ import {
7
7
  type ClipManifestClip,
8
8
  mergeTimelineElementsPreservingDowngrades,
9
9
  resolveStandaloneRootCompositionSrc,
10
+ shouldIgnorePlaybackShortcutEvent,
11
+ shouldIgnorePlaybackShortcutTarget,
10
12
  } from "./useTimelinePlayer";
11
13
 
12
14
  function createDocument(markup: string): Document {
@@ -32,6 +34,26 @@ function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
32
34
  };
33
35
  }
34
36
 
37
+ function mockTargetMatching(selectorNeedle: string): EventTarget {
38
+ return {
39
+ closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
40
+ } as unknown as EventTarget;
41
+ }
42
+
43
+ function mockKeyboardEvent(
44
+ code: string,
45
+ overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "target">> = {},
46
+ ): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "code" | "target"> {
47
+ return {
48
+ altKey: false,
49
+ ctrlKey: false,
50
+ metaKey: false,
51
+ code,
52
+ target: mockTargetMatching("[data-missing]"),
53
+ ...overrides,
54
+ };
55
+ }
56
+
35
57
  describe("buildStandaloneRootTimelineElement", () => {
36
58
  it("includes selector and source metadata for standalone composition fallback clips", () => {
37
59
  expect(
@@ -153,3 +175,60 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
153
175
  ).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
154
176
  });
155
177
  });
178
+
179
+ describe("shouldIgnorePlaybackShortcutTarget", () => {
180
+ it("ignores focused toolbar buttons so Space can activate the button itself", () => {
181
+ expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("button"))).toBe(true);
182
+ });
183
+
184
+ it("ignores the seek slider so ArrowRight reaches the slider key handler", () => {
185
+ expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[role='slider']"))).toBe(true);
186
+ });
187
+
188
+ it("allows non-interactive preview targets to use playback shortcuts", () => {
189
+ expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[data-missing]"))).toBe(false);
190
+ });
191
+ });
192
+
193
+ describe("shouldIgnorePlaybackShortcutEvent", () => {
194
+ it("ignores modified playback shortcuts so browser and app chords can handle them", () => {
195
+ expect(
196
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft", { altKey: true })),
197
+ ).toBe(true);
198
+ expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyK", { ctrlKey: true }))).toBe(
199
+ true,
200
+ );
201
+ expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyL", { metaKey: true }))).toBe(
202
+ true,
203
+ );
204
+ });
205
+
206
+ it("defers Arrow frame shortcuts while caption edit mode has selected words", () => {
207
+ const captionSelection = { isCaptionEditMode: true, selectedCaptionSegmentCount: 1 };
208
+
209
+ expect(
210
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft"), captionSelection),
211
+ ).toBe(true);
212
+ expect(
213
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), captionSelection),
214
+ ).toBe(true);
215
+ expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyJ"), captionSelection)).toBe(
216
+ false,
217
+ );
218
+ });
219
+
220
+ it("allows Arrow frame shortcuts when captions are not selected", () => {
221
+ expect(
222
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
223
+ isCaptionEditMode: true,
224
+ selectedCaptionSegmentCount: 0,
225
+ }),
226
+ ).toBe(false);
227
+ expect(
228
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
229
+ isCaptionEditMode: false,
230
+ selectedCaptionSegmentCount: 1,
231
+ }),
232
+ ).toBe(false);
233
+ });
234
+ });
@@ -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.
@@ -452,6 +512,13 @@ export function useTimelinePlayer() {
452
512
  const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
453
513
  const pendingSeekRef = useRef<number | null>(null);
454
514
  const isRefreshingRef = useRef(false);
515
+ const reverseRafRef = useRef<number>(0);
516
+ const shuttleDirectionRef = useRef<"forward" | "backward" | null>(null);
517
+ const shuttleSpeedIndexRef = useRef(0);
518
+ const pressedCodesRef = useRef(new Set<string>());
519
+ const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
520
+ const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
521
+ const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
455
522
 
456
523
  // ZERO store subscriptions — this hook never causes re-renders.
457
524
  // All reads use getState() (point-in-time), all writes use the stable setters.
@@ -510,6 +577,10 @@ export function useTimelinePlayer() {
510
577
  }
511
578
  }, []);
512
579
 
580
+ const stopReverseLoop = useCallback(() => {
581
+ cancelAnimationFrame(reverseRafRef.current);
582
+ }, []);
583
+
513
584
  const startRAFLoop = useCallback(() => {
514
585
  const tick = () => {
515
586
  const adapter = getAdapter();
@@ -518,6 +589,14 @@ export function useTimelinePlayer() {
518
589
  const dur = adapter.getDuration();
519
590
  liveTime.notify(time); // direct DOM updates, no React re-render
520
591
  if (time >= dur && !adapter.isPlaying()) {
592
+ if (usePlayerStore.getState().loopEnabled && dur > 0) {
593
+ adapter.seek(0);
594
+ liveTime.notify(0);
595
+ adapter.play();
596
+ setIsPlaying(true);
597
+ rafRef.current = requestAnimationFrame(tick);
598
+ return;
599
+ }
521
600
  setCurrentTime(time); // sync Zustand once at end
522
601
  setIsPlaying(false);
523
602
  cancelAnimationFrame(rafRef.current);
@@ -560,6 +639,8 @@ export function useTimelinePlayer() {
560
639
  }, []);
561
640
 
562
641
  const play = useCallback(() => {
642
+ stopRAFLoop();
643
+ stopReverseLoop();
563
644
  const adapter = getAdapter();
564
645
  if (!adapter) return;
565
646
  if (adapter.getTime() >= adapter.getDuration()) {
@@ -568,18 +649,68 @@ export function useTimelinePlayer() {
568
649
  unmutePreviewMedia(iframeRef.current);
569
650
  applyPlaybackRate(usePlayerStore.getState().playbackRate);
570
651
  adapter.play();
652
+ shuttleDirectionRef.current = "forward";
571
653
  setIsPlaying(true);
572
654
  startRAFLoop();
573
- }, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate]);
655
+ }, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate, stopRAFLoop, stopReverseLoop]);
656
+
657
+ const playBackward = useCallback(
658
+ (rate: number) => {
659
+ stopRAFLoop();
660
+ stopReverseLoop();
661
+ const adapter = getAdapter();
662
+ if (!adapter) return;
663
+ const duration = Math.max(0, adapter.getDuration());
664
+ const initialTime = adapter.getTime() <= 0 && duration > 0 ? duration : adapter.getTime();
665
+ adapter.pause();
666
+ if (initialTime !== adapter.getTime()) adapter.seek(initialTime);
667
+ unmutePreviewMedia(iframeRef.current);
668
+ const speed = Math.max(0.1, Math.min(4, rate));
669
+ let startTime = initialTime;
670
+ let startedAt = performance.now();
671
+
672
+ const tick = (now: number) => {
673
+ const elapsed = ((now - startedAt) / 1000) * speed;
674
+ let nextTime = startTime - elapsed;
675
+ if (nextTime <= 0) {
676
+ if (usePlayerStore.getState().loopEnabled && duration > 0) {
677
+ startTime = duration;
678
+ startedAt = now;
679
+ nextTime = duration;
680
+ } else {
681
+ adapter.seek(0);
682
+ liveTime.notify(0);
683
+ setCurrentTime(0);
684
+ setIsPlaying(false);
685
+ shuttleDirectionRef.current = null;
686
+ reverseRafRef.current = 0;
687
+ return;
688
+ }
689
+ }
690
+ adapter.seek(Math.max(0, nextTime));
691
+ liveTime.notify(Math.max(0, nextTime));
692
+ setIsPlaying(true);
693
+ reverseRafRef.current = requestAnimationFrame(tick);
694
+ };
695
+
696
+ setIsPlaying(true);
697
+ shuttleDirectionRef.current = "backward";
698
+ reverseRafRef.current = requestAnimationFrame(tick);
699
+ },
700
+ [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
701
+ );
574
702
 
575
703
  const pause = useCallback(() => {
704
+ stopReverseLoop();
576
705
  const adapter = getAdapter();
577
706
  if (!adapter) return;
578
707
  adapter.pause();
579
708
  setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
580
709
  setIsPlaying(false);
710
+ shuttleDirectionRef.current = null;
711
+ shuttleSpeedIndexRef.current = 0;
581
712
  stopRAFLoop();
582
- }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop]);
713
+ }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
583
714
 
584
715
  const togglePlay = useCallback(() => {
585
716
  if (usePlayerStore.getState().isPlaying) {
@@ -591,18 +722,136 @@ export function useTimelinePlayer() {
591
722
 
592
723
  const seek = useCallback(
593
724
  (time: number) => {
725
+ stopReverseLoop();
594
726
  const adapter = getAdapter();
595
727
  if (!adapter) return;
596
- adapter.seek(time);
597
- liveTime.notify(time); // Direct DOM updates (playhead, timecode, progress) no re-render
598
- setCurrentTime(time); // sync store so Split/Delete have accurate time
728
+ const duration = Math.max(0, adapter.getDuration());
729
+ const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
730
+ adapter.seek(nextTime);
731
+ liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
732
+ setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
599
733
  stopRAFLoop();
600
734
  // Only update store if state actually changes (avoids unnecessary re-renders)
601
735
  if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
736
+ shuttleDirectionRef.current = null;
737
+ shuttleSpeedIndexRef.current = 0;
738
+ },
739
+ [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
740
+ );
741
+
742
+ const stepFrames = useCallback(
743
+ (deltaFrames: number) => {
744
+ const adapter = getAdapter();
745
+ const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
746
+ seek(currentTime + frameToSeconds(deltaFrames, STUDIO_PREVIEW_FPS));
602
747
  },
603
- [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop],
748
+ [getAdapter, seek],
604
749
  );
605
750
 
751
+ const shuttle = useCallback(
752
+ (direction: "forward" | "backward") => {
753
+ if (shuttleDirectionRef.current === direction) {
754
+ shuttleSpeedIndexRef.current = Math.min(
755
+ shuttleSpeedIndexRef.current + 1,
756
+ SHUTTLE_SPEEDS.length - 1,
757
+ );
758
+ } else {
759
+ shuttleSpeedIndexRef.current = 0;
760
+ }
761
+ const speed = SHUTTLE_SPEEDS[shuttleSpeedIndexRef.current];
762
+ usePlayerStore.getState().setPlaybackRate(speed);
763
+ if (direction === "forward") {
764
+ play();
765
+ } else {
766
+ playBackward(speed);
767
+ }
768
+ },
769
+ [play, playBackward],
770
+ );
771
+
772
+ const handlePlaybackKeyDown = useCallback(
773
+ (e: KeyboardEvent) => {
774
+ if (e.defaultPrevented) return;
775
+ const captionState = useCaptionStore.getState();
776
+ if (
777
+ shouldIgnorePlaybackShortcutEvent(e, {
778
+ isCaptionEditMode: captionState.isEditMode,
779
+ selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
780
+ })
781
+ ) {
782
+ return;
783
+ }
784
+ pressedCodesRef.current.add(e.code);
785
+ if (e.code === "Space") {
786
+ e.preventDefault();
787
+ togglePlay();
788
+ return;
789
+ }
790
+ if (e.code === "ArrowLeft") {
791
+ e.preventDefault();
792
+ stepFrames(e.shiftKey ? -10 : -1);
793
+ return;
794
+ }
795
+ if (e.code === "ArrowRight") {
796
+ e.preventDefault();
797
+ stepFrames(e.shiftKey ? 10 : 1);
798
+ return;
799
+ }
800
+ if (e.repeat) return;
801
+ if (e.code === "KeyK") {
802
+ e.preventDefault();
803
+ pause();
804
+ return;
805
+ }
806
+ if (e.code === "KeyJ") {
807
+ e.preventDefault();
808
+ if (pressedCodesRef.current.has("KeyK")) {
809
+ stepFrames(-1);
810
+ return;
811
+ }
812
+ shuttle("backward");
813
+ return;
814
+ }
815
+ if (e.code === "KeyL") {
816
+ e.preventDefault();
817
+ if (pressedCodesRef.current.has("KeyK")) {
818
+ stepFrames(1);
819
+ return;
820
+ }
821
+ shuttle("forward");
822
+ }
823
+ },
824
+ [pause, shuttle, stepFrames, togglePlay],
825
+ );
826
+
827
+ const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
828
+ pressedCodesRef.current.delete(e.code);
829
+ }, []);
830
+ playbackKeyDownRef.current = handlePlaybackKeyDown;
831
+ playbackKeyUpRef.current = handlePlaybackKeyUp;
832
+
833
+ const attachIframeShortcutListeners = useCallback(() => {
834
+ iframeShortcutCleanupRef.current?.();
835
+ iframeShortcutCleanupRef.current = null;
836
+
837
+ const iframeWin = iframeRef.current?.contentWindow;
838
+ const iframeDoc = iframeRef.current?.contentDocument;
839
+ if (!iframeWin && !iframeDoc) return;
840
+
841
+ const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
842
+ const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
843
+ iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
844
+ iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
845
+ iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
846
+ iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
847
+ iframeShortcutCleanupRef.current = () => {
848
+ iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
849
+ iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
850
+ iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
851
+ iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
852
+ };
853
+ }, []);
854
+
606
855
  // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
607
856
  const processTimelineMessage = useCallback(
608
857
  (data: {
@@ -906,6 +1155,7 @@ export function useTimelinePlayer() {
906
1155
  if (doc && iframeWin) {
907
1156
  normalizePreviewViewport(doc, iframeWin);
908
1157
  autoHealMissingCompositionIds(doc);
1158
+ attachIframeShortcutListeners();
909
1159
  }
910
1160
 
911
1161
  // Try reading __clipManifest if already available (fast path)
@@ -972,6 +1222,7 @@ export function useTimelinePlayer() {
972
1222
  processTimelineMessage,
973
1223
  enrichMissingCompositions,
974
1224
  syncTimelineElements,
1225
+ attachIframeShortcutListeners,
975
1226
  ]);
976
1227
 
977
1228
  /** Save the current playback time so the next onIframeLoad restores it. */
@@ -982,11 +1233,25 @@ export function useTimelinePlayer() {
982
1233
  : (usePlayerStore.getState().currentTime ?? 0);
983
1234
  isRefreshingRef.current = true;
984
1235
  stopRAFLoop();
1236
+ stopReverseLoop();
985
1237
  setIsPlaying(false);
986
- }, [getAdapter, stopRAFLoop, setIsPlaying]);
1238
+ }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
987
1239
 
988
1240
  const togglePlayRef = useRef(togglePlay);
989
1241
  togglePlayRef.current = togglePlay;
1242
+
1243
+ const refreshPlayer = useCallback(() => {
1244
+ const iframe = iframeRef.current;
1245
+ if (!iframe) return;
1246
+
1247
+ saveSeekPosition();
1248
+
1249
+ const src = iframe.src;
1250
+ const url = new URL(src, window.location.origin);
1251
+ url.searchParams.set("_t", String(Date.now()));
1252
+ iframe.src = url.toString();
1253
+ }, [saveSeekPosition]);
1254
+
990
1255
  const getAdapterRef = useRef(getAdapter);
991
1256
  getAdapterRef.current = getAdapter;
992
1257
  const processTimelineMessageRef = useRef(processTimelineMessage);
@@ -995,12 +1260,8 @@ export function useTimelinePlayer() {
995
1260
  enrichMissingCompositionsRef.current = enrichMissingCompositions;
996
1261
 
997
1262
  useMountEffect(() => {
998
- const handleKeyDown = (e: KeyboardEvent) => {
999
- if (e.code === "Space" && e.target === document.body) {
1000
- e.preventDefault();
1001
- togglePlayRef.current();
1002
- }
1003
- };
1263
+ const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
1264
+ const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
1004
1265
 
1005
1266
  // Listen for timeline messages from the iframe runtime.
1006
1267
  // The runtime sends this AFTER all external compositions load,
@@ -1073,14 +1334,19 @@ export function useTimelinePlayer() {
1073
1334
  }
1074
1335
  };
1075
1336
 
1076
- window.addEventListener("keydown", handleKeyDown);
1337
+ window.addEventListener("keydown", handleWindowKeyDown, true);
1338
+ window.addEventListener("keyup", handleWindowKeyUp, true);
1077
1339
  window.addEventListener("message", handleMessage);
1078
1340
  document.addEventListener("visibilitychange", handleVisibilityChange);
1079
1341
  return () => {
1080
- window.removeEventListener("keydown", handleKeyDown);
1342
+ window.removeEventListener("keydown", handleWindowKeyDown, true);
1343
+ window.removeEventListener("keyup", handleWindowKeyUp, true);
1344
+ iframeShortcutCleanupRef.current?.();
1345
+ iframeShortcutCleanupRef.current = null;
1081
1346
  window.removeEventListener("message", handleMessage);
1082
1347
  document.removeEventListener("visibilitychange", handleVisibilityChange);
1083
1348
  stopRAFLoop();
1349
+ stopReverseLoop();
1084
1350
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1085
1351
  };
1086
1352
  });
@@ -1088,9 +1354,10 @@ export function useTimelinePlayer() {
1088
1354
  /** Reset the player store (elements, duration, etc.) — call when switching sessions. */
1089
1355
  const resetPlayer = useCallback(() => {
1090
1356
  stopRAFLoop();
1357
+ stopReverseLoop();
1091
1358
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1092
1359
  usePlayerStore.getState().reset();
1093
- }, [stopRAFLoop]);
1360
+ }, [stopRAFLoop, stopReverseLoop]);
1094
1361
 
1095
1362
  return {
1096
1363
  iframeRef,
@@ -1099,6 +1366,7 @@ export function useTimelinePlayer() {
1099
1366
  togglePlay,
1100
1367
  seek,
1101
1368
  onIframeLoad,
1369
+ refreshPlayer,
1102
1370
  saveSeekPosition,
1103
1371
  resetPlayer,
1104
1372
  };
@@ -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
  });