feat: implement overlay architecture for automation lanes and effects
Automation lanes and effects now render as overlays on top of the waveform instead of below the track, solving both visibility and layout proportion issues. Changes: - Wrapped Track waveforms in relative container for overlay positioning - Automation lanes render with bg-black/60 backdrop-blur overlay - Effects render with TrackExtensions in overlay mode (asOverlay prop) - Added overlay-specific rendering with close button and better empty state - Both overlays use absolute positioning with z-10 for proper stacking - Eliminated height mismatch between controls and waveform areas This approach provides better visual integration and eliminates the need to match heights between the two-column layout. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ export interface TrackExtensionsProps {
|
||||
onRemoveEffect?: (effectId: string) => void;
|
||||
onUpdateEffect?: (effectId: string, parameters: any) => void;
|
||||
onAddEffect?: (effectType: EffectType) => void;
|
||||
asOverlay?: boolean; // When true, renders as full overlay without header
|
||||
}
|
||||
|
||||
export function TrackExtensions({
|
||||
@@ -25,14 +26,99 @@ export function TrackExtensions({
|
||||
onRemoveEffect,
|
||||
onUpdateEffect,
|
||||
onAddEffect,
|
||||
asOverlay = false,
|
||||
}: TrackExtensionsProps) {
|
||||
const [effectBrowserOpen, setEffectBrowserOpen] = React.useState(false);
|
||||
|
||||
// Don't render if track is collapsed
|
||||
if (track.collapsed) {
|
||||
// Don't render if track is collapsed (unless it's an overlay, which handles its own visibility)
|
||||
if (!asOverlay && track.collapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Overlay mode: render full-screen effect rack
|
||||
if (asOverlay) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full bg-card/95 rounded-lg border border-border shadow-2xl">
|
||||
{/* Header with close button */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Effects</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({track.effectChain.effects.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setEffectBrowserOpen(true)}
|
||||
title="Add effect"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onUpdateTrack(track.id, { showEffects: false })}
|
||||
title="Close effects"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Effects rack */}
|
||||
<div className="flex-1 overflow-x-auto custom-scrollbar p-4">
|
||||
<div className="flex h-full gap-4">
|
||||
{track.effectChain.effects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center w-full text-center gap-3">
|
||||
<Sparkles className="h-12 w-12 text-muted-foreground/30" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">No effects yet</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Click + to add an effect
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
track.effectChain.effects.map((effect) => (
|
||||
<EffectDevice
|
||||
key={effect.id}
|
||||
effect={effect}
|
||||
onToggleEnabled={() => onToggleEffect?.(effect.id)}
|
||||
onRemove={() => onRemoveEffect?.(effect.id)}
|
||||
onUpdateParameters={(params) => onUpdateEffect?.(effect.id, params)}
|
||||
onToggleExpanded={() => {
|
||||
const updatedEffects = track.effectChain.effects.map((e) =>
|
||||
e.id === effect.id ? { ...e, expanded: !e.expanded } : e
|
||||
);
|
||||
onUpdateTrack(track.id, {
|
||||
effectChain: { ...track.effectChain, effects: updatedEffects },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Effect Browser Dialog */}
|
||||
<EffectBrowser
|
||||
open={effectBrowserOpen}
|
||||
onClose={() => setEffectBrowserOpen(false)}
|
||||
onSelectEffect={(effectType) => {
|
||||
if (onAddEffect) {
|
||||
onAddEffect(effectType);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Original inline mode
|
||||
return (
|
||||
<>
|
||||
{/* Effects Section (Collapsible, Full Width) */}
|
||||
|
||||
@@ -205,197 +205,208 @@ export function TrackList({
|
||||
<div className="flex flex-col">
|
||||
{tracks.map((track) => (
|
||||
<React.Fragment key={track.id}>
|
||||
{/* Track Waveform Row */}
|
||||
<Track
|
||||
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}
|
||||
/>
|
||||
{/* Track Waveform Row with Overlays */}
|
||||
<div className="relative">
|
||||
<Track
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* Automation Lane Section */}
|
||||
{!track.collapsed && track.automation?.showAutomation && (
|
||||
<div className="bg-muted/30 border-b border-border">
|
||||
{track.automation.lanes
|
||||
.filter((lane) => lane.parameterId === track.automation.selectedParameterId)
|
||||
.map((lane) => (
|
||||
<AutomationLane
|
||||
key={lane.id}
|
||||
lane={lane}
|
||||
trackId={track.id}
|
||||
zoom={zoom}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isPlaying={isPlaying}
|
||||
onAddPoint={(time, value) => {
|
||||
const newPoint = createAutomationPoint(time, value);
|
||||
const updatedLanes = track.automation.lanes.map((l) =>
|
||||
l.id === lane.id
|
||||
? { ...l, points: [...l.points, newPoint].sort((a, b) => a.time - b.time) }
|
||||
: l
|
||||
);
|
||||
onUpdateTrack(track.id, {
|
||||
automation: { ...track.automation, lanes: updatedLanes },
|
||||
});
|
||||
{/* Automation Lane Overlay */}
|
||||
{!track.collapsed && track.automation?.showAutomation && (
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm pointer-events-auto z-10">
|
||||
<div className="h-full p-4">
|
||||
{track.automation.lanes
|
||||
.filter((lane) => lane.parameterId === track.automation.selectedParameterId)
|
||||
.map((lane) => (
|
||||
<AutomationLane
|
||||
key={lane.id}
|
||||
lane={lane}
|
||||
trackId={track.id}
|
||||
zoom={zoom}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isPlaying={isPlaying}
|
||||
onAddPoint={(time, value) => {
|
||||
const newPoint = createAutomationPoint(time, value);
|
||||
const updatedLanes = track.automation.lanes.map((l) =>
|
||||
l.id === lane.id
|
||||
? { ...l, points: [...l.points, newPoint].sort((a, b) => a.time - b.time) }
|
||||
: l
|
||||
);
|
||||
onUpdateTrack(track.id, {
|
||||
automation: { ...track.automation, lanes: updatedLanes },
|
||||
});
|
||||
}}
|
||||
onUpdatePoint={(pointId, updates) => {
|
||||
const updatedLanes = track.automation.lanes.map((l) =>
|
||||
l.id === lane.id
|
||||
? {
|
||||
...l,
|
||||
points: l.points.map((p) =>
|
||||
p.id === pointId ? { ...p, ...updates } : p
|
||||
),
|
||||
}
|
||||
: l
|
||||
);
|
||||
onUpdateTrack(track.id, {
|
||||
automation: { ...track.automation, lanes: updatedLanes },
|
||||
});
|
||||
}}
|
||||
onRemovePoint={(pointId) => {
|
||||
const updatedLanes = track.automation.lanes.map((l) =>
|
||||
l.id === lane.id
|
||||
? { ...l, points: l.points.filter((p) => p.id !== pointId) }
|
||||
: l
|
||||
);
|
||||
onUpdateTrack(track.id, {
|
||||
automation: { ...track.automation, lanes: updatedLanes },
|
||||
});
|
||||
}}
|
||||
onUpdateLane={(updates) => {
|
||||
const updatedLanes = track.automation.lanes.map((l) =>
|
||||
l.id === lane.id ? { ...l, ...updates } : l
|
||||
);
|
||||
onUpdateTrack(track.id, {
|
||||
automation: { ...track.automation, lanes: updatedLanes },
|
||||
});
|
||||
}}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Effects Overlay */}
|
||||
{!track.collapsed && track.showEffects && (
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm pointer-events-auto z-10">
|
||||
<div className="h-full p-4">
|
||||
<TrackExtensions
|
||||
track={track}
|
||||
onUpdateTrack={onUpdateTrack}
|
||||
asOverlay={true}
|
||||
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 });
|
||||
}}
|
||||
onUpdatePoint={(pointId, updates) => {
|
||||
const updatedLanes = track.automation.lanes.map((l) =>
|
||||
l.id === lane.id
|
||||
? {
|
||||
...l,
|
||||
points: l.points.map((p) =>
|
||||
p.id === pointId ? { ...p, ...updates } : p
|
||||
),
|
||||
}
|
||||
: l
|
||||
);
|
||||
onUpdateTrack(track.id, {
|
||||
automation: { ...track.automation, lanes: updatedLanes },
|
||||
});
|
||||
onRemoveEffect={(effectId) => {
|
||||
const updatedChain = {
|
||||
...track.effectChain,
|
||||
effects: track.effectChain.effects.filter((e) => e.id !== effectId),
|
||||
};
|
||||
onUpdateTrack(track.id, { effectChain: updatedChain });
|
||||
}}
|
||||
onRemovePoint={(pointId) => {
|
||||
const updatedLanes = track.automation.lanes.map((l) =>
|
||||
l.id === lane.id
|
||||
? { ...l, points: l.points.filter((p) => p.id !== pointId) }
|
||||
: l
|
||||
);
|
||||
onUpdateTrack(track.id, {
|
||||
automation: { ...track.automation, lanes: updatedLanes },
|
||||
});
|
||||
onUpdateEffect={(effectId, parameters) => {
|
||||
const updatedChain = {
|
||||
...track.effectChain,
|
||||
effects: track.effectChain.effects.map((e) =>
|
||||
e.id === effectId ? { ...e, parameters } : e
|
||||
),
|
||||
};
|
||||
onUpdateTrack(track.id, { effectChain: updatedChain });
|
||||
}}
|
||||
onUpdateLane={(updates) => {
|
||||
const updatedLanes = track.automation.lanes.map((l) =>
|
||||
l.id === lane.id ? { ...l, ...updates } : l
|
||||
onAddEffect={(effectType) => {
|
||||
const newEffect = createEffect(
|
||||
effectType,
|
||||
EFFECT_NAMES[effectType]
|
||||
);
|
||||
onUpdateTrack(track.id, {
|
||||
automation: { ...track.automation, lanes: updatedLanes },
|
||||
});
|
||||
const updatedChain = {
|
||||
...track.effectChain,
|
||||
effects: [...track.effectChain.effects, newEffect],
|
||||
};
|
||||
onUpdateTrack(track.id, { effectChain: updatedChain });
|
||||
}}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Effects Section */}
|
||||
<TrackExtensions
|
||||
track={track}
|
||||
onUpdateTrack={onUpdateTrack}
|
||||
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 });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user