Skip to content

feat: add integrated mixer panel in session view#992

Open
ChuxiJ wants to merge 1 commit intomainfrom
feat/issue-925
Open

feat: add integrated mixer panel in session view#992
ChuxiJ wants to merge 1 commit intomainfrom
feat/issue-925

Conversation

@ChuxiJ
Copy link
Copy Markdown

@ChuxiJ ChuxiJ commented Mar 27, 2026

Summary

  • Add collapsible mixer panel below the session view clip grid with per-track volume fader, pan knob, solo/mute buttons, and level meters
  • New SessionMixerStrip component renders a compact horizontal strip (~40px) per track with track color accent
  • New SessionMixer container with show/hide toggle, integrated into SessionView
  • 15 unit tests covering rendering, user interactions, and Zustand store updates

Closes #925

Test plan

  • npx tsc --noEmit passes with 0 errors
  • npm test passes (2770 tests, 15 new)
  • npm run build succeeds
  • Visual verification: toggle mixer visibility in session view
  • Verify volume fader, pan knob, solo/mute buttons work correctly
  • Verify level meters animate during playback

🤖 Generated with Claude Code

Add a collapsible mixer strip below the session grid showing per-track
volume fader, pan knob, solo/mute buttons, and level meters. The mixer
can be toggled via a button at the bottom of the session view.

- SessionMixerStrip: horizontal strip with fader, pan knob, S/M buttons, meter
- SessionMixer: container rendering strips for all tracks with toggle
- Integrated below the clip grid in SessionView
- 15 unit tests covering rendering, interaction, and store updates

Closes #925

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 27, 2026 06:21
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an integrated, collapsible mixer panel to the Session View so users can adjust per-track volume/pan and toggle solo/mute without leaving the clip grid workflow.

Changes:

  • Introduces SessionMixer container with show/hide toggle and renders one strip per track.
  • Adds SessionMixerStrip component with volume “fader” + meters, pan knob, solo/mute buttons, and track color accent.
  • Integrates the new mixer panel into SessionView and adds unit tests for rendering and interactions.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.

File Description
src/components/session/SessionView.tsx Wires the new session mixer panel into the session view layout.
src/components/session/SessionMixer.tsx Implements the collapsible mixer container and connects mixer actions to the Zustand project store.
src/components/session/SessionMixerStrip.tsx Implements the per-track mixer UI (metering, volume/pan control, solo/mute).
src/components/session/tests/SessionMixer.test.tsx Adds unit tests for the mixer container and strip interactions/store updates.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +57 to +72
{tracks.map((track) => (
<SessionMixerStrip
key={track.id}
trackId={track.id}
trackName={track.displayName}
trackColor={track.color}
volume={track.volume}
pan={track.pan ?? 0}
muted={track.muted}
soloed={track.soloed}
onVolumeChange={(v) => handleVolumeChange(track.id, v)}
onPanChange={(v) => handlePanChange(track.id, v)}
onMuteToggle={() => handleMuteToggle(track.id, track.muted)}
onSoloToggle={() => handleSoloToggle(track.id, track.soloed)}
/>
))}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When visible is false, the mixer still renders all SessionMixerStrip children inside a 0px-tall container. Each strip starts its own requestAnimationFrame loop for meters, so the mixer continues doing per-frame work even while hidden. Consider conditionally rendering the strips only when visible is true (or passing visible down to pause metering) to avoid unnecessary CPU usage when the panel is collapsed.

Suggested change
{tracks.map((track) => (
<SessionMixerStrip
key={track.id}
trackId={track.id}
trackName={track.displayName}
trackColor={track.color}
volume={track.volume}
pan={track.pan ?? 0}
muted={track.muted}
soloed={track.soloed}
onVolumeChange={(v) => handleVolumeChange(track.id, v)}
onPanChange={(v) => handlePanChange(track.id, v)}
onMuteToggle={() => handleMuteToggle(track.id, track.muted)}
onSoloToggle={() => handleSoloToggle(track.id, track.soloed)}
/>
))}
{visible &&
tracks.map((track) => (
<SessionMixerStrip
key={track.id}
trackId={track.id}
trackName={track.displayName}
trackColor={track.color}
volume={track.volume}
pan={track.pan ?? 0}
muted={track.muted}
soloed={track.soloed}
onVolumeChange={(v) => handleVolumeChange(track.id, v)}
onPanChange={(v) => handlePanChange(track.id, v)}
onMuteToggle={() => handleMuteToggle(track.id, track.muted)}
onSoloToggle={() => handleSoloToggle(track.id, track.soloed)}
/>
))}

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +29
const handleMuteToggle = useCallback((trackId: string, currentMuted: boolean) => {
updateTrack(trackId, { muted: !currentMuted });
}, [updateTrack]);

