@hyperframes/studio 0.8.131 → 0.8.133

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,10 +1,10 @@
1
1
  /**
2
2
  * timelineStackingSync — lane ↔ stacking unification (pure).
3
3
  *
4
- * The approved design: **lane order implies stacking**. A clip on a higher lane
5
- * (rendered ABOVE another in the timeline) should render ON TOP of any clip it
6
- * OVERLAPS IN TIME. But authored z-indexes are sacred: z only changes on a user
7
- * edit, and ONLY for the clip(s) the user actually edited.
4
+ * A row move restacks the moved clip only: moved up, it rises above every clip
5
+ * it overlaps in time on the rows now below it; moved down, it sinks below every
6
+ * such clip on the rows now above it. Neighbours never change, so a clip nobody
7
+ * moved never goes behind anything (a full-frame scene on the top row stays put).
8
8
  *
9
9
  * Lane → screen mapping (see Timeline.tsx trackOrder / TimelineCanvas rows):
10
10
  * tracks are sorted ASCENDING and rendered top → bottom, so a LOWER `track`
@@ -38,7 +38,7 @@ export interface StackingElement {
38
38
  * (e.g. an unmounted / nested sub-comp element, or one outside the active file).
39
39
  * A non-finite-z clip is EXCLUDED from the computation — it is neither a stacking
40
40
  * neighbour nor resolvable as an edit — so an unresolved node never fabricates a
41
- * z=0 neighbour that poisons the boundary math (item 13). The reader signals a
41
+ * z=0 neighbour. The reader signals a
42
42
  * miss with NaN rather than null so the value stays assignable to the existing
43
43
  * `(el) => number` reader contract the drag hook / commit deps declare.
44
44
  */
@@ -57,10 +57,9 @@ export interface StackingElement {
57
57
  /**
58
58
  * Discovery / DOM document position (optional). Two clips with EQUAL z paint by
59
59
  * DOM order — the one LATER in the DOM paints ON TOP. When supplied, "is A above
60
- * B" uses (zIndex, domIndex); without it equal-z is ambiguous and the sync can
61
- * under-patch (the reported bug: a clip dragged to the bottom lane over an
62
- * equal-z neighbour changed nothing on canvas). Callers pass the index of the
63
- * element in the discovery order array.
60
+ * B" uses (zIndex, domIndex); without it equal z counts as "not above", so the
61
+ * move writes a z. Callers pass the index of the element in the discovery order
62
+ * array.
64
63
  */
65
64
  domIndex?: number;
66
65
  }
