diff --git a/src/App.tsx b/src/App.tsx index c73b477..eede663 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { PlaylistEditPage } from './pages/PlaylistEditPage' import { PlaylistsPage } from './pages/PlaylistsPage' import { RegisterPage } from './pages/RegisterPage' import { SchedulersPage } from './pages/SchedulersPage' +import { SequencerPage } from './pages/SequencerPage' import { SoundsPage } from './pages/SoundsPage' import { SettingsPage } from './pages/admin/SettingsPage' import { UsersPage } from './pages/admin/UsersPage' @@ -111,6 +112,14 @@ function AppRoutes() { } /> + + + + } + /> + diff --git a/src/components/schedulers/CreateTaskDialog.tsx b/src/components/schedulers/CreateTaskDialog.tsx index be3ccdf..f7aca27 100644 --- a/src/components/schedulers/CreateTaskDialog.tsx +++ b/src/components/schedulers/CreateTaskDialog.tsx @@ -39,7 +39,7 @@ interface CreateTaskDialogProps { } const TASK_TYPES: TaskType[] = ['credit_recharge', 'play_sound', 'play_playlist'] -const RECURRENCE_TYPES: RecurrenceType[] = ['none', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'cron'] +const RECURRENCE_TYPES: RecurrenceType[] = ['none', 'minutely', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'cron'] export function CreateTaskDialog({ open, diff --git a/src/components/schedulers/SchedulersTable.tsx b/src/components/schedulers/SchedulersTable.tsx index 6d4c331..c32f9e8 100644 --- a/src/components/schedulers/SchedulersTable.tsx +++ b/src/components/schedulers/SchedulersTable.tsx @@ -64,9 +64,9 @@ export function SchedulersTable({ tasks, onTaskUpdated, onTaskDeleted }: Schedul await schedulersService.cancelTask(task.id) onTaskDeleted?.(task.id) - toast.success('Task cancelled successfully') + toast.success('Task deleted successfully') } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to cancel task' + const message = error instanceof Error ? error.message : 'Failed to delete task' toast.error(message) } finally { setLoadingActions(prev => ({ ...prev, [task.id]: false })) @@ -144,7 +144,7 @@ export function SchedulersTable({ tasks, onTaskUpdated, onTaskDeleted }: Schedul className="text-destructive focus:text-destructive" > - Cancel Task + Delete Task diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx new file mode 100644 index 0000000..ab8037e --- /dev/null +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -0,0 +1,406 @@ +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 { forwardRef, useRef, useEffect } from 'react' + +interface SequencerCanvasProps { + tracks: Track[] + duration: number + zoom: number + currentTime: number + isPlaying: boolean + onScroll?: () => void + draggedItem?: any // Current dragged item from parent + dragOverInfo?: {trackId: string, x: number} | null // Drag over position info + onRemoveSound: (soundId: string, trackId: string) => void + timeIntervals: {minorIntervals: number[], majorIntervals: number[], minorInterval: number, majorInterval: number} + onZoomChange?: (newZoom: number, mouseX?: number) => void + minZoom?: number + maxZoom?: number +} + +interface TrackRowProps { + track: Track + duration: number + zoom: number + isPlaying: boolean + currentTime: number + draggedItem?: any // Current dragged item + dragOverInfo?: {trackId: string, x: number} | null // Drag over position info + onRemoveSound: (soundId: string, trackId: string) => void + timeIntervals: {minorIntervals: number[], majorIntervals: number[], minorInterval: number, majorInterval: 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 / 1000) * zoom // Convert ms to seconds for zoom calculation + // Ensure placed sounds are positioned at 100ms snapped locations + const startTimeSeconds = sound.startTime / 1000 + const snapIntervalMs = 100 // 100ms snap interval + const snappedStartTime = Math.round((startTimeSeconds * 1000) / snapIntervalMs) * snapIntervalMs / 1000 + const left = Math.max(0, snappedStartTime) * 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 ( +
+
+ + + {sound.name} + +
+ + +
+ ) +} + +function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, dragOverInfo, onRemoveSound, timeIntervals }: TrackRowProps) { + const playheadPosition = (currentTime / 1000) * zoom // Convert ms to seconds for zoom calculation + + const { isOver, setNodeRef: setDropRef } = useDroppable({ + id: `track-${track.id}`, + data: { + type: 'track', + trackId: track.id, + }, + }) + + const handleRemoveSound = (soundId: string) => { + onRemoveSound(soundId, track.id) + } + + const { minorIntervals, majorIntervals } = timeIntervals + + return ( +
+
+ {/* Grid lines for time markers */} +
+ {/* Minor grid lines */} + {minorIntervals.map((time) => ( +
+ ))} + {/* Major grid lines */} + {majorIntervals.map((time) => ( +
+ ))} +
+ + {/* Precise drag preview (dragOverInfo.x is already snapped) */} + {draggedItem && dragOverInfo && dragOverInfo.trackId === track.id && (() => { + const soundDurationMs = draggedItem.type === 'sound' + ? draggedItem.sound.duration // Already in ms + : draggedItem.duration // Already in ms + const soundDurationSeconds = soundDurationMs / 1000 + + // dragOverInfo.x is already snapped in the parent component + const startTimeSeconds = dragOverInfo.x / zoom + const endTimeSeconds = startTimeSeconds + soundDurationSeconds + const durationSeconds = duration / 1000 + const isValidPosition = startTimeSeconds >= 0 && endTimeSeconds <= durationSeconds + + return ( +
+
+ {draggedItem.type === 'sound' + ? (draggedItem.sound.name || draggedItem.sound.filename) + : draggedItem.name + } + {!isValidPosition && ' (Invalid)'} +
+
+ ) + })()} + + {/* Playhead */} + {isPlaying && ( +
+ )} + + {/* Placed sounds */} + {track.sounds.map((sound) => ( + + ))} +
+
+ ) +} + +export const SequencerCanvas = forwardRef(({ + tracks, + duration, + zoom, + currentTime, + isPlaying, + onScroll, + draggedItem, + dragOverInfo, + onRemoveSound, + timeIntervals, + onZoomChange, + minZoom = 10, + maxZoom = 200, +}, ref) => { + const totalWidth = (duration / 1000) * zoom // Convert ms to seconds for zoom calculation + const timelineRef = useRef(null) + + // Add a fallback droppable for the entire canvas area + const { setNodeRef: setCanvasDropRef } = useDroppable({ + id: 'sequencer-canvas', + data: { + type: 'canvas', + }, + }) + + const { minorIntervals, majorIntervals } = timeIntervals + + const handleTracksScroll = (e: React.UIEvent) => { + // 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?.() + } + + // Handle mouse wheel zoom with Ctrl key using native event listeners + useEffect(() => { + if (!onZoomChange) return + + const handleWheel = (e: WheelEvent) => { + if (!e.ctrlKey) return + + e.preventDefault() + e.stopPropagation() + + // Use the same discrete steps as the zoom buttons (+5/-5) + const zoomStep = 5 + const delta = e.deltaY > 0 ? -zoomStep : zoomStep // Inverted for natural feel + const newZoom = Math.min(Math.max(zoom + delta, minZoom), maxZoom) + + if (newZoom !== zoom) { + // Get mouse position relative to the scrollable content + const target = e.target as HTMLElement + const scrollContainer = target.closest('[data-scroll-container]') as HTMLElement + if (scrollContainer) { + const rect = scrollContainer.getBoundingClientRect() + const mouseX = e.clientX - rect.left + scrollContainer.scrollLeft + onZoomChange(newZoom, mouseX) + } + } + } + + // Add wheel event listeners to both timeline and tracks + const timelineElement = timelineRef.current + const tracksElement = ref && typeof ref === 'object' && ref.current ? ref.current : null + + if (timelineElement) { + timelineElement.addEventListener('wheel', handleWheel, { passive: false }) + } + if (tracksElement) { + tracksElement.addEventListener('wheel', handleWheel, { passive: false }) + } + + return () => { + if (timelineElement) { + timelineElement.removeEventListener('wheel', handleWheel) + } + if (tracksElement) { + tracksElement.removeEventListener('wheel', handleWheel) + } + } + }, [onZoomChange, zoom, minZoom, maxZoom, ref]) + + return ( +
+ {/* Time ruler */} +
+
+
+ {/* Minor time markers */} + {minorIntervals.map((time) => ( +
+
+
+ ))} + + {/* Major time markers with labels */} + {majorIntervals.map((time) => { + const formatTime = (seconds: number): string => { + if (seconds < 60) { + // For times under 1 minute, show seconds with decimal places if needed + return seconds < 10 && seconds % 1 !== 0 + ? seconds.toFixed(1) + 's' + : Math.floor(seconds) + 's' + } else { + // For times over 1 minute, show MM:SS format + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${secs.toString().padStart(2, '0')}` + } + } + + return ( +
+
+
+ {formatTime(time)} +
+
+ ) + })} + + {/* Playhead in ruler */} + {isPlaying && ( +
+ )} +
+
+
+ + {/* Tracks */} +
+
+ {tracks.map((track) => ( + + ))} +
+
+
+ ) +}) + +SequencerCanvas.displayName = 'SequencerCanvas' \ No newline at end of file diff --git a/src/components/sequencer/SoundLibrary.tsx b/src/components/sequencer/SoundLibrary.tsx new file mode 100644 index 0000000..051b88a --- /dev/null +++ b/src/components/sequencer/SoundLibrary.tsx @@ -0,0 +1,262 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Skeleton } from '@/components/ui/skeleton' +import { useDraggable } from '@dnd-kit/core' +import { soundsService, type Sound } from '@/lib/api/services/sounds' +import { + AlertCircle, + Music, + RefreshCw, + Search, + Volume2, + X +} from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' +import { formatDuration } from '@/utils/format-duration' +import { formatSize } from '@/utils/format-size' + +interface DraggableSoundProps { + sound: Sound +} + +function DraggableSound({ sound }: DraggableSoundProps) { + const { + attributes, + listeners, + setNodeRef, + isDragging, + } = useDraggable({ + id: `sound-${sound.id}`, + data: { + type: 'sound', + sound, + }, + }) + + // Don't apply transform to prevent layout shift - DragOverlay handles the visual feedback + const style = undefined + + return ( +
+
+
+ {sound.type === 'SDB' && } + {sound.type === 'TTS' && TTS} + {sound.type === 'EXT' && } +
+
+
+ {sound.name || sound.filename} +
+
+ {formatDuration(sound.duration)} + + {formatSize(sound.size)} + {sound.play_count > 0 && ( + <> + + {sound.play_count} plays + + )} +
+
+
+
+ ) +} + +export function SoundLibrary() { + const [sounds, setSounds] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [soundType, setSoundType] = useState<'all' | 'SDB' | 'TTS' | 'EXT'>('all') + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('') + + // Debounce search query + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearchQuery(searchQuery) + }, 300) + + return () => clearTimeout(handler) + }, [searchQuery]) + + const fetchSounds = useCallback(async () => { + try { + setLoading(true) + setError(null) + + // Build API params + const params: { types?: string[]; search?: string } = {} + + // Filter by type + if (soundType !== 'all') { + params.types = [soundType] + } + + // Filter by search query + if (debouncedSearchQuery.trim()) { + params.search = debouncedSearchQuery.trim() + } + + const fetchedSounds = await soundsService.getSounds(params) + setSounds(fetchedSounds) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch sounds' + setError(errorMessage) + toast.error(errorMessage) + } finally { + setLoading(false) + } + }, [debouncedSearchQuery, soundType]) + + useEffect(() => { + fetchSounds() + }, [fetchSounds]) + + const renderContent = () => { + if (loading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ ))} +
+ ) + } + + if (error) { + return ( +
+ +

Failed to load sounds

+

{error}

+ +
+ ) + } + + if (sounds.length === 0) { + return ( +
+
+ +
+

No sounds found

+

+ {searchQuery + ? `No sounds match "${searchQuery}"` + : 'No sounds available in your library'} +

+
+ ) + } + + return ( + +
+ {sounds.map((sound) => ( + + ))} +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Sound Library

+ +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-9 h-9" + /> + {searchQuery && ( + + )} +
+ + {/* Type Filter */} + +
+ + {/* Content */} +
+ {renderContent()} +
+ + {/* Footer */} + {!loading && !error && ( +
+
+ {sounds.length} sound{sounds.length !== 1 ? 's' : ''} + {searchQuery && ` matching "${searchQuery}"`} +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/components/sequencer/TimelineControls.tsx b/src/components/sequencer/TimelineControls.tsx new file mode 100644 index 0000000..46f6fac --- /dev/null +++ b/src/components/sequencer/TimelineControls.tsx @@ -0,0 +1,157 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Slider } from '@/components/ui/slider' +import { Minus, Plus, ZoomIn, ZoomOut } from 'lucide-react' +import { useState } from 'react' + +interface TimelineControlsProps { + duration: number // in milliseconds + zoom: number + onDurationChange: (duration: number) => void // expects milliseconds + onZoomChange: (zoom: number) => void + minZoom: number + maxZoom: number +} + +export function TimelineControls({ + duration, + zoom, + onDurationChange, + onZoomChange, + minZoom, + maxZoom, +}: TimelineControlsProps) { + const durationInSeconds = duration / 1000 + const [durationInput, setDurationInput] = useState(durationInSeconds.toString()) + + const handleDurationInputChange = (value: string) => { + setDurationInput(value) + const numValue = parseFloat(value) + if (!isNaN(numValue) && numValue > 0 && numValue <= 600) { // Max 10 minutes + onDurationChange(numValue * 1000) // Convert to milliseconds + } + } + + const handleDurationInputBlur = () => { + const numValue = parseFloat(durationInput) + if (isNaN(numValue) || numValue <= 0) { + setDurationInput(durationInSeconds.toString()) + } + } + + const increaseDuration = () => { + const newDurationSeconds = Math.min(600, durationInSeconds + 10) + onDurationChange(newDurationSeconds * 1000) // Convert to milliseconds + setDurationInput(newDurationSeconds.toString()) + } + + const decreaseDuration = () => { + const newDurationSeconds = Math.max(5, durationInSeconds - 10) + onDurationChange(newDurationSeconds * 1000) // Convert to milliseconds + setDurationInput(newDurationSeconds.toString()) + } + + const increaseZoom = () => { + onZoomChange(Math.min(maxZoom, zoom + 5)) + } + + const decreaseZoom = () => { + onZoomChange(Math.max(minZoom, zoom - 5)) + } + + 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 ( +
+ {/* Duration Controls */} +
+ +
+ + handleDurationInputChange(e.target.value)} + onBlur={handleDurationInputBlur} + className="h-8 w-16 text-center" + /> + +
+ + seconds ({formatTime(duration / 1000)}) + +
+ + {/* Zoom Controls */} +
+ +
+ +
+ onZoomChange(value)} + className="w-full" + /> +
+ +
+ + {zoom}px/s + +
+ + {/* Timeline Info */} +
+
+ Total width: {Math.round((duration / 1000) * zoom)}px +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/sequencer/TrackControls.tsx b/src/components/sequencer/TrackControls.tsx new file mode 100644 index 0000000..b75e44f --- /dev/null +++ b/src/components/sequencer/TrackControls.tsx @@ -0,0 +1,124 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import type { Track } from '@/pages/SequencerPage' +import { Plus, Trash2 } from 'lucide-react' +import { useState, forwardRef } from 'react' + +interface TrackControlsProps { + tracks: Track[] + onAddTrack: () => void + onRemoveTrack: (trackId: string) => void + onUpdateTrackName: (trackId: string, name: string) => void + onScroll?: () => void +} + +export const TrackControls = forwardRef(({ + tracks, + onAddTrack, + onRemoveTrack, + onUpdateTrackName, + onScroll, +}, ref) => { + const [editingTrackId, setEditingTrackId] = useState(null) + const [editingName, setEditingName] = useState('') + + const handleStartEditing = (track: Track) => { + setEditingTrackId(track.id) + setEditingName(track.name) + } + + const handleFinishEditing = () => { + if (editingTrackId && editingName.trim()) { + onUpdateTrackName(editingTrackId, editingName.trim()) + } + setEditingTrackId(null) + setEditingName('') + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleFinishEditing() + } else if (e.key === 'Escape') { + setEditingTrackId(null) + setEditingName('') + } + } + + return ( +
+ {/* Header - matches time ruler height of h-8 (32px) */} +
+

Tracks

+ +
+ + {/* Track List */} +
+ {tracks.map((track) => ( +
+
+ {editingTrackId === track.id ? ( + setEditingName(e.target.value)} + onBlur={handleFinishEditing} + onKeyDown={handleKeyDown} + className="h-8 text-sm" + autoFocus + /> + ) : ( +
handleStartEditing(track)} + title={`Click to rename track: ${track.name}`} + > + {track.name} +
+ )} +
+ {track.sounds.length} sound{track.sounds.length !== 1 ? 's' : ''} +
+
+ + {tracks.length > 1 && ( + + )} +
+ ))} +
+ + {/* Footer */} +
+
+ {tracks.length} track{tracks.length !== 1 ? 's' : ''} +
+
+
+ ) +}) + +TrackControls.displayName = 'TrackControls' \ No newline at end of file diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index a2df8dc..8db9fa8 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { diff --git a/src/lib/api/services/schedulers.ts b/src/lib/api/services/schedulers.ts index 5bf0349..03b1f35 100644 --- a/src/lib/api/services/schedulers.ts +++ b/src/lib/api/services/schedulers.ts @@ -6,7 +6,7 @@ export type TaskType = 'credit_recharge' | 'play_sound' | 'play_playlist' export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' -export type RecurrenceType = 'none' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'cron' +export type RecurrenceType = 'none' | 'minutely' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'cron' // Task interfaces export interface ScheduledTask { @@ -166,6 +166,8 @@ export function getRecurrenceTypeLabel(recurrenceType: RecurrenceType): string { switch (recurrenceType) { case 'none': return 'None' + case 'minutely': + return 'Minutely' case 'hourly': return 'Hourly' case 'daily': diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx new file mode 100644 index 0000000..f2bee81 --- /dev/null +++ b/src/pages/SequencerPage.tsx @@ -0,0 +1,489 @@ +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, DragOverlay, 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 milliseconds + startTime: number // in milliseconds + trackId: string +} + +interface SequencerState { + tracks: Track[] + duration: number // in milliseconds + zoom: number // pixels per second + currentTime: number // in milliseconds + isPlaying: boolean +} + +const INITIAL_DURATION = 30000 // 30 seconds in milliseconds +const INITIAL_ZOOM = 40 // 40 pixels per second +const MIN_ZOOM = 5 +const MAX_ZOOM = 200 + +export function SequencerPage() { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ) + + const [state, setState] = useState({ + tracks: [ + { + id: 'track-1', + name: 'Track 1', + sounds: [], + }, + ], + duration: INITIAL_DURATION, + zoom: INITIAL_ZOOM, + currentTime: 0, + isPlaying: false, + }) + + const [draggedItem, setDraggedItem] = useState(null) + const [dragOverInfo, setDragOverInfo] = useState<{trackId: string, x: number} | null>(null) + const [currentMousePos, setCurrentMousePos] = useState<{x: number, y: number} | null>(null) + + const trackControlsRef = useRef(null) + const sequencerCanvasRef = useRef(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) + } + }, []) + + // Calculate logical time intervals based on zoom level (shared with SequencerCanvas) + const getTimeIntervals = useCallback((zoom: number, duration: number) => { + const durationSeconds = duration / 1000 + const minorIntervals: number[] = [] + const majorIntervals: number[] = [] + + // Define logical interval progressions + const intervalSets = [ + { minor: 0.1, major: 1 }, // 0.1s minor, 1s major (mega zoomed in) + { minor: 1, major: 5 }, // 1s minor, 5s major (very zoomed in) + { minor: 5, major: 30 }, // 5s minor, 30s major + { minor: 10, major: 60 }, // 10s minor, 1min major + { minor: 30, major: 300 }, // 30s minor, 5min major + { minor: 60, major: 600 }, // 1min minor, 10min major + { minor: 300, major: 1800 }, // 5min minor, 30min major + { minor: 600, major: 3600 } // 10min minor, 1h major + ] + + // Find appropriate interval set based on zoom level + // We want major intervals to be roughly 100-200px apart + const targetMajorSpacing = 150 + + let selectedIntervals = intervalSets[intervalSets.length - 1] // fallback to largest + for (const intervals of intervalSets) { + if (intervals.major * zoom >= targetMajorSpacing) { + selectedIntervals = intervals + break + } + } + + // Generate minor intervals (every interval) + for (let i = 0; i * selectedIntervals.minor <= durationSeconds; i++) { + const time = i * selectedIntervals.minor + minorIntervals.push(time) + } + + // Generate major intervals (at major boundaries) + for (let i = 0; i * selectedIntervals.major <= durationSeconds; i++) { + const time = i * selectedIntervals.major + majorIntervals.push(time) + } + + return { minorIntervals, majorIntervals, minorInterval: selectedIntervals.minor, majorInterval: selectedIntervals.major } + }, []) + + // Helper function to snap time to 100ms intervals + const snapToGrid = useCallback((timeInSeconds: number): number => { + const snapIntervalMs = 100 // 100ms snap interval + const timeInMs = Math.max(0, timeInSeconds * 1000) // Ensure non-negative + const snappedMs = Math.round(timeInMs / snapIntervalMs) * snapIntervalMs + return Math.max(0, snappedMs / 1000) // Convert back to seconds, ensure non-negative + }, []) + + // 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 rawX = currentMousePos.x - rect.left + // Apply 100ms snapping to the drag over position + const rawTimeSeconds = rawX / state.zoom + const snappedTimeSeconds = snapToGrid(rawTimeSeconds) + const snappedX = snappedTimeSeconds * state.zoom + setDragOverInfo({ trackId: track.id, x: Math.max(0, snappedX) }) + return + } + } + } + // Mouse is not over any track + setDragOverInfo(null) + } else { + setDragOverInfo(null) + } + }, [draggedItem, currentMousePos, state.tracks, state.zoom, snapToGrid]) + + 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 (dragOverInfo.x is already snapped) + let startTime = 0 + if (dragOverInfo && dragOverInfo.trackId === overData.trackId) { + startTime = Math.max(0, (dragOverInfo.x / state.zoom) * 1000) // Convert to milliseconds + } + + const soundDuration = dragData.sound.duration // Already in milliseconds + + // Restrict placement to within track duration (all in milliseconds) + const maxStartTime = Math.max(0, state.duration - soundDuration) + startTime = Math.min(startTime, maxStartTime) + + // Only proceed if the sound can fit within the track + if (startTime >= 0 && startTime + soundDuration <= state.duration) { + const newPlacedSound: PlacedSound = { + id: `placed-${Date.now()}-${Math.random()}`, + soundId: dragData.sound.id, + name: dragData.sound.name || dragData.sound.filename, + duration: soundDuration, + 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') { + // Use precise drop position if available (dragOverInfo.x is already snapped) + let startTime = dragData.startTime || 0 + if (dragOverInfo && dragOverInfo.trackId === overData.trackId) { + startTime = Math.max(0, (dragOverInfo.x / state.zoom) * 1000) // Convert to milliseconds + } + + // Restrict placement to within track duration (all in milliseconds) + const maxStartTime = Math.max(0, state.duration - dragData.duration) + startTime = Math.min(startTime, maxStartTime) + + // Only proceed if the sound can fit within the track + if (startTime >= 0 && startTime + dragData.duration <= state.duration) { + const sourceTrackId = dragData.trackId + const targetTrackId = overData.trackId + + setState(prev => ({ + ...prev, + tracks: prev.tracks.map(track => { + if (track.id === sourceTrackId && sourceTrackId === targetTrackId) { + // Moving within the same track - just update position + const updatedSound: PlacedSound = { + id: dragData.id, + soundId: dragData.soundId, + name: dragData.name, + duration: dragData.duration, + 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) + return { + ...track, + sounds: track.sounds.filter(s => s.id !== dragData.id), + } + } else if (track.id === targetTrackId) { + // Add to target track (different track move) + 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 + }), + })) + } + } + + // 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 })) + } + + // Handle mouse wheel zoom with position centering + const handleZoomChangeWithPosition = (newZoom: number, mouseX?: number) => { + if (mouseX !== undefined && sequencerCanvasRef.current) { + const oldZoom = state.zoom + const currentScrollLeft = sequencerCanvasRef.current.scrollLeft + + // Calculate the time position that the mouse is pointing at + const timeAtMouse = (currentScrollLeft + mouseX) / oldZoom + + // Calculate what the new scroll position should be to keep the same time under the mouse + const newScrollLeft = timeAtMouse * newZoom - mouseX + + setState(prev => ({ ...prev, zoom: newZoom })) + + // Apply the new scroll position after the zoom change + requestAnimationFrame(() => { + if (sequencerCanvasRef.current) { + sequencerCanvasRef.current.scrollLeft = Math.max(0, newScrollLeft) + } + }) + } else { + setState(prev => ({ ...prev, zoom: newZoom })) + } + } + + const handleDurationChange = (duration: number) => { + setState(prev => ({ ...prev, duration })) + } + + const handleRemoveSound = (soundId: string, trackId: string) => { + setState(prev => ({ + ...prev, + tracks: prev.tracks.map(track => + track.id === trackId + ? { ...track, sounds: track.sounds.filter(sound => sound.id !== soundId) } + : track + ), + })) + } + + 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 + 100 // Add 100ms every 100ms + if (newTime >= prev.duration) { + return { ...prev, currentTime: prev.duration, isPlaying: false } + } + return { ...prev, currentTime: newTime } + }) + }, 100) + return () => clearInterval(interval) + } + }, [state.isPlaying, state.duration]) + + return ( +
+ {/* Simple Header */} +
+ Dashboard + / + Sequencer +
+ + {/* Main Content */} +
+ + {/* Left Sidebar - Sound Library */} +
+
+ +
+
+ + {/* Center Content - Tracks */} +
+ {/* Timeline Controls */} +
+ +
+ + {/* Track Area */} +
+ {/* Track Controls */} +
+ +
+ + {/* Sequencer Canvas */} +
+ +
+
+
+ + {draggedItem?.type === 'sound' ? ( +
+
+
+ {draggedItem.sound.type === 'SDB' && SDB} + {draggedItem.sound.type === 'TTS' && TTS} + {draggedItem.sound.type === 'EXT' && EXT} +
+
+
+ {draggedItem.sound.name || draggedItem.sound.filename} +
+
+
+
+ ) : draggedItem?.type === 'placed-sound' ? ( +
+
+ {draggedItem.name} +
+
+ ) : null} +
+
+
+
+ ) +} \ No newline at end of file