@hyperframes/studio 0.8.11 → 0.8.12

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.
Files changed (154) hide show
  1. package/dist/assets/{hyperframes-player-C6J6fsAD.js → hyperframes-player-B243szNa.js} +1 -1
  2. package/dist/assets/{index-BX0piiWc.js → index-BTP86sD-.js} +1 -1
  3. package/dist/assets/index-BZfUJ2He.js +431 -0
  4. package/dist/assets/{index-1vMlv05L.js → index-DQ_lkuqY.js} +1 -1
  5. package/dist/assets/index-yGhfxxoL.css +1 -0
  6. package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
  7. package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
  8. package/dist/index.d.ts +84 -43
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +6431 -5515
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +0 -2
  14. package/src/components/StudioRightPanel.tsx +15 -57
  15. package/src/components/editor/PropertyPanel.test.tsx +101 -3
  16. package/src/components/editor/PropertyPanel.tsx +15 -1
  17. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  18. package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
  19. package/src/components/editor/PropertyPanelFlat.tsx +82 -21
  20. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
  21. package/src/components/editor/TimelineFxPopover.tsx +24 -4
  22. package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
  23. package/src/components/editor/audioFxRevealTarget.ts +113 -0
  24. package/src/components/editor/audioFxSignalPath.test.ts +50 -0
  25. package/src/components/editor/audioFxSignalPath.ts +71 -0
  26. package/src/components/editor/audioFxSummary.test.ts +8 -0
  27. package/src/components/editor/audioFxSummary.ts +28 -16
  28. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
  29. package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
  30. package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
  31. package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
  32. package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
  33. package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
  34. package/src/components/editor/propertyPanelFxControls.tsx +10 -2
  35. package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
  36. package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
  37. package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
  38. package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
  39. package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
  40. package/src/components/editor/propertyPanelFxSection.tsx +63 -4
  41. package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
  42. package/src/components/editor/useAudioFxRevealSection.ts +64 -0
  43. package/src/components/editor/useAuditionTransport.test.ts +36 -0
  44. package/src/components/editor/useAuditionTransport.ts +78 -0
  45. package/src/components/editor/useFxAudition.ts +12 -1
  46. package/src/components/editor/useFxCarve.ts +0 -0
  47. package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
  48. package/src/components/editor/useFxCarveGrouping.ts +139 -4
  49. package/src/components/editor/useFxCarveNodes.ts +153 -0
  50. package/src/components/editor/useFxLevelling.ts +5 -34
  51. package/src/components/nle/NLEContext.tsx +3 -10
  52. package/src/components/nle/PreviewPane.tsx +0 -1
  53. package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
  54. package/src/contexts/DesignPanelInputContext.tsx +6 -5
  55. package/src/contexts/DomEditContext.tsx +10 -3
  56. package/src/contexts/FileManagerContext.tsx +3 -2
  57. package/src/contexts/PanelLayoutContext.tsx +3 -2
  58. package/src/contexts/StudioContext.tsx +7 -3
  59. package/src/contexts/TimelineEditContext.tsx +6 -2
  60. package/src/contexts/VariablePromoteContext.tsx +6 -2
  61. package/src/contexts/ViewModeContext.tsx +2 -2
  62. package/src/hooks/domEditDeleteMembers.ts +34 -0
  63. package/src/hooks/timelineAudioGroupCreate.ts +345 -0
  64. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  65. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  66. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  67. package/src/hooks/timelineEditingHelpers.ts +19 -7
  68. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  69. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  70. package/src/hooks/timelineTrackVisibility.ts +6 -182
  71. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  72. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  73. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  74. package/src/hooks/useDomEditSession.test.tsx +57 -2
  75. package/src/hooks/useDomEditSession.ts +23 -24
  76. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  77. package/src/hooks/useLivePlayheadTime.ts +3 -1
  78. package/src/hooks/useRemoveBackground.ts +70 -0
  79. package/src/hooks/useTimelineEditing.ts +5 -19
  80. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  81. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  82. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  83. package/src/player/components/PlayerControls.tsx +0 -38
  84. package/src/player/components/Timeline.test.ts +40 -1
  85. package/src/player/components/Timeline.tsx +5 -2
  86. package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
  87. package/src/player/components/TimelineAutomationLane.tsx +89 -12
  88. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  89. package/src/player/components/TimelineFxButton.test.tsx +167 -21
  90. package/src/player/components/TimelineFxButton.tsx +156 -31
  91. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  92. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  93. package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
  94. package/src/player/components/TimelineGroupRow.test.tsx +146 -0
  95. package/src/player/components/TimelineGroupRow.tsx +123 -64
  96. package/src/player/components/TimelineLanes.test.tsx +3 -3
  97. package/src/player/components/TimelineLanes.tsx +25 -23
  98. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  99. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  100. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  101. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  102. package/src/player/components/automationLaneData.test.ts +94 -0
  103. package/src/player/components/automationLaneData.ts +23 -0
  104. package/src/player/components/groupAutomationElement.test.ts +51 -0
  105. package/src/player/components/groupAutomationElement.ts +37 -0
  106. package/src/player/components/timelineCallbacks.ts +6 -2
  107. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  108. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  109. package/src/player/components/timelineLayout.ts +0 -1
  110. package/src/player/components/timelineViewModel.ts +30 -1
  111. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  112. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  113. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  114. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  115. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  116. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  117. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  118. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  120. package/src/player/hooks/previewMessageRouter.ts +4 -11
  121. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  122. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  123. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  124. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  126. package/src/player/lib/automationStoreSync.test.ts +26 -0
  127. package/src/player/lib/automationStoreSync.ts +28 -4
  128. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  129. package/src/player/lib/timelineDOM.test.ts +115 -0
  130. package/src/player/lib/timelineDOM.ts +3 -32
  131. package/src/player/lib/timelineElementHelpers.ts +27 -0
  132. package/src/player/lib/timelineGroupInfo.ts +134 -0
  133. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  134. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  135. package/src/player/store/keyframeSlice.ts +79 -5
  136. package/src/player/store/playerStore.ts +37 -47
  137. package/src/player/store/playerStoreDevHandle.ts +22 -0
  138. package/src/player/store/playerStoreSelection.ts +41 -0
  139. package/src/player/store/timelineElement.ts +32 -0
  140. package/src/utils/hmrStableContext.ts +64 -0
  141. package/src/utils/timelineInspector.test.ts +35 -1
  142. package/src/utils/timelineInspector.ts +38 -0
  143. package/dist/assets/index-Ba9zbpT9.css +0 -1
  144. package/dist/assets/index-DoW5v5eS.js +0 -428
  145. package/src/hooks/useAudioSoloBridge.ts +0 -61
  146. package/src/hooks/useGroupLevel.ts +0 -36
  147. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  148. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  149. package/src/player/components/TimelineSoloButton.tsx +0 -32
  150. package/src/player/store/audioSoloSlice.test.ts +0 -113
  151. package/src/player/store/audioSoloSlice.ts +0 -43
  152. package/src/player/store/groupLevels.ts +0 -33
  153. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  154. /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
