forked from averyfelts/Lofi_Generator
work in progress implementation of: - mixer with channel strips, faders, pan knobs, level meters - timeline with ruler, playhead, sections, keyframe tracks - pattern and progression pickers for drums/chords - automation lanes and mute tracks - loop bracket for loop region selection - export modal placeholder known issues: - drum pattern changes don't update audio engine - timeline keyframes not connected to scheduler - some UI bugs remain this is a checkpoint commit for further iteration
306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useRef, useState } from 'react';
|
|
import { Drum, Music, Cloud, Volume2, VolumeX } from 'lucide-react';
|
|
import { Toggle } from '@/components/ui/toggle';
|
|
import { TimelineRuler } from './TimelineRuler';
|
|
import { Playhead } from './Playhead';
|
|
import { TimeDisplay } from './TimeDisplay';
|
|
import { DurationSelector } from './DurationSelector';
|
|
import { LoopBracket } from './LoopBracket';
|
|
import { SectionTrack } from './SectionTrack';
|
|
import { KeyframeTrack } from './KeyframeTrack';
|
|
import { PatternPicker } from './PatternPicker';
|
|
import { ProgressionPicker } from './ProgressionPicker';
|
|
import { MuteTrack } from './MuteTrack';
|
|
import { AutomationLane } from './AutomationLane';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
TimelineState,
|
|
LayerName,
|
|
PatternKeyframe,
|
|
ChordKeyframe,
|
|
SectionType,
|
|
} from '@/types/audio';
|
|
|
|
interface TimelineProps {
|
|
state: TimelineState;
|
|
bpm: number;
|
|
isPlaying: boolean;
|
|
pixelsPerBar?: number;
|
|
onDurationChange: (bars: number) => void;
|
|
onSeek: (bar: number, beat: number) => void;
|
|
onLoopRegionChange: (start: number, end: number, enabled: boolean) => void;
|
|
onSectionAdd: (type: SectionType, startBar: number, endBar: number) => void;
|
|
onSectionResize: (id: string, startBar: number, endBar: number) => void;
|
|
onSectionMove: (id: string, startBar: number) => void;
|
|
onSectionDelete: (id: string) => void;
|
|
onDrumKeyframeAdd: (bar: number, patternIndex: number) => void;
|
|
onDrumKeyframeUpdate: (id: string, patternIndex: number) => void;
|
|
onDrumKeyframeDelete: (id: string) => void;
|
|
onChordKeyframeAdd: (bar: number, progressionIndex: number) => void;
|
|
onChordKeyframeUpdate: (id: string, progressionIndex: number) => void;
|
|
onChordKeyframeDelete: (id: string) => void;
|
|
onMuteKeyframeToggle: (layer: LayerName, bar: number) => void;
|
|
onAutomationPointAdd: (
|
|
layer: LayerName,
|
|
bar: number,
|
|
beat: number,
|
|
value: number
|
|
) => void;
|
|
onAutomationPointUpdate: (
|
|
layer: LayerName,
|
|
id: string,
|
|
bar: number,
|
|
beat: number,
|
|
value: number
|
|
) => void;
|
|
onAutomationPointDelete: (layer: LayerName, id: string) => void;
|
|
className?: string;
|
|
}
|
|
|
|
const PIXELS_PER_BAR = 48;
|
|
|
|
export function Timeline({
|
|
state,
|
|
bpm,
|
|
isPlaying,
|
|
pixelsPerBar = PIXELS_PER_BAR,
|
|
onDurationChange,
|
|
onSeek,
|
|
onLoopRegionChange,
|
|
onSectionAdd,
|
|
onSectionResize,
|
|
onSectionMove,
|
|
onSectionDelete,
|
|
onDrumKeyframeAdd,
|
|
onDrumKeyframeUpdate,
|
|
onDrumKeyframeDelete,
|
|
onChordKeyframeAdd,
|
|
onChordKeyframeUpdate,
|
|
onChordKeyframeDelete,
|
|
onMuteKeyframeToggle,
|
|
onAutomationPointAdd,
|
|
onAutomationPointUpdate,
|
|
onAutomationPointDelete,
|
|
className,
|
|
}: TimelineProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
|
|
const [selectedDrumKeyframeId, setSelectedDrumKeyframeId] = useState<string | null>(null);
|
|
const [selectedChordKeyframeId, setSelectedChordKeyframeId] = useState<string | null>(null);
|
|
const [expandedLayers, setExpandedLayers] = useState<Record<LayerName, boolean>>({
|
|
drums: false,
|
|
chords: false,
|
|
ambient: false,
|
|
});
|
|
|
|
const toggleLayerExpand = (layer: LayerName) => {
|
|
setExpandedLayers((prev) => ({ ...prev, [layer]: !prev[layer] }));
|
|
};
|
|
|
|
const handleRulerClick = useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const x = e.clientX - rect.left - 80;
|
|
const totalBeats = x / (pixelsPerBar / 4);
|
|
const bar = Math.floor(totalBeats / 4);
|
|
const beat = Math.floor(totalBeats % 4);
|
|
|
|
if (bar >= 0 && bar < state.durationBars) {
|
|
onSeek(bar, beat);
|
|
}
|
|
},
|
|
[pixelsPerBar, state.durationBars, onSeek]
|
|
);
|
|
|
|
const totalHeight =
|
|
24 + 20 + 32 + 32 + 32 +
|
|
(expandedLayers.drums ? 48 : 0) +
|
|
(expandedLayers.chords ? 48 : 0) +
|
|
(expandedLayers.ambient ? 48 : 0) +
|
|
24 + 24 + 24;
|
|
|
|
return (
|
|
<div className={cn('flex flex-col bg-card/30 rounded-lg border border-border/50', className)}>
|
|
<div className="flex items-center justify-between p-2 border-b border-border/50">
|
|
<TimeDisplay
|
|
bar={state.playheadBar}
|
|
beat={state.playheadBeat}
|
|
durationBars={state.durationBars}
|
|
bpm={bpm}
|
|
/>
|
|
<DurationSelector value={state.durationBars} onChange={onDurationChange} />
|
|
</div>
|
|
|
|
<div ref={containerRef} className="relative overflow-x-auto overflow-y-visible">
|
|
<div className="relative" style={{ minWidth: state.durationBars * pixelsPerBar + 80 }}>
|
|
<div className="ml-20 cursor-pointer" onClick={handleRulerClick}>
|
|
<TimelineRuler durationBars={state.durationBars} pixelsPerBar={pixelsPerBar} />
|
|
</div>
|
|
|
|
<div className="ml-20">
|
|
<LoopBracket
|
|
region={state.loopRegion}
|
|
durationBars={state.durationBars}
|
|
pixelsPerBar={pixelsPerBar}
|
|
onChange={(r) => onLoopRegionChange(r.start, r.end, r.enabled)}
|
|
/>
|
|
</div>
|
|
|
|
<SectionTrack
|
|
sections={state.sections}
|
|
durationBars={state.durationBars}
|
|
pixelsPerBar={pixelsPerBar}
|
|
selectedId={selectedSectionId}
|
|
onSelect={setSelectedSectionId}
|
|
onAdd={onSectionAdd}
|
|
onResize={onSectionResize}
|
|
onMove={onSectionMove}
|
|
onDelete={onSectionDelete}
|
|
/>
|
|
|
|
<KeyframeTrack<PatternKeyframe>
|
|
label="Drums"
|
|
icon={<Drum className="h-3 w-3" />}
|
|
keyframes={state.drumKeyframes}
|
|
durationBars={state.durationBars}
|
|
pixelsPerBar={pixelsPerBar}
|
|
selectedId={selectedDrumKeyframeId}
|
|
onSelect={setSelectedDrumKeyframeId}
|
|
onAdd={(bar) => onDrumKeyframeAdd(bar, 0)}
|
|
onDelete={onDrumKeyframeDelete}
|
|
markerColor="bg-lofi-orange"
|
|
renderPicker={({ keyframe, onClose }) => (
|
|
<PatternPicker
|
|
selectedIndex={keyframe.patternIndex}
|
|
onSelect={(idx) => onDrumKeyframeUpdate(keyframe.id, idx)}
|
|
onRandom={() => onDrumKeyframeUpdate(keyframe.id, Math.floor(Math.random() * 5))}
|
|
onClose={onClose}
|
|
/>
|
|
)}
|
|
/>
|
|
|
|
<div className="flex items-center h-5 px-2 ml-20 bg-muted/5">
|
|
<Toggle
|
|
pressed={expandedLayers.drums}
|
|
onPressedChange={() => toggleLayerExpand('drums')}
|
|
size="sm"
|
|
className="h-4 px-1 text-[9px]"
|
|
>
|
|
{expandedLayers.drums ? 'Hide' : 'Auto'}
|
|
</Toggle>
|
|
</div>
|
|
|
|
{expandedLayers.drums && (
|
|
<AutomationLane
|
|
label="Vol"
|
|
icon={<Volume2 className="h-3 w-3" />}
|
|
points={state.volumeAutomation.drums}
|
|
durationBars={state.durationBars}
|
|
pixelsPerBar={pixelsPerBar}
|
|
onAddPoint={(bar, beat, value) => onAutomationPointAdd('drums', bar, beat, value)}
|
|
onUpdatePoint={(id, bar, beat, value) =>
|
|
onAutomationPointUpdate('drums', id, bar, beat, value)
|
|
}
|
|
onDeletePoint={(id) => onAutomationPointDelete('drums', id)}
|
|
/>
|
|
)}
|
|
|
|
<KeyframeTrack<ChordKeyframe>
|
|
label="Chords"
|
|
icon={<Music className="h-3 w-3" />}
|
|
keyframes={state.chordKeyframes}
|
|
durationBars={state.durationBars}
|
|
pixelsPerBar={pixelsPerBar}
|
|
selectedId={selectedChordKeyframeId}
|
|
onSelect={setSelectedChordKeyframeId}
|
|
onAdd={(bar) => onChordKeyframeAdd(bar, 0)}
|
|
onDelete={onChordKeyframeDelete}
|
|
markerColor="bg-lofi-pink"
|
|
renderPicker={({ keyframe, onClose }) => (
|
|
<ProgressionPicker
|
|
selectedIndex={keyframe.progressionIndex}
|
|
onSelect={(idx) => onChordKeyframeUpdate(keyframe.id, idx)}
|
|
onRandom={() => onChordKeyframeUpdate(keyframe.id, Math.floor(Math.random() * 5))}
|
|
onClose={onClose}
|
|
/>
|
|
)}
|
|
/>
|
|
|
|
<div className="flex items-center h-5 px-2 ml-20 bg-muted/5">
|
|
<Toggle
|
|
pressed={expandedLayers.chords}
|
|
onPressedChange={() => toggleLayerExpand('chords')}
|
|
size="sm"
|
|
className="h-4 px-1 text-[9px]"
|
|
>
|
|
{expandedLayers.chords ? 'Hide' : 'Auto'}
|
|
</Toggle>
|
|
</div>
|
|
|
|
{expandedLayers.chords && (
|
|
<AutomationLane
|
|
label="Vol"
|
|
icon={<Volume2 className="h-3 w-3" />}
|
|
points={state.volumeAutomation.chords}
|
|
durationBars={state.durationBars}
|
|
pixelsPerBar={pixelsPerBar}
|
|
onAddPoint={(bar, beat, value) => onAutomationPointAdd('chords', bar, beat, value)}
|
|
onUpdatePoint={(id, bar, beat, value) =>
|
|
onAutomationPointUpdate('chords', id, bar, beat, value)
|
|
}
|
|
onDeletePoint={(id) => onAutomationPointDelete('chords', id)}
|
|
/>
|
|
)}
|
|
|
|
<MuteTrack
|
|
label="Ambient"
|
|
icon={<Cloud className="h-3 w-3" />}
|
|
keyframes={state.muteKeyframes.ambient}
|
|
durationBars={state.durationBars}
|
|
pixelsPerBar={pixelsPerBar}
|
|
onToggle={(bar) => onMuteKeyframeToggle('ambient', bar)}
|
|
/>
|
|
|
|
<div className="flex items-center h-5 px-2 ml-20 bg-muted/5">
|
|
<Toggle
|
|
pressed={expandedLayers.ambient}
|
|
onPressedChange={() => toggleLayerExpand('ambient')}
|
|
size="sm"
|
|
className="h-4 px-1 text-[9px]"
|
|
>
|
|
{expandedLayers.ambient ? 'Hide' : 'Auto'}
|
|
</Toggle>
|
|
</div>
|
|
|
|
{expandedLayers.ambient && (
|
|
<AutomationLane
|
|
label="Vol"
|
|
icon={<Volume2 className="h-3 w-3" />}
|
|
points={state.volumeAutomation.ambient}
|
|
durationBars={state.durationBars}
|
|
pixelsPerBar={pixelsPerBar}
|
|
onAddPoint={(bar, beat, value) => onAutomationPointAdd('ambient', bar, beat, value)}
|
|
onUpdatePoint={(id, bar, beat, value) =>
|
|
onAutomationPointUpdate('ambient', id, bar, beat, value)
|
|
}
|
|
onDeletePoint={(id) => onAutomationPointDelete('ambient', id)}
|
|
/>
|
|
)}
|
|
|
|
<Playhead
|
|
bar={state.playheadBar}
|
|
beat={state.playheadBeat}
|
|
pixelsPerBar={pixelsPerBar}
|
|
height={totalHeight}
|
|
durationBars={state.durationBars}
|
|
onSeek={onSeek}
|
|
className="ml-20"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|