feat: add SequencerPage with sequencer functionality including track and sound management
feat: implement SequencerCanvas for visualizing tracks and placed sounds feat: create SoundLibrary for draggable sound selection feat: add TimelineControls for managing duration and zoom levels feat: implement TrackControls for adding, removing, and renaming tracks
This commit is contained in:
290
src/components/sequencer/SequencerCanvas.tsx
Normal file
290
src/components/sequencer/SequencerCanvas.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { useDroppable, useDraggable } from '@dnd-kit/core'
|
||||
import type { Track, PlacedSound } from '@/pages/SequencerPage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Trash2, Volume2 } from 'lucide-react'
|
||||
import { useState, forwardRef, useRef } from 'react'
|
||||
|
||||
interface SequencerCanvasProps {
|
||||
tracks: Track[]
|
||||
duration: number
|
||||
zoom: number
|
||||
currentTime: number
|
||||
isPlaying: boolean
|
||||
onScroll?: () => void
|
||||
}
|
||||
|
||||
interface TrackRowProps {
|
||||
track: Track
|
||||
duration: number
|
||||
zoom: number
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
}
|
||||
|
||||
interface PlacedSoundItemProps {
|
||||
sound: PlacedSound
|
||||
zoom: number
|
||||
trackId: string
|
||||
onRemove: (soundId: string) => void
|
||||
}
|
||||
|
||||
function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
isDragging,
|
||||
} = useDraggable({
|
||||
id: sound.id,
|
||||
data: {
|
||||
type: 'placed-sound',
|
||||
...sound,
|
||||
trackId,
|
||||
},
|
||||
})
|
||||
|
||||
const style = transform ? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
} : undefined
|
||||
|
||||
const width = sound.duration * zoom
|
||||
const left = sound.startTime * zoom
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
...style,
|
||||
position: 'absolute',
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
}}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`
|
||||
h-12 bg-primary/20 border-2 border-primary/40 rounded
|
||||
flex items-center justify-between px-2 text-xs
|
||||
cursor-grab active:cursor-grabbing
|
||||
hover:bg-primary/30 hover:border-primary/60
|
||||
group transition-colors
|
||||
${isDragging ? 'opacity-50 z-10' : 'z-0'}
|
||||
`}
|
||||
title={`${sound.name} (${formatTime(sound.duration)})`}
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Volume2 className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||
<span className="truncate font-medium text-primary">
|
||||
{sound.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove(sound.id)
|
||||
}}
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
title="Remove sound"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowProps) {
|
||||
const totalWidth = duration * zoom
|
||||
const playheadPosition = currentTime * zoom
|
||||
|
||||
const { isOver, setNodeRef: setDropRef } = useDroppable({
|
||||
id: `track-${track.id}`,
|
||||
data: {
|
||||
type: 'track',
|
||||
trackId: track.id,
|
||||
},
|
||||
})
|
||||
|
||||
const [dragOverX, setDragOverX] = useState<number | null>(null)
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isOver) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
setDragOverX(x)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setDragOverX(null)
|
||||
}
|
||||
|
||||
const handleRemoveSound = (soundId: string) => {
|
||||
// This would typically be handled by the parent component
|
||||
// For now, we'll just console.log
|
||||
console.log('Remove sound:', soundId, 'from track:', track.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ height: '80px' }}>
|
||||
<div
|
||||
ref={setDropRef}
|
||||
className={`
|
||||
w-full h-full border-b border-border/50
|
||||
relative overflow-hidden
|
||||
${isOver ? 'bg-accent/30' : 'bg-muted/10'}
|
||||
transition-colors
|
||||
`}
|
||||
style={{ minWidth: `${totalWidth}px` }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Grid lines for time markers */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{Array.from({ length: Math.ceil(duration) + 1 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute top-0 bottom-0 w-px bg-border/30"
|
||||
style={{ left: `${i * zoom}px` }}
|
||||
/>
|
||||
))}
|
||||
{/* Major grid lines every 10 seconds */}
|
||||
{Array.from({ length: Math.floor(duration / 10) + 1 }).map((_, i) => (
|
||||
<div
|
||||
key={`major-${i}`}
|
||||
className="absolute top-0 bottom-0 w-px bg-border/60"
|
||||
style={{ left: `${i * 10 * zoom}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Drop indicator */}
|
||||
{isOver && dragOverX !== null && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-primary/60 pointer-events-none z-20"
|
||||
style={{ left: `${dragOverX}px` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Playhead */}
|
||||
{isPlaying && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-30"
|
||||
style={{ left: `${playheadPosition}px` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Placed sounds */}
|
||||
{track.sounds.map((sound) => (
|
||||
<PlacedSoundItem
|
||||
key={sound.id}
|
||||
sound={sound}
|
||||
zoom={zoom}
|
||||
trackId={track.id}
|
||||
onRemove={handleRemoveSound}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(({
|
||||
tracks,
|
||||
duration,
|
||||
zoom,
|
||||
currentTime,
|
||||
isPlaying,
|
||||
onScroll,
|
||||
}, ref) => {
|
||||
const totalWidth = duration * zoom
|
||||
const timelineRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleTracksScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
// Sync timeline horizontal scroll with tracks
|
||||
if (timelineRef.current) {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
// Only update if different to prevent scroll fighting
|
||||
if (Math.abs(timelineRef.current.scrollLeft - scrollLeft) > 1) {
|
||||
timelineRef.current.scrollLeft = scrollLeft
|
||||
}
|
||||
}
|
||||
// Call the original scroll handler for vertical sync
|
||||
onScroll?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Time ruler */}
|
||||
<div className="h-8 bg-muted/50 border-b border-border/50 flex-shrink-0 overflow-hidden">
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className="h-full overflow-x-auto [&::-webkit-scrollbar]:hidden"
|
||||
style={{
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
}}
|
||||
>
|
||||
<div className="relative h-full" style={{ width: `${totalWidth}px` }}>
|
||||
{Array.from({ length: Math.ceil(duration) + 1 }).map((_, i) => (
|
||||
<div key={i} className="absolute top-0 bottom-0" style={{ left: `${i * zoom}px` }}>
|
||||
{/* Time markers */}
|
||||
{i % 5 === 0 && (
|
||||
<>
|
||||
<div className="absolute top-0 w-px h-3 bg-border/60" />
|
||||
<div className="absolute top-4 text-xs text-muted-foreground font-mono">
|
||||
{Math.floor(i / 60)}:{(i % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{i % 5 !== 0 && (
|
||||
<div className="absolute top-0 w-px h-2 bg-border/40" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Playhead in ruler */}
|
||||
{isPlaying && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-30"
|
||||
style={{ left: `${currentTime * zoom}px` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tracks */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className="w-full h-full overflow-auto"
|
||||
onScroll={handleTracksScroll}
|
||||
>
|
||||
<div style={{ minWidth: `${totalWidth}px` }}>
|
||||
{tracks.map((track) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
duration={duration}
|
||||
zoom={zoom}
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
SequencerCanvas.displayName = 'SequencerCanvas'
|
||||
Reference in New Issue
Block a user