@hyperframes/studio 0.7.107 → 0.7.109
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/{hyperframes-player-BLrfwq5o.js → hyperframes-player-BYgqAPib.js} +1 -1
- package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
- package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
- package/dist/assets/index-CfBGQdrX.js +428 -0
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/index.d.ts +85 -2
- package/dist/index.html +2 -2
- package/dist/index.js +17696 -12512
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/contexts/DomEditContext.tsx +11 -0
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +30 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +38 -0
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.ts +2 -0
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/system.ts +8 -0
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioTelemetry.ts +16 -10
- package/dist/assets/index-dF-CmLZu.js +0 -428
- package/dist/assets/index-zQ4JFwwB.css +0 -1
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard surface for the active automation selection: Escape clears,
|
|
3
|
+
* Delete/Backspace deletes every breakpoint inside the selection box, Cmd/Ctrl+C
|
|
4
|
+
* copies its span, Cmd/Ctrl+V pastes at the selection's start (or the playhead) onto the
|
|
5
|
+
* selected clip's lane. Sibling of
|
|
6
|
+
* useKeyframeKeyboard and copies its contract: capture phase so playback
|
|
7
|
+
* shortcuts cannot swallow keys we act on, inert while any text input has
|
|
8
|
+
* focus, and a key is only consumed when it does something.
|
|
9
|
+
*
|
|
10
|
+
* Falling through is NOT enough to keep clip-level copy/paste working:
|
|
11
|
+
* useAppHotkeys listens on `window` with capture, so it always runs before this
|
|
12
|
+
* document-level listener and `stopImmediatePropagation` here comes too late.
|
|
13
|
+
* `automationOwnsKey` below is the arbitration that actually works — the
|
|
14
|
+
* central dispatcher asks it first and stands down.
|
|
15
|
+
*/
|
|
16
|
+
import { useEffect } from "react";
|
|
17
|
+
import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
|
|
18
|
+
import { laneFor, withLane } from "../player/components/automationLaneGeometry";
|
|
19
|
+
import { pointInSelection, replaceRange } from "../player/components/automationLaneSelection";
|
|
20
|
+
import {
|
|
21
|
+
copyRange,
|
|
22
|
+
isLastPasteSpan,
|
|
23
|
+
markLastPaste,
|
|
24
|
+
pastePoints,
|
|
25
|
+
readClipboard,
|
|
26
|
+
} from "../player/components/automationClipboard";
|
|
27
|
+
import {
|
|
28
|
+
resolveAutomationRange,
|
|
29
|
+
type AutomationRange,
|
|
30
|
+
type HfAutomation,
|
|
31
|
+
type HfAutomationLane,
|
|
32
|
+
} from "@hyperframes/core/audio-automation";
|
|
33
|
+
import { clampNumber } from "../utils/studioHelpers";
|
|
34
|
+
import type { AutomationSelection } from "../player/store/automationSelectionSlice";
|
|
35
|
+
import type {
|
|
36
|
+
AutomationLaneBinding,
|
|
37
|
+
UseAutomationLanesResult,
|
|
38
|
+
} from "../player/components/useAutomationLanes";
|
|
39
|
+
|
|
40
|
+
type PlayerState = ReturnType<typeof usePlayerStore.getState>;
|
|
41
|
+
|
|
42
|
+
function isTextInput(el: Element | null): boolean {
|
|
43
|
+
if (!el) return false;
|
|
44
|
+
const tag = el.tagName;
|
|
45
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
46
|
+
return el instanceof HTMLElement && el.isContentEditable;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A Cmd/Ctrl+<letter> chord. `e.key` is normalised because CapsLock makes it
|
|
51
|
+
* "V"/"C", and useAppHotkeys already lowercases — a raw `e.key === "v"` test
|
|
52
|
+
* would silently drop the keystroke here while that dispatcher still acted on
|
|
53
|
+
* it. Shift and Alt are excluded for the same parity reason (useAppHotkeys
|
|
54
|
+
* gates its own copy/paste on `!shiftKey && !altKey`).
|
|
55
|
+
*/
|
|
56
|
+
function isChord(e: KeyboardEvent, letter: string): boolean {
|
|
57
|
+
return (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === letter;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A `TimelineElement`'s identity as the selection and lane bindings key by. */
|
|
61
|
+
function elementKeyOf(element: TimelineElement): string {
|
|
62
|
+
return element.key ?? element.id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function findElement(elements: TimelineElement[], key: string | null): TimelineElement | null {
|
|
66
|
+
if (!key) return null;
|
|
67
|
+
return elements.find((el) => elementKeyOf(el) === key) ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A selection's element, binding, lane and range — the resolution Delete and
|
|
72
|
+
* copy both need. Null when the clip is gone, its lane is read-only, or the
|
|
73
|
+
* target no longer resolves to a range.
|
|
74
|
+
*/
|
|
75
|
+
function resolveSelectionContext(
|
|
76
|
+
state: PlayerState,
|
|
77
|
+
lanes: UseAutomationLanesResult,
|
|
78
|
+
sel: AutomationSelection,
|
|
79
|
+
): { binding: AutomationLaneBinding; lane: HfAutomationLane; range: AutomationRange } | null {
|
|
80
|
+
const element = findElement(state.elements, sel.elementKey);
|
|
81
|
+
if (!element) return null;
|
|
82
|
+
const binding = lanes.bind(element, sel.elementKey === state.selectedElementId);
|
|
83
|
+
if (binding.readOnly) return null;
|
|
84
|
+
const range = resolveAutomationRange(sel.target, binding.chain ?? undefined);
|
|
85
|
+
if (!range) return null;
|
|
86
|
+
return { binding, lane: laneFor(binding.automation, sel.target), range };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The write that deletes the breakpoints inside the active selection, or null when
|
|
91
|
+
* there is nothing to do: the clip is gone, its lane is read-only, the target no
|
|
92
|
+
* longer resolves to a range, or the selection covers no breakpoints.
|
|
93
|
+
*
|
|
94
|
+
* Deletes them outright rather than emptying the span behind anchor points. Anchors
|
|
95
|
+
* are what `replaceRange` exists for, and they are right for a shape insert or a
|
|
96
|
+
* paste — the envelope either side of the edit must not move. But Delete over a
|
|
97
|
+
* selection is the author saying "these points, gone", and answering that with two
|
|
98
|
+
* NEW points at the selection's edges reads as the delete not having worked. The
|
|
99
|
+
* envelope between the surviving neighbours re-interpolates, which is what deleting
|
|
100
|
+
* a breakpoint means everywhere else in the lane (right-clicking one does exactly
|
|
101
|
+
* this).
|
|
102
|
+
*
|
|
103
|
+
* Both axes of the selection box, edges included: what Delete removes is exactly
|
|
104
|
+
* what the lane drew a ring around. A point at the right time but outside the box's
|
|
105
|
+
* value bounds stays — which is the whole reason the box has them.
|
|
106
|
+
*/
|
|
107
|
+
function resolveDeleteWrite(
|
|
108
|
+
state: PlayerState,
|
|
109
|
+
lanes: UseAutomationLanesResult,
|
|
110
|
+
sel: AutomationSelection,
|
|
111
|
+
): { onCommit(next: HfAutomation): void; next: HfAutomation } | null {
|
|
112
|
+
const ctx = resolveSelectionContext(state, lanes, sel);
|
|
113
|
+
if (!ctx) return null;
|
|
114
|
+
const points = ctx.lane.points.filter((p) => !pointInSelection(p, sel));
|
|
115
|
+
// Nothing inside is nothing to do — and it must stay a no-op rather than
|
|
116
|
+
// writing, or Delete over a smooth stretch would push an undo entry that
|
|
117
|
+
// changed nothing.
|
|
118
|
+
if (points.length === ctx.lane.points.length) return null;
|
|
119
|
+
return {
|
|
120
|
+
onCommit: ctx.binding.onCommit,
|
|
121
|
+
next: withLane(ctx.binding.automation, { target: sel.target, points }),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The lane target Cmd+V writes to: the active selection's, when the
|
|
127
|
+
* selection belongs to the same clip the paste is landing on, else the
|
|
128
|
+
* clip's first automation lane. A selection left over on a different clip
|
|
129
|
+
* does not redirect the paste.
|
|
130
|
+
*/
|
|
131
|
+
function pasteTargetName(
|
|
132
|
+
binding: AutomationLaneBinding,
|
|
133
|
+
elementKey: string,
|
|
134
|
+
sel: AutomationSelection | null,
|
|
135
|
+
): string | undefined {
|
|
136
|
+
if (sel && sel.elementKey === elementKey) return sel.target;
|
|
137
|
+
return binding.lanes[0]?.target;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Where Cmd+V lands, or null when nothing is selected, the clip's lanes are
|
|
142
|
+
* read-only, it has no automation lane to fall back to, or the dom-edit layer
|
|
143
|
+
* would write the result to a DIFFERENT clip.
|
|
144
|
+
*
|
|
145
|
+
* That last guard is the one with teeth. `binding.onCommit` persists through
|
|
146
|
+
* handleDomAttributeQuietCommit, which targets whatever the dom-edit layer
|
|
147
|
+
* currently has selected — not the element `bind()` was handed (see the
|
|
148
|
+
* doc-comment on `onSelect` in useAutomationLanes). Selecting a clip in the
|
|
149
|
+
* timeline sets `selectedElementId` synchronously but resolves the dom-edit
|
|
150
|
+
* selection asynchronously, so clicking clip B and immediately pressing Cmd+V
|
|
151
|
+
* would serialize B's automation onto A. Every other lane path is a pointer
|
|
152
|
+
* gesture on the lane itself, which cannot run before the selection lands;
|
|
153
|
+
* paste is the only one that can, so it refuses rather than write blind.
|
|
154
|
+
*/
|
|
155
|
+
function resolvePasteTarget(
|
|
156
|
+
state: PlayerState,
|
|
157
|
+
lanes: UseAutomationLanesResult,
|
|
158
|
+
sel: AutomationSelection | null,
|
|
159
|
+
): {
|
|
160
|
+
elementKey: string;
|
|
161
|
+
element: TimelineElement;
|
|
162
|
+
target: string;
|
|
163
|
+
binding: AutomationLaneBinding;
|
|
164
|
+
lane: HfAutomationLane;
|
|
165
|
+
range: AutomationRange;
|
|
166
|
+
} | null {
|
|
167
|
+
const element = findElement(state.elements, state.selectedElementId);
|
|
168
|
+
if (!element) return null;
|
|
169
|
+
const elementKey = elementKeyOf(element);
|
|
170
|
+
const binding = lanes.bind(element, true);
|
|
171
|
+
if (binding.readOnly) return null;
|
|
172
|
+
if (binding.commitTargetKey !== elementKey) return null;
|
|
173
|
+
const target = pasteTargetName(binding, elementKey, sel);
|
|
174
|
+
if (!target) return null;
|
|
175
|
+
const range = resolveAutomationRange(target, binding.chain ?? undefined);
|
|
176
|
+
if (!range) return null;
|
|
177
|
+
return { elementKey, element, target, binding, lane: laneFor(binding.automation, target), range };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Is the playhead over this clip? Mirrors TimelineAutomationLaneSlot's own
|
|
181
|
+
* in-clip test, which is what decides a lane draws a playhead at all. */
|
|
182
|
+
function playheadInClip(state: PlayerState, element: TimelineElement): boolean {
|
|
183
|
+
return (
|
|
184
|
+
state.currentTime >= element.start && state.currentTime <= element.start + element.duration
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Clip-local seconds a `span`-wide paste should start at, or null when nothing
|
|
190
|
+
* can anchor it.
|
|
191
|
+
*
|
|
192
|
+
* Three refusals, all of which used to be silent mispastes:
|
|
193
|
+
* - A span wider than the clip has nowhere to go. Clamping the start to 0 still
|
|
194
|
+
* writes breakpoints past the clip's end and leaves a selection whose far
|
|
195
|
+
* edge can never be grabbed again.
|
|
196
|
+
* - A playhead outside the clip is not an anchor. It used to collapse to the
|
|
197
|
+
* clip's own t=0, so a playhead at 0:00 pasted into the head of a clip
|
|
198
|
+
* starting at 0:30.
|
|
199
|
+
* - No selection on this clip and no in-clip playhead means no anchor at all.
|
|
200
|
+
*
|
|
201
|
+
* A repeated Cmd+V chains. Paste leaves its own span selected (the user's only
|
|
202
|
+
* feedback that it landed), so anchoring at `sel.t0` unconditionally made the
|
|
203
|
+
* second press overwrite the first. When the live selection is exactly the mark
|
|
204
|
+
* the last paste left, anchor at its END; a selection the user drew themselves
|
|
205
|
+
* still pastes at its start.
|
|
206
|
+
*/
|
|
207
|
+
function pasteAnchor(
|
|
208
|
+
state: PlayerState,
|
|
209
|
+
element: TimelineElement,
|
|
210
|
+
span: number,
|
|
211
|
+
sel: AutomationSelection | null,
|
|
212
|
+
): number | null {
|
|
213
|
+
if (span > element.duration) return null;
|
|
214
|
+
const onThisElement = sel !== null && sel.elementKey === elementKeyOf(element);
|
|
215
|
+
const raw = onThisElement
|
|
216
|
+
? isLastPasteSpan(sel)
|
|
217
|
+
? sel.t1
|
|
218
|
+
: sel.t0
|
|
219
|
+
: playheadInClip(state, element)
|
|
220
|
+
? state.currentTime - element.start
|
|
221
|
+
: null;
|
|
222
|
+
if (raw === null) return null;
|
|
223
|
+
// Keeps the whole pasted span inside the clip — including a chain that has
|
|
224
|
+
// walked to the end — so its own selection stays grabbable.
|
|
225
|
+
return clampNumber(raw, 0, element.duration - span);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Cmd/Ctrl+V: paste the clipboard onto the selected clip's lane, at the active
|
|
230
|
+
* selection or the playhead. Returns false (untouched event) when the chord
|
|
231
|
+
* doesn't match, there is nothing to paste, or no lane can take it.
|
|
232
|
+
* Checked ahead of the "no selection" guard in the handler below: paste must
|
|
233
|
+
* work from the playhead with no active selection at all.
|
|
234
|
+
*/
|
|
235
|
+
function handlePaste(
|
|
236
|
+
e: KeyboardEvent,
|
|
237
|
+
state: PlayerState,
|
|
238
|
+
lanes: UseAutomationLanesResult,
|
|
239
|
+
): boolean {
|
|
240
|
+
if (!isChord(e, "v")) return false;
|
|
241
|
+
const clip = readClipboard(state.timelineProjectId);
|
|
242
|
+
if (!clip) return false;
|
|
243
|
+
const sel = state.automationSelection;
|
|
244
|
+
const paste = resolvePasteTarget(state, lanes, sel);
|
|
245
|
+
if (!paste) return false;
|
|
246
|
+
const atT = pasteAnchor(state, paste.element, clip.span, sel);
|
|
247
|
+
if (atT === null) return false;
|
|
248
|
+
|
|
249
|
+
const t1 = atT + clip.span;
|
|
250
|
+
const inner = pastePoints(clip, paste.range, atT);
|
|
251
|
+
const points = replaceRange({ lane: paste.lane, range: paste.range, t0: atT, t1, inner });
|
|
252
|
+
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
e.stopImmediatePropagation();
|
|
255
|
+
paste.binding.onCommit(withLane(paste.binding.automation, { target: paste.target, points }));
|
|
256
|
+
// Select the pasted span — the only feedback that it landed — and mark it, so
|
|
257
|
+
// an immediate second Cmd+V recognises this selection as the paste's own and
|
|
258
|
+
// chains right after it instead of overwriting it.
|
|
259
|
+
// Full-height box over the pasted span: everything that landed is selected, so
|
|
260
|
+
// Delete straight after a paste undoes it in one press.
|
|
261
|
+
const mark = {
|
|
262
|
+
elementKey: paste.elementKey,
|
|
263
|
+
target: paste.target,
|
|
264
|
+
t0: atT,
|
|
265
|
+
t1,
|
|
266
|
+
v0: paste.range.min,
|
|
267
|
+
v1: paste.range.max,
|
|
268
|
+
};
|
|
269
|
+
state.setAutomationSelection(mark);
|
|
270
|
+
markLastPaste(mark);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Cmd/Ctrl+C on the active selection. Returns false when the chord doesn't
|
|
275
|
+
* match, the selection no longer resolves to a copyable lane, or the lane is
|
|
276
|
+
* empty so there is no shape to capture. */
|
|
277
|
+
function handleCopy(
|
|
278
|
+
e: KeyboardEvent,
|
|
279
|
+
state: PlayerState,
|
|
280
|
+
lanes: UseAutomationLanesResult,
|
|
281
|
+
sel: AutomationSelection,
|
|
282
|
+
): boolean {
|
|
283
|
+
if (!isChord(e, "c")) return false;
|
|
284
|
+
const ctx = resolveSelectionContext(state, lanes, sel);
|
|
285
|
+
if (!ctx) return false;
|
|
286
|
+
if (!copyRange(state.timelineProjectId, ctx.lane, ctx.range, sel.t0, sel.t1)) return false;
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
e.stopImmediatePropagation();
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Will an automation range claim this keystroke? Asked by useAppHotkeys, which
|
|
294
|
+
* listens on `window` with capture and therefore always runs BEFORE this
|
|
295
|
+
* hook's document listener — so `stopImmediatePropagation` cannot arbitrate and
|
|
296
|
+
* the dispatcher has to stand down of its own accord. Without it Cmd+C armed
|
|
297
|
+
* both clipboards and Cmd+V ran the whole clip-duplication path (read file →
|
|
298
|
+
* insert → save with history → reload preview) alongside the automation write:
|
|
299
|
+
* two async read-modify-writes of one file from one keypress.
|
|
300
|
+
*
|
|
301
|
+
* Store and clipboard only, no lane binding, so the dispatcher can call it
|
|
302
|
+
* without holding the binding factory. That leaves one accepted residual: the
|
|
303
|
+
* predicate cannot see a read-only lane, an unresolvable target, or the
|
|
304
|
+
* dom-edit target mismatch `resolvePasteTarget` guards, so in those rare cases
|
|
305
|
+
* the keystroke is a no-op instead of falling through to clip copy/paste. A
|
|
306
|
+
* dead key beats today's double write.
|
|
307
|
+
*/
|
|
308
|
+
export function automationOwnsKey(e: KeyboardEvent): boolean {
|
|
309
|
+
if (isTextInput(document.activeElement)) return false;
|
|
310
|
+
const state = usePlayerStore.getState();
|
|
311
|
+
if (isChord(e, "c")) return state.automationSelection !== null;
|
|
312
|
+
if (!isChord(e, "v")) return false;
|
|
313
|
+
const clip = readClipboard(state.timelineProjectId);
|
|
314
|
+
if (!clip) return false;
|
|
315
|
+
const element = findElement(state.elements, state.selectedElementId);
|
|
316
|
+
if (!element) return false;
|
|
317
|
+
// Same anchor resolution the handler uses, so predicate and handler cannot
|
|
318
|
+
// disagree about whether the paste has somewhere to land.
|
|
319
|
+
return pasteAnchor(state, element, clip.span, state.automationSelection) !== null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Delete/Backspace on the active selection. Returns false when the key
|
|
323
|
+
* doesn't match or there is nothing to empty. */
|
|
324
|
+
function handleDelete(
|
|
325
|
+
e: KeyboardEvent,
|
|
326
|
+
state: PlayerState,
|
|
327
|
+
lanes: UseAutomationLanesResult,
|
|
328
|
+
sel: AutomationSelection,
|
|
329
|
+
): boolean {
|
|
330
|
+
const isDeleteKey = e.key === "Delete" || e.key === "Backspace";
|
|
331
|
+
if (!isDeleteKey || e.metaKey || e.ctrlKey) return false;
|
|
332
|
+
const write = resolveDeleteWrite(state, lanes, sel);
|
|
333
|
+
if (!write) return false;
|
|
334
|
+
e.preventDefault();
|
|
335
|
+
e.stopImmediatePropagation();
|
|
336
|
+
write.onCommit(write.next);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function useAutomationSelectionKeyboard({
|
|
341
|
+
lanes,
|
|
342
|
+
}: {
|
|
343
|
+
lanes: UseAutomationLanesResult;
|
|
344
|
+
}): void {
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
const handler = (e: KeyboardEvent): void => {
|
|
347
|
+
if (isTextInput(document.activeElement)) return;
|
|
348
|
+
// Somebody upstream already claimed this key. useAppHotkeys is on
|
|
349
|
+
// window/capture so it always runs first, and it deliberately lets a
|
|
350
|
+
// keyframe selection outrank an automation range on Delete — without
|
|
351
|
+
// this, that keystroke deleted the keyframes there AND emptied the range
|
|
352
|
+
// here, two edits from one press. preventDefault does not stop
|
|
353
|
+
// propagation, so the claim has to be read, not assumed.
|
|
354
|
+
if (e.defaultPrevented) return;
|
|
355
|
+
const state = usePlayerStore.getState();
|
|
356
|
+
if (handlePaste(e, state, lanes)) return;
|
|
357
|
+
|
|
358
|
+
const sel = state.automationSelection;
|
|
359
|
+
if (!sel) return;
|
|
360
|
+
|
|
361
|
+
if (e.key === "Escape") {
|
|
362
|
+
state.clearAutomationSelection();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (handleCopy(e, state, lanes, sel)) return;
|
|
366
|
+
handleDelete(e, state, lanes, sel);
|
|
367
|
+
};
|
|
368
|
+
document.addEventListener("keydown", handler, true);
|
|
369
|
+
return () => document.removeEventListener("keydown", handler, true);
|
|
370
|
+
}, [lanes]);
|
|
371
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
9
9
|
import { reportDomEditPersistFailure } from "./domEditPersistFailure";
|
|
10
10
|
import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
|
|
11
|
+
import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
|
|
11
12
|
|
|
12
13
|
// ── Types ──
|
|
13
14
|
|
|
@@ -26,6 +27,26 @@ interface DataAttributeCommitOptions {
|
|
|
26
27
|
skipRefresh: boolean;
|
|
27
28
|
refreshAfter?: boolean;
|
|
28
29
|
onSettled?: (ok: boolean) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Undo grouping for a gesture that spans several commits.
|
|
32
|
+
*
|
|
33
|
+
* Without it the key is derived from the prefix, attribute and element, and the
|
|
34
|
+
* window is history's own 300ms — so a drag's moves and the release that ends it
|
|
35
|
+
* landed in different entries, and a drag slower than the window split further.
|
|
36
|
+
* A caller that knows a gesture is in progress passes one key for all of it.
|
|
37
|
+
*/
|
|
38
|
+
coalesce?: { key: string; ms: number };
|
|
39
|
+
/**
|
|
40
|
+
* Apply to the preview and stop there — no file write, no history entry.
|
|
41
|
+
*
|
|
42
|
+
* What a gesture wants from every pointermove: the preview document and the
|
|
43
|
+
* audio graph following the pointer, with the file written once on release.
|
|
44
|
+
* Persisting each move put a fragment of one drag in the undo stack, and since
|
|
45
|
+
* those writes race, a follow-up's "before" was often not the previous entry's
|
|
46
|
+
* "after" — history refuses to coalesce across that gap, so undo took back a
|
|
47
|
+
* few milliseconds of the gesture and looked like it had done nothing.
|
|
48
|
+
*/
|
|
49
|
+
previewOnly?: boolean;
|
|
29
50
|
}
|
|
30
51
|
|
|
31
52
|
function resolveFullAttrName(attr: string, prefixData: boolean | undefined): string {
|
|
@@ -108,7 +129,9 @@ export function useDomEditAttributeCommits({
|
|
|
108
129
|
if (!domEditSelection) return;
|
|
109
130
|
const iframe = previewIframeRef.current;
|
|
110
131
|
const fullAttr = resolveFullAttrName(attr, true);
|
|
111
|
-
const commitKey =
|
|
132
|
+
const commitKey =
|
|
133
|
+
options.coalesce?.key ??
|
|
134
|
+
`${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`;
|
|
112
135
|
const isLatestCommit = bumpDomEditCommitMapVersion(
|
|
113
136
|
domAttributeCommitVersionRef.current,
|
|
114
137
|
commitKey,
|
|
@@ -134,12 +157,15 @@ export function useDomEditAttributeCommits({
|
|
|
134
157
|
const nextValue = value === null || value === "" ? null : value;
|
|
135
158
|
setOrRemovePreviewAttribute(editedElement, fullAttr, nextValue);
|
|
136
159
|
},
|
|
137
|
-
persist:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
160
|
+
persist: options.previewOnly
|
|
161
|
+
? async () => {}
|
|
162
|
+
: () =>
|
|
163
|
+
persistDomEditOperations(domEditSelection, [op], {
|
|
164
|
+
label: options.label,
|
|
165
|
+
coalesceKey: commitKey,
|
|
166
|
+
...(options.coalesce ? { coalesceMs: options.coalesce.ms } : {}),
|
|
167
|
+
skipRefresh: options.skipRefresh,
|
|
168
|
+
}),
|
|
143
169
|
shouldRevert: () => isLatestCommit(),
|
|
144
170
|
revert: () => {
|
|
145
171
|
if (!editedElement) return;
|
|
@@ -147,7 +173,19 @@ export function useDomEditAttributeCommits({
|
|
|
147
173
|
},
|
|
148
174
|
onError: (error) => reportDomEditPersistFailure(domEditSelection, [op], error, showToast),
|
|
149
175
|
shouldResync: () => isLatestCommit() && !!options.refreshAfter,
|
|
150
|
-
resync: () =>
|
|
176
|
+
resync: () => {
|
|
177
|
+
refreshDomEditSelectionFromPreview(domEditSelection);
|
|
178
|
+
// The player store keeps its own copy of each element's attributes, and
|
|
179
|
+
// that copy is what the timeline's automation lanes draw from. Nothing
|
|
180
|
+
// else refreshes it: a commit patches the preview document and the file,
|
|
181
|
+
// and resyncs the dom-edit SELECTION for the panel. So every writer that
|
|
182
|
+
// did not also update the store by hand — the FX panel's automate and
|
|
183
|
+
// un-automate buttons, the keyboard Delete, a paste — changed the file and
|
|
184
|
+
// the audio while the lane went on drawing what it had, until a reload.
|
|
185
|
+
// One sink here rather than a sync in each writer, because three of them
|
|
186
|
+
// shipped without one.
|
|
187
|
+
syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
|
|
188
|
+
},
|
|
151
189
|
onSettled: options.onSettled,
|
|
152
190
|
});
|
|
153
191
|
},
|
|
@@ -273,12 +311,41 @@ export function useDomEditAttributeCommits({
|
|
|
273
311
|
);
|
|
274
312
|
|
|
275
313
|
const handleDomAttributeLiveCommit = useCallback(
|
|
276
|
-
async (
|
|
314
|
+
async (
|
|
315
|
+
attr: string,
|
|
316
|
+
value: string | null,
|
|
317
|
+
onSettled?: (ok: boolean) => void,
|
|
318
|
+
live?: { coalesce?: { key: string; ms: number }; previewOnly?: boolean },
|
|
319
|
+
) => {
|
|
277
320
|
await commitDataAttribute(attr, value, {
|
|
278
321
|
label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
|
|
279
322
|
coalescePrefix: "attr-live",
|
|
280
323
|
skipRefresh: true,
|
|
281
324
|
onSettled,
|
|
325
|
+
...(live?.coalesce ? { coalesce: live.coalesce } : {}),
|
|
326
|
+
...(live?.previewOnly ? { previewOnly: true } : {}),
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
[commitDataAttribute],
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Persist without reloading the preview, but re-read the selection afterwards.
|
|
334
|
+
*
|
|
335
|
+
* For attributes the runtime applies to the live graph itself — an audio FX
|
|
336
|
+
* chain, its automation — a reload would only interrupt playback to reach the
|
|
337
|
+
* state the preview already has. The resync is still needed: without it the
|
|
338
|
+
* panel keeps reading the selection snapshot it was built with, so a second
|
|
339
|
+
* edit computes from a pre-edit value and appears to do nothing.
|
|
340
|
+
*/
|
|
341
|
+
const handleDomAttributeQuietCommit = useCallback(
|
|
342
|
+
async (attr: string, value: string | null, coalesce?: { key: string; ms: number }) => {
|
|
343
|
+
await commitDataAttribute(attr, value, {
|
|
344
|
+
label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
|
|
345
|
+
coalescePrefix: "attr-quiet",
|
|
346
|
+
skipRefresh: true,
|
|
347
|
+
refreshAfter: true,
|
|
348
|
+
...(coalesce ? { coalesce } : {}),
|
|
282
349
|
});
|
|
283
350
|
},
|
|
284
351
|
[commitDataAttribute],
|
|
@@ -327,7 +394,19 @@ export function useDomEditAttributeCommits({
|
|
|
327
394
|
},
|
|
328
395
|
onError: (error) => reportDomEditPersistFailure(domEditSelection, [op], error, showToast),
|
|
329
396
|
shouldResync: () => isLatestCommit(),
|
|
330
|
-
resync: () =>
|
|
397
|
+
resync: () => {
|
|
398
|
+
refreshDomEditSelectionFromPreview(domEditSelection);
|
|
399
|
+
// The player store keeps its own copy of each element's attributes, and
|
|
400
|
+
// that copy is what the timeline's automation lanes draw from. Nothing
|
|
401
|
+
// else refreshes it: a commit patches the preview document and the file,
|
|
402
|
+
// and resyncs the dom-edit SELECTION for the panel. So every writer that
|
|
403
|
+
// did not also update the store by hand — the FX panel's automate and
|
|
404
|
+
// un-automate buttons, the keyboard Delete, a paste — changed the file and
|
|
405
|
+
// the audio while the lane went on drawing what it had, until a reload.
|
|
406
|
+
// One sink here rather than a sync in each writer, because three of them
|
|
407
|
+
// shipped without one.
|
|
408
|
+
syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
|
|
409
|
+
},
|
|
331
410
|
});
|
|
332
411
|
},
|
|
333
412
|
[
|
|
@@ -343,6 +422,7 @@ export function useDomEditAttributeCommits({
|
|
|
343
422
|
return {
|
|
344
423
|
handleDomAttributeCommit,
|
|
345
424
|
handleDomAttributeLiveCommit,
|
|
425
|
+
handleDomAttributeQuietCommit,
|
|
346
426
|
handleDomHtmlAttributeCommit,
|
|
347
427
|
handleDomAttributesCommit,
|
|
348
428
|
};
|
|
@@ -1338,6 +1338,44 @@ describe("useDomEditCommits attribute persist handling", () => {
|
|
|
1338
1338
|
}
|
|
1339
1339
|
});
|
|
1340
1340
|
|
|
1341
|
+
it("applies a preview-only write without persisting it", async () => {
|
|
1342
|
+
// What a drag needs from every pointermove: the preview and the audio graph
|
|
1343
|
+
// follow, the file does not. Persisting each move filled the undo stack with
|
|
1344
|
+
// fragments of one gesture — and because those writes race, a follow-up's
|
|
1345
|
+
// "before" often was not the previous entry's "after", so history refused to
|
|
1346
|
+
// coalesce them and undo took back a few milliseconds of the drag.
|
|
1347
|
+
const fetchSpy = stubPatchFetch({ ok: true, changed: true, matched: true });
|
|
1348
|
+
const { iframe, element } = createPreviewElement();
|
|
1349
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
1350
|
+
|
|
1351
|
+
try {
|
|
1352
|
+
await act(async () => {
|
|
1353
|
+
await rendered.hook.handleDomAttributeLiveCommit("volume", "0.7", undefined, {
|
|
1354
|
+
previewOnly: true,
|
|
1355
|
+
});
|
|
1356
|
+
});
|
|
1357
|
+
expect(element.getAttribute("data-volume")).toBe("0.7");
|
|
1358
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
1359
|
+
} finally {
|
|
1360
|
+
rendered.cleanup();
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it("still persists a live write that does not ask to be preview-only", async () => {
|
|
1365
|
+
const fetchSpy = stubPatchFetch({ ok: true, changed: true, matched: true });
|
|
1366
|
+
const { iframe, element } = createPreviewElement();
|
|
1367
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
1368
|
+
|
|
1369
|
+
try {
|
|
1370
|
+
await act(async () => {
|
|
1371
|
+
await rendered.hook.handleDomAttributeLiveCommit("volume", "0.7");
|
|
1372
|
+
});
|
|
1373
|
+
expect(fetchSpy).toHaveBeenCalled();
|
|
1374
|
+
} finally {
|
|
1375
|
+
rendered.cleanup();
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1341
1379
|
it("keeps a data-attribute commit on success", async () => {
|
|
1342
1380
|
stubPatchFetch({
|
|
1343
1381
|
ok: true,
|
|
@@ -375,6 +375,7 @@ export function useDomEditCommits({
|
|
|
375
375
|
handleDomStyleCommit,
|
|
376
376
|
handleDomAttributeCommit,
|
|
377
377
|
handleDomAttributeLiveCommit,
|
|
378
|
+
handleDomAttributeQuietCommit,
|
|
378
379
|
handleDomHtmlAttributeCommit,
|
|
379
380
|
handleDomAttributesCommit,
|
|
380
381
|
handleDomTextCommit,
|
|
@@ -439,6 +440,7 @@ export function useDomEditCommits({
|
|
|
439
440
|
handleDomStyleCommit,
|
|
440
441
|
handleDomAttributeCommit,
|
|
441
442
|
handleDomAttributeLiveCommit,
|
|
443
|
+
handleDomAttributeQuietCommit,
|
|
442
444
|
handleDomHtmlAttributeCommit,
|
|
443
445
|
handleDomAttributesCommit,
|
|
444
446
|
handleDomTextCommit,
|
|
@@ -228,6 +228,7 @@ export function useDomEditSession({
|
|
|
228
228
|
handleDomStyleCommit,
|
|
229
229
|
handleDomAttributeCommit,
|
|
230
230
|
handleDomAttributeLiveCommit,
|
|
231
|
+
handleDomAttributeQuietCommit,
|
|
231
232
|
handleDomHtmlAttributeCommit,
|
|
232
233
|
handleDomAttributesCommit,
|
|
233
234
|
handleDomTextCommit,
|
|
@@ -493,6 +494,7 @@ export function useDomEditSession({
|
|
|
493
494
|
handleDomStyleCommit,
|
|
494
495
|
handleDomAttributeCommit,
|
|
495
496
|
handleDomAttributeLiveCommit,
|
|
497
|
+
handleDomAttributeQuietCommit,
|
|
496
498
|
handleDomHtmlAttributeCommit,
|
|
497
499
|
handleDomAttributesCommit,
|
|
498
500
|
handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
|
|
@@ -173,6 +173,7 @@ export function useDomEditTextCommits({
|
|
|
173
173
|
const {
|
|
174
174
|
handleDomAttributeCommit,
|
|
175
175
|
handleDomAttributeLiveCommit,
|
|
176
|
+
handleDomAttributeQuietCommit,
|
|
176
177
|
handleDomHtmlAttributeCommit,
|
|
177
178
|
handleDomAttributesCommit,
|
|
178
179
|
} = useDomEditAttributeCommits({
|
|
@@ -572,6 +573,7 @@ export function useDomEditTextCommits({
|
|
|
572
573
|
handleDomStyleCommit,
|
|
573
574
|
handleDomAttributeCommit,
|
|
574
575
|
handleDomAttributeLiveCommit,
|
|
576
|
+
handleDomAttributeQuietCommit,
|
|
575
577
|
handleDomHtmlAttributeCommit,
|
|
576
578
|
handleDomAttributesCommit,
|
|
577
579
|
handleDomTextCommit,
|
|
@@ -142,6 +142,9 @@ export function useElementLifecycleOps({
|
|
|
142
142
|
kind: "timeline",
|
|
143
143
|
files: { [targetPath]: patchedContent },
|
|
144
144
|
readFile: async () => originalContent,
|
|
145
|
+
// remove-element already wrote the removal, so disk holds THAT — not
|
|
146
|
+
// the content read at the top. Undo still goes back to the original.
|
|
147
|
+
diskContent: { [targetPath]: patchedContent },
|
|
145
148
|
writeFile: writeProjectFile,
|
|
146
149
|
recordEdit: editHistory.recordEdit,
|
|
147
150
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The playhead in composition seconds, live while the transport runs.
|
|
3
|
+
*
|
|
4
|
+
* The RAF loop deliberately does not push every frame through the store — it
|
|
5
|
+
* notifies `liveTime` instead, so the playhead can move without re-rendering the
|
|
6
|
+
* app. A panel that wants to follow it therefore has to subscribe itself, and
|
|
7
|
+
* throttle: 30 Hz reads as continuous and costs an order of magnitude less than a
|
|
8
|
+
* render per frame.
|
|
9
|
+
*
|
|
10
|
+
* Paused, the store is the truth — a seek or a scrub lands there — so this returns
|
|
11
|
+
* that instead, which is what lets a readout follow the playhead while it is being
|
|
12
|
+
* dragged as well as while it is playing.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef, useState } from "react";
|
|
15
|
+
import { liveTime, usePlayerStore } from "../player";
|
|
16
|
+
|
|
17
|
+
/** Long enough to be much cheaper than a frame, short enough to read as motion. */
|
|
18
|
+
const THROTTLE_MS = 33;
|
|
19
|
+
|
|
20
|
+
export function useLivePlayheadTime(): number {
|
|
21
|
+
const storeTime = usePlayerStore((s) => s.currentTime);
|
|
22
|
+
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
23
|
+
const liveRef = useRef(storeTime);
|
|
24
|
+
const [, forceRender] = useState(0);
|
|
25
|
+
|
|
26
|
+
// Paused, the ref tracks the store so the first frame of playback is never a
|
|
27
|
+
// stale value from the last time the transport ran.
|
|
28
|
+
if (!isPlaying) liveRef.current = storeTime;
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!isPlaying) return;
|
|
32
|
+
let timerId: ReturnType<typeof setTimeout> | 0 = 0;
|
|
33
|
+
const unsubscribe = liveTime.subscribe((t) => {
|
|
34
|
+
liveRef.current = t;
|
|
35
|
+
if (!timerId) {
|
|
36
|
+
timerId = setTimeout(() => {
|
|
37
|
+
timerId = 0;
|
|
38
|
+
forceRender((v) => v + 1);
|
|
39
|
+
}, THROTTLE_MS);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return () => {
|
|
43
|
+
unsubscribe();
|
|
44
|
+
if (timerId) clearTimeout(timerId);
|
|
45
|
+
};
|
|
46
|
+
}, [isPlaying]);
|
|
47
|
+
|
|
48
|
+
return isPlaying ? liveRef.current : storeTime;
|
|
49
|
+
}
|