@hyperframes/studio 0.8.131 → 0.8.132

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/player.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-DRVWEPO5.js";
3
+ } from "./chunk-FOEID72M.js";
4
4
  import "./chunk-5VSXRGIX.js";
5
5
  import "./chunk-TDWGXBUT.js";
6
6
  import "./chunk-PZ5AY32C.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.131",
3
+ "version": "0.8.132",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.131",
64
- "@hyperframes/parsers": "0.8.131",
65
- "@hyperframes/player": "0.8.131",
66
- "@hyperframes/shader-transitions": "0.8.131",
67
- "@hyperframes/sdk": "0.8.131",
68
- "@hyperframes/studio-server": "0.8.131"
63
+ "@hyperframes/core": "0.8.132",
64
+ "@hyperframes/player": "0.8.132",
65
+ "@hyperframes/sdk": "0.8.132",
66
+ "@hyperframes/parsers": "0.8.132",
67
+ "@hyperframes/shader-transitions": "0.8.132",
68
+ "@hyperframes/studio-server": "0.8.132"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.131"
84
+ "@hyperframes/producer": "0.8.132"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -60,6 +60,7 @@ export function usePlaybackKeyboard({
60
60
 
61
61
  const shuttle = useCallback(
62
62
  (direction: "forward" | "backward") => {
63
+ if (usePlayerStore.getState().playLocked) return;
63
64
  if (shuttleDirectionRef.current === direction) {
64
65
  shuttleSpeedIndexRef.current = Math.min(
65
66
  shuttleSpeedIndexRef.current + 1,
@@ -0,0 +1,137 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+ import {
6
+ makeAdapterWindow,
7
+ makeFakeIframe,
8
+ renderTimelinePlayerHarness,
9
+ resetPlayerStore,
10
+ } from "./timelinePlayerTestHarness";
11
+ import { usePlayerStore } from "../store/playerStore";
12
+ import { scrubMusicAtSeek } from "../lib/playbackScrub";
13
+
14
+ vi.mock("../lib/playbackScrub", async (importOriginal) => ({
15
+ ...(await importOriginal<typeof import("../lib/playbackScrub")>()),
16
+ scrubMusicAtSeek: vi.fn(),
17
+ }));
18
+
19
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
20
+
21
+ afterEach(() => {
22
+ document.body.innerHTML = "";
23
+ resetPlayerStore();
24
+ usePlayerStore.setState({ playLocked: false, loopEnabled: false, playbackRate: 1 });
25
+ vi.mocked(scrubMusicAtSeek).mockClear();
26
+ });
27
+
28
+ function readyPlayer() {
29
+ const { api, root } = renderTimelinePlayerHarness();
30
+ const { adapter, win } = makeAdapterWindow();
31
+ act(() => {
32
+ api.iframeRef.current = makeFakeIframe(win);
33
+ api.onIframeLoad();
34
+ usePlayerStore.setState({ timelineReady: true });
35
+ });
36
+ return { api, root, adapter };
37
+ }
38
+
39
+ const press = (key: string) =>
40
+ act(() => {
41
+ window.dispatchEvent(new KeyboardEvent("keydown", { key, code: `Key${key.toUpperCase()}` }));
42
+ });
43
+
44
+ it("starts nothing while the host holds playback: Play, a playback request or play backward", () => {
45
+ const { api, root, adapter } = readyPlayer();
46
+ act(() => usePlayerStore.getState().setPlayLocked(true));
47
+ act(() => api.play());
48
+ act(() => usePlayerStore.getState().requestPlayback(true));
49
+ press("j");
50
+ expect(adapter.play).not.toHaveBeenCalled();
51
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
52
+ act(() => root.unmount());
53
+ });
54
+
55
+ it("J and L keep the chosen speed while the host holds playback", () => {
56
+ const { root } = readyPlayer();
57
+ act(() => usePlayerStore.getState().setPlaybackRate(0.5));
58
+ act(() => usePlayerStore.getState().setPlayLocked(true));
59
+ press("l");
60
+ press("j");
61
+ expect(usePlayerStore.getState().playbackRate).toBe(0.5);
62
+ act(() => root.unmount());
63
+ });
64
+
65
+ it("a looping film reaching its end while held stops instead of starting again", () => {
66
+ const frames: FrameRequestCallback[] = [];
67
+ const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation((tick) => {
68
+ frames.push(tick);
69
+ return frames.length;
70
+ });
71
+ const { api, root, adapter } = readyPlayer();
72
+ act(() => usePlayerStore.setState({ loopEnabled: true }));
73
+ act(() => api.play());
74
+ // Held with the store already reading stopped, so nothing pauses the frame loop first.
75
+ act(() => usePlayerStore.setState({ playLocked: true, isPlaying: false }));
76
+ adapter.seek(30);
77
+ act(() => frames.at(-1)!(0));
78
+ expect(adapter.play).toHaveBeenCalledTimes(1);
79
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
80
+ raf.mockRestore();
81
+ act(() => root.unmount());
82
+ });
83
+
84
+ it("turning the hold on before the preview has a player still clears playing", () => {
85
+ const { root } = renderTimelinePlayerHarness();
86
+ act(() => usePlayerStore.setState({ isPlaying: true }));
87
+ act(() => usePlayerStore.getState().setPlayLocked(true));
88
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
89
+ act(() => root.unmount());
90
+ });
91
+
92
+ it("a seek that would keep playing does not resume while the host holds playback", () => {
93
+ const { api, root, adapter } = readyPlayer();
94
+ act(() => usePlayerStore.getState().setPlayLocked(true));
95
+ // The store still reads playing (a host that set the hold before the player caught up): only the hold stops it.
96
+ act(() => usePlayerStore.setState({ isPlaying: true }));
97
+ act(() => {
98
+ api.seek(5, { keepPlaying: true });
99
+ });
100
+ expect(adapter.play).not.toHaveBeenCalled();
101
+ act(() => root.unmount());
102
+ });
103
+
104
+ it("a seek while the host holds playback moves the picture without scrub audio", () => {
105
+ const { api, root, adapter } = readyPlayer();
106
+ act(() => {
107
+ api.seek(2);
108
+ });
109
+ expect(scrubMusicAtSeek).toHaveBeenCalledTimes(1);
110
+ act(() => usePlayerStore.getState().setPlayLocked(true));
111
+ act(() => {
112
+ api.seek(4);
113
+ });
114
+ expect(adapter.getTime()).toBe(4);
115
+ expect(scrubMusicAtSeek).toHaveBeenCalledTimes(1);
116
+ act(() => root.unmount());
117
+ });
118
+
119
+ it("a timeline reset keeps the hold: the host owns it", () => {
120
+ act(() => usePlayerStore.getState().setPlayLocked(true));
121
+ act(() => usePlayerStore.getState().reset());
122
+ expect(usePlayerStore.getState().playLocked).toBe(true);
123
+ });
124
+
125
+ it("turning the hold on pauses a playing film; turning it off lets Play start again", () => {
126
+ const { api, root, adapter } = readyPlayer();
127
+ act(() => api.play());
128
+ expect(usePlayerStore.getState().isPlaying).toBe(true);
129
+ act(() => usePlayerStore.getState().setPlayLocked(true));
130
+ expect(adapter.pause).toHaveBeenCalled();
131
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
132
+ act(() => usePlayerStore.getState().setPlayLocked(false));
133
+ act(() => api.play());
134
+ expect(adapter.play).toHaveBeenCalledTimes(2);
135
+ expect(usePlayerStore.getState().isPlaying).toBe(true);
136
+ act(() => root.unmount());
137
+ });
@@ -200,7 +200,8 @@ export function useTimelinePlayer({
200
200
  applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume);
201
201
  }, []);
202
202
  const play = useCallback(() => {
203
- if (!usePlayerStore.getState().timelineReady) return;
203
+ const { timelineReady, playLocked } = usePlayerStore.getState();
204
+ if (!timelineReady || playLocked) return;
204
205
  stopRAFLoop();
205
206
  stopReverseLoop();
206
207
  stopScrubPreviewAudio();
@@ -286,9 +287,8 @@ export function useTimelinePlayer({
286
287
  const pause = useCallback(() => {
287
288
  stopReverseLoop();
288
289
  const adapter = getAdapter();
289
- if (!adapter) return;
290
- adapter.pause();
291
- setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
290
+ adapter?.pause();
291
+ if (adapter) setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
292
292
  setIsPlaying(false);
293
293
  shuttleDirectionRef.current = null;
294
294
  shuttleSpeedIndexRef.current = 0;
@@ -306,17 +306,19 @@ export function useTimelinePlayer({
306
306
  const duration = Math.max(0, adapter.getDuration());
307
307
  const nextTime = clampToDuration(time, duration);
308
308
  const keepPlaying = options?.keepPlaying === true;
309
+ const { playLocked, isPlaying: storeWasPlaying } = usePlayerStore.getState();
309
310
  const shouldResumeAfterSeek = shouldResumeForwardPlaybackAfterSeek({
310
311
  keepPlaying,
311
312
  wasReverseShuttle,
312
- storeWasPlaying: usePlayerStore.getState().isPlaying,
313
+ storeWasPlaying,
313
314
  duration,
314
315
  nextTime,
316
+ playLocked,
315
317
  });
316
318
  adapter.seek(nextTime, options);
317
319
  publishSeek(nextTime, options); // Direct DOM updates (playhead, timecode, progress) — no re-render
318
320
  setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
319
- if (!shouldResumeAfterSeek && !keepPlaying) scrubMusicAtSeek(iframeRef.current, nextTime);
321
+ if (!keepPlaying && !playLocked) scrubMusicAtSeek(iframeRef.current, nextTime);
320
322
  if (shouldResumeAfterSeek) {
321
323
  stopRAFLoop();
322
324
  applyPlaybackRate(usePlayerStore.getState().playbackRate);
@@ -349,6 +351,13 @@ export function useTimelinePlayer({
349
351
  ],
350
352
  );
351
353
 
354
+ useEffect(
355
+ () =>
356
+ usePlayerStore.subscribe((state, prev) => {
357
+ if (state.playLocked && !prev.playLocked && state.isPlaying) pause();
358
+ }),
359
+ [pause],
360
+ );
352
361
  useEffect(() => {
353
362
  return usePlayerStore.subscribe((state, prev) => {
354
363
  if (state.requestedSeekTime !== null && state.requestedSeekTime !== prev.requestedSeekTime) {
@@ -60,7 +60,7 @@ export function useTimelinePlayerLoop({
60
60
  const looping = usePlayerStore.getState().loopEnabled && dur > 0;
61
61
  setLoopStart(looping ? loopStart : null);
62
62
  if (time >= loopEnd) {
63
- if (looping) {
63
+ if (looping && !usePlayerStore.getState().playLocked) {
64
64
  // keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
65
65
  adapter.seek(loopStart, { keepPlaying: true });
66
66
  liveTime.notify(loopStart);
@@ -4,8 +4,10 @@ export function shouldResumeForwardPlaybackAfterSeek(input: {
4
4
  storeWasPlaying: boolean;
5
5
  duration: number;
6
6
  nextTime: number;
7
+ playLocked: boolean;
7
8
  }): boolean {
8
9
  return (
10
+ !input.playLocked &&
9
11
  input.keepPlaying &&
10
12
  !input.wasReverseShuttle &&
11
13
  input.storeWasPlaying &&
@@ -13,6 +13,8 @@ export interface PlaybackReadinessSlice {
13
13
  previewBooted: boolean;
14
14
  setTimelineReady: (ready: boolean) => void;
15
15
  markPreviewBooted: () => void;
16
+ playLocked: boolean;
17
+ setPlayLocked: (locked: boolean) => void;
16
18
  /** Sets timelineReady once doc's readiness inputs settle, or immediately
17
19
  * if doc is null. A wait a later call supersedes never wins the race.
18
20
  * Waits for doc's load step first: it pauses and rewinds the preview, so a
@@ -47,6 +49,8 @@ export function createPlaybackReadinessSlice(
47
49
  timelineReady: false,
48
50
  previewBooted: false,
49
51
  markPreviewBooted: () => set({ previewBooted: true }),
52
+ playLocked: false,
53
+ setPlayLocked: (locked) => set({ playLocked: locked }),
50
54
  setTimelineReady: (ready) => {
51
55
  timelineReadyGeneration++;
52
56
  set({ timelineReady: ready });