359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Slider } from '@/components/ui/slider'
|
|
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, DragEndEvent, DragStartEvent, DragOverEvent, PointerSensor, useSensors, useSensor } from '@dnd-kit/core'
|
|
import { Play, Square, RotateCcw } from 'lucide-react'
|
|
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 flex flex-col bg-background">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b">
|
|
<h1 className="text-2xl font-bold">Sequencer</h1>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
<DndContext
|
|
sensors={sensors}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
{/* Left Sidebar - Sound Library */}
|
|
<div className="w-80 border-r bg-muted/30 flex flex-col">
|
|
<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-64 border-r">
|
|
<TrackControls
|
|
ref={trackControlsRef}
|
|
tracks={state.tracks}
|
|
onAddTrack={handleAddTrack}
|
|
onRemoveTrack={handleRemoveTrack}
|
|
onUpdateTrackName={handleUpdateTrackName}
|
|
onScroll={handleVerticalScroll}
|
|
/>
|
|
</div>
|
|
|
|
{/* Sequencer Canvas */}
|
|
<div className="flex-1">
|
|
<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>
|
|
)
|
|
} |