@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.
- package/dist/assets/index-Bj3m6A02.js +93 -0
- package/dist/assets/index-_h8opaGY.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/components/nle/NLEPreview.tsx +5 -1
- package/src/player/components/PlayerControls.tsx +120 -11
- package/src/player/hooks/useTimelinePlayer.test.ts +79 -0
- package/src/player/hooks/useTimelinePlayer.ts +270 -18
- package/src/player/lib/time.test.ts +19 -1
- package/src/player/lib/time.ts +20 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +5 -1
- package/dist/assets/index-BV9ymBm4.js +0 -93
- package/dist/assets/index-DeztUnf4.css +0 -1
|
@@ -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.
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
[
|
|
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
|
|
970
|
-
|
|
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",
|
|
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",
|
|
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
|
+
});
|
package/src/player/lib/time.ts
CHANGED
|
@@ -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({
|