Files
audio-ui/components/tracks/TrackList.tsx
Sebastian Krüger 45d46067ea fix: move useRef before early return to comply with Rules of Hooks
Fixed React Hooks error by moving waveformScrollRef declaration
before the conditional early return. Hooks must always be called
in the same order on every render.

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

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

291 lines
10 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 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, Vertical Scroll) */}
<div className="w-48 flex-shrink-0 overflow-y-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}
renderControlsOnly={true}
/>
))}
</div>
{/* Right Column: Waveforms (Flexible Width, Shared Horizontal Scroll) */}
<div
ref={waveformScrollRef}
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>
);
}