@hyperframes/studio 0.8.58 → 0.8.60
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-zGyOcwwh.js → hyperframes-player-Di7GyCOP.js} +3 -3
- package/dist/assets/{index-DKJpHYYd.css → index-CBKb_0TI.css} +1 -1
- package/dist/assets/{index-d8ferjRc.js → index-Dpq2lkOm.js} +190 -190
- package/dist/assets/{index-ZJaRCDYV.js → index-Dx_pt9m4.js} +1 -1
- package/dist/assets/{index-BcE2OSVA.js → index-jAjhQTrB.js} +1 -1
- package/dist/index.d.ts +549 -13
- package/dist/index.html +2 -2
- package/dist/index.js +23133 -21941
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/StudioHeader.tsx +2 -13
- package/src/components/TimelineToolbar.test.tsx +42 -0
- package/src/components/TimelineToolbar.tsx +22 -8
- package/src/components/editor/audioInspectorInput.test.ts +55 -0
- package/src/components/editor/audioInspectorInput.ts +55 -0
- package/src/components/editor/propertyPanelFill.tsx +3 -2
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
- package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
- package/src/components/editor/useColorGradingController.ts +2 -6
- package/src/components/icons/AudioMetersIcon.tsx +16 -0
- package/src/components/icons/InspectorIcon.tsx +18 -0
- package/src/components/icons/ReverseGradientIcon.tsx +23 -0
- package/src/components/icons/RippleEditIcon.tsx +19 -0
- package/src/components/nle/AudioMeterStrip.test.tsx +395 -0
- package/src/components/nle/AudioMeterStrip.tsx +430 -0
- package/src/components/nle/TimelinePane.tsx +29 -23
- package/src/components/ui/Menu.test.tsx +3 -4
- package/src/components/ui/Menu.tsx +18 -5
- package/src/components/ui/Select.tsx +7 -1
- package/src/components/ui/Tooltip.tsx +6 -1
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +16 -0
- package/src/player/components/AudioWaveform.tsx +16 -7
- package/src/player/components/ImageThumbnail.tsx +3 -5
- package/src/player/components/LayerDisclosureRow.tsx +9 -4
- package/src/player/components/PlayheadIndicator.tsx +3 -3
- package/src/player/components/Timeline.test.ts +22 -31
- package/src/player/components/Timeline.tsx +34 -7
- package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLane.tsx +5 -5
- package/src/player/components/TimelineCanvas.tsx +28 -71
- package/src/player/components/TimelineClip.test.tsx +41 -4
- package/src/player/components/TimelineClip.tsx +25 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.tsx +2 -2
- package/src/player/components/TimelineClipFades.test.tsx +171 -0
- package/src/player/components/TimelineClipFades.tsx +302 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
- package/src/player/components/TimelineEmptyState.tsx +1 -1
- package/src/player/components/TimelineFxButton.tsx +10 -8
- package/src/player/components/TimelineGroupHeader.tsx +18 -9
- package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +14 -0
- package/src/player/components/TimelineOverlays.tsx +164 -165
- package/src/player/components/TimelineParts.test.tsx +101 -0
- package/src/player/components/TimelineParts.tsx +74 -0
- package/src/player/components/TimelinePartsParity.test.tsx +58 -0
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +25 -0
- package/src/player/components/TimelineRazorInteraction.tsx +2 -2
- package/src/player/components/TimelineRuler.tsx +3 -2
- package/src/player/components/TimelineRulerPart.tsx +22 -0
- package/src/player/components/TimelineShortcutHint.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +4 -6
- package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
- package/src/player/components/VideoThumbnail.tsx +3 -5
- package/src/player/components/timelineMotionStyles.test.ts +96 -30
- package/src/player/components/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/timelineTheme.test.ts +14 -0
- package/src/player/components/timelineTheme.ts +12 -11
- package/src/player/components/useTimelineProviderState.tsx +19 -12
- package/src/player/lib/timelineElementHelpers.ts +20 -0
- package/src/player/store/timelineElement.ts +5 -0
- package/src/styles/hex-baseline.json +4 -23
- package/src/styles/studio.css +43 -47
- package/src/styles/theme.css +109 -1
- package/src/styles/theme.test.ts +9 -0
- package/src/utils/audioMeterMath.test.ts +44 -0
- package/src/utils/audioMeterMath.ts +79 -0
- package/src/utils/audioMeterVisibility.ts +14 -0
- package/src/utils/studioHelpers.ts +21 -0
- package/src/utils/studioUiPreferences.test.ts +10 -0
- package/src/utils/studioUiPreferences.ts +5 -0
- package/src/utils/timelineAssetDrop.test.ts +32 -0
- package/src/utils/timelineAssetDrop.ts +6 -1
|
@@ -10,6 +10,7 @@ import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
|
|
10
10
|
import type { ClipContextMenuState, TimelineContextValue } from "./TimelineProvider";
|
|
11
11
|
import {
|
|
12
12
|
buildTimelineMeta,
|
|
13
|
+
resolveMultiDragPreview,
|
|
13
14
|
resolveRenderClipContent,
|
|
14
15
|
resolveResizingElementIds,
|
|
15
16
|
shouldIgnoreTimelinePointerDown,
|
|
@@ -30,7 +31,7 @@ import {
|
|
|
30
31
|
} from "./useTimelineTrackLayout";
|
|
31
32
|
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
|
32
33
|
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
|
33
|
-
import {
|
|
34
|
+
import { TimelineRazorGuideOverlay, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
|
34
35
|
import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
|
|
35
36
|
import {
|
|
36
37
|
getEffectiveTimelineDuration,
|
|
@@ -44,6 +45,8 @@ import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
|
|
|
44
45
|
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
|
45
46
|
import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
|
|
46
47
|
import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
|
|
48
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
49
|
+
import { resolveSnapGuide } from "./timelineSnapping";
|
|
47
50
|
export function useTimelineProviderState({
|
|
48
51
|
onSeek,
|
|
49
52
|
onDrillDown,
|
|
@@ -90,10 +93,10 @@ export function useTimelineProviderState({
|
|
|
90
93
|
onSplitElement: onSplitElementOverride,
|
|
91
94
|
});
|
|
92
95
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
|
96
|
+
const editContext = useTimelineEditContextOptional();
|
|
93
97
|
const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
|
|
94
98
|
useMusicBeatAnalysis();
|
|
95
|
-
const
|
|
96
|
-
const timelineElements = rawElements;
|
|
99
|
+
const timelineElements = usePlayerStore((s) => s.elements);
|
|
97
100
|
const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
|
|
98
101
|
const duration = usePlayerStore((s) => s.duration);
|
|
99
102
|
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
|
@@ -108,7 +111,6 @@ export function useTimelineProviderState({
|
|
|
108
111
|
);
|
|
109
112
|
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
|
|
110
113
|
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
|
111
|
-
const contentGutter = labelMode ? GUTTER : 0;
|
|
112
114
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
113
115
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
114
116
|
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
|
@@ -130,8 +132,8 @@ export function useTimelineProviderState({
|
|
|
130
132
|
}, []);
|
|
131
133
|
const lastScrollLeftRef = useRef(0);
|
|
132
134
|
const effectiveDuration = useMemo(
|
|
133
|
-
() => getEffectiveTimelineDuration(duration,
|
|
134
|
-
[duration,
|
|
135
|
+
() => getEffectiveTimelineDuration(duration, timelineElements),
|
|
136
|
+
[duration, timelineElements],
|
|
135
137
|
);
|
|
136
138
|
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
|
137
139
|
useAutoExpandKeyframedClips(gsapAnimations);
|
|
@@ -406,7 +408,6 @@ export function useTimelineProviderState({
|
|
|
406
408
|
handlePointerCancel,
|
|
407
409
|
} = overlaysProps;
|
|
408
410
|
const { rangeSelection, setRangeSelection } = overlays;
|
|
409
|
-
|
|
410
411
|
const laneGapStrips = useTimelineGapHighlights({
|
|
411
412
|
gapHighlight,
|
|
412
413
|
tracks,
|
|
@@ -416,25 +417,24 @@ export function useTimelineProviderState({
|
|
|
416
417
|
dragActive: draggedClip?.started === true || resizingClip != null,
|
|
417
418
|
displayDuration,
|
|
418
419
|
});
|
|
419
|
-
|
|
420
420
|
const { major, minor, majorTickInterval } = useTimelineTicks(
|
|
421
421
|
displayDuration,
|
|
422
422
|
pps,
|
|
423
423
|
timeDisplayMode,
|
|
424
424
|
timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined,
|
|
425
425
|
);
|
|
426
|
-
|
|
427
426
|
const getPreviewElement = useCallback(
|
|
428
427
|
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
|
|
429
428
|
[resizingClip],
|
|
430
429
|
);
|
|
431
|
-
|
|
430
|
+
const draggedElement = draggedClip?.element ?? null;
|
|
431
|
+
const multiDragPreview = resolveMultiDragPreview(draggedClip, selectedElementIds);
|
|
432
432
|
const canvasProps = {
|
|
433
433
|
major,
|
|
434
434
|
minor,
|
|
435
435
|
pps,
|
|
436
436
|
contentOrigin,
|
|
437
|
-
contentGutter,
|
|
437
|
+
contentGutter: labelMode ? GUTTER : 0,
|
|
438
438
|
trackContentWidth: displayContentWidth,
|
|
439
439
|
totalH: displayLayout.totalH,
|
|
440
440
|
effectiveDuration,
|
|
@@ -501,6 +501,13 @@ export function useTimelineProviderState({
|
|
|
501
501
|
onResizeElement,
|
|
502
502
|
onMoveElement,
|
|
503
503
|
beatDragging,
|
|
504
|
+
draggedElement,
|
|
505
|
+
snapGuide: resolveSnapGuide(draggedClip, resizingClip),
|
|
506
|
+
multiDragPreview,
|
|
507
|
+
onToggleTrackHidden: editContext.onToggleTrackHidden,
|
|
508
|
+
onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
|
|
509
|
+
onRazorSplit: editContext.onRazorSplit,
|
|
510
|
+
onRazorSplitAll: editContext.onRazorSplitAll,
|
|
504
511
|
};
|
|
505
512
|
const timelineRenderClipContent = resolveRenderClipContent(
|
|
506
513
|
timelineFocus.rowVirtualizationActive,
|
|
@@ -559,7 +566,7 @@ export function useTimelineProviderState({
|
|
|
559
566
|
labelColumnWidth: LABEL_COL_W,
|
|
560
567
|
razorGuide:
|
|
561
568
|
activeTool === "razor" && razorGuideX !== null ? (
|
|
562
|
-
<
|
|
569
|
+
<TimelineRazorGuideOverlay x={razorGuideX} />
|
|
563
570
|
) : null,
|
|
564
571
|
});
|
|
565
572
|
const contextValue: TimelineContextValue = {
|
|
@@ -12,6 +12,7 @@ import type { ClipManifestClip } from "./playbackTypes";
|
|
|
12
12
|
import { isFinitePositive } from "./playbackAdapter";
|
|
13
13
|
import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
|
|
14
14
|
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
15
|
+
import { readElementFades } from "@hyperframes/core/audio-fade";
|
|
15
16
|
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
// Layer-reveal lift transparency
|
|
@@ -173,8 +174,27 @@ function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element):
|
|
|
173
174
|
}
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
/** Sets or clears an optional field, so a re-parse after the attribute is removed drops it. */
|
|
178
|
+
function setOptional<K extends keyof TimelineElement>(
|
|
179
|
+
entry: TimelineElement,
|
|
180
|
+
key: K,
|
|
181
|
+
value: TimelineElement[K] | undefined,
|
|
182
|
+
): void {
|
|
183
|
+
if (value === undefined) delete entry[key];
|
|
184
|
+
else entry[key] = value;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `data-has-audio` and the clip-edge fades: what the mixer hears and how it enters and leaves. */
|
|
188
|
+
function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
189
|
+
setOptional(entry, "hasAudio", el.getAttribute("data-has-audio") === "true" ? true : undefined);
|
|
190
|
+
const fades = readElementFades(el);
|
|
191
|
+
setOptional(entry, "fadeIn", fades.fadeIn > 0 ? fades.fadeIn : undefined);
|
|
192
|
+
setOptional(entry, "fadeOut", fades.fadeOut > 0 ? fades.fadeOut : undefined);
|
|
193
|
+
}
|
|
194
|
+
|
|
176
195
|
export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
177
196
|
applyPlaybackMetadataFromElement(entry, el);
|
|
197
|
+
applyAudioMetadataFromElement(entry, el);
|
|
178
198
|
|
|
179
199
|
const mediaEl = resolveMediaElement(el);
|
|
180
200
|
if (!mediaEl) return;
|
|
@@ -54,6 +54,11 @@ export interface TimelineElement {
|
|
|
54
54
|
playbackRate?: number;
|
|
55
55
|
sourceDuration?: number;
|
|
56
56
|
volume?: number;
|
|
57
|
+
/** `data-has-audio="true"` on a video: its sound is mixed, so it takes audio affordances (fades). */
|
|
58
|
+
hasAudio?: boolean;
|
|
59
|
+
/** Clip-edge fades from `data-fade-in` / `data-fade-out`, seconds; absent means none. */
|
|
60
|
+
fadeIn?: number;
|
|
61
|
+
fadeOut?: number;
|
|
57
62
|
/** Verbatim `data-fx-chain` / `data-automation`; see automationLaneData. */
|
|
58
63
|
fxChain?: string;
|
|
59
64
|
automation?: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"total":
|
|
2
|
+
"total": 302,
|
|
3
3
|
"files": {
|
|
4
4
|
"packages/studio/src/captions/components/CaptionOverlay.tsx": 5,
|
|
5
5
|
"packages/studio/src/captions/components/CaptionTimeline.tsx": 11,
|
|
@@ -49,35 +49,16 @@
|
|
|
49
49
|
"packages/studio/src/components/sidebar/AudioRow.tsx": 2,
|
|
50
50
|
"packages/studio/src/components/ui/HyperframesMark.tsx": 4,
|
|
51
51
|
"packages/studio/src/hooks/useInlineTextEdit.ts": 1,
|
|
52
|
-
"packages/studio/src/player/components/AudioWaveform.tsx":
|
|
52
|
+
"packages/studio/src/player/components/AudioWaveform.tsx": 1,
|
|
53
53
|
"packages/studio/src/player/components/BeatStrip.tsx": 6,
|
|
54
54
|
"packages/studio/src/player/components/CompositionThumbnail.tsx": 2,
|
|
55
|
-
"packages/studio/src/player/components/ImageThumbnail.tsx": 6,
|
|
56
|
-
"packages/studio/src/player/components/LayerDisclosureRow.tsx": 3,
|
|
57
55
|
"packages/studio/src/player/components/Player.tsx": 2,
|
|
58
56
|
"packages/studio/src/player/components/PlayerControls.tsx": 1,
|
|
59
|
-
"packages/studio/src/player/components/PlayheadIndicator.tsx": 3,
|
|
60
57
|
"packages/studio/src/player/components/ShortcutsPanel.tsx": 7,
|
|
61
58
|
"packages/studio/src/player/components/SpeedMenu.tsx": 2,
|
|
62
|
-
"packages/studio/src/player/components/TimelineAutomationLane.tsx": 4,
|
|
63
|
-
"packages/studio/src/player/components/TimelineCanvas.tsx": 13,
|
|
64
|
-
"packages/studio/src/player/components/TimelineClipDiamonds.tsx": 2,
|
|
65
|
-
"packages/studio/src/player/components/TimelineDiamondConnectors.tsx": 2,
|
|
66
|
-
"packages/studio/src/player/components/TimelineEmptyState.tsx": 1,
|
|
67
|
-
"packages/studio/src/player/components/TimelineFxButton.tsx": 8,
|
|
68
|
-
"packages/studio/src/player/components/TimelineGroupHeader.tsx": 6,
|
|
69
|
-
"packages/studio/src/player/components/TimelineGroupLaneLabels.tsx": 1,
|
|
70
|
-
"packages/studio/src/player/components/TimelineGroupRow.tsx": 1,
|
|
71
|
-
"packages/studio/src/player/components/TimelineRazorInteraction.tsx": 1,
|
|
72
|
-
"packages/studio/src/player/components/TimelineRuler.tsx": 1,
|
|
73
|
-
"packages/studio/src/player/components/TimelineShortcutHint.tsx": 2,
|
|
74
|
-
"packages/studio/src/player/components/TimelineTrackHeader.tsx": 2,
|
|
75
|
-
"packages/studio/src/player/components/TimelineTrackPlainHeader.tsx": 2,
|
|
76
|
-
"packages/studio/src/player/components/VideoThumbnail.tsx": 6,
|
|
77
|
-
"packages/studio/src/player/components/timelineTheme.ts": 5,
|
|
78
59
|
"packages/studio/src/player/components/trackHeaderLabelRows.tsx": 6,
|
|
79
|
-
"packages/studio/src/styles/studio.css": 63,
|
|
80
60
|
"packages/studio/src/telemetry/client.ts": 1,
|
|
81
|
-
"packages/studio/src/webmcp/webmcpTestUtils.ts": 1
|
|
61
|
+
"packages/studio/src/webmcp/webmcpTestUtils.ts": 1,
|
|
62
|
+
"packages/studio/src/styles/studio.css": 48
|
|
82
63
|
}
|
|
83
64
|
}
|
package/src/styles/studio.css
CHANGED
|
@@ -202,7 +202,10 @@ body {
|
|
|
202
202
|
.timeline-clip {
|
|
203
203
|
background-color: var(--clip-bg);
|
|
204
204
|
border: 1px solid var(--clip-border);
|
|
205
|
-
border-radius:
|
|
205
|
+
border-radius: var(--timeline-clip-radius);
|
|
206
|
+
box-shadow:
|
|
207
|
+
inset 0 1px 0 var(--timeline-clip-highlight),
|
|
208
|
+
var(--timeline-clip-shadow);
|
|
206
209
|
}
|
|
207
210
|
|
|
208
211
|
.timeline-clip::before {
|
|
@@ -227,16 +230,19 @@ body {
|
|
|
227
230
|
accented visual clips — so the audio zone at the bottom is visually separate
|
|
228
231
|
at a glance (the timeline's kinds: visual on top, audio below). */
|
|
229
232
|
.timeline-clip.is-audio {
|
|
230
|
-
background-color:
|
|
231
|
-
border-color:
|
|
233
|
+
background-color: var(--timeline-clip-audio-bg);
|
|
234
|
+
border-color: var(--timeline-clip-audio-border);
|
|
235
|
+
border-radius: var(--timeline-clip-audio-radius);
|
|
232
236
|
}
|
|
233
237
|
|
|
234
238
|
.timeline-clip.is-audio.is-hovered {
|
|
235
|
-
background-color:
|
|
239
|
+
background-color: var(--timeline-clip-audio-bg-hover);
|
|
236
240
|
}
|
|
237
241
|
|
|
238
242
|
.timeline-clip.is-audio.is-dragging {
|
|
239
|
-
|
|
243
|
+
box-shadow:
|
|
244
|
+
inset 0 0 0 999px var(--timeline-clip-audio-bg-dragging),
|
|
245
|
+
0 8px 24px var(--timeline-clip-shadow-dragging);
|
|
240
246
|
}
|
|
241
247
|
|
|
242
248
|
.timeline-clip.is-hovered {
|
|
@@ -248,53 +254,56 @@ body {
|
|
|
248
254
|
}
|
|
249
255
|
|
|
250
256
|
.timeline-clip.is-selected {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
.timeline-clip[data-active].is-selected {
|
|
255
|
-
box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.85);
|
|
257
|
+
outline: 1.5px solid var(--timeline-clip-selection);
|
|
258
|
+
outline-offset: -1.5px;
|
|
256
259
|
}
|
|
257
260
|
|
|
258
|
-
.timeline-clip.is-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
261
|
+
.timeline-clip.is-selected::after {
|
|
262
|
+
content: "";
|
|
263
|
+
position: absolute;
|
|
264
|
+
inset: 0;
|
|
265
|
+
z-index: 4;
|
|
266
|
+
border-radius: inherit;
|
|
267
|
+
box-shadow: inset 0 0 0 1px var(--timeline-clip-selection-highlight);
|
|
268
|
+
pointer-events: none;
|
|
263
269
|
}
|
|
264
270
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
box-shadow:
|
|
269
|
-
0 0 0 1.5px rgba(255, 255, 255, 0.85),
|
|
270
|
-
0 8px 24px rgba(0, 0, 0, 0.4);
|
|
271
|
+
.timeline-clip[data-active].is-selected {
|
|
272
|
+
outline: 1.5px solid var(--timeline-clip-selection);
|
|
273
|
+
outline-offset: -1.5px;
|
|
271
274
|
}
|
|
272
275
|
|
|
273
|
-
.timeline-clip
|
|
276
|
+
.timeline-clip.is-dragging {
|
|
277
|
+
/* Veil is a shadow, not background-color, so drop does not fade the hover fill. */
|
|
274
278
|
box-shadow:
|
|
275
|
-
0 0 0
|
|
276
|
-
0 8px 24px
|
|
279
|
+
inset 0 0 0 999px var(--clip-bg-dragging),
|
|
280
|
+
0 8px 24px var(--timeline-clip-shadow-dragging);
|
|
277
281
|
}
|
|
278
282
|
|
|
279
283
|
.timeline-clip__label {
|
|
280
284
|
position: absolute;
|
|
281
|
-
top:
|
|
282
|
-
left:
|
|
283
|
-
right: 10px;
|
|
285
|
+
top: 5px;
|
|
286
|
+
left: 6px;
|
|
284
287
|
z-index: 6;
|
|
288
|
+
width: fit-content;
|
|
289
|
+
max-width: calc(100% - 22px);
|
|
285
290
|
overflow: hidden;
|
|
286
|
-
|
|
291
|
+
padding: 1px 5px;
|
|
292
|
+
border-radius: 4px;
|
|
293
|
+
background: var(--timeline-clip-chip-bg);
|
|
294
|
+
color: var(--timeline-clip-chip-text);
|
|
287
295
|
font-size: 10px;
|
|
288
296
|
font-weight: 500;
|
|
289
|
-
line-height:
|
|
297
|
+
line-height: 16px;
|
|
290
298
|
pointer-events: none;
|
|
291
|
-
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
|
|
292
299
|
text-overflow: ellipsis;
|
|
293
300
|
white-space: nowrap;
|
|
294
301
|
}
|
|
295
302
|
|
|
296
|
-
.timeline-clip
|
|
297
|
-
|
|
303
|
+
.timeline-clip.is-audio .timeline-clip__label {
|
|
304
|
+
top: 11px;
|
|
305
|
+
left: 9px;
|
|
306
|
+
background: var(--timeline-clip-chip-audio-bg);
|
|
298
307
|
}
|
|
299
308
|
|
|
300
309
|
.timeline-clip__timecode {
|
|
@@ -302,7 +311,7 @@ body {
|
|
|
302
311
|
bottom: 6px;
|
|
303
312
|
left: 12px;
|
|
304
313
|
overflow: hidden;
|
|
305
|
-
color:
|
|
314
|
+
color: var(--timeline-tick-text);
|
|
306
315
|
font-family: var(--font-mono);
|
|
307
316
|
font-size: 9px;
|
|
308
317
|
font-variant-numeric: tabular-nums;
|
|
@@ -313,20 +322,7 @@ body {
|
|
|
313
322
|
|
|
314
323
|
@media (prefers-reduced-motion: no-preference) {
|
|
315
324
|
.timeline-clip {
|
|
316
|
-
transition:
|
|
317
|
-
background-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
318
|
-
border-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
319
|
-
box-shadow 200ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
320
|
-
color 200ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
321
|
-
opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
.timeline-clip::before {
|
|
325
|
-
transition: opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
.timeline-clip__label {
|
|
329
|
-
transition: color 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
325
|
+
transition: background-color 120ms ease-out;
|
|
330
326
|
}
|
|
331
327
|
|
|
332
328
|
.timeline-clip__handle-bar {
|
package/src/styles/theme.css
CHANGED
|
@@ -62,6 +62,42 @@
|
|
|
62
62
|
--timeline-tick-major: rgba(255, 255, 255, 0.1);
|
|
63
63
|
--timeline-tick-minor: rgba(255, 255, 255, 0.06);
|
|
64
64
|
--timeline-clip-bg: rgba(255, 255, 255, 0.12);
|
|
65
|
+
--timeline-clip-label-active: rgba(232, 255, 247, 0.95);
|
|
66
|
+
--timeline-clip-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
|
|
67
|
+
--timeline-clip-audio-bg: rgba(167, 139, 250, 0.16);
|
|
68
|
+
--timeline-clip-audio-border: rgba(167, 139, 250, 0.4);
|
|
69
|
+
--timeline-clip-audio-bg-hover: rgba(167, 139, 250, 0.24);
|
|
70
|
+
--timeline-clip-audio-bg-dragging: rgba(60, 52, 84, 0.96);
|
|
71
|
+
--timeline-clip-selection: rgba(255, 255, 255, 0.85);
|
|
72
|
+
--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
|
|
73
|
+
--timeline-clip-radius: 8px;
|
|
74
|
+
--timeline-clip-audio-radius: 21px;
|
|
75
|
+
--timeline-clip-highlight: rgb(255 255 255 / 0.08);
|
|
76
|
+
--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
|
|
77
|
+
--timeline-clip-chip-bg: rgba(15, 22, 18, 0.73);
|
|
78
|
+
--timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7);
|
|
79
|
+
--timeline-clip-chip-text: #ffffff;
|
|
80
|
+
--timeline-shortcut-bg: rgba(17, 23, 35, 0.84);
|
|
81
|
+
--timeline-thumbnail-shimmer: linear-gradient(
|
|
82
|
+
90deg,
|
|
83
|
+
rgba(255, 255, 255, 0.02) 0%,
|
|
84
|
+
rgba(255, 255, 255, 0.05) 50%,
|
|
85
|
+
rgba(255, 255, 255, 0.02) 100%
|
|
86
|
+
);
|
|
87
|
+
--timeline-thumbnail-label-gradient: linear-gradient(
|
|
88
|
+
to top,
|
|
89
|
+
rgba(0, 0, 0, 0.85) 0%,
|
|
90
|
+
rgba(0, 0, 0, 0.4) 60%,
|
|
91
|
+
transparent 100%
|
|
92
|
+
);
|
|
93
|
+
--timeline-thumbnail-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
94
|
+
--timeline-waveform-bar-rgb: 75, 163, 210;
|
|
95
|
+
--timeline-waveform-error: repeating-linear-gradient(
|
|
96
|
+
90deg,
|
|
97
|
+
rgba(75, 163, 210, 0.35) 0 2px,
|
|
98
|
+
transparent 2px 5px
|
|
99
|
+
);
|
|
100
|
+
--timeline-waveform-label-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
|
|
65
101
|
--timeline-clip-bg-active: rgba(60, 230, 172, 0.2);
|
|
66
102
|
--timeline-clip-bg-hover: rgba(255, 255, 255, 0.16);
|
|
67
103
|
--timeline-clip-bg-dragging: rgba(38, 42, 52, 0.96);
|
|
@@ -69,11 +105,49 @@
|
|
|
69
105
|
--timeline-clip-border-hover: rgba(60, 230, 172, 0.75);
|
|
70
106
|
--timeline-clip-border-active: rgba(60, 230, 172, 0.55);
|
|
71
107
|
--timeline-handle: rgba(255, 255, 255, 0.55);
|
|
108
|
+
/* Audio clip fades: the shaded wedge over the faded gain, and the corner dots. */
|
|
109
|
+
--timeline-fade-shade: rgba(0, 0, 0, 0.38);
|
|
110
|
+
--timeline-fade-dot-active: rgba(255, 255, 255, 1);
|
|
111
|
+
--timeline-fade-dot-ring: rgba(0, 0, 0, 0.55);
|
|
72
112
|
--timeline-resize-seam: rgba(255, 255, 255, 0.12);
|
|
73
113
|
--timeline-resize-active: rgba(255, 255, 255, 0.24);
|
|
74
114
|
--timeline-track-clip-fill: rgba(255, 255, 255, 0.055);
|
|
75
115
|
--timeline-track-clip-active: rgba(60, 230, 172, 0.16);
|
|
76
116
|
--timeline-track-label: rgba(255, 255, 255, 0.5);
|
|
117
|
+
--timeline-border-strong: rgba(255, 255, 255, 0.2);
|
|
118
|
+
--timeline-group-member-tint: rgba(255, 255, 255, 0.035);
|
|
119
|
+
--timeline-overlay-text: rgba(255, 255, 255, 0.8);
|
|
120
|
+
--timeline-accent: #3ce6ac;
|
|
121
|
+
--timeline-accent-soft: rgba(60, 230, 172, 0.18);
|
|
122
|
+
--timeline-accent-faint: rgba(60, 230, 172, 0.055);
|
|
123
|
+
--timeline-accent-fill: rgba(60, 230, 172, 0.1);
|
|
124
|
+
--timeline-accent-glow: rgba(60, 230, 172, 0.5);
|
|
125
|
+
--timeline-accent-border: rgba(60, 230, 172, 0.7);
|
|
126
|
+
--timeline-accent-rail: rgba(60, 230, 172, 0.35);
|
|
127
|
+
--timeline-accent-hover: rgba(60, 230, 172, 0.25);
|
|
128
|
+
--timeline-accent-button-bg: rgba(60, 230, 172, 0.15);
|
|
129
|
+
--timeline-danger: rgba(239, 68, 68, 0.7);
|
|
130
|
+
--timeline-info-bg: rgba(59, 130, 246, 0.12);
|
|
131
|
+
--timeline-info-border: rgba(59, 130, 246, 0.4);
|
|
132
|
+
--timeline-dialog-bg: #1b1b1f;
|
|
133
|
+
--timeline-diamond-bg: #171717;
|
|
134
|
+
--timeline-diamond-border: rgba(255, 255, 255, 0.14);
|
|
135
|
+
--timeline-text-solid: #ffffff;
|
|
136
|
+
--timeline-text-faint: rgba(255, 255, 255, 0.35);
|
|
137
|
+
--timeline-text-dim: rgba(255, 255, 255, 0.4);
|
|
138
|
+
--timeline-text-soft: rgba(255, 255, 255, 0.75);
|
|
139
|
+
--timeline-text-bright: rgba(255, 255, 255, 0.7);
|
|
140
|
+
--timeline-snap-guide: rgba(255, 255, 255, 0.6);
|
|
141
|
+
--timeline-overlay-bg: rgba(0, 0, 0, 0.8);
|
|
142
|
+
--timeline-overlay-bg-strong: rgba(0, 0, 0, 0.85);
|
|
143
|
+
--timeline-input-bg: rgba(0, 0, 0, 0.3);
|
|
144
|
+
--timeline-automation-rail: rgba(255, 255, 255, 0.08);
|
|
145
|
+
--timeline-automation-point-muted: rgba(0, 0, 0, 0.5);
|
|
146
|
+
--timeline-diamond-muted: #a3a3a3;
|
|
147
|
+
--timeline-beat: rgb(34, 197, 94);
|
|
148
|
+
--timeline-clip-shadow-dragging: rgba(0, 0, 0, 0.4);
|
|
149
|
+
--timeline-playhead-glow: rgba(60, 230, 172, 0.14);
|
|
150
|
+
--timeline-playhead-shadow: rgba(0, 0, 0, 0.55);
|
|
77
151
|
|
|
78
152
|
/* -------------------------------------------------------------- geometry */
|
|
79
153
|
|
|
@@ -142,7 +216,14 @@
|
|
|
142
216
|
--duration-press: 120ms;
|
|
143
217
|
--duration-hover: 150ms;
|
|
144
218
|
--duration-expand: 160ms;
|
|
145
|
-
|
|
219
|
+
/* Menus, popovers, and dropdowns. Open is ease-out; close is shorter and ease-in. */
|
|
220
|
+
--duration-open: 120ms;
|
|
221
|
+
--duration-close: 90ms;
|
|
222
|
+
/* Tooltips use the same enter shape on a shorter open. */
|
|
223
|
+
--duration-tooltip: 100ms;
|
|
224
|
+
/* Visible on the first frame. Never enter from scale 0 or opacity 0. */
|
|
225
|
+
--popup-enter-opacity: 0.8;
|
|
226
|
+
--popup-enter-scale: 0.97;
|
|
146
227
|
|
|
147
228
|
/* ------------------------------------------------------------------ icon */
|
|
148
229
|
|
|
@@ -260,6 +341,33 @@
|
|
|
260
341
|
--color-white: #fff;
|
|
261
342
|
}
|
|
262
343
|
|
|
344
|
+
/* One paper block. A host sets data-theme="paper" on the document element. */
|
|
345
|
+
:root[data-theme="paper"] {
|
|
346
|
+
--timeline-shell-bg: #f4f6f3;
|
|
347
|
+
--timeline-row-bg: #edf0ed;
|
|
348
|
+
--timeline-clip-bg: #c9d1cb;
|
|
349
|
+
--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.12);
|
|
350
|
+
--timeline-clip-highlight: rgb(255 255 255 / 0.08);
|
|
351
|
+
--timeline-clip-selection: #0f766e;
|
|
352
|
+
--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
|
|
353
|
+
--timeline-clip-chip-bg: rgba(15, 22, 18, 0.73);
|
|
354
|
+
--timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7);
|
|
355
|
+
--timeline-clip-chip-text: #ffffff;
|
|
356
|
+
--timeline-clip-audio-bg: rgb(150, 128, 180);
|
|
357
|
+
--timeline-clip-audio-bg-hover: rgb(135, 109, 170);
|
|
358
|
+
--timeline-clip-audio-bg-dragging: rgb(232, 224, 242);
|
|
359
|
+
--timeline-clip-audio-border: rgba(113, 80, 155, 0.45);
|
|
360
|
+
--timeline-clip-audio-muted-bg: rgb(156, 161, 157);
|
|
361
|
+
--timeline-audio-link: #626d66;
|
|
362
|
+
--timeline-waveform-bar-rgb: 20, 12, 32;
|
|
363
|
+
--timeline-waveform-muted-rgb: 24, 28, 26;
|
|
364
|
+
--timeline-waveform-error: repeating-linear-gradient(
|
|
365
|
+
90deg,
|
|
366
|
+
rgba(20, 12, 32, 0.35) 0 2px,
|
|
367
|
+
transparent 2px 5px
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
263
371
|
/* Motion durations as utilities, since Tailwind has no `--duration-*`
|
|
264
372
|
* namespace. Each one zeroes itself under `prefers-reduced-motion`, so a
|
|
265
373
|
* caller cannot forget the reduced-motion case. */
|
package/src/styles/theme.test.ts
CHANGED
|
@@ -109,6 +109,15 @@ describe("studio theme", () => {
|
|
|
109
109
|
expect(vars.get("--text-lg")).toBe("1.125rem");
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
it("opens menus from a visible shape and closes them faster", () => {
|
|
113
|
+
expect(declaredValue("--duration-open")).toBe("120ms");
|
|
114
|
+
expect(declaredValue("--duration-close")).toBe("90ms");
|
|
115
|
+
expect(declaredValue("--duration-tooltip")).toBe("100ms");
|
|
116
|
+
expect(declaredValue("--popup-enter-opacity")).toBe("0.8");
|
|
117
|
+
expect(declaredValue("--popup-enter-scale")).toBe("0.97");
|
|
118
|
+
expect(declaredValue("--duration-hover")).toBe("150ms");
|
|
119
|
+
});
|
|
120
|
+
|
|
112
121
|
it("compiles a motion-duration utility with a reduced-motion variant", async () => {
|
|
113
122
|
const css = await build("studio.css", ["duration-press", "duration-open"]);
|
|
114
123
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
levelToFraction,
|
|
4
|
+
markFraction,
|
|
5
|
+
SILENT_CHANNEL,
|
|
6
|
+
stepChannel,
|
|
7
|
+
stepPair,
|
|
8
|
+
} from "./audioMeterMath";
|
|
9
|
+
|
|
10
|
+
describe("levelToFraction", () => {
|
|
11
|
+
it("puts each dB mark on its equally spaced stop", () => {
|
|
12
|
+
[1, 0.8, 0.6, 0.4, 0.2].forEach((f, i) =>
|
|
13
|
+
expect(markFraction([0, -3, -6, -12, -24][i]!)).toBeCloseTo(f, 6),
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
it("clamps silence and overs", () => {
|
|
17
|
+
expect(levelToFraction(0)).toBe(0);
|
|
18
|
+
expect(levelToFraction(4)).toBe(1);
|
|
19
|
+
});
|
|
20
|
+
it("is monotonic between marks", () => {
|
|
21
|
+
expect(levelToFraction(0.5)).toBeGreaterThan(markFraction(-12));
|
|
22
|
+
expect(levelToFraction(0.5)).toBeLessThan(markFraction(-3));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("stepChannel", () => {
|
|
27
|
+
it("attacks instantly, holds the peak, then falls", () => {
|
|
28
|
+
let ch = stepChannel(SILENT_CHANNEL, 1, 0, 16);
|
|
29
|
+
expect(ch.level).toBe(1);
|
|
30
|
+
ch = stepChannel(ch, 0, 500, 16);
|
|
31
|
+
expect(ch.peak).toBe(1);
|
|
32
|
+
expect(ch.level).toBeLessThan(1);
|
|
33
|
+
ch = stepChannel(ch, 0, 2000, 500);
|
|
34
|
+
expect(ch.peak).toBeLessThan(1);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("stepPair", () => {
|
|
39
|
+
it("returns the same pair while silent at rest, and moves on sound", () => {
|
|
40
|
+
const rest = stepPair(undefined, undefined, 0, 16);
|
|
41
|
+
expect(stepPair(rest, { l: 0, r: 0 }, 16, 16)).toBe(rest);
|
|
42
|
+
expect(stepPair(rest, { l: 1, r: 0 }, 32, 16)).not.toBe(rest);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { audioDbToGain, audioGainToDb } from "@hyperframes/core/audio-gain";
|
|
3
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
4
|
+
import { isAudioTimelineElement } from "./timelineInspector";
|
|
5
|
+
|
|
6
|
+
/** dB stops of the meter scale, top to bottom; equal spacing between stops. */
|
|
7
|
+
export const METER_DB_MARKS = [0, -3, -6, -12, -24] as const;
|
|
8
|
+
const FLOOR_DB = -60;
|
|
9
|
+
const STOPS = [...METER_DB_MARKS, FLOOR_DB];
|
|
10
|
+
|
|
11
|
+
const FALL_PER_SECOND = 0.9;
|
|
12
|
+
const HOLD_MS = 1200;
|
|
13
|
+
|
|
14
|
+
/** Linear peak (0..1) to a 0..1 bar fraction along the piecewise-linear dB scale. */
|
|
15
|
+
export function levelToFraction(linear: number): number {
|
|
16
|
+
const db = linear > 0 ? audioGainToDb(linear) : FLOOR_DB;
|
|
17
|
+
if (db >= 0) return 1;
|
|
18
|
+
if (db <= FLOOR_DB) return 0;
|
|
19
|
+
const j = STOPS.findIndex((stop) => db >= stop);
|
|
20
|
+
const hi = STOPS[j - 1] as number;
|
|
21
|
+
const lo = STOPS[j] as number;
|
|
22
|
+
return (STOPS.length - 1 - j + (db - lo) / (hi - lo)) / (STOPS.length - 1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Fraction of the bar height at which a dB mark sits, 0 at the bottom. */
|
|
26
|
+
export function markFraction(db: number): number {
|
|
27
|
+
return levelToFraction(audioDbToGain(db));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MeterChannel {
|
|
31
|
+
level: number;
|
|
32
|
+
peak: number;
|
|
33
|
+
peakAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const SILENT_CHANNEL: MeterChannel = { level: 0, peak: 0, peakAt: 0 };
|
|
37
|
+
|
|
38
|
+
/** Instant attack, steady fall; the peak holds for HOLD_MS, then falls with the bar. */
|
|
39
|
+
export function stepChannel(
|
|
40
|
+
prev: MeterChannel,
|
|
41
|
+
linear: number,
|
|
42
|
+
now: number,
|
|
43
|
+
dtMs: number,
|
|
44
|
+
): MeterChannel {
|
|
45
|
+
const fall = (dtMs / 1000) * FALL_PER_SECOND;
|
|
46
|
+
const target = levelToFraction(linear);
|
|
47
|
+
const level = Math.max(target, prev.level - fall);
|
|
48
|
+
if (target >= prev.peak) return { level, peak: target, peakAt: now };
|
|
49
|
+
if (now - prev.peakAt < HOLD_MS) return { level, peak: prev.peak, peakAt: prev.peakAt };
|
|
50
|
+
return { level, peak: Math.max(target, prev.peak - fall), peakAt: prev.peakAt };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type MeterPair = [MeterChannel, MeterChannel];
|
|
54
|
+
|
|
55
|
+
const atRest = (ch: MeterChannel): boolean => ch.level === 0 && ch.peak === 0;
|
|
56
|
+
|
|
57
|
+
/** Steps both channels; returns `prev` itself while silent and at rest, so callers can skip the paint. */
|
|
58
|
+
export function stepPair(
|
|
59
|
+
prev: MeterPair | undefined,
|
|
60
|
+
raw: { l: number; r: number } | undefined,
|
|
61
|
+
now: number,
|
|
62
|
+
dtMs: number,
|
|
63
|
+
): MeterPair {
|
|
64
|
+
const [l, r] = prev ?? [SILENT_CHANNEL, SILENT_CHANNEL];
|
|
65
|
+
const silent = !raw?.l && !raw?.r;
|
|
66
|
+
if (prev && silent && atRest(l) && atRest(r)) return prev;
|
|
67
|
+
return [stepChannel(l, raw?.l ?? 0, now, dtMs), stepChannel(r, raw?.r ?? 0, now, dtMs)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The meters appear only for a project with at least one audio clip or group. */
|
|
71
|
+
function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
|
|
72
|
+
return elements.some((el) => isAudioTimelineElement(el) || el.audioGroup !== undefined);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Scans once per `elements` change, not on every store update (the playhead ticks each frame). */
|
|
76
|
+
export function useProjectHasAudio(): boolean {
|
|
77
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
78
|
+
return useMemo(() => hasProjectAudio(elements), [elements]);
|
|
79
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
|
|
3
|
+
|
|
4
|
+
/** Whether the audio meter strip is shown; persisted, off unless the user showed it. */
|
|
5
|
+
export const useAudioMetersVisible = create<{
|
|
6
|
+
visible: boolean;
|
|
7
|
+
setVisible: (visible: boolean) => void;
|
|
8
|
+
}>((set) => ({
|
|
9
|
+
visible: readStudioUiPreferences().audioMetersVisible ?? false,
|
|
10
|
+
setVisible: (visible) => {
|
|
11
|
+
writeStudioUiPreferences({ audioMetersVisible: visible });
|
|
12
|
+
set({ visible });
|
|
13
|
+
},
|
|
14
|
+
}));
|