Files
audio-ui/components/tracks/TrackList.tsx
Sebastian Krüger 39ea599f18 fix: synchronize vertical scrolling between track controls and waveforms
Track controls now stay perfectly aligned with their waveforms during
vertical scrolling. The waveform column handles all scrolling (both
horizontal and vertical), and synchronizes its vertical scroll position
to the controls column.

Changes:
- Removed independent vertical scroll from controls column
- Added scroll event handler to waveforms column
- Controls column scrollTop is synced with waveforms column
- Track controls and waveforms now stay aligned at all times

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:11:38 +01:00

300 lines
11 KiB
TypeScript

'use client';
import * as React from 'react';
import { Plus, Upload } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Track } from './Track';
import { ImportTrackDialog } from './ImportTrackDialog';
import type { Track as TrackType } from '@/types/track';
import { createEffect, type EffectType, EFFECT_NAMES } from '@/lib/audio/effects/chain';
export interface TrackListProps {
tracks: TrackType[];
zoom: number;
currentTime: number;
duration: number;
selectedTrackId?: string | null;
onSelectTrack?: (trackId: string | null) => void;
onAddTrack: () => void;
onImportTrack?: (buffer: AudioBuffer, name: string) => void;
onRemoveTrack: (trackId: string) => void;
onUpdateTrack: (trackId: string, updates: Partial<TrackType>) => void;
onSeek?: (time: number) => void;
onSelectionChange?: (trackId: string, selection: { start: number; end: number } | null) => void;
onToggleRecordEnable?: (trackId: string) => void;
recordingTrackId?: string | null;
recordingLevel?: number;
trackLevels?: Record<string, number>;
onParameterTouched?: (trackId: string, laneId: string, touched: boolean) => void;
isPlaying?: boolean;
}
export function TrackList({
tracks,
zoom,
currentTime,
duration,
selectedTrackId,
onSelectTrack,
onAddTrack,
onImportTrack,
onRemoveTrack,
onUpdateTrack,
onSeek,
onSelectionChange,
onToggleRecordEnable,
recordingTrackId,
recordingLevel = 0,
trackLevels = {},
onParameterTouched,
isPlaying = false,
}: TrackListProps) {
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
const waveformScrollRef = React.useRef<HTMLDivElement>(null);
const controlsScrollRef = React.useRef<HTMLDivElement>(null);
// Synchronize vertical scroll between controls and waveforms
const handleWaveformScroll = React.useCallback(() => {
if (waveformScrollRef.current && controlsScrollRef.current) {
controlsScrollRef.current.scrollTop = waveformScrollRef.current.scrollTop;
}
}, []);
const handleImportTrack = (buffer: AudioBuffer, name: string) => {
if (onImportTrack) {
onImportTrack(buffer, name);
}
};
if (tracks.length === 0) {
return (
<>
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-muted-foreground">
<p className="text-sm">No tracks yet. Add a track to get started.</p>
<div className="flex gap-2">
<Button onClick={onAddTrack} variant="secondary">
<Plus className="h-4 w-4 mr-2" />
Add Empty Track
</Button>
{onImportTrack && (
<Button onClick={() => setImportDialogOpen(true)} variant="secondary">
<Upload className="h-4 w-4 mr-2" />
Import Audio Files
</Button>
)}
</div>
</div>
{onImportTrack && (
<ImportTrackDialog
open={importDialogOpen}
onClose={() => setImportDialogOpen(false)}
onImportTrack={handleImportTrack}
/>
)}
</>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Track List - Two Column Layout */}
<div className="flex-1 flex overflow-hidden">
{/* Left Column: Track Controls (Fixed Width, No Scroll - synced with waveforms) */}
<div ref={controlsScrollRef} className="w-48 flex-shrink-0 overflow-hidden">
{tracks.map((track) => (
<Track
key={track.id}
track={track}
zoom={zoom}
currentTime={currentTime}
duration={duration}
isSelected={selectedTrackId === track.id}
onSelect={onSelectTrack ? () => onSelectTrack(track.id) : undefined}
onToggleMute={() =>
onUpdateTrack(track.id, { mute: !track.mute })
}
onToggleSolo={() =>
onUpdateTrack(track.id, { solo: !track.solo })
}
onToggleCollapse={() =>
onUpdateTrack(track.id, { collapsed: !track.collapsed })
}
onVolumeChange={(volume) =>
onUpdateTrack(track.id, { volume })
}
onPanChange={(pan) =>
onUpdateTrack(track.id, { pan })
}
onRemove={() => onRemoveTrack(track.id)}
onNameChange={(name) =>
onUpdateTrack(track.id, { name })
}
onUpdateTrack={onUpdateTrack}
onSeek={onSeek}
onLoadAudio={(buffer) =>
onUpdateTrack(track.id, { audioBuffer: buffer })
}
onToggleEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, enabled: !e.enabled } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onRemoveEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.filter((e) => e.id !== effectId),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onUpdateEffect={(effectId, parameters) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, parameters } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onAddEffect={(effectType) => {
const newEffect = createEffect(
effectType,
EFFECT_NAMES[effectType]
);
const updatedChain = {
...track.effectChain,
effects: [...track.effectChain.effects, newEffect],
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onSelectionChange={
onSelectionChange
? (selection) => onSelectionChange(track.id, selection)
: undefined
}
onToggleRecordEnable={
onToggleRecordEnable
? () => onToggleRecordEnable(track.id)
: undefined
}
isRecording={recordingTrackId === track.id}
recordingLevel={recordingTrackId === track.id ? recordingLevel : 0}
playbackLevel={trackLevels[track.id] || 0}
onParameterTouched={onParameterTouched}
isPlaying={isPlaying}
renderControlsOnly={true}
/>
))}
</div>
{/* Right Column: Waveforms (Flexible Width, Shared Horizontal & Vertical Scroll) */}
<div
ref={waveformScrollRef}
onScroll={handleWaveformScroll}
className="flex-1 overflow-auto custom-scrollbar"
>
{tracks.map((track) => (
<Track
key={track.id}
track={track}
zoom={zoom}
currentTime={currentTime}
duration={duration}
isSelected={selectedTrackId === track.id}
onSelect={onSelectTrack ? () => onSelectTrack(track.id) : undefined}
onToggleMute={() =>
onUpdateTrack(track.id, { mute: !track.mute })
}
onToggleSolo={() =>
onUpdateTrack(track.id, { solo: !track.solo })
}
onToggleCollapse={() =>
onUpdateTrack(track.id, { collapsed: !track.collapsed })
}
onVolumeChange={(volume) =>
onUpdateTrack(track.id, { volume })
}
onPanChange={(pan) =>
onUpdateTrack(track.id, { pan })
}
onRemove={() => onRemoveTrack(track.id)}
onNameChange={(name) =>
onUpdateTrack(track.id, { name })
}
onUpdateTrack={onUpdateTrack}
onSeek={onSeek}
onLoadAudio={(buffer) =>
onUpdateTrack(track.id, { audioBuffer: buffer })
}
onToggleEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, enabled: !e.enabled } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onRemoveEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.filter((e) => e.id !== effectId),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onUpdateEffect={(effectId, parameters) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, parameters } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onAddEffect={(effectType) => {
const newEffect = createEffect(
effectType,
EFFECT_NAMES[effectType]
);
const updatedChain = {
...track.effectChain,
effects: [...track.effectChain.effects, newEffect],
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onSelectionChange={
onSelectionChange
? (selection) => onSelectionChange(track.id, selection)
: undefined
}
onToggleRecordEnable={
onToggleRecordEnable
? () => onToggleRecordEnable(track.id)
: undefined
}
isRecording={recordingTrackId === track.id}
recordingLevel={recordingTrackId === track.id ? recordingLevel : 0}
playbackLevel={trackLevels[track.id] || 0}
onParameterTouched={onParameterTouched}
isPlaying={isPlaying}
renderWaveformOnly={true}
/>
))}
</div>
</div>
{/* Import Dialog */}
{onImportTrack && (
<ImportTrackDialog
open={importDialogOpen}
onClose={() => setImportDialogOpen(false)}
onImportTrack={handleImportTrack}
/>
)}
</div>
);
}