const handleSoloToggle = useCallback((trackId: string, currentSoloed: boolean) => {
updateTrack(trackId, { soloed: !currentSoloed });
}, [updateTrack]);
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mute/solo handling here does not account for group tracks. Elsewhere in the UI (e.g. MixerPanel/TrackHeader) group tracks use setGroupMuted / setGroupSoloed so the action cascades to child tracks. With the current implementation, muting/soloing a group from the Session mixer will behave differently than other mixers.

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +21
const handlePanChange = useCallback((trackId: string, pan: number) => {
// updateTrackMixer handles pan updates
useProjectStore.getState().updateTrackMixer(trackId, { pan });
}, []);
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handlePanChange reaches into the store via useProjectStore.getState() and uses an empty dependency array. For consistency with the rest of the codebase (and to avoid bypassing React hooks patterns), select updateTrackMixer via useProjectStore((s) => s.updateTrackMixer) and include it in the callback dependencies.

Copilot uses AI. Check for mistakes.
role="slider"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(volume * 100)}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This slider is implemented as a non-focusable div with role="slider". For accessibility, ARIA sliders should be keyboard operable and focusable (e.g. tabIndex=0 plus arrow/home/end key handling), otherwise screen-reader users can discover it but not change the value without a pointer.

Suggested change
aria-valuenow={Math.round(volume * 100)}
aria-valuenow={Math.round(volume * 100)}
aria-orientation="horizontal"
tabIndex={0}
onKeyDown={(e) => {
const step = 0.05;
const largeStep = 0.1;
let newVolume: number | null = null;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowDown':
newVolume = Math.max(0, volume - step);
break;
case 'ArrowRight':
case 'ArrowUp':
newVolume = Math.min(1, volume + step);
break;
case 'PageDown':
newVolume = Math.max(0, volume - largeStep);
break;
case 'PageUp':
newVolume = Math.min(1, volume + largeStep);
break;
case 'Home':
newVolume = 0;
break;
case 'End':
newVolume = 1;
break;
default:
break;
}
if (newVolume !== null) {
e.preventDefault();
// Prefer prop callback if available; otherwise fall back to direct state update if present.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore onVolumeChange is expected to be provided via props.
onVolumeChange?.(newVolume);
}
}}

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +216
it('does not render strips when hidden', () => {
render(<SessionMixer visible={false} onToggle={() => {}} />);

const mixer = screen.getByTestId('session-mixer');
// The inner container should have height 0
const container = mixer.querySelector('.overflow-hidden');
expect(container).toBeInTheDocument();
expect((container as HTMLElement).style.height).toBe('0px');
});
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name/comment says strips are not rendered when hidden, but the assertion only checks the container height style. Given the implementation still mounts strips (and their metering loops), this test currently wouldn't catch regressions in actual rendering behavior. Either assert that no strips are present when hidden, or update the test name to reflect what’s being checked.

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +96
fireEvent.pointerDown(fader, { clientX: 50 });
expect(onVolumeChange).toHaveBeenCalled();
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This interaction test doesn't stub getBoundingClientRect(), so in JSDOM the element width may be 0 and the handler can end up calling onVolumeChange with NaN. Consider mocking the bounding rect (as done in the store-update test) and asserting the callback receives a sane normalized value.

Suggested change
fireEvent.pointerDown(fader, { clientX: 50 });
expect(onVolumeChange).toHaveBeenCalled();
// Stub getBoundingClientRect so width is non-zero and calculations are stable in JSDOM
const boundingRect = {
x: 0,
y: 0,
top: 0,
left: 0,
right: 100,
bottom: 10,
width: 100,
height: 10,
toJSON: () => ({}),
} as DOMRect;
vi.spyOn(fader as any, 'getBoundingClientRect').mockReturnValue(boundingRect);
fireEvent.pointerDown(fader, { clientX: 50 });
expect(onVolumeChange).toHaveBeenCalledWith(expect.any(Number));
const value = onVolumeChange.mock.calls[0][0] as number;
expect(Number.isNaN(value)).toBe(false);
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThanOrEqual(1);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

ChuxiJ commented Mar 27, 2026

Triage: Hold for Phase 2 — Session view integrated mixer is a UX improvement but not blocking. Defer until core session view features are stable.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Add integrated mixer panel in session view

2 participants