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() { } /> + + + + } + /> void +} + +interface TrackRowProps { + track: Track + duration: number + zoom: number + isPlaying: boolean + currentTime: 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 * zoom + const left = sound.startTime * 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 }: TrackRowProps) { + const totalWidth = duration * zoom + const playheadPosition = currentTime * zoom + + const { isOver, setNodeRef: setDropRef } = useDroppable({ + id: `track-${track.id}`, + data: { + type: 'track', + trackId: track.id, + }, + }) + + const [dragOverX, setDragOverX] = useState(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) => { + // 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) + } + + return ( +
+
+ {/* Grid lines for time markers */} +
+ {Array.from({ length: Math.ceil(duration) + 1 }).map((_, i) => ( +
+ ))} + {/* Major grid lines every 10 seconds */} + {Array.from({ length: Math.floor(duration / 10) + 1 }).map((_, i) => ( +
+ ))} +
+ + {/* Drop indicator */} + {isOver && dragOverX !== null && ( +
+ )} + + {/* Playhead */} + {isPlaying && ( +
+ )} + + {/* Placed sounds */} + {track.sounds.map((sound) => ( + + ))} +
+
+ ) +} + +export const SequencerCanvas = forwardRef(({ + tracks, + duration, + zoom, + currentTime, + isPlaying, + onScroll, +}, ref) => { + const totalWidth = duration * zoom + const timelineRef = useRef(null) + + 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?.() + } + + return ( +
+ {/* Time ruler */} +
+
+
+ {Array.from({ length: Math.ceil(duration) + 1 }).map((_, i) => ( +
+ {/* Time markers */} + {i % 5 === 0 && ( + <> +
+
+ {Math.floor(i / 60)}:{(i % 60).toString().padStart(2, '0')} +
+ + )} + {i % 5 !== 0 && ( +
+ )} +
+ ))} + + {/* 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..065067b --- /dev/null +++ b/src/components/sequencer/SoundLibrary.tsx @@ -0,0 +1,269 @@ +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 { useEffect, useState } from 'react' +import { toast } from 'sonner' + +interface DraggableSoundProps { + sound: Sound +} + +function DraggableSound({ sound }: DraggableSoundProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + isDragging, + } = useDraggable({ + id: `sound-${sound.id}`, + data: { + type: 'sound', + sound, + }, + }) + + const style = transform ? { + transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + } : undefined + + const formatDuration = (ms: number): string => { + const seconds = Math.floor(ms / 1000) + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + const formatFileSize = (bytes: number): string => { + const mb = bytes / (1024 * 1024) + return `${mb.toFixed(1)}MB` + } + + return ( +
+
+
+ {sound.type === 'SDB' && } + {sound.type === 'TTS' && TTS} + {sound.type === 'EXT' && } +
+
+
+ {sound.name || sound.filename} +
+
+ {formatDuration(sound.duration)} + + {formatFileSize(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 = async () => { + try { + setLoading(true) + setError(null) + + const params = { + search: debouncedSearchQuery.trim() || undefined, + types: soundType === 'all' ? undefined : [soundType], + sort_by: 'name' as const, + sort_order: 'asc' as const, + limit: 100, // Limit to 100 sounds for performance + } + + 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) + } + } + + useEffect(() => { + fetchSounds() + }, [debouncedSearchQuery, soundType]) + + 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..c01631e --- /dev/null +++ b/src/components/sequencer/TimelineControls.tsx @@ -0,0 +1,156 @@ +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 + zoom: number + onDurationChange: (duration: number) => void + onZoomChange: (zoom: number) => void + minZoom: number + maxZoom: number +} + +export function TimelineControls({ + duration, + zoom, + onDurationChange, + onZoomChange, + minZoom, + maxZoom, +}: TimelineControlsProps) { + const [durationInput, setDurationInput] = useState(duration.toString()) + + const handleDurationInputChange = (value: string) => { + setDurationInput(value) + const numValue = parseFloat(value) + if (!isNaN(numValue) && numValue > 0 && numValue <= 600) { // Max 10 minutes + onDurationChange(numValue) + } + } + + const handleDurationInputBlur = () => { + const numValue = parseFloat(durationInput) + if (isNaN(numValue) || numValue <= 0) { + setDurationInput(duration.toString()) + } + } + + const increaseDuration = () => { + const newDuration = Math.min(600, duration + 10) + onDurationChange(newDuration) + setDurationInput(newDuration.toString()) + } + + const decreaseDuration = () => { + const newDuration = Math.max(10, duration - 10) + onDurationChange(newDuration) + setDurationInput(newDuration.toString()) + } + + const increaseZoom = () => { + onZoomChange(Math.min(maxZoom, zoom + 10)) + } + + const decreaseZoom = () => { + onZoomChange(Math.max(minZoom, zoom - 10)) + } + + 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)}) + +
+ + {/* Zoom Controls */} +
+ +
+ +
+ onZoomChange(value)} + className="w-full" + /> +
+ +
+ + {zoom}px/s + +
+ + {/* Timeline Info */} +
+
+ Total width: {Math.round(duration * 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..7469197 --- /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/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx new file mode 100644 index 0000000..ae35604 --- /dev/null +++ b/src/pages/SequencerPage.tsx @@ -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({ + 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(null) + const [activeDragData, setActiveDragData] = useState | null>(null) + + // Refs for scroll synchronization + const trackControlsScrollRef = useRef(null) + const sequencerCanvasScrollRef = useRef(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 ( + +
+
+
+

Sequencer

+

+ Create sequences by dragging sounds onto tracks +

+
+
+ + +
+
+ + +
+ {/* Sound Library Panel */} +
+ +
+ + {/* Main Sequencer Area */} +
+ {/* Timeline Controls */} +
+ +
+ + {/* Sequencer Content */} +
+ {/* Track Controls */} +
+ +
+ + {/* Sequencer Canvas */} +
+ +
+
+
+
+ + {/* Drag Overlay */} + + {activeId && activeDragData ? ( +
+ {activeDragData.type === 'sound' + ? activeDragData.sound?.name || activeDragData.sound?.filename + : activeDragData.name} +
+ ) : null} +
+
+
+
+ ) +} \ No newline at end of file