Files
sbd2-frontend/src/pages/SequencerPage.tsx

357 lines
12 KiB
TypeScript

import { TrackControls } from '@/components/sequencer/TrackControls'
import { TimelineControls } from '@/components/sequencer/TimelineControls'
import { SoundLibrary } from '@/components/sequencer/SoundLibrary'
import { SequencerCanvas } from '@/components/sequencer/SequencerCanvas'
import { DndContext, type DragEndEvent, type DragStartEvent, PointerSensor, useSensors, useSensor } from '@dnd-kit/core'
import { useState, useRef, useCallback, useEffect } from 'react'
export interface Track {
id: string
name: string
sounds: PlacedSound[]
}
export interface PlacedSound {
id: string
soundId: number
name: string
duration: number // in seconds
startTime: number // in seconds
trackId: string
}
interface SequencerState {
tracks: Track[]
duration: number
zoom: number
currentTime: number
isPlaying: boolean
}
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 sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
)
const [state, setState] = useState<SequencerState>({
tracks: [
{
id: 'track-1',
name: 'Track 1',
sounds: [],
},
],
duration: INITIAL_DURATION,
zoom: INITIAL_ZOOM,
currentTime: 0,
isPlaying: false,
})
const [draggedItem, setDraggedItem] = useState<any>(null)
const [dragOverInfo, setDragOverInfo] = useState<{trackId: string, x: number} | null>(null)
const [currentMousePos, setCurrentMousePos] = useState<{x: number, y: number} | null>(null)
const trackControlsRef = useRef<HTMLDivElement>(null)
const sequencerCanvasRef = useRef<HTMLDivElement>(null)
const handleDragStart = useCallback((event: DragStartEvent) => {
setDraggedItem(event.active.data.current)
// Start tracking mouse position globally
const handleMouseMove = (e: MouseEvent) => {
setCurrentMousePos({ x: e.clientX, y: e.clientY })
}
document.addEventListener('mousemove', handleMouseMove)
// Store cleanup function
;(window as any).dragMouseCleanup = () => {
document.removeEventListener('mousemove', handleMouseMove)
}
}, [])
// Update drag over info based on current mouse position and over target
useEffect(() => {
if (draggedItem && currentMousePos && (draggedItem.type === 'sound' || draggedItem.type === 'placed-sound')) {
// Find which track the mouse is currently over
for (const track of state.tracks) {
const trackElement = document.getElementById(`track-${track.id}`)
if (trackElement) {
const rect = trackElement.getBoundingClientRect()
if (
currentMousePos.x >= rect.left &&
currentMousePos.x <= rect.right &&
currentMousePos.y >= rect.top &&
currentMousePos.y <= rect.bottom
) {
const x = currentMousePos.x - rect.left
setDragOverInfo({ trackId: track.id, x: Math.max(0, x) })
return
}
}
}
// Mouse is not over any track
setDragOverInfo(null)
} else {
setDragOverInfo(null)
}
}, [draggedItem, currentMousePos, state.tracks])
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
const dragData = active.data.current
const overData = over?.data.current
// Handle sound drop from library to track
if (dragData?.type === 'sound' && overData?.type === 'track') {
// Use precise drop position if available
let startTime = 0
if (dragOverInfo && dragOverInfo.trackId === overData.trackId) {
startTime = Math.max(0, dragOverInfo.x / state.zoom)
}
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: overData.trackId,
}
setState(prev => ({
...prev,
tracks: prev.tracks.map(track =>
track.id === overData.trackId
? { ...track, sounds: [...track.sounds, newPlacedSound] }
: track
),
}))
}
// Handle moving placed sounds within tracks
if (dragData?.type === 'placed-sound' && overData?.type === 'track') {
console.log('Moving placed sound:', dragData, 'to track:', overData.trackId)
// Use precise drop position if available
let startTime = dragData.startTime || 0
if (dragOverInfo && dragOverInfo.trackId === overData.trackId) {
startTime = Math.max(0, dragOverInfo.x / state.zoom)
}
const sourceTrackId = dragData.trackId
const targetTrackId = overData.trackId
console.log('Source track:', sourceTrackId, 'Target track:', targetTrackId, 'New start time:', startTime)
setState(prev => ({
...prev,
tracks: prev.tracks.map(track => {
if (track.id === sourceTrackId && sourceTrackId === targetTrackId) {
// Moving within the same track - just update position
console.log('Moving within same track')
const updatedSound: PlacedSound = {
...dragData,
startTime,
trackId: targetTrackId,
}
return {
...track,
sounds: track.sounds.map(s =>
s.id === dragData.id ? updatedSound : s
),
}
} else if (track.id === sourceTrackId) {
// Remove from source track (different track move)
console.log('Removing sound from source track:', track.id, 'sounds before:', track.sounds.length)
const filtered = track.sounds.filter(s => s.id !== dragData.id)
console.log('Sounds after removal:', filtered.length)
return {
...track,
sounds: filtered,
}
} else if (track.id === targetTrackId) {
// Add to target track (different track move)
console.log('Adding sound to target track:', track.id, 'sounds before:', track.sounds.length)
const updatedSound: PlacedSound = {
...dragData,
startTime,
trackId: targetTrackId,
}
console.log('Updated sound:', updatedSound)
const newSounds = [...track.sounds, updatedSound]
console.log('Sounds after addition:', newSounds.length)
return {
...track,
sounds: newSounds,
}
}
return track
}),
}))
}
// Clear state
setDraggedItem(null)
setDragOverInfo(null)
setCurrentMousePos(null)
// Clean up mouse tracking
if ((window as any).dragMouseCleanup) {
(window as any).dragMouseCleanup()
delete (window as any).dragMouseCleanup
}
}, [dragOverInfo, state.zoom])
const handleAddTrack = () => {
const newTrackNumber = state.tracks.length + 1
const newTrack: Track = {
id: `track-${Date.now()}`,
name: `Track ${newTrackNumber}`,
sounds: [],
}
setState(prev => ({ ...prev, tracks: [...prev.tracks, newTrack] }))
}
const handleRemoveTrack = (trackId: string) => {
setState(prev => ({
...prev,
tracks: prev.tracks.filter(track => track.id !== trackId),
}))
}
const handleUpdateTrackName = (trackId: string, name: string) => {
setState(prev => ({
...prev,
tracks: prev.tracks.map(track =>
track.id === trackId ? { ...track, name } : track
),
}))
}
const handlePlay = () => {
setState(prev => ({ ...prev, isPlaying: !prev.isPlaying }))
}
const handleStop = () => {
setState(prev => ({ ...prev, isPlaying: false, currentTime: 0 }))
}
const handleReset = () => {
setState(prev => ({ ...prev, currentTime: 0 }))
}
const handleZoomChange = (value: number) => {
setState(prev => ({ ...prev, zoom: value }))
}
const handleDurationChange = (duration: number) => {
setState(prev => ({ ...prev, duration }))
}
const handleVerticalScroll = useCallback(() => {
if (trackControlsRef.current && sequencerCanvasRef.current) {
const canvasScrollTop = sequencerCanvasRef.current.scrollTop
if (Math.abs(trackControlsRef.current.scrollTop - canvasScrollTop) > 1) {
trackControlsRef.current.scrollTop = canvasScrollTop
}
}
}, [])
// Simple playhead animation
useEffect(() => {
if (state.isPlaying) {
const interval = setInterval(() => {
setState(prev => {
const newTime = prev.currentTime + 0.1
if (newTime >= prev.duration) {
return { ...prev, currentTime: prev.duration, isPlaying: false }
}
return { ...prev, currentTime: newTime }
})
}, 100)
return () => clearInterval(interval)
}
}, [state.isPlaying, state.duration])
return (
<div className="h-screen w-screen flex flex-col overflow-hidden">
{/* Simple Header */}
<div className="h-12 bg-background border-b flex items-center px-4 flex-shrink-0">
<a href="/" className="text-sm text-muted-foreground hover:text-foreground">Home</a>
<span className="mx-2 text-muted-foreground">/</span>
<span className="text-sm font-medium">Sequencer</span>
</div>
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{/* Left Sidebar - Sound Library */}
<div className="w-64 border-r bg-muted/30 flex flex-col flex-shrink-0">
<div className="p-4 h-full">
<SoundLibrary />
</div>
</div>
{/* Center Content - Tracks */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Timeline Controls */}
<div className="p-4 border-b bg-muted/30">
<TimelineControls
duration={state.duration}
zoom={state.zoom}
onDurationChange={handleDurationChange}
onZoomChange={handleZoomChange}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
/>
</div>
{/* Track Area */}
<div className="flex-1 flex overflow-hidden">
{/* Track Controls */}
<div className="w-32 border-r flex-shrink-0">
<TrackControls
ref={trackControlsRef}
tracks={state.tracks}
onAddTrack={handleAddTrack}
onRemoveTrack={handleRemoveTrack}
onUpdateTrackName={handleUpdateTrackName}
onScroll={handleVerticalScroll}
/>
</div>
{/* Sequencer Canvas */}
<div className="flex-1 overflow-hidden">
<SequencerCanvas
ref={sequencerCanvasRef}
tracks={state.tracks}
duration={state.duration}
zoom={state.zoom}
currentTime={state.currentTime}
isPlaying={state.isPlaying}
onScroll={handleVerticalScroll}
draggedItem={draggedItem}
dragOverInfo={dragOverInfo}
/>
</div>
</div>
</div>
</DndContext>
</div>
</div>
)
}