Skip to content

Commit dc61ce1

Browse files
feat(studio): a host can hide the timeline's audio effects controls (#4704)
* feat(studio): a host can hide the timeline's audio effects controls * test(studio): share the whole-timeline mount setup between timeline tests * fix(studio): hidden audio effects also take the effect-rack shortcut off lane names * test(studio): share the gain-automated group fixture * test(studio): use the shared group fixture in every automated-group test * fix(studio): a disabled group lane name does not light up on hover * refactor(studio): keep the timeline provider and lanes under the file size limit * refactor(studio): format the lanes row indexes within the size limit
1 parent 0af671c commit dc61ce1

15 files changed

Lines changed: 238 additions & 94 deletions
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// @vitest-environment happy-dom
2+
3+
import React, { act } from "react";
4+
import { createRoot } from "react-dom/client";
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import { Timeline } from "./Timeline";
7+
import { installTimelineMountEnv } from "./timelineMountTestEnv";
8+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
9+
10+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11+
12+
installTimelineMountEnv();
13+
14+
afterEach(() => {
15+
document.body.innerHTML = "";
16+
});
17+
18+
const voice: TimelineElement = {
19+
id: "vo",
20+
domId: "vo",
21+
tag: "audio",
22+
start: 0,
23+
duration: 4,
24+
track: 0,
25+
};
26+
const grouped: TimelineElement[] = ["vo-1", "vo-2"].map((id, track) => ({
27+
id,
28+
domId: id,
29+
tag: "audio",
30+
start: 0,
31+
duration: 4,
32+
track,
33+
audioGroup: "voiceover",
34+
audioGroupLabel: "Voiceover",
35+
}));
36+
37+
async function mount(elements: TimelineElement[], showAudioEffects?: boolean) {
38+
usePlayerStore.setState({ duration: 10, currentTime: 0, timelineReady: true, elements });
39+
const host = document.createElement("div");
40+
document.body.append(host);
41+
const root = createRoot(host);
42+
await act(async () => root.render(<Timeline showAudioEffects={showAudioEffects} />));
43+
const labels = Array.from(host.querySelectorAll("button")).map(
44+
(b) => b.getAttribute("aria-label") ?? "",
45+
);
46+
act(() => root.unmount());
47+
return labels;
48+
}
49+
50+
const effects = (labels: string[]) => labels.filter((l) => l.startsWith("Effects"));
51+
52+
describe("Timeline showAudioEffects", () => {
53+
it("shows the effects button on an audio track by default", async () => {
54+
expect(effects(await mount([voice]))).toHaveLength(1);
55+
});
56+
57+
it("hides the effects button on an audio track when the host turns effects off", async () => {
58+
expect(effects(await mount([voice], false))).toEqual([]);
59+
});
60+
61+
it("hides the group-these-clips effects button on a track of several clips", async () => {
62+
const clips: TimelineElement[] = [
63+
{ ...voice, id: "sfx-1", domId: "sfx-1" },
64+
{ ...voice, id: "sfx-2", domId: "sfx-2", start: 5 },
65+
];
66+
expect(effects(await mount(clips))).toHaveLength(1);
67+
expect(effects(await mount(clips, false))).toEqual([]);
68+
});
69+
70+
it("hides a group's effects button and its open-effects name when effects are off", async () => {
71+
const on = await mount(grouped);
72+
expect(effects(on).length).toBeGreaterThan(0);
73+
expect(on).toContain("Open Voiceover effects");
74+
75+
const off = await mount(grouped, false);
76+
expect(effects(off)).toEqual([]);
77+
expect(off).not.toContain("Open Voiceover effects");
78+
expect(off).toContain("Select Voiceover");
79+
});
80+
});

‎packages/studio/src/player/components/Timeline.playheadRemount.test.tsx‎

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,14 @@
22

33
import React, { act } from "react";
44
import { createRoot } from "react-dom/client";
5-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
5+
import { describe, expect, it } from "vitest";
66
import { Timeline } from "./Timeline";
7+
import { installTimelineMountEnv } from "./timelineMountTestEnv";
78
import { liveTime, usePlayerStore } from "../store/playerStore";
89