@@ -1,61 +0,0 @@
1
- import { useEffect, useMemo } from "react";
2
- import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
3
- import { usePlayerStore } from "../player/store/playerStore";
4
- import { getTimelineElementDisplayLabel } from "../player/lib/timelineElementHelpers";
5
-
6
- interface IframeWindow extends Window {
7
- __hf?: { setAudioSolo?: (ids: readonly string[]) => void };
8
- }
9
-
10
- /**
11
- * Pushes the studio's "Hear only this" set into the preview runtime whenever
12
- * it changes. A dedicated push, not a DOM write: solo is session-only and
13
- * must never touch an attribute (design doc §2.2 / the export-safety
14
- * guarantee), so it can't ride `syncTimedElementVisibility`'s attribute-diff
15
- * the way group mute does — see `window.__hf.setAudioSolo`.
16
- */
17
- export function useAudioSoloBridge(previewIframeRef: { current: HTMLIFrameElement | null }): void {
18
- const soloed = usePlayerStore((s) => s.soloed);
19
- useEffect(() => {
20
- const win = previewIframeRef.current?.contentWindow as IframeWindow | null;
21
- win?.__hf?.setAudioSolo?.([...soloed]);
22
- }, [soloed, previewIframeRef]);
23
- }
24
-
25
- /** One soloed id's display label — reads the live preview DOM directly (same
26
- * approach as `patchLiveGroupAttribute`), since solo ids are never anywhere
27
- * but the document's own element ids. A group carries its label on
28
- * `data-label`; anything else falls back to the same label rule the
29
- * timeline itself uses. */
30
- function resolveSoloLabel(doc: Document | null | undefined, id: string): string {
31
- const el = doc?.getElementById(id);
32
- if (!el) return id;
33
- if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
34
- return getTimelineElementDisplayLabel({ id, label: el.getAttribute("data-label") });
35
- }
36
- return getTimelineElementDisplayLabel({
37
- id,
38
- label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
39
- tag: el.tagName,
40
- });
41
- }
42
-
43
- /**
44
- * The transport bar's "Hear only this" banner text — `null` while nothing is
45
- * soloed. One name when exactly one thing is soloed, `"N tracks"` otherwise
46
- * (design doc §2.2's banner rule); "your export is not affected" is fixed
47
- * copy the caller owns, this only resolves the variable half.
48
- */
49
- export function useSoloBannerText(previewIframeRef: {
50
- current: HTMLIFrameElement | null;
51
- }): string | null {
52
- const soloed = usePlayerStore((s) => s.soloed);
53
- return useMemo(() => {
54
- if (soloed.size === 0) return null;
55
- if (soloed.size === 1) {
56
- const doc = previewIframeRef.current?.contentDocument;
57
- return resolveSoloLabel(doc, [...soloed][0]);
58
- }
59
- return `${soloed.size} tracks`;
60
- }, [soloed, previewIframeRef]);
61
- }
@@ -1,36 +0,0 @@
1
- /**
2
- * A group's live meter reading (0..1 RMS-ish level, whether it just clipped),
3
- * or null when the group is idle/unknown — never zero for "not playing yet".
4
- *
5
- * Mirrors useLivePlayheadTime's shape: the runtime posts readings at its own
6
- * cadence (only while playing — see `postGroupLevels` in init.ts), this just
7
- * throttles the re-render, it does not add its own polling loop.
8
- */
9
- import { useEffect, useRef, useState } from "react";
10
- import { groupLevels, type GroupLevelReading } from "../player/store/groupLevels";
11
-
12
- const THROTTLE_MS = 33;
13
-
14
- export function useGroupLevel(groupId: string): GroupLevelReading | null {
15
- const latestRef = useRef<GroupLevelReading | null>(groupLevels.get().get(groupId) ?? null);
16
- const [, forceRender] = useState(0);
17
-
18
- useEffect(() => {
19
- let timerId: ReturnType<typeof setTimeout> | 0 = 0;
20
- const unsubscribe = groupLevels.subscribe((levels) => {
21
- latestRef.current = levels.get(groupId) ?? null;
22
- if (!timerId) {
23
- timerId = setTimeout(() => {
24
- timerId = 0;
25
- forceRender((v) => v + 1);
26
- }, THROTTLE_MS);
27
- }
28
- });
29
- return () => {
30
- unsubscribe();
31
- if (timerId) clearTimeout(timerId);
32
- };
33
- }, [groupId]);
34
-
35
- return latestRef.current;
36
- }
@@ -1,146 +0,0 @@
1
- // @vitest-environment happy-dom
2
-
3
- import React, { act } from "react";
4
- import { createRoot, type Root } from "react-dom/client";
5
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
- import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
7
- import { defaultTimelineTheme } from "./timelineTheme";
8
- import { groupLevels } from "../store/groupLevels";
9
-
10
- (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
-
12
- let container: HTMLDivElement;
13
- let root: Root;
14
-
15
- beforeEach(() => {
16
- container = document.createElement("div");
17
- document.body.appendChild(container);
18
- root = createRoot(container);
19
- groupLevels.notify(new Map());
20
- });
21
-
22
- afterEach(() => {
23
- act(() => root.unmount());
24
- container.remove();
25
- vi.useRealTimers();
26
- });
27
-
28
- function renderStrip(overrides: Partial<React.ComponentProps<typeof TimelineGroupBusStrip>> = {}) {
29
- const onVolumeChange = vi.fn();
30
- const onVolumeCommit = vi.fn();
31
- act(() => {
32
- root.render(
33
- <TimelineGroupBusStrip
34
- groupId="vo"
35
- volume={1}
36
- memberLabels={["vo-1", "vo-2"]}
37
- onVolumeChange={onVolumeChange}
38
- onVolumeCommit={onVolumeCommit}
39
- theme={defaultTimelineTheme}
40
- {...overrides}
41
- />,
42
- );
43
- });
44
- return { onVolumeChange, onVolumeCommit };
45
- }
46
-
47
- function slider(): HTMLInputElement {
48
- return container.querySelector('input[type="range"]') as HTMLInputElement;
49
- }
50
-
51
- const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
52
- window.HTMLInputElement.prototype,
53
- "value",
54
- )?.set;
55
-
56
- /** React tracks the DOM's own value setter to detect real changes — a plain
57
- * `input.value = ...` assignment is invisible to it, so onChange never
58
- * fires. Go through the native setter, same as this codebase's other
59
- * range/text input tests (e.g. propertyPanelFlatStyleSections.test.tsx). */
60
- function setSliderValue(input: HTMLInputElement, value: string) {
61
- nativeInputValueSetter?.call(input, value);
62
- input.dispatchEvent(new Event("input", { bubbles: true }));
63
- }
64
-
65
- describe("TimelineGroupBusStrip", () => {
66
- it('renders "Holds …" from the member labels, comma-joined', () => {
67
- renderStrip({ memberLabels: ["vo-1", "vo-2"] });
68
- expect(container.textContent).toContain("Holds vo-1, vo-2");
69
- });
70
-
71
- it("falls back to a neutral line when a group has no members yet", () => {
72
- renderStrip({ memberLabels: [] });
73
- expect(container.textContent).toContain("Holds nothing yet");
74
- });
75
-
76
- it("live-writes on every drag tick, but only commits once on release", () => {
77
- const { onVolumeChange, onVolumeCommit } = renderStrip({ volume: 1 });
78
- const input = slider();
79
-
80
- act(() => setSliderValue(input, "1.2"));
81
- act(() => setSliderValue(input, "1.5"));
82
- expect(onVolumeChange).toHaveBeenCalledTimes(2);
83
- expect(onVolumeChange).toHaveBeenLastCalledWith(1.5);
84
- expect(onVolumeCommit).not.toHaveBeenCalled();
85
-
86
- act(() => {
87
- input.value = "1.5";
88
- input.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
89
- });
90
- expect(onVolumeCommit).toHaveBeenCalledTimes(1);
91
- expect(onVolumeCommit).toHaveBeenCalledWith(1.5);
92
- });
93
-
94
- it("clamps the volume slider to the 0..2 range", () => {
95
- const { onVolumeChange } = renderStrip();
96
- const input = slider();
97
- expect(input.min).toBe("0");
98
- expect(input.max).toBe("2");
99
-
100
- act(() => setSliderValue(input, "2"));
101
- expect(onVolumeChange).toHaveBeenLastCalledWith(2);
102
- });
103
-
104
- it("the level bar tracks a live reading and shows nothing extra when it isn't clipping", () => {
105
- vi.useFakeTimers();
106
- renderStrip();
107
- act(() => {
108
- groupLevels.notify(new Map([["vo", { level: 0.4, clipped: false }]]));
109
- vi.advanceTimersByTime(40); // past useGroupLevel's 33ms throttle
110
- });
111
- expect(container.textContent).not.toContain("Too loud");
112
- });
113
-
114
- it('shows "Too loud" while clipped and holds it for ~2s after clipping stops', () => {
115
- vi.useFakeTimers();
116
- renderStrip();
117
-
118
- act(() => {
119
- groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
120
- vi.advanceTimersByTime(40);
121
- });
122
- expect(container.textContent).toContain("Too loud");
123
-
124
- // Clipping stops — the warning must still hold for a couple seconds.
125
- act(() => {
126
- groupLevels.notify(new Map([["vo", { level: 0.2, clipped: false }]]));
127
- vi.advanceTimersByTime(40);
128
- });
129
- expect(container.textContent).toContain("Too loud");
130
-
131
- act(() => {
132
- vi.advanceTimersByTime(2001);
133
- });
134
- expect(container.textContent).not.toContain("Too loud");
135
- });
136
-
137
- it("never renders a dB number anywhere in the strip (design constraint: no dB, no peak-hold readout)", () => {
138
- vi.useFakeTimers();
139
- renderStrip({ volume: 1.5 });
140
- act(() => {
141
- groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
142
- vi.advanceTimersByTime(40);
143
- });
144
- expect(container.textContent ?? "").not.toMatch(/dB/i);
145
- });
146
- });
@@ -1,103 +0,0 @@
1
- /**
2
- * B7: the group's own volume slider + a living level bar + "Holds …" — the
3
- * bus, not the mechanism. No dB numbers, no peak-hold readout, no routing
4
- * row (groups doc §5, casual-user section) — a slider, a bar that moves with
5
- * the sound, and the words "Too loud" when it clips.
6
- */
7
- import { useEffect, useRef, useState } from "react";
8
- import { useGroupLevel } from "../../hooks/useGroupLevel";
9
- import { STRIP_H, TRACK_H } from "./timelineLayout";
10
- import type { TimelineTheme } from "./timelineTheme";
11
-
12
- /** How long "Too loud" stays lit after the last clipped block. */
13
- const CLIP_HOLD_MS = 2000;
14
-
15
- function clampVolume(value: number): number {
16
- return Math.min(2, Math.max(0, value));
17
- }
18
-
19
- interface TimelineGroupBusStripProps {
20
- groupId: string;
21
- volume: number;
22
- memberLabels: readonly string[];
23
- onVolumeChange: (value: number) => void;
24
- onVolumeCommit: (value: number) => void;
25
- theme: TimelineTheme;
26
- }
27
-
28
- export function TimelineGroupBusStrip({
29
- groupId,
30
- volume,
31
- memberLabels,
32
- onVolumeChange,
33
- onVolumeCommit,
34
- theme,
35
- }: TimelineGroupBusStripProps) {
36
- const [dragValue, setDragValue] = useState<number | null>(null);
37
- const reading = useGroupLevel(groupId);
38
- const [clipped, setClipped] = useState(false);
39
- const clipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
40
-
41
- useEffect(() => {
42
- if (!reading?.clipped) return;
43
- setClipped(true);
44
- if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
45
- clipTimerRef.current = setTimeout(() => setClipped(false), CLIP_HOLD_MS);
46
- }, [reading?.clipped]);
47
-
48
- useEffect(
49
- () => () => {
50
- if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
51
- },
52
- [],
53
- );
54
-
55
- const shownVolume = dragValue ?? volume;
56
- const level = Math.min(1, reading?.level ?? 0);
57
- const holdsText =
58
- memberLabels.length > 0 ? `Holds ${memberLabels.join(", ")}` : "Holds nothing yet";
59
-
60
- return (
61
- <div
62
- className="absolute left-0 right-0 flex items-center gap-2 px-2 text-[10px] text-white/70"
63
- style={{ top: TRACK_H, height: STRIP_H }}
64
- >
65
- <input
66
- type="range"
67
- aria-label="Group volume"
68
- min={0}
69
- max={2}
70
- step={0.01}
71
- value={shownVolume}
72
- className="h-1 w-20 shrink-0 accent-[#3CE6AC]"
73
- onChange={(event) => {
74
- const next = clampVolume(Number(event.currentTarget.value));
75
- setDragValue(next);
76
- onVolumeChange(next);
77
- }}
78
- onPointerUp={(event) => {
79
- const next = clampVolume(Number(event.currentTarget.value));
80
- setDragValue(null);
81
- onVolumeCommit(next);
82
- }}
83
- />
84
- <div
85
- className="relative h-1.5 w-16 shrink-0 overflow-hidden rounded-full"
86
- style={{ background: theme.gutterBorder }}
87
- aria-hidden="true"
88
- >
89
- <div
90
- className="absolute inset-y-0 left-0 rounded-full"
91
- style={{
92
- width: `${level * 100}%`,
93
- background: clipped ? "#ff5c5c" : "#3CE6AC",
94
- }}
95
- />
96
- </div>
97
- {clipped && <span className="shrink-0 font-medium text-[#ff5c5c]">Too loud</span>}
98
- <span className="min-w-0 flex-1 truncate" title={holdsText}>
99
- {holdsText}
100
- </span>
101
- </div>
102
- );
103
- }
@@ -1,32 +0,0 @@
1
- /**
2
- * "Hear only this" — the `⌗` toggle beside a track's mute control. Session
3
- * state only (see `audioSoloSlice`): a plain click is exclusive, ⌘/Ctrl-click
4
- * toggles membership without disturbing the rest of the set.
5
- */
6
- export function TimelineSoloButton({
7
- isSoloed,
8
- onToggle,
9
- }: {
10
- isSoloed: boolean;
11
- onToggle: (options?: { add?: boolean }) => void;
12
- }) {
13
- return (
14
- <button
15
- type="button"
16
- tabIndex={-1}
17
- aria-pressed={isSoloed}
18
- aria-label="Hear only this"
19
- title="Hear only this"
20
- className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[13px] font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
21
- isSoloed ? "text-[#F5C542] hover:text-white" : "text-white/35 hover:text-white/75"
22
- }`}
23
- onPointerDown={(event) => event.stopPropagation()}
24
- onClick={(event) => {
25
- event.stopPropagation();
26
- onToggle({ add: event.metaKey || event.ctrlKey });
27
- }}
28
- >
29
- <span aria-hidden="true">⌗</span>
30
- </button>
31
- );
32
- }
@@ -1,113 +0,0 @@
1
- // @vitest-environment happy-dom
2
-
3
- import { describe, expect, it, vi } from "vitest";
4
- import { usePlayerStore } from "./playerStore";
5
- import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "./audioSoloSlice";
6
- import * as studioFileHistory from "../../utils/studioFileHistory";
7
-
8
- describe("audioSoloSlice", () => {
9
- it("is exclusive by default, and clicking the only soloed element again clears it", () => {
10
- usePlayerStore.getState().toggleSolo("a");
11
- expect(usePlayerStore.getState().soloed).toEqual(new Set(["a"]));
12
- usePlayerStore.getState().toggleSolo("b");
13
- expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"]));
14
- usePlayerStore.getState().toggleSolo("b");
15
- expect(usePlayerStore.getState().soloed).toEqual(new Set());
16
- });
17
-
18
- it("⌘/Ctrl-click adds and removes membership without disturbing the rest", () => {
19
- usePlayerStore.getState().toggleSolo("a");
20
- usePlayerStore.getState().toggleSolo("b", { add: true });
21
- expect(usePlayerStore.getState().soloed).toEqual(new Set(["a", "b"]));
22
- usePlayerStore.getState().toggleSolo("a", { add: true });
23
- expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"]));
24
- });
25
-
26
- it("clearSolo empties the set", () => {
27
- usePlayerStore.getState().toggleSolo("a");
28
- usePlayerStore.getState().clearSolo();
29
- expect(usePlayerStore.getState().soloed).toEqual(new Set());
30
- });
31
- });
32
-
33
- describe("export-safety: solo never touches an attribute or the save path", () => {
34
- it("toggling, ⌘-adding, and clearing solo never calls setAttribute/removeAttribute on any element", () => {
35
- usePlayerStore.getState().clearSolo();
36
- const setAttributeSpy = vi.spyOn(Element.prototype, "setAttribute");
37
- const removeAttributeSpy = vi.spyOn(Element.prototype, "removeAttribute");
38
-
39
- usePlayerStore.getState().toggleSolo("a");
40
- usePlayerStore.getState().toggleSolo("b", { add: true });
41
- usePlayerStore.getState().toggleSolo("a", { add: true });
42
- usePlayerStore.getState().clearSolo();
43
-
44
- expect(setAttributeSpy).not.toHaveBeenCalled();
45
- expect(removeAttributeSpy).not.toHaveBeenCalled();
46
- setAttributeSpy.mockRestore();
47
- removeAttributeSpy.mockRestore();
48
- });
49
-
50
- it("toggling, ⌘-adding, and clearing solo never invokes the project save path (setElementsHidden's own write function)", () => {
51
- usePlayerStore.getState().clearSolo();
52
- const saveSpy = vi.spyOn(studioFileHistory, "saveProjectFilesWithHistory");
53
-
54
- usePlayerStore.getState().toggleSolo("a");
55
- usePlayerStore.getState().toggleSolo("b", { add: true });
56
- usePlayerStore.getState().clearSolo();
57
-
58
- expect(saveSpy).not.toHaveBeenCalled();
59
- saveSpy.mockRestore();
60
- });
61
- });
62
-
63
- describe("isAudibleUnderSolo — the three-bullet rule", () => {
64
- it("everything is audible when no solo is active", () => {
65
- expect(isAudibleUnderSolo(new Set(), "clip-a")).toBe(true);
66
- expect(isAudibleUnderSolo(new Set(), "clip-a", "group-1")).toBe(true);
67
- });
68
-
69
- it("a soloed element is audible", () => {
70
- expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-a")).toBe(true);
71
- });
72
-
73
- it("a sibling of a soloed element is silent", () => {
74
- expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b")).toBe(false);
75
- });
76
-
77
- it("a member of a soloed group is audible (group solo = members solo)", () => {
78
- expect(isAudibleUnderSolo(new Set(["group-1"]), "clip-a", "group-1")).toBe(true);
79
- });
80
-
81
- it("a member of an UNsoloed group, while a sibling group is soloed, is silent", () => {
82
- expect(isAudibleUnderSolo(new Set(["group-2"]), "clip-a", "group-1")).toBe(false);
83
- });
84
-
85
- it("soloing one member of a group does not un-attenuate its siblings in the same group", () => {
86
- // clip-a is soloed directly; clip-b shares its group but is not itself
87
- // soloed and the group itself is not soloed — clip-b stays silent.
88
- expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b", "group-1")).toBe(false);
89
- });
90
-
91
- it("the group itself, monitored as a bus, is audible only when it or a member is soloed", () => {
92
- expect(isAudibleUnderSolo(new Set(["clip-a"]), "group-1", null)).toBe(false);
93
- expect(isAudibleUnderSolo(new Set(["group-1"]), "group-1", null)).toBe(true);
94
- });
95
- });
96
-
97
- describe("isGroupHalfLitUnderSolo", () => {
98
- it("is false when no solo is active", () => {
99
- expect(isGroupHalfLitUnderSolo(new Set(), "group-1", ["a", "b"])).toBe(false);
100
- });
101
-
102
- it("is false when the group itself is soloed (fully lit, not half)", () => {
103
- expect(isGroupHalfLitUnderSolo(new Set(["group-1"]), "group-1", ["a", "b"])).toBe(false);
104
- });
105
-
106
- it("is true when a member is soloed but the group is not", () => {
107
- expect(isGroupHalfLitUnderSolo(new Set(["a"]), "group-1", ["a", "b"])).toBe(true);
108
- });
109
-
110
- it("is false when an unrelated element is soloed", () => {
111
- expect(isGroupHalfLitUnderSolo(new Set(["other"]), "group-1", ["a", "b"])).toBe(false);
112
- });
113
- });
@@ -1,43 +0,0 @@
1
- /**
2
- * "Hear only this" — session-only monitoring override, never persisted.
3
- *
4
- * Holds clip ids and group ids (never track numbers, per the design doc: solo
5
- * is a per-element/per-group concept, not a row-position one). Exclusive by
6
- * default (a plain toggle replaces the set); ⌘/Ctrl-click adds/removes one
7
- * member without disturbing the rest. Never written to any attribute or
8
- * document — the export-safety guarantee this slice exists to hold.
9
- */
10
- import type { StoreApi } from "zustand";
11
- import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "@hyperframes/core/audio-groups";
12
-
13
- export { isAudibleUnderSolo, isGroupHalfLitUnderSolo };
14
-
15
- export interface AudioSoloSlice {
16
- soloed: ReadonlySet<string>;
17
- toggleSolo: (id: string, options?: { add?: boolean }) => void;
18
- clearSolo: () => void;
19
- }
20
-
21
- export function createAudioSoloSlice(
22
- set: StoreApi<AudioSoloSlice>["setState"],
23
- get: StoreApi<AudioSoloSlice>["getState"],
24
- ): AudioSoloSlice {
25
- return {
26
- soloed: new Set(),
27
- toggleSolo: (id, options) => {
28
- const current = get().soloed;
29
- if (options?.add) {
30
- const next = new Set(current);
31
- if (next.has(id)) next.delete(id);
32
- else next.add(id);
33
- set({ soloed: next });
34
- return;
35
- }
36
- // Exclusive click: soloing the only-soloed element again clears it;
37
- // otherwise it replaces the set.
38
- const isOnlyMember = current.size === 1 && current.has(id);
39
- set({ soloed: isOnlyMember ? new Set() : new Set([id]) });
40
- },
41
- clearSolo: () => set({ soloed: new Set() }),
42
- };
43
- }
@@ -1,33 +0,0 @@
1
- // ponytail: mirrors liveTime.ts's plain pub-sub — same throttle-at-the-edge shape.
2
- /** A group's live meter reading, or absent when idle/unknown (never zero). */
3
- export type GroupLevelReading = { level: number; clipped: boolean };
4
-
5
- type Listener = (levels: ReadonlyMap<string, GroupLevelReading>) => void;
6
- const listeners = new Set<Listener>();
7
- let latest: ReadonlyMap<string, GroupLevelReading> = new Map();
8
-
9
- export const groupLevels = {
10
- notify: (levels: ReadonlyMap<string, GroupLevelReading>) => {
11
- latest = levels;
12
- listeners.forEach((listener) => listener(levels));
13
- },
14
- subscribe: (listener: Listener) => {
15
- listeners.add(listener);
16
- return () => listeners.delete(listener);
17
- },
18
- get: () => latest,
19
- };
20
-
21
- /** Turns the runtime's `group-levels` postMessage payload into the map `groupLevels.notify` wants. */
22
- export function parseGroupLevelsMessage(
23
- data: unknown,
24
- ): ReadonlyMap<string, GroupLevelReading> | null {
25
- const levels = (data as { levels?: unknown } | null)?.levels;
26
- if (!Array.isArray(levels)) return null;
27
- return new Map(
28
- (levels as Array<{ groupId: string; level: number; clipped: boolean }>).map((entry) => [
29
- entry.groupId,
30
- { level: entry.level, clipped: entry.clipped },
31
- ]),
32
- );
33
- }