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:
335
src/pages/SequencerPage.tsx
Normal file
335
src/pages/SequencerPage.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { SequencerCanvas } from '@/components/sequencer/SequencerCanvas'
|
||||
import { SoundLibrary } from '@/components/sequencer/SoundLibrary'
|
||||
import { TimelineControls } from '@/components/sequencer/TimelineControls'
|
||||
import { TrackControls } from '@/components/sequencer/TrackControls'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DndContext, type DragEndEvent, DragOverlay, type DragStartEvent } from '@dnd-kit/core'
|
||||
import { Play, Square, RotateCcw } from 'lucide-react'
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
|
||||
export interface Track {
|
||||
id: string
|
||||
name: string
|
||||
sounds: PlacedSound[]
|
||||
}
|
||||
|
||||
export interface PlacedSound {
|
||||
id: string
|
||||
soundId: number
|
||||
name: string
|
||||
duration: number
|
||||
startTime: number
|
||||
trackId: string
|
||||
}
|
||||
|
||||
export interface SequencerState {
|
||||
tracks: Track[]
|
||||
duration: number // in seconds
|
||||
zoom: number // pixels per second
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
selectedTrack?: string
|
||||
selectedSound?: string
|
||||
}
|
||||
|
||||
const INITIAL_DURATION = 30 // 30 seconds
|
||||
const INITIAL_ZOOM = 40 // 40 pixels per second
|
||||
const MIN_ZOOM = 10
|
||||
const MAX_ZOOM = 200
|
||||
|
||||
export function SequencerPage() {
|
||||
const [state, setState] = useState<SequencerState>({
|
||||
tracks: [
|
||||
{ id: '1', name: 'Track 1', sounds: [] },
|
||||
{ id: '2', name: 'Track 2', sounds: [] },
|
||||
],
|
||||
duration: INITIAL_DURATION,
|
||||
zoom: INITIAL_ZOOM,
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
})
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const [activeDragData, setActiveDragData] = useState<Record<string, any> | null>(null)
|
||||
|
||||
// Refs for scroll synchronization
|
||||
const trackControlsScrollRef = useRef<HTMLDivElement>(null)
|
||||
const sequencerCanvasScrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Track last scroll positions to detect which dimension changed
|
||||
const lastScrollTopRef = useRef<{ trackControls: number; sequencerCanvas: number }>({
|
||||
trackControls: 0,
|
||||
sequencerCanvas: 0
|
||||
})
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string)
|
||||
setActiveDragData(event.active.data.current || null)
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (!over || !active.data.current) {
|
||||
setActiveId(null)
|
||||
setActiveDragData(null)
|
||||
return
|
||||
}
|
||||
|
||||
const dragData = active.data.current
|
||||
const overData = over.data.current
|
||||
|
||||
// Handle dropping a sound from the library onto a track
|
||||
if (dragData.type === 'sound' && overData?.type === 'track') {
|
||||
const trackId = overData.trackId
|
||||
// For now, place sounds at time 0. In a real implementation,
|
||||
// you'd calculate the drop position based on mouse coordinates
|
||||
const startTime = 0
|
||||
|
||||
const newPlacedSound: PlacedSound = {
|
||||
id: `placed-${Date.now()}-${Math.random()}`,
|
||||
soundId: dragData.sound.id,
|
||||
name: dragData.sound.name || dragData.sound.filename,
|
||||
duration: dragData.sound.duration / 1000, // Convert from ms to seconds
|
||||
startTime,
|
||||
trackId,
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tracks: prev.tracks.map(track =>
|
||||
track.id === trackId
|
||||
? { ...track, sounds: [...track.sounds, newPlacedSound] }
|
||||
: track
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle moving a placed sound within tracks
|
||||
if (dragData.type === 'placed-sound' && overData?.type === 'track') {
|
||||
const sourceTrackId = dragData.trackId
|
||||
const targetTrackId = overData.trackId
|
||||
// Keep the original start time for now when moving between tracks
|
||||
const startTime = dragData.startTime || 0
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tracks: prev.tracks.map(track => {
|
||||
// Remove from source track
|
||||
if (track.id === sourceTrackId) {
|
||||
return {
|
||||
...track,
|
||||
sounds: track.sounds.filter(s => s.id !== dragData.id),
|
||||
}
|
||||
}
|
||||
// Add to target track
|
||||
if (track.id === targetTrackId) {
|
||||
const updatedSound: PlacedSound = {
|
||||
id: dragData.id,
|
||||
soundId: dragData.soundId,
|
||||
name: dragData.name,
|
||||
duration: dragData.duration,
|
||||
startTime,
|
||||
trackId: targetTrackId,
|
||||
}
|
||||
return {
|
||||
...track,
|
||||
sounds: [...track.sounds, updatedSound],
|
||||
}
|
||||
}
|
||||
return track
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
setActiveId(null)
|
||||
setActiveDragData(null)
|
||||
}, [state.zoom])
|
||||
|
||||
const addTrack = useCallback(() => {
|
||||
const newTrackId = `${Date.now()}`
|
||||
const newTrack: Track = {
|
||||
id: newTrackId,
|
||||
name: `Track ${state.tracks.length + 1}`,
|
||||
sounds: [],
|
||||
}
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tracks: [...prev.tracks, newTrack],
|
||||
}))
|
||||
}, [state.tracks.length])
|
||||
|
||||
const removeTrack = useCallback((trackId: string) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tracks: prev.tracks.filter(track => track.id !== trackId),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const updateTrackName = useCallback((trackId: string, name: string) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tracks: prev.tracks.map(track =>
|
||||
track.id === trackId ? { ...track, name } : track
|
||||
),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const updateDuration = useCallback((duration: number) => {
|
||||
setState(prev => ({ ...prev, duration }))
|
||||
}, [])
|
||||
|
||||
const updateZoom = useCallback((zoom: number) => {
|
||||
const clampedZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))
|
||||
setState(prev => ({ ...prev, zoom: clampedZoom }))
|
||||
}, [])
|
||||
|
||||
const togglePlayback = useCallback(() => {
|
||||
setState(prev => ({ ...prev, isPlaying: !prev.isPlaying }))
|
||||
}, [])
|
||||
|
||||
const resetSequencer = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tracks: prev.tracks.map(track => ({ ...track, sounds: [] })),
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
// Scroll synchronization handlers - only sync vertical scrolling
|
||||
const handleTrackControlsScroll = useCallback(() => {
|
||||
if (trackControlsScrollRef.current && sequencerCanvasScrollRef.current) {
|
||||
const currentScrollTop = trackControlsScrollRef.current.scrollTop
|
||||
// Only sync if vertical scroll actually changed
|
||||
if (currentScrollTop !== lastScrollTopRef.current.trackControls) {
|
||||
sequencerCanvasScrollRef.current.scrollTop = currentScrollTop
|
||||
lastScrollTopRef.current.trackControls = currentScrollTop
|
||||
lastScrollTopRef.current.sequencerCanvas = currentScrollTop
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSequencerCanvasScroll = useCallback(() => {
|
||||
if (sequencerCanvasScrollRef.current && trackControlsScrollRef.current) {
|
||||
const currentScrollTop = sequencerCanvasScrollRef.current.scrollTop
|
||||
// Only sync if vertical scroll actually changed
|
||||
if (currentScrollTop !== lastScrollTopRef.current.sequencerCanvas) {
|
||||
trackControlsScrollRef.current.scrollTop = currentScrollTop
|
||||
lastScrollTopRef.current.sequencerCanvas = currentScrollTop
|
||||
lastScrollTopRef.current.trackControls = currentScrollTop
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Sequencer' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4 h-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Sequencer</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create sequences by dragging sounds onto tracks
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={togglePlayback}
|
||||
className="w-20"
|
||||
>
|
||||
{state.isPlaying ? (
|
||||
<>
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
Stop
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Play
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetSequencer}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-12 gap-4 h-[calc(100vh-200px)]">
|
||||
{/* Sound Library Panel */}
|
||||
<div className="col-span-3 bg-card rounded-lg border p-4 overflow-hidden">
|
||||
<SoundLibrary />
|
||||
</div>
|
||||
|
||||
{/* Main Sequencer Area */}
|
||||
<div className="col-span-9 bg-card rounded-lg border overflow-hidden flex flex-col">
|
||||
{/* Timeline Controls */}
|
||||
<div className="border-b p-4">
|
||||
<TimelineControls
|
||||
duration={state.duration}
|
||||
zoom={state.zoom}
|
||||
onDurationChange={updateDuration}
|
||||
onZoomChange={updateZoom}
|
||||
minZoom={MIN_ZOOM}
|
||||
maxZoom={MAX_ZOOM}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sequencer Content */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* Track Controls */}
|
||||
<div className="w-48 border-r bg-muted/30">
|
||||
<TrackControls
|
||||
ref={trackControlsScrollRef}
|
||||
tracks={state.tracks}
|
||||
onAddTrack={addTrack}
|
||||
onRemoveTrack={removeTrack}
|
||||
onUpdateTrackName={updateTrackName}
|
||||
onScroll={handleTrackControlsScroll}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sequencer Canvas */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<SequencerCanvas
|
||||
ref={sequencerCanvasScrollRef}
|
||||
tracks={state.tracks}
|
||||
duration={state.duration}
|
||||
zoom={state.zoom}
|
||||
currentTime={state.currentTime}
|
||||
isPlaying={state.isPlaying}
|
||||
onScroll={handleSequencerCanvasScroll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay>
|
||||
{activeId && activeDragData ? (
|
||||
<div className="bg-primary/20 border-2 border-primary rounded px-3 py-2 text-sm font-medium opacity-80">
|
||||
{activeDragData.type === 'sound'
|
||||
? activeDragData.sound?.name || activeDragData.sound?.filename
|
||||
: activeDragData.name}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user