910
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
1011

11-
class MockResizeObserver {
12-
constructor(private readonly callback: ResizeObserverCallback) {}
13-
observe(target: Element) {
14-
this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver);
15-
}
16-
unobserve() {}
17-
disconnect() {}
18-
}
19-
20-
const originalResizeObserver = globalThis.ResizeObserver;
21-
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
22-
23-
beforeAll(() => {
24-
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
25-
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
26-
configurable: true,
27-
get: () => 900,
28-
});
29-
});
30-
31-
afterAll(() => {
32-
globalThis.ResizeObserver = originalResizeObserver;
33-
if (originalClientWidth)
34-
Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
35-
document.body.innerHTML = "";
36-
});
12+
installTimelineMountEnv();
3713

3814
const clips = [{ id: "intro", tag: "div", start: 2, duration: 10, track: 0 }];
3915
const needle = (host: HTMLElement) =>

‎packages/studio/src/player/components/TimelineGroupHeader.tsx‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ interface TimelineGroupHeaderProps {
2424
onOpenFxRack: () => void;
2525
columnWidth: number;
2626
theme: TimelineTheme;
27+
showAudioEffects?: boolean;
2728
}
2829

2930
/**
@@ -41,17 +42,19 @@ function GroupNameButton({
4142
label,
4243
memberCount,
4344
onOpenFxRack,
45+
showAudioEffects,
4446
}: {
4547
label: string;
4648
memberCount: number;
4749
onOpenFxRack: () => void;
50+
showAudioEffects: boolean;
4851
}) {
4952
return (
5053
<button
5154
type="button"
5255
tabIndex={-1}
53-
aria-label={`Open ${label} effects`}
54-
title="Open effects"
56+
aria-label={showAudioEffects ? `Open ${label} effects` : `Select ${label}`}
57+
title={showAudioEffects ? "Open effects" : "Select group"}
5558
// No `flex-1`: the row's control group owns the slack now (`ml-auto`), so
5659
// claiming it here would push the controls off the right edge — and the
5760
// count with them, since it rides inside this button.
@@ -97,6 +100,7 @@ export function TimelineGroupHeader({
97100
onOpenFxRack,
98101
columnWidth,
99102
theme,
103+
showAudioEffects = true,
100104
}: TimelineGroupHeaderProps) {
101105
return (
102106
<div
@@ -137,17 +141,24 @@ export function TimelineGroupHeader({
137141
▸ sits off-centre in its box because the glyph is not square. */}
138142
<span aria-hidden="true">{isExpanded ? "▾" : "▸"}</span>
139143
</button>
140-
<GroupNameButton label={label} memberCount={memberCount} onOpenFxRack={onOpenFxRack} />
144+
<GroupNameButton
145+
label={label}
146+
memberCount={memberCount}
147+
onOpenFxRack={onOpenFxRack}
148+
showAudioEffects={showAudioEffects}
149+
/>
141150
{/* `ml-auto` absorbs the slack the truncating name leaves, so the controls
142151
sit on the edge whatever the name's length. */}
143152
<div className="ml-auto flex shrink-0 items-center gap-1.5">
144-
<TimelineFxButton
145-
fxChainRaw={fxChain}
146-
onChainChange={onFxChainChange}
147-
onChainPreview={onFxChainPreview}
148-
auditionSpans={auditionSpans}
149-
onOpenRack={onOpenFxRack}
150-
/>
153+
{showAudioEffects && (
154+
<TimelineFxButton
155+
fxChainRaw={fxChain}
156+
onChainChange={onFxChainChange}
157+
onChainPreview={onFxChainPreview}
158+
auditionSpans={auditionSpans}
159+
onOpenRack={onOpenFxRack}
160+
/>
161+
)}
151162
{/* No lanes, no control: an author who opens it meets an empty row and
152163
learns nothing. A track header already gates its own `∿` this way
153164
(`disclosable`); the group's was the one that still offered a

‎packages/studio/src/player/components/TimelineGroupLaneLabels.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,13 @@ export function TimelineGroupLaneLabels({
6363
tabIndex={-1}
6464
key={lane.target}
6565
data-group-lane-label={lane.target}
66-
aria-label={`Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`}
67-
className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-[var(--timeline-text-secondary)] hover:text-[var(--timeline-text-solid)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
66+
aria-label={
67+
onReveal
68+
? `Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`
69+
: undefined
70+
}
71+
disabled={!onReveal}
72+
className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-[var(--timeline-text-secondary)] enabled:hover:text-[var(--timeline-text-solid)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
6873
style={{
6974
top: top + index * AUTOMATION_LANE_H,
7075
width: columnWidth,

‎packages/studio/src/player/components/TimelineGroupRow.test.tsx‎

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,21 @@ const GROUP: TimelineTrackGroupInfo = {
4545
hidden: false,
4646
};
4747

48+
const GAIN_AUTOMATED: Partial<TimelineTrackGroupInfo> = {
49+
fxChain: JSON.stringify({
50+
version: 1,
51+
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
52+
}),
53+
automation: JSON.stringify({
54+
version: 1,
55+
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
56+
}),
57+
};
58+
4859
function renderRow(
4960
overrides: Partial<TimelineTrackGroupInfo> = {},
5061
expandedLaneOwnerIds = new Set<string>(),
62+
showAudioEffects?: boolean,
5163
) {
5264
const onSetAudioGroupAttributeQuiet = vi.fn();
5365
const onSetElementAttributeQuiet = vi.fn();
@@ -66,6 +78,7 @@ function renderRow(
6678
virtualized={false}
6779
contentOrigin={232}
6880
theme={defaultTimelineTheme}
81+
showAudioEffects={showAudioEffects}
6982
collapsedGroupIds={new Set()}
7083
expandedLaneOwnerIds={expandedLaneOwnerIds}
7184
toggleGroupExpanded={vi.fn()}
@@ -118,19 +131,7 @@ describe("TimelineGroupRow", () => {
118131
});
119132

120133
it("opens a group automation lane on its exact rack parameter", async () => {
121-
const { host } = renderRow(
122-
{
123-
fxChain: JSON.stringify({
124-
version: 1,
125-
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
126-
}),
127-
automation: JSON.stringify({
128-
version: 1,
129-
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
130-
}),
131-
},
132-
new Set(["voiceover"]),
133-
);
134+
const { host } = renderRow(GAIN_AUTOMATED, new Set(["voiceover"]));
134135
const laneTitle = host.querySelector<HTMLButtonElement>('[data-group-lane-label="fx.p1.gain"]');
135136

136137
await act(async () => {
@@ -147,6 +148,14 @@ describe("TimelineGroupRow", () => {
147148
});
148149
});
149150

151+
it("keeps a group lane's name off the rack while audio effects are hidden", () => {
152+
const { host } = renderRow(GAIN_AUTOMATED, new Set(["voiceover"]), false);
153+
const laneTitle = host.querySelector<HTMLButtonElement>('[data-group-lane-label="fx.p1.gain"]');
154+
155+
expect(laneTitle?.disabled).toBe(true);
156+
expect(laneTitle?.getAttribute("aria-label")).toBeNull();
157+
});
158+
150159
// C1 names this as the step's own definition of done: "opening the popover on
151160
// a GROUP and applying a preset results in exactly ONE `data-fx-chain` write,
152161
// on the group element, and zero writes on members". A group IS a bus — a
@@ -180,33 +189,15 @@ describe("TimelineGroupRow", () => {
180189

181190
expect(laneToggle(renderRow().host)).toBeUndefined();
182191

183-
const automated = renderRow({
184-
fxChain: JSON.stringify({
185-
version: 1,
186-
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
187-
}),
188-
automation: JSON.stringify({
189-
version: 1,
190-
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
191-
}),
192-
});
192+
const automated = renderRow(GAIN_AUTOMATED);
193193
expect(laneToggle(automated.host)).toBeDefined();
194194
});
195195

196196
// Same shape as a track header: caret and name on the left, every control in
197197
// one right-anchored group. It was two lines — name, then controls — which is
198198
// what let a stray child overflow the 48px box on the track side.
199199
it("keeps the caret, the name and every control on one line", () => {
200-
const { host } = renderRow({
201-
fxChain: JSON.stringify({
202-
version: 1,
203-
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
204-
}),
205-
automation: JSON.stringify({
206-
version: 1,
207-
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
208-
}),
209-
});
200+
const { host } = renderRow(GAIN_AUTOMATED);
210201
const header = host.querySelector<HTMLElement>('[role="rowheader"]');
211202
// Caret, name, control group — no second line.
212203
expect(header?.children).toHaveLength(3);

‎packages/studio/src/player/components/TimelineGroupRow.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ interface TimelineGroupRowProps {
3333
virtualized: boolean;
3434
contentOrigin: number;
3535
theme: TimelineTheme;
36+
showAudioEffects?: boolean;
3637
rovingTargetId?: string | null;
3738
collapsedGroupIds: ReadonlySet<string>;
3839
expandedLaneOwnerIds: ReadonlySet<string>;
@@ -59,6 +60,7 @@ export function TimelineGroupRow({
5960
virtualized,
6061
contentOrigin,
6162
theme,
63+
showAudioEffects = true,
6264
rovingTargetId = null,
6365
collapsedGroupIds,
6466
expandedLaneOwnerIds,
@@ -173,6 +175,7 @@ export function TimelineGroupRow({
173175
// stays pinned there through horizontal scroll.
174176
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
175177
theme={theme}
178+
showAudioEffects={showAudioEffects}
176179
/>
177180
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
178181
clip's: a lane is editable once the group is selected, which clicking
@@ -187,7 +190,7 @@ export function TimelineGroupRow({
187190
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
188191
gutterBackground={theme.gutterBackground}
189192
accentColor={GROUP_LANE_ACCENT}
190-
onReveal={openGroupFxRack}
193+
onReveal={showAudioEffects ? openGroupFxRack : undefined}
191194
/>
192195
)}
193196
</div>

‎packages/studio/src/player/components/TimelineLanes.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export function TimelineLanes({
4141
contentGutter,
4242
trackContentWidth,
4343
theme,
44+
showAudioEffects,
4445
displayTrackOrder,
4546
rowGeometry,
4647
virtualRows,
@@ -115,11 +116,8 @@ export function TimelineLanes({
115116
// synthetic lane element spans the whole composition rather than a clip.
116117
const compositionDuration = usePlayerStore((s) => s.duration);
117118
useAutomationSelectionKeyboard({ lanes: automationLanes });
118-
const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
119-
const groupMemberTracks = useMemo(
120-
() => new Set(groups.flatMap((group) => group.memberTracks)),
121-
[groups],
122-
);
119+
const rowIndexes = useTimelineLaneRowIndexes(logicalRows, groups);
120+
const { logicalRowsByTrack, groupByAnchor, groupMemberTracks } = rowIndexes;
123121
const {
124122
toggleRowExpanded: toggleRowExpandedTracked,
125123
toggleClipExpanded: toggleClipExpandedTracked,
@@ -168,6 +166,7 @@ export function TimelineLanes({
168166
virtualized={rowsVirtualized}
169167
contentOrigin={contentOrigin}
170168
theme={theme}
169+
showAudioEffects={showAudioEffects}
171170
rovingTargetId={keyboard.rovingTargetId}
172171
collapsedGroupIds={collapsedGroupIds}
173172
expandedLaneOwnerIds={expandedLaneOwnerIds}
@@ -292,6 +291,7 @@ export function TimelineLanes({
292291
isAudioTrack={isAudioTrack}
293292
isGroupMember={groupMemberTracks.has(trackNum)}
294293
theme={theme}
294+
showAudioEffects={showAudioEffects}
295295
onToggleClipExpanded={() => {
296296
const keys = els.map(getTimelineElementIdentity);
297297
if (keys.length > 0) toggleRowExpandedTracked(keys);

0 commit comments

Comments
 (0)