@@ -74,7 +73,7 @@ export interface StackingPatch {
74
73
  /**
75
74
  * Canonical paint-scope key: leaf z-indexes are comparable only within the same
76
75
  * source document and CSS stacking context. The ONLY place this normalization
77
- * lives — partitioning, membership checks, and pairwise equality all use it.
76
+ * lives; samePaintScope compares with it.
78
77
  */
79
78
  const paintScopeKey = (el: { sourceFile?: string; stackingContextId?: string | null }): string =>
80
79
  JSON.stringify([el.sourceFile ?? null, el.stackingContextId ?? null]);
@@ -114,21 +113,13 @@ export function laneIsAbove(
114
113
  return a.track < b.track;
115
114
  }
116
115
 
117
- /**
118
- * Working record for the cascade resolver: a live-mutable, RESOLVED (non-null) z
119
- * the resolver can bump, plus the immutable identity/lane/time/dom fields. Clips
120
- * whose z could not be resolved (null) are dropped before this stage.
121
- */
122
- interface MutZ extends StackingElement {
123
- zIndex: number;
124
- }
116
+ /** Which way the edited clips moved between rows. */
117
+ export type StackingDirection = "up" | "down";
125
118
 
126
119
  /**
127
120
  * Does `a` currently paint ON TOP of `b`? Higher z wins; equal z breaks by DOM
128
- * order (later in DOM paints on top). When either domIndex is absent, equal z is
129
- * treated as "not strictly above" (ambiguous) — callers should supply domIndex to
130
- * disambiguate (see StackingElement.domIndex). Exported (like laneIsAbove) as the
131
- * ONE paint-order predicate so every consumer agrees on what "paints above" means.
121
+ * order (later in DOM paints on top). Without both domIndex values equal z is
122
+ * ambiguous and counts as "not above", so the move still writes a z.
132
123
  */
133
124
  function paintsAbove(
134
125
  a: Pick<StackingElement, "zIndex" | "domIndex">,
@@ -139,233 +130,58 @@ function paintsAbove(
139
130
  return false;
140
131
  }
141
132
 
142
- /** Reduce a neighbour set's z-indices to a single bound, or null when empty. */
143
- function boundaryZ(neighbours: MutZ[], reduce: (zs: number[]) => number): number | null {
144
- return neighbours.length > 0 ? reduce(neighbours.map((o) => o.zIndex)) : null;
133
+ /** Moved up: one above the highest clip it now sits above, or null when it already paints above them all. */
134
+ function raisedZ(clip: StackingElement, overlapping: StackingElement[]): number | null {
135
+ const below = overlapping.filter((o) => laneIsAbove(clip, o));
136
+ if (below.every((o) => paintsAbove(clip, o))) return null;
137
+ return Math.max(...below.map((o) => o.zIndex)) + 1;
145
138
  }
146
139
 
147
- /**
148
- * Resolve `edited` so that, among the clips it OVERLAPS IN TIME, its paint order
149
- * matches its lane order (lower lane ⇒ paints on top). Records every z change
150
- * (edited clip AND any neighbours that must be bumped) into `patchZ`.
151
- *
152
- * Fast path (unchanged behaviour): when a single non-negative z for the edited
153
- * clip alone realises the order — strictly between the neighbours if there is
154
- * integer room, else just above the lower neighbour, else just below the upper —
155
- * emit only that. This keeps every existing single-patch test passing.
156
- *
157
- * Cascade path: when ties/clamping make the single-clip patch impossible or
158
- * ineffective (must sit below an overlapping z=0 neighbour, or between adjacent /
159
- * equal-z neighbours where DOM order alone can't express it), bump the minimum set
160
- * of overlapping neighbours that must stay ABOVE by +1 (cascading only as far as
161
- * needed) so the edited clip's intended lane order is realised with all z ≥ 0.
162
- * "Authored z sacred" stays the default — neighbours are touched only when the
163
- * user's explicit lane move is otherwise inexpressible (same precedent as the
164
- * canvas context-menu tie-aware fix).
165
- *
166
- * Returns true when any z changed (recorded in `patchZ`), false for a no-op.
167
- */
168
- function resolveEditedZ(
169
- edited: MutZ,
170
- overlapping: MutZ[],
171
- overlappersOf: (clip: MutZ) => MutZ[],
172
- patchZ: (clip: MutZ, z: number) => void,
173
- ): boolean {
174
- const visualOverlap = overlapping.filter((o) => !o.isAudio);
175
- if (visualOverlap.length === 0) return false;
176
-
177
- // Neighbours that must end up BELOW edited (lower lane) vs ABOVE (higher lane).
178
- const below = visualOverlap.filter((o) => laneIsAbove(edited, o));
179
- const above = visualOverlap.filter((o) => laneIsAbove(o, edited));
180
-
181
- // Already correct against every overlapping neighbour → no-op (authored z kept).
182
- const correct =
183
- below.every((o) => paintsAbove(edited, o)) && above.every((o) => paintsAbove(o, edited));
184
- if (correct) return false;
185
-
186
- const maxBelow = boundaryZ(below, (zs) => Math.max(...zs));
187
-
188
- // ── Fast path: try to realise the order by moving only `edited`. ──────────────
189
- const single = trySingleZ(edited, below, above);
190
- if (single != null) {
191
- if (single !== edited.zIndex) patchZ(edited, single);
192
- // Even at an unchanged z the DOM-order ties may already be satisfied; if not,
193
- // `trySingleZ` returned null and we fall through to the cascade.
194
- return single !== edited.zIndex;
195
- }
196
-
197
- // ── Cascade path: can't fit `edited` between the neighbours with one z ≥ 0. ───
198
- // Sit edited at maxBelow+1 (or 0 when it only has above-neighbours) and lift the
199
- // above-neighbours that are now not strictly above, minimally, one step past it.
200
- const target = maxBelow != null ? maxBelow + 1 : 0;
201
- const clamped = Math.max(0, target);
202
- if (clamped !== edited.zIndex) patchZ(edited, clamped);
203
- liftAbove(edited, overlappersOf, patchZ);
204
- return true;
140
+ /** Moved down: one below the lowest clip it now sits under, but never under z 0 (a negative z can paint behind the
141
+ * composition's own background) and never under a clip on a lower row it paints over now, so the move cannot hide it.
142
+ * Null when it already paints under them all or cannot go lower. */
143
+ function loweredZ(clip: StackingElement, overlapping: StackingElement[]): number | null {
144
+ const above = overlapping.filter((o) => laneIsAbove(o, clip));
145
+ if (above.every((o) => paintsAbove(o, clip))) return null;
146
+ const keepOver = overlapping.filter((o) => laneIsAbove(clip, o) && paintsAbove(clip, o));
147
+ const floor = Math.max(
148
+ 0,
149
+ ...keepOver.map((o) =>
150
+ paintsAbove({ ...clip, zIndex: o.zIndex }, o) ? o.zIndex : o.zIndex + 1,
151
+ ),
152
+ );
153
+ const z = Math.max(floor, Math.min(...above.map((o) => o.zIndex)) - 1);
154
+ return z < clip.zIndex ? z : null;
205
155
  }
206
156
 
207
157
  /**
208
- * Pick a single non-negative z for `edited` that lands it correctly against its
209
- * neighbours (paints above every below-neighbour, below every above-neighbour), or
210
- * null when no such z exists and the caller must cascade.
211
- *
212
- * The candidate is verified with the SAME `paintsAbove` predicate the resolver uses
213
- * (z + DOM tie-break), so an authored z that already paints correctly by DOM order
214
- * is honoured instead of over-patched: with below=z3 and an above-neighbour at z4
215
- * that is LATER in the DOM, edited=4 ties the neighbour but the neighbour still
216
- * paints on top by DOM order — a valid single patch, no neighbour bump (item 12).
217
- * When the tie would INVERT (the above-neighbour is earlier in DOM) the candidate
218
- * fails verification and the caller cascades.
219
- */
220
- // edited has neighbours on BOTH sides: prefer the integer midpoint of a real gap;
221
- // with no strict gap a DOM tie-break may still let it sit AT minAbove (item 12),
222
- // else the caller cascades.
223
- function zBetweenNeighbours(
224
- maxBelow: number,
225
- minAbove: number,
226
- correctAt: (z: number) => boolean,
227
- ): number | null {
228
- if (minAbove - maxBelow >= 2) {
229
- const mid = Math.floor((maxBelow + minAbove) / 2);
230
- return mid > maxBelow && mid < minAbove ? mid : null;
231
- }
232
- return correctAt(minAbove) ? minAbove : null;
233
- }
234
-
235
- // edited has only above-neighbours: sit one step below minAbove, or at the z=0
236
- // floor tie minAbove when a DOM tie-break keeps that neighbour on top.
237
- function zBelowOnly(minAbove: number, correctAt: (z: number) => boolean): number | null {
238
- const candidate = minAbove - 1;
239
- if (candidate >= 0) return candidate;
240
- return correctAt(minAbove) ? minAbove : null;
241
- }
242
-
243
- function trySingleZ(edited: MutZ, below: MutZ[], above: MutZ[]): number | null {
244
- const maxBelow = boundaryZ(below, (zs) => Math.max(...zs));
245
- const minAbove = boundaryZ(above, (zs) => Math.min(...zs));
246
-
247
- const correctAt = (z: number): boolean => {
248
- const probe: MutZ = { ...edited, zIndex: z };
249
- return below.every((b) => paintsAbove(probe, b)) && above.every((a) => paintsAbove(a, probe));
250
- };
251
-
252
- if (maxBelow != null && minAbove != null)
253
- return zBetweenNeighbours(maxBelow, minAbove, correctAt);
254
- if (maxBelow != null) return maxBelow + 1; // only below-neighbours → grow upward
255
- if (minAbove != null) return zBelowOnly(minAbove, correctAt);
256
- return null;
257
- }
258
-
259
- /**
260
- * Enforce the module invariant — for every OVERLAPPING pair, the clip on the upper
261
- * lane paints on top — starting from `edited` and cascading TRANSITIVELY.
262
- *
263
- * Seeded with `edited`: each of its upper-lane overlappers must paint strictly
264
- * above it (the deliberate lane move). Raising a clip can then tie or cross ANOTHER
265
- * clip it overlaps that sits on an even higher lane — that clip must be lifted too,
266
- * and so on. Without the cascade a lifted neighbour could tie an untouched clip on
267
- * a higher lane and, being later in the DOM, paint above it — an untouched pair
268
- * visibly inverting (#2198). The condition is LANE order (not "was originally
269
- * above"), so a clip that was already violating lane order — e.g. a bottom-lane
270
- * clip painting on top — is fixed, never preserved. Only clips whose z actually
271
- * changes are patched; z climbs by +1 each step so the walk terminates.
272
- */
273
- function liftAbove(
274
- edited: MutZ,
275
- overlappersOf: (clip: MutZ) => MutZ[],
276
- patchZ: (clip: MutZ, z: number) => void,
277
- ): void {
278
- const queue: MutZ[] = [edited];
279
- const raiseAbove = (clip: MutZ, floor: MutZ): void => {
280
- if (paintsAbove(clip, floor)) return; // already strictly on top
281
- patchZ(clip, floor.zIndex + 1); // patchZ mutates clip.zIndex in place
282
- queue.push(clip);
283
- };
284
- while (queue.length > 0) {
285
- const floor = queue.shift()!;
286
- for (const other of overlappersOf(floor)) {
287
- if (laneIsAbove(other, floor) && !paintsAbove(other, floor)) raiseAbove(other, floor);
288
- }
289
- }
290
- }
291
-
292
- /**
293
- * Compute z-index patches so each edited clip's stacking matches its lane order.
294
- *
295
- * @param elements The FULL post-edit element set (edited clips already carry
296
- * their new lane/time). Untouched clips keep their current z.
297
- * @param editedKeys Keys of the clip(s) the user just edited.
298
- * @returns Minimal z patches. When a single-clip patch realises the order it is
299
- * the only patch (authored z of neighbours untouched); when ties or a
300
- * z=0 floor make that impossible, the minimum set of overlapping
301
- * neighbours is bumped too so the lane move is always realisable with
302
- * all z ≥ 0. Non-overlapping / already-correct edits yield nothing.
303
- *
304
- * Multi-clip edits: each edited clip is resolved against the CURRENT (already-
305
- * patched) z of all OTHER clips, lower lane first, so a group dragged onto a busy
306
- * region stacks consistently.
158
+ * The z-index patches for a row move: each edited clip, and only it, is raised (`up`) or lowered (`down`) against the
159
+ * clips it overlaps in time in its own paint scope. Clips whose live z is unresolved (non-finite) and audio clips take
160
+ * no part. Several edited clips resolve against each other's new z: the bottom one first when moving up, the top one
161
+ * first when moving down.
307
162
  */
308
163
  export function computeStackingPatches(
309
164
  elements: StackingElement[],
310
165
  editedKeys: Iterable<string>,
166
+ direction: StackingDirection,
311
167
  ): StackingPatch[] {
312
168
  const editedSet = new Set(editedKeys);
313
- if (editedSet.size === 0) return [];
169
+ const live = elements
170
+ .filter((e) => Number.isFinite(e.zIndex) && !e.isAudio)
171
+ .map((e) => ({ ...e }));
172
+ const edited = live
173
+ .filter((e) => editedSet.has(e.key))
174
+ .sort((a, b) => (direction === "up" ? b.track - a.track : a.track - b.track));
314
175
 
315
- // Drop clips whose live z couldn't be resolved (non-finite / NaN): a fabricated
316
- // z=0 would enter the boundary math as a phantom neighbour at the z-floor. An
317
- // unresolved clip is neither a neighbour nor resolvable as an edit, so it is
318
- // excluded outright (item 13).
319
- const allResolved = elements.filter((e) => Number.isFinite(e.zIndex));
320
-
321
- // Leaf z is only meaningful within ONE source document and stacking context:
322
- // across either boundary the ancestor composition/context decides paint order.
323
- // Restrict the computation to the edited clips' own paint scope(s).
324
- const editedScopes = new Set(allResolved.filter((e) => editedSet.has(e.key)).map(paintScopeKey));
325
- const resolved = allResolved.filter((e) => editedScopes.has(paintScopeKey(e)));
326
-
327
- // Mutable z snapshot so edits + cascaded bumps see each other's applied z.
328
- const byKey = new Map<string, MutZ>(resolved.map((e) => [e.key, { ...e }]));
329
- const edited = resolved
330
- .filter((e) => editedSet.has(e.key) && !e.isAudio)
331
- .map((e) => byKey.get(e.key)!)
332
- // Resolve lower-lane (renders below) clips first so their new z is visible
333
- // to higher-lane siblings resolved after them.
334
- .sort((a, b) => b.track - a.track);
335
-
336
- const changed = new Map<string, number>();
337
- const patchZ = (clip: MutZ, z: number): void => {
338
- clip.zIndex = z;
339
- changed.set(clip.key, z);
340
- };
341
-
342
- // The full live set, so the transitive cascade can reach clips that overlap a
343
- // LIFTED neighbour without overlapping the edited clip itself (#2198).
344
- const all = [...byKey.values()];
345
- const overlappersOf = (clip: MutZ): MutZ[] =>
346
- all.filter(
347
- (o) => o.key !== clip.key && !o.isAudio && samePaintScope(clip, o) && overlapsInTime(clip, o),
348
- );
349
-
350
- for (const clip of edited) {
351
- resolveEditedZ(clip, overlappersOf(clip), overlappersOf, patchZ);
352
- }
353
-
354
- // Emit in a stable order (edited clips first in their resolve order, then any
355
- // cascaded neighbours) — deterministic for tests and undo grouping.
356
- const emitted = new Set<string>();
357
176
  const patches: StackingPatch[] = [];
358
177
  for (const clip of edited) {
359
- if (changed.has(clip.key) && !emitted.has(clip.key)) {
360
- patches.push({ key: clip.key, zIndex: changed.get(clip.key)! });
361
- emitted.add(clip.key);
362
- }
363
- }
364
- for (const [key, zIndex] of changed) {
365
- if (!emitted.has(key)) {
366
- patches.push({ key, zIndex });
367
- emitted.add(key);
368
- }
178
+ const overlapping = live.filter(
179
+ (o) => o.key !== clip.key && overlapsInTime(clip, o) && samePaintScope(clip, o),
180
+ );
181
+ const zIndex = direction === "up" ? raisedZ(clip, overlapping) : loweredZ(clip, overlapping);
182
+ if (zIndex === null) continue;
183
+ clip.zIndex = zIndex;
184
+ patches.push({ key: clip.key, zIndex });
369
185
  }
370
186
  return patches;
371
187
  }
@@ -55,7 +55,7 @@ export function useTimelineStackingSync({
55
55
  // NaN (NOT 0) when the element can't be resolved in the preview iframe — a
56
56
  // nested / unmounted sub-comp node, or one outside the active file. Fabricating
57
57
  // z=0 would enter computeStackingPatches as a real overlapping neighbour at the
58
- // z-floor and skew the boundary math; a non-finite value tells it to EXCLUDE this
58
+ // z-floor; a non-finite value tells it to EXCLUDE this
59
59
  // clip instead. NaN (rather than null) keeps the return assignable to the
60
60
  // `(el) => number` reader contract the drag hook / commit deps declare.
61
61
  const readClipZIndex = useCallback(
@@ -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 });
@@ -24,8 +24,9 @@ it("restacks the host's preview when a lane move puts a clip below another", asy
24
24
  const iframe = document.createElement("iframe");
25
25
  document.body.appendChild(iframe);
26
26
  const doc = iframe.contentDocument!;
27
- // No authored z: DOM order paints tag over title, which matches the rows until the move.
28
- doc.body.innerHTML = '<div id="title"></div><div id="tag"></div><div id="note"></div>';
27
+ // Tag (top row) paints over title, matching the rows until the move.
28
+ doc.body.innerHTML =
29
+ '<div id="title" style="z-index: 2"></div><div id="tag" style="z-index: 3"></div><div id="note"></div>';
29
30
  const clip = (id: string, start: number, duration: number, track: number) =>
30
31
  ({ id, key: id, domId: id, tag: "div", start, duration, track }) as const;
31
32
  usePlayerStore.setState({