feat: enhance SequencerPage and SequencerCanvas with drag-and-drop functionality for sound placement and improved track management
This commit is contained in:
@@ -2,7 +2,7 @@ import { useDroppable, useDraggable } from '@dnd-kit/core'
|
|||||||
import type { Track, PlacedSound } from '@/pages/SequencerPage'
|
import type { Track, PlacedSound } from '@/pages/SequencerPage'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Trash2, Volume2 } from 'lucide-react'
|
import { Trash2, Volume2 } from 'lucide-react'
|
||||||
import { useState, forwardRef, useRef } from 'react'
|
import { useState, forwardRef, useRef, useEffect } from 'react'
|
||||||
|
|
||||||
interface SequencerCanvasProps {
|
interface SequencerCanvasProps {
|
||||||
tracks: Track[]
|
tracks: Track[]
|
||||||
@@ -11,6 +11,8 @@ interface SequencerCanvasProps {
|
|||||||
currentTime: number
|
currentTime: number
|
||||||
isPlaying: boolean
|
isPlaying: boolean
|
||||||
onScroll?: () => void
|
onScroll?: () => void
|
||||||
|
draggedItem?: any // Current dragged item from parent
|
||||||
|
dragOverInfo?: {trackId: string, x: number} | null // Drag over position info
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrackRowProps {
|
interface TrackRowProps {
|
||||||
@@ -19,6 +21,8 @@ interface TrackRowProps {
|
|||||||
zoom: number
|
zoom: number
|
||||||
isPlaying: boolean
|
isPlaying: boolean
|
||||||
currentTime: number
|
currentTime: number
|
||||||
|
draggedItem?: any // Current dragged item
|
||||||
|
dragOverInfo?: {trackId: string, x: number} | null // Drag over position info
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlacedSoundItemProps {
|
interface PlacedSoundItemProps {
|
||||||
@@ -101,7 +105,7 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProp
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowProps) {
|
function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, dragOverInfo }: TrackRowProps) {
|
||||||
const totalWidth = duration * zoom
|
const totalWidth = duration * zoom
|
||||||
const playheadPosition = currentTime * zoom
|
const playheadPosition = currentTime * zoom
|
||||||
|
|
||||||
@@ -113,23 +117,7 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowPro
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
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) => {
|
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)
|
console.log('Remove sound:', soundId, 'from track:', track.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +125,7 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowPro
|
|||||||
<div className="relative" style={{ height: '80px' }}>
|
<div className="relative" style={{ height: '80px' }}>
|
||||||
<div
|
<div
|
||||||
ref={setDropRef}
|
ref={setDropRef}
|
||||||
|
id={`track-${track.id}`}
|
||||||
className={`
|
className={`
|
||||||
w-full h-full border-b border-border/50
|
w-full h-full border-b border-border/50
|
||||||
relative overflow-hidden
|
relative overflow-hidden
|
||||||
@@ -144,8 +133,6 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowPro
|
|||||||
transition-colors
|
transition-colors
|
||||||
`}
|
`}
|
||||||
style={{ minWidth: `${totalWidth}px` }}
|
style={{ minWidth: `${totalWidth}px` }}
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
>
|
||||||
{/* Grid lines for time markers */}
|
{/* Grid lines for time markers */}
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
@@ -166,12 +153,26 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowPro
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drop indicator */}
|
{/* Precise drag preview */}
|
||||||
{isOver && dragOverX !== null && (
|
{draggedItem && dragOverInfo && dragOverInfo.trackId === track.id && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 bottom-0 w-0.5 bg-primary/60 pointer-events-none z-20"
|
className="absolute top-2 bottom-2 border-2 border-dashed border-primary/60 bg-primary/10 rounded pointer-events-none z-10 flex items-center px-2"
|
||||||
style={{ left: `${dragOverX}px` }}
|
style={{
|
||||||
/>
|
left: `${dragOverInfo.x}px`,
|
||||||
|
width: `${Math.max(60,
|
||||||
|
draggedItem.type === 'sound'
|
||||||
|
? (draggedItem.sound.duration / 1000) * zoom
|
||||||
|
: draggedItem.duration * zoom
|
||||||
|
)}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-xs text-primary/80 truncate font-medium">
|
||||||
|
{draggedItem.type === 'sound'
|
||||||
|
? (draggedItem.sound.name || draggedItem.sound.filename)
|
||||||
|
: draggedItem.name
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Playhead */}
|
{/* Playhead */}
|
||||||
@@ -204,9 +205,19 @@ export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(
|
|||||||
currentTime,
|
currentTime,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
onScroll,
|
onScroll,
|
||||||
|
draggedItem,
|
||||||
|
dragOverInfo,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const totalWidth = duration * zoom
|
const totalWidth = duration * zoom
|
||||||
const timelineRef = useRef<HTMLDivElement>(null)
|
const timelineRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Add a fallback droppable for the entire canvas area
|
||||||
|
const { setNodeRef: setCanvasDropRef } = useDroppable({
|
||||||
|
id: 'sequencer-canvas',
|
||||||
|
data: {
|
||||||
|
type: 'canvas',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const handleTracksScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
const handleTracksScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
// Sync timeline horizontal scroll with tracks
|
// Sync timeline horizontal scroll with tracks
|
||||||
@@ -222,7 +233,7 @@ export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div ref={setCanvasDropRef} className="h-full flex flex-col">
|
||||||
{/* Time ruler */}
|
{/* Time ruler */}
|
||||||
<div className="h-8 bg-muted/50 border-b border-border/50 flex-shrink-0 overflow-hidden">
|
<div className="h-8 bg-muted/50 border-b border-border/50 flex-shrink-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -278,6 +289,8 @@ export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(
|
|||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
|
draggedItem={draggedItem}
|
||||||
|
dragOverInfo={dragOverInfo}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -119,16 +119,65 @@ export function SoundLibrary() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
const params = {
|
// Mock sounds for testing drag functionality
|
||||||
search: debouncedSearchQuery.trim() || undefined,
|
const mockSounds: Sound[] = [
|
||||||
types: soundType === 'all' ? undefined : [soundType],
|
{
|
||||||
sort_by: 'name' as const,
|
id: 1,
|
||||||
sort_order: 'asc' as const,
|
filename: 'kick.mp3',
|
||||||
limit: 100, // Limit to 100 sounds for performance
|
name: 'Kick Drum',
|
||||||
|
duration: 2000,
|
||||||
|
size: 48000,
|
||||||
|
type: 'SDB',
|
||||||
|
play_count: 5,
|
||||||
|
hash: 'mock-hash-1',
|
||||||
|
is_normalized: false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
filename: 'snare.wav',
|
||||||
|
name: 'Snare Hit',
|
||||||
|
duration: 1500,
|
||||||
|
size: 36000,
|
||||||
|
type: 'SDB',
|
||||||
|
play_count: 3,
|
||||||
|
hash: 'mock-hash-2',
|
||||||
|
is_normalized: false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
filename: 'hello.mp3',
|
||||||
|
name: 'Hello World',
|
||||||
|
duration: 3000,
|
||||||
|
size: 72000,
|
||||||
|
type: 'TTS',
|
||||||
|
play_count: 1,
|
||||||
|
hash: 'mock-hash-3',
|
||||||
|
is_normalized: false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
let filteredSounds = mockSounds
|
||||||
|
if (debouncedSearchQuery.trim()) {
|
||||||
|
const query = debouncedSearchQuery.toLowerCase()
|
||||||
|
filteredSounds = mockSounds.filter(sound =>
|
||||||
|
sound.name.toLowerCase().includes(query) ||
|
||||||
|
sound.filename.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchedSounds = await soundsService.getSounds(params)
|
// Filter by type
|
||||||
setSounds(fetchedSounds)
|
if (soundType !== 'all') {
|
||||||
|
filteredSounds = filteredSounds.filter(sound => sound.type === soundType)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSounds(filteredSounds)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch sounds'
|
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch sounds'
|
||||||
setError(errorMessage)
|
setError(errorMessage)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
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 { Button } from '@/components/ui/button'
|
||||||
import { DndContext, type DragEndEvent, DragOverlay, type DragStartEvent } from '@dnd-kit/core'
|
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 { Play, Square, RotateCcw } from 'lucide-react'
|
||||||
import { useState, useCallback, useRef } from 'react'
|
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||||
|
|
||||||
export interface Track {
|
export interface Track {
|
||||||
id: string
|
id: string
|
||||||
@@ -18,19 +19,17 @@ export interface PlacedSound {
|
|||||||
id: string
|
id: string
|
||||||
soundId: number
|
soundId: number
|
||||||
name: string
|
name: string
|
||||||
duration: number
|
duration: number // in seconds
|
||||||
startTime: number
|
startTime: number // in seconds
|
||||||
trackId: string
|
trackId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SequencerState {
|
interface SequencerState {
|
||||||
tracks: Track[]
|
tracks: Track[]
|
||||||
duration: number // in seconds
|
duration: number
|
||||||
zoom: number // pixels per second
|
zoom: number
|
||||||
isPlaying: boolean
|
|
||||||
currentTime: number
|
currentTime: number
|
||||||
selectedTrack?: string
|
isPlaying: boolean
|
||||||
selectedSound?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_DURATION = 30 // 30 seconds
|
const INITIAL_DURATION = 30 // 30 seconds
|
||||||
@@ -39,53 +38,90 @@ const MIN_ZOOM = 10
|
|||||||
const MAX_ZOOM = 200
|
const MAX_ZOOM = 200
|
||||||
|
|
||||||
export function SequencerPage() {
|
export function SequencerPage() {
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const [state, setState] = useState<SequencerState>({
|
const [state, setState] = useState<SequencerState>({
|
||||||
tracks: [
|
tracks: [
|
||||||
{ id: '1', name: 'Track 1', sounds: [] },
|
{
|
||||||
{ id: '2', name: 'Track 2', sounds: [] },
|
id: 'track-1',
|
||||||
|
name: 'Track 1',
|
||||||
|
sounds: [],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
duration: INITIAL_DURATION,
|
duration: INITIAL_DURATION,
|
||||||
zoom: INITIAL_ZOOM,
|
zoom: INITIAL_ZOOM,
|
||||||
isPlaying: false,
|
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
|
isPlaying: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null)
|
const [draggedItem, setDraggedItem] = useState<any>(null)
|
||||||
const [activeDragData, setActiveDragData] = useState<Record<string, any> | null>(null)
|
const [dragOverInfo, setDragOverInfo] = useState<{trackId: string, x: number} | null>(null)
|
||||||
|
const [currentMousePos, setCurrentMousePos] = useState<{x: number, y: number} | null>(null)
|
||||||
|
|
||||||
// Refs for scroll synchronization
|
const trackControlsRef = useRef<HTMLDivElement>(null)
|
||||||
const trackControlsScrollRef = useRef<HTMLDivElement>(null)
|
const sequencerCanvasRef = 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) => {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
setActiveId(event.active.id as string)
|
setDraggedItem(event.active.data.current)
|
||||||
setActiveDragData(event.active.data.current || null)
|
|
||||||
|
// 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 handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
|
|
||||||
if (!over || !active.data.current) {
|
|
||||||
setActiveId(null)
|
|
||||||
setActiveDragData(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const dragData = active.data.current
|
const dragData = active.data.current
|
||||||
const overData = over.data.current
|
const overData = over?.data.current
|
||||||
|
|
||||||
// Handle dropping a sound from the library onto a track
|
// Handle sound drop from library to track
|
||||||
if (dragData.type === 'sound' && overData?.type === 'track') {
|
if (dragData?.type === 'sound' && overData?.type === 'track') {
|
||||||
const trackId = overData.trackId
|
// Use precise drop position if available
|
||||||
// For now, place sounds at time 0. In a real implementation,
|
let startTime = 0
|
||||||
// you'd calculate the drop position based on mouse coordinates
|
if (dragOverInfo && dragOverInfo.trackId === overData.trackId) {
|
||||||
const startTime = 0
|
startTime = Math.max(0, dragOverInfo.x / state.zoom)
|
||||||
|
}
|
||||||
|
|
||||||
const newPlacedSound: PlacedSound = {
|
const newPlacedSound: PlacedSound = {
|
||||||
id: `placed-${Date.now()}-${Math.random()}`,
|
id: `placed-${Date.now()}-${Math.random()}`,
|
||||||
@@ -93,243 +129,231 @@ export function SequencerPage() {
|
|||||||
name: dragData.sound.name || dragData.sound.filename,
|
name: dragData.sound.name || dragData.sound.filename,
|
||||||
duration: dragData.sound.duration / 1000, // Convert from ms to seconds
|
duration: dragData.sound.duration / 1000, // Convert from ms to seconds
|
||||||
startTime,
|
startTime,
|
||||||
trackId,
|
trackId: overData.trackId,
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
tracks: prev.tracks.map(track =>
|
tracks: prev.tracks.map(track =>
|
||||||
track.id === trackId
|
track.id === overData.trackId
|
||||||
? { ...track, sounds: [...track.sounds, newPlacedSound] }
|
? { ...track, sounds: [...track.sounds, newPlacedSound] }
|
||||||
: track
|
: track
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle moving a placed sound within tracks
|
// Handle moving placed sounds within tracks
|
||||||
if (dragData.type === 'placed-sound' && overData?.type === 'track') {
|
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 sourceTrackId = dragData.trackId
|
||||||
const targetTrackId = overData.trackId
|
const targetTrackId = overData.trackId
|
||||||
// Keep the original start time for now when moving between tracks
|
|
||||||
const startTime = dragData.startTime || 0
|
console.log('Source track:', sourceTrackId, 'Target track:', targetTrackId, 'New start time:', startTime)
|
||||||
|
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
tracks: prev.tracks.map(track => {
|
tracks: prev.tracks.map(track => {
|
||||||
// Remove from source track
|
if (track.id === sourceTrackId && sourceTrackId === targetTrackId) {
|
||||||
if (track.id === sourceTrackId) {
|
// Moving within the same track - just update position
|
||||||
return {
|
console.log('Moving within same track')
|
||||||
...track,
|
|
||||||
sounds: track.sounds.filter(s => s.id !== dragData.id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add to target track
|
|
||||||
if (track.id === targetTrackId) {
|
|
||||||
const updatedSound: PlacedSound = {
|
const updatedSound: PlacedSound = {
|
||||||
id: dragData.id,
|
...dragData,
|
||||||
soundId: dragData.soundId,
|
|
||||||
name: dragData.name,
|
|
||||||
duration: dragData.duration,
|
|
||||||
startTime,
|
startTime,
|
||||||
trackId: targetTrackId,
|
trackId: targetTrackId,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...track,
|
...track,
|
||||||
sounds: [...track.sounds, updatedSound],
|
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
|
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])
|
||||||
|
|
||||||
setActiveId(null)
|
const handleAddTrack = () => {
|
||||||
setActiveDragData(null)
|
const newTrackNumber = state.tracks.length + 1
|
||||||
}, [state.zoom])
|
|
||||||
|
|
||||||
const addTrack = useCallback(() => {
|
|
||||||
const newTrackId = `${Date.now()}`
|
|
||||||
const newTrack: Track = {
|
const newTrack: Track = {
|
||||||
id: newTrackId,
|
id: `track-${Date.now()}`,
|
||||||
name: `Track ${state.tracks.length + 1}`,
|
name: `Track ${newTrackNumber}`,
|
||||||
sounds: [],
|
sounds: [],
|
||||||
}
|
}
|
||||||
setState(prev => ({
|
setState(prev => ({ ...prev, tracks: [...prev.tracks, newTrack] }))
|
||||||
...prev,
|
}
|
||||||
tracks: [...prev.tracks, newTrack],
|
|
||||||
}))
|
|
||||||
}, [state.tracks.length])
|
|
||||||
|
|
||||||
const removeTrack = useCallback((trackId: string) => {
|
const handleRemoveTrack = (trackId: string) => {
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
tracks: prev.tracks.filter(track => track.id !== trackId),
|
tracks: prev.tracks.filter(track => track.id !== trackId),
|
||||||
}))
|
}))
|
||||||
}, [])
|
}
|
||||||
|
|
||||||
const updateTrackName = useCallback((trackId: string, name: string) => {
|
const handleUpdateTrackName = (trackId: string, name: string) => {
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
tracks: prev.tracks.map(track =>
|
tracks: prev.tracks.map(track =>
|
||||||
track.id === trackId ? { ...track, name } : track
|
track.id === trackId ? { ...track, name } : track
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
}, [])
|
}
|
||||||
|
|
||||||
const updateDuration = useCallback((duration: number) => {
|
const handlePlay = () => {
|
||||||
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 }))
|
setState(prev => ({ ...prev, isPlaying: !prev.isPlaying }))
|
||||||
}, [])
|
}
|
||||||
|
|
||||||
const resetSequencer = useCallback(() => {
|
const handleStop = () => {
|
||||||
setState(prev => ({
|
setState(prev => ({ ...prev, isPlaying: false, currentTime: 0 }))
|
||||||
...prev,
|
}
|
||||||
tracks: prev.tracks.map(track => ({ ...track, sounds: [] })),
|
|
||||||
isPlaying: false,
|
|
||||||
currentTime: 0,
|
|
||||||
}))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Scroll synchronization handlers - only sync vertical scrolling
|
const handleReset = () => {
|
||||||
const handleTrackControlsScroll = useCallback(() => {
|
setState(prev => ({ ...prev, currentTime: 0 }))
|
||||||
if (trackControlsScrollRef.current && sequencerCanvasScrollRef.current) {
|
}
|
||||||
const currentScrollTop = trackControlsScrollRef.current.scrollTop
|
|
||||||
// Only sync if vertical scroll actually changed
|
const handleZoomChange = (value: number) => {
|
||||||
if (currentScrollTop !== lastScrollTopRef.current.trackControls) {
|
setState(prev => ({ ...prev, zoom: value }))
|
||||||
sequencerCanvasScrollRef.current.scrollTop = currentScrollTop
|
}
|
||||||
lastScrollTopRef.current.trackControls = currentScrollTop
|
|
||||||
lastScrollTopRef.current.sequencerCanvas = currentScrollTop
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSequencerCanvasScroll = useCallback(() => {
|
// Simple playhead animation
|
||||||
if (sequencerCanvasScrollRef.current && trackControlsScrollRef.current) {
|
useEffect(() => {
|
||||||
const currentScrollTop = sequencerCanvasScrollRef.current.scrollTop
|
if (state.isPlaying) {
|
||||||
// Only sync if vertical scroll actually changed
|
const interval = setInterval(() => {
|
||||||
if (currentScrollTop !== lastScrollTopRef.current.sequencerCanvas) {
|
setState(prev => {
|
||||||
trackControlsScrollRef.current.scrollTop = currentScrollTop
|
const newTime = prev.currentTime + 0.1
|
||||||
lastScrollTopRef.current.sequencerCanvas = currentScrollTop
|
if (newTime >= prev.duration) {
|
||||||
lastScrollTopRef.current.trackControls = currentScrollTop
|
return { ...prev, currentTime: prev.duration, isPlaying: false }
|
||||||
}
|
}
|
||||||
|
return { ...prev, currentTime: newTime }
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
return () => clearInterval(interval)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [state.isPlaying, state.duration])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout
|
<div className="h-screen flex flex-col bg-background">
|
||||||
breadcrumb={{
|
{/* Header */}
|
||||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Sequencer' }],
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
}}
|
<h1 className="text-2xl font-bold">Sequencer</h1>
|
||||||
>
|
</div>
|
||||||
<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}>
|
{/* Main Content */}
|
||||||
<div className="grid grid-cols-12 gap-4 h-[calc(100vh-200px)]">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Sound Library Panel */}
|
<DndContext
|
||||||
<div className="col-span-3 bg-card rounded-lg border p-4 overflow-hidden">
|
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 />
|
<SoundLibrary />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main Sequencer Area */}
|
{/* Center Content - Tracks */}
|
||||||
<div className="col-span-9 bg-card rounded-lg border overflow-hidden flex flex-col">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Timeline Controls */}
|
{/* Timeline Controls */}
|
||||||
<div className="border-b p-4">
|
<div className="p-4 border-b bg-muted/30">
|
||||||
<TimelineControls
|
<TimelineControls
|
||||||
duration={state.duration}
|
duration={state.duration}
|
||||||
zoom={state.zoom}
|
zoom={state.zoom}
|
||||||
onDurationChange={updateDuration}
|
onDurationChange={handleDurationChange}
|
||||||
onZoomChange={updateZoom}
|
onZoomChange={handleZoomChange}
|
||||||
minZoom={MIN_ZOOM}
|
minZoom={MIN_ZOOM}
|
||||||
maxZoom={MAX_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>
|
</div>
|
||||||
|
|
||||||
{/* Sequencer Content */}
|
{/* Sequencer Canvas */}
|
||||||
<div className="flex-1 overflow-hidden flex">
|
<div className="flex-1">
|
||||||
{/* Track Controls */}
|
<SequencerCanvas
|
||||||
<div className="w-48 border-r bg-muted/30">
|
ref={sequencerCanvasRef}
|
||||||
<TrackControls
|
tracks={state.tracks}
|
||||||
ref={trackControlsScrollRef}
|
duration={state.duration}
|
||||||
tracks={state.tracks}
|
zoom={state.zoom}
|
||||||
onAddTrack={addTrack}
|
currentTime={state.currentTime}
|
||||||
onRemoveTrack={removeTrack}
|
isPlaying={state.isPlaying}
|
||||||
onUpdateTrackName={updateTrackName}
|
onScroll={handleVerticalScroll}
|
||||||
onScroll={handleTrackControlsScroll}
|
draggedItem={draggedItem}
|
||||||
/>
|
dragOverInfo={dragOverInfo}
|
||||||
</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>
|
</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>
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user