From 28faf9b14912a291ea158157445e315224c68e18 Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 00:23:59 +0200 Subject: [PATCH 01/24] feat: add SequencerPage with sequencer functionality including track and sound management feat: implement SequencerCanvas for visualizing tracks and placed sounds feat: create SoundLibrary for draggable sound selection feat: add TimelineControls for managing duration and zoom levels feat: implement TrackControls for adding, removing, and renaming tracks --- src/App.tsx | 9 + src/components/sequencer/SequencerCanvas.tsx | 290 +++++++++++++++ src/components/sequencer/SoundLibrary.tsx | 269 ++++++++++++++ src/components/sequencer/TimelineControls.tsx | 156 ++++++++ src/components/sequencer/TrackControls.tsx | 124 +++++++ src/pages/SequencerPage.tsx | 335 ++++++++++++++++++ 6 files changed, 1183 insertions(+) create mode 100644 src/components/sequencer/SequencerCanvas.tsx create mode 100644 src/components/sequencer/SoundLibrary.tsx create mode 100644 src/components/sequencer/TimelineControls.tsx create mode 100644 src/components/sequencer/TrackControls.tsx create mode 100644 src/pages/SequencerPage.tsx 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 From 25eacbc85f7edb5e9a5c0e278fe22a8b51373167 Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 14:46:28 +0200 Subject: [PATCH 02/24] feat: enhance SequencerPage and SequencerCanvas with drag-and-drop functionality for sound placement and improved track management --- src/components/sequencer/SequencerCanvas.tsx | 65 +-- src/components/sequencer/SoundLibrary.tsx | 65 ++- src/pages/SequencerPage.tsx | 454 ++++++++++--------- 3 files changed, 335 insertions(+), 249 deletions(-) diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index e34682c..58d9f41 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -2,7 +2,7 @@ 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 { useState, forwardRef, useRef } from 'react' +import { useState, forwardRef, useRef, useEffect } from 'react' interface SequencerCanvasProps { tracks: Track[] @@ -11,6 +11,8 @@ interface SequencerCanvasProps { currentTime: number isPlaying: boolean onScroll?: () => void + draggedItem?: any // Current dragged item from parent + dragOverInfo?: {trackId: string, x: number} | null // Drag over position info } interface TrackRowProps { @@ -19,6 +21,8 @@ interface TrackRowProps { zoom: number isPlaying: boolean currentTime: number + draggedItem?: any // Current dragged item + dragOverInfo?: {trackId: string, x: number} | null // Drag over position info } 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 playheadPosition = currentTime * zoom @@ -113,23 +117,7 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowPro }, }) - 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) } @@ -137,6 +125,7 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowPro
{/* Grid lines for time markers */}
@@ -166,12 +153,26 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowPro ))}
- {/* Drop indicator */} - {isOver && dragOverX !== null && ( + {/* Precise drag preview */} + {draggedItem && dragOverInfo && dragOverInfo.trackId === track.id && (
+ 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: `${dragOverInfo.x}px`, + width: `${Math.max(60, + draggedItem.type === 'sound' + ? (draggedItem.sound.duration / 1000) * zoom + : draggedItem.duration * zoom + )}px`, + }} + > +
+ {draggedItem.type === 'sound' + ? (draggedItem.sound.name || draggedItem.sound.filename) + : draggedItem.name + } +
+
)} {/* Playhead */} @@ -204,9 +205,19 @@ export const SequencerCanvas = forwardRef( currentTime, isPlaying, onScroll, + draggedItem, + dragOverInfo, }, ref) => { const totalWidth = duration * zoom const timelineRef = useRef(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) => { // Sync timeline horizontal scroll with tracks @@ -222,7 +233,7 @@ export const SequencerCanvas = forwardRef( } return ( -
+
{/* Time ruler */}
( zoom={zoom} isPlaying={isPlaying} currentTime={currentTime} + draggedItem={draggedItem} + dragOverInfo={dragOverInfo} /> ))}
diff --git a/src/components/sequencer/SoundLibrary.tsx b/src/components/sequencer/SoundLibrary.tsx index 065067b..aa387c4 100644 --- a/src/components/sequencer/SoundLibrary.tsx +++ b/src/components/sequencer/SoundLibrary.tsx @@ -119,16 +119,65 @@ export function SoundLibrary() { 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 + // Mock sounds for testing drag functionality + const mockSounds: Sound[] = [ + { + id: 1, + filename: 'kick.mp3', + 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) - setSounds(fetchedSounds) + // Filter by type + if (soundType !== 'all') { + filteredSounds = filteredSounds.filter(sound => sound.type === soundType) + } + + setSounds(filteredSounds) } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to fetch sounds' setError(errorMessage) diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index ae35604..ca70ee5 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -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 { 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 { useState, useCallback, useRef } from 'react' +import { useState, useRef, useCallback, useEffect } from 'react' export interface Track { id: string @@ -18,19 +19,17 @@ export interface PlacedSound { id: string soundId: number name: string - duration: number - startTime: number + duration: number // in seconds + startTime: number // in seconds trackId: string } -export interface SequencerState { +interface SequencerState { tracks: Track[] - duration: number // in seconds - zoom: number // pixels per second - isPlaying: boolean + duration: number + zoom: number currentTime: number - selectedTrack?: string - selectedSound?: string + isPlaying: boolean } const INITIAL_DURATION = 30 // 30 seconds @@ -39,53 +38,90 @@ const MIN_ZOOM = 10 const MAX_ZOOM = 200 export function SequencerPage() { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ) + const [state, setState] = useState({ tracks: [ - { id: '1', name: 'Track 1', sounds: [] }, - { id: '2', name: 'Track 2', sounds: [] }, + { + id: 'track-1', + name: 'Track 1', + sounds: [], + }, ], duration: INITIAL_DURATION, zoom: INITIAL_ZOOM, - isPlaying: false, currentTime: 0, + isPlaying: false, }) - const [activeId, setActiveId] = useState(null) - const [activeDragData, setActiveDragData] = useState | null>(null) + 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) - // 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 trackControlsRef = useRef(null) + const sequencerCanvasRef = useRef(null) const handleDragStart = useCallback((event: DragStartEvent) => { - setActiveId(event.active.id as string) - setActiveDragData(event.active.data.current || null) + 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 - - if (!over || !active.data.current) { - setActiveId(null) - setActiveDragData(null) - return - } - 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 - 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 + // 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()}`, @@ -93,243 +129,231 @@ export function SequencerPage() { name: dragData.sound.name || dragData.sound.filename, duration: dragData.sound.duration / 1000, // Convert from ms to seconds startTime, - trackId, + trackId: overData.trackId, } setState(prev => ({ ...prev, tracks: prev.tracks.map(track => - track.id === trackId + track.id === overData.trackId ? { ...track, sounds: [...track.sounds, newPlacedSound] } : track ), })) } - // Handle moving a placed sound within tracks - if (dragData.type === 'placed-sound' && overData?.type === '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 - // 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 => ({ ...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) { + if (track.id === sourceTrackId && sourceTrackId === targetTrackId) { + // Moving within the same track - just update position + console.log('Moving within same track') const updatedSound: PlacedSound = { - id: dragData.id, - soundId: dragData.soundId, - name: dragData.name, - duration: dragData.duration, + ...dragData, startTime, trackId: targetTrackId, } return { ...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 }), })) } + + // 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) - setActiveDragData(null) - }, [state.zoom]) - - const addTrack = useCallback(() => { - const newTrackId = `${Date.now()}` + const handleAddTrack = () => { + const newTrackNumber = state.tracks.length + 1 const newTrack: Track = { - id: newTrackId, - name: `Track ${state.tracks.length + 1}`, + id: `track-${Date.now()}`, + name: `Track ${newTrackNumber}`, sounds: [], } - setState(prev => ({ - ...prev, - tracks: [...prev.tracks, newTrack], - })) - }, [state.tracks.length]) + setState(prev => ({ ...prev, tracks: [...prev.tracks, newTrack] })) + } - const removeTrack = useCallback((trackId: string) => { + const handleRemoveTrack = (trackId: string) => { setState(prev => ({ ...prev, tracks: prev.tracks.filter(track => track.id !== trackId), })) - }, []) + } - const updateTrackName = useCallback((trackId: string, name: string) => { + const handleUpdateTrackName = (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(() => { + const handlePlay = () => { setState(prev => ({ ...prev, isPlaying: !prev.isPlaying })) - }, []) + } - const resetSequencer = useCallback(() => { - setState(prev => ({ - ...prev, - tracks: prev.tracks.map(track => ({ ...track, sounds: [] })), - isPlaying: false, - currentTime: 0, - })) - }, []) + const handleStop = () => { + setState(prev => ({ ...prev, 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 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 } } }, []) - 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 - } + // 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 ( - -
-
-
-

Sequencer

-

- Create sequences by dragging sounds onto tracks -

-
-
- - -
-
+
+ {/* Header */} +
+

Sequencer

+
- -
- {/* Sound Library Panel */} -
+ {/* Main Content */} +
+ + {/* Left Sidebar - Sound Library */} +
+
+
- {/* Main Sequencer Area */} -
- {/* Timeline Controls */} -
- + {/* Timeline Controls */} +
+ +
+ + {/* Track Area */} +
+ {/* Track Controls */} +
+
- {/* Sequencer Content */} -
- {/* Track Controls */} -
- -
- - {/* Sequencer Canvas */} -
- -
+ {/* Sequencer Canvas */} +
+
- - {/* Drag Overlay */} - - {activeId && activeDragData ? ( -
- {activeDragData.type === 'sound' - ? activeDragData.sound?.name || activeDragData.sound?.filename - : activeDragData.name} -
- ) : null} -
- +
) } \ No newline at end of file From a0d5840166f562c2fd48431ac555da7fa89f2c65 Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 15:06:38 +0200 Subject: [PATCH 03/24] feat: improve layout of SequencerCanvas and SequencerPage for better responsiveness and overflow handling --- src/components/sequencer/SequencerCanvas.tsx | 41 +++++++++----------- src/pages/SequencerPage.tsx | 2 +- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index 58d9f41..f388023 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -127,12 +127,11 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, ref={setDropRef} id={`track-${track.id}`} className={` - w-full h-full border-b border-border/50 + h-full border-b border-border/50 relative overflow-hidden ${isOver ? 'bg-accent/30' : 'bg-muted/10'} transition-colors `} - style={{ minWidth: `${totalWidth}px` }} > {/* Grid lines for time markers */}
@@ -274,26 +273,24 @@ export const SequencerCanvas = forwardRef(
{/* Tracks */} -
-
-
- {tracks.map((track) => ( - - ))} -
+
+
+ {tracks.map((track) => ( + + ))}
diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index ca70ee5..874fae5 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -337,7 +337,7 @@ export function SequencerPage() {
{/* Sequencer Canvas */} -
+
Date: Wed, 3 Sep 2025 15:11:23 +0200 Subject: [PATCH 04/24] feat: adjust styling of PlacedSoundItem for improved positioning and visual consistency --- src/components/sequencer/SequencerCanvas.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index f388023..71b3249 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -69,11 +69,13 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProp position: 'absolute', left: `${left}px`, width: `${width}px`, + top: '8px', + bottom: '8px', }} {...listeners} {...attributes} className={` - h-12 bg-primary/20 border-2 border-primary/40 rounded + bg-primary/20 border-2 border-primary/40 rounded flex items-center justify-between px-2 text-xs cursor-grab active:cursor-grabbing hover:bg-primary/30 hover:border-primary/60 From d7b1d97a28adc7f1e010c7207c4a4ce7a550169a Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 15:17:52 +0200 Subject: [PATCH 05/24] feat: add padding to bottom of SequencerCanvas and TrackControls for improved layout --- src/components/sequencer/SequencerCanvas.tsx | 2 +- src/components/sequencer/TrackControls.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index 71b3249..c6f7873 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -280,7 +280,7 @@ export const SequencerCanvas = forwardRef( className="flex-1 min-h-0 min-w-0 overflow-auto" onScroll={handleTracksScroll} > -
+
{tracks.map((track) => ( (({ ref={ref} className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:hidden" onScroll={onScroll} - style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }} + style={{ scrollbarWidth: 'none', msOverflowStyle: 'none', paddingBottom: '52px' }} > {tracks.map((track) => (
Date: Wed, 3 Sep 2025 15:20:46 +0200 Subject: [PATCH 06/24] feat: reduce width of TrackControls for improved layout --- src/pages/SequencerPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index 874fae5..099f0f4 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -325,7 +325,7 @@ export function SequencerPage() { {/* Track Area */}
{/* Track Controls */} -
+
Date: Wed, 3 Sep 2025 15:30:46 +0200 Subject: [PATCH 07/24] feat: add Sequencer navigation item to AppSidebar and wrap SequencerPage in AppLayout for improved structure --- src/components/AppSidebar.tsx | 2 ++ src/pages/SequencerPage.tsx | 29 +++++++++++++++-------------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 982aa95..da2e2fc 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -15,6 +15,7 @@ import { PlayCircle, Settings, Users, + AudioLines, } from 'lucide-react' import { CreditsNav } from './nav/CreditsNav' import { NavGroup } from './nav/NavGroup' @@ -48,6 +49,7 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) { + diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index 099f0f4..cf4b0b2 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -1,12 +1,9 @@ -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 { AppLayout } from '@/components/AppLayout' +import { DndContext, type DragEndEvent, type DragStartEvent, PointerSensor, useSensors, useSensor } from '@dnd-kit/core' import { useState, useRef, useCallback, useEffect } from 'react' export interface Track { @@ -288,14 +285,17 @@ export function SequencerPage() { }, [state.isPlaying, state.duration]) return ( -
- {/* Header */} -
-

Sequencer

-
- - {/* Main Content */} -
+ +
+ {/* Main Content */} +
+
-
+ ) } \ No newline at end of file From 80a18575a13928008aaf3d2f5a21be98f814fe4a Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 15:55:35 +0200 Subject: [PATCH 08/24] feat: update SequencerPage layout for full height and improved responsiveness --- src/pages/SequencerPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index cf4b0b2..fb91909 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -293,7 +293,7 @@ export function SequencerPage() { ] }} > -
+
{/* Main Content */}
Date: Wed, 3 Sep 2025 16:32:05 +0200 Subject: [PATCH 09/24] feat: enhance AppLayout and SequencerPage for improved layout and responsiveness --- src/components/AppLayout.tsx | 5 ++-- src/components/sequencer/SequencerCanvas.tsx | 11 +++---- src/pages/SequencerPage.tsx | 31 +++++++++----------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index a1fde25..352e2b7 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -24,9 +24,10 @@ interface AppLayoutProps { href?: string }> } + noPadding?: boolean } -export function AppLayout({ children, breadcrumb }: AppLayoutProps) { +export function AppLayout({ children, breadcrumb, noPadding = false }: AppLayoutProps) { const [playerDisplayMode, setPlayerDisplayMode] = useState( () => { // Initialize from localStorage or default to 'normal' @@ -77,7 +78,7 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) { )}
-
{children}
+
{children}
diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index c6f7873..fcf8ea2 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -211,6 +211,7 @@ export const SequencerCanvas = forwardRef( }, ref) => { const totalWidth = duration * zoom const timelineRef = useRef(null) + const containerRef = useRef(null) // Add a fallback droppable for the entire canvas area const { setNodeRef: setCanvasDropRef } = useDroppable({ @@ -234,18 +235,18 @@ export const SequencerCanvas = forwardRef( } return ( -
+
{/* Time ruler */}
-
+
{Array.from({ length: Math.ceil(duration) + 1 }).map((_, i) => (
{/* Time markers */} @@ -277,10 +278,10 @@ export const SequencerCanvas = forwardRef( {/* Tracks */}
-
+
{tracks.map((track) => ( -
- {/* Main Content */} -
+
+ {/* Simple Header */} +
+ Home + / + Sequencer +
+ + {/* Main Content */} +
{/* Left Sidebar - Sound Library */} -
+
@@ -325,7 +323,7 @@ export function SequencerPage() { {/* Track Area */}
{/* Track Controls */} -
+
{/* Sequencer Canvas */} -
+
-
- +
) } \ No newline at end of file From 2ec58ea2683313b49fdc9fed3c473e8e8e8288e9 Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 16:39:22 +0200 Subject: [PATCH 10/24] feat: update link text in SequencerPage header from "Home" to "Dashboard" --- src/pages/SequencerPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index 2ab18ee..ba04585 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -287,7 +287,7 @@ export function SequencerPage() {
{/* Simple Header */}
- Home + Dashboard / Sequencer
From 9603daa5ceca1360260ce3d8dc0734e3d0398f7b Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 16:45:34 +0200 Subject: [PATCH 11/24] refactor: remove noPadding prop from AppLayout and simplify class names in SequencerCanvas --- src/components/AppLayout.tsx | 5 ++--- src/components/sequencer/SequencerCanvas.tsx | 10 +++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 352e2b7..a1fde25 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -24,10 +24,9 @@ interface AppLayoutProps { href?: string }> } - noPadding?: boolean } -export function AppLayout({ children, breadcrumb, noPadding = false }: AppLayoutProps) { +export function AppLayout({ children, breadcrumb }: AppLayoutProps) { const [playerDisplayMode, setPlayerDisplayMode] = useState( () => { // Initialize from localStorage or default to 'normal' @@ -78,7 +77,7 @@ export function AppLayout({ children, breadcrumb, noPadding = false }: AppLayout )}
-
{children}
+
{children}
diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index fcf8ea2..f04f11e 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -235,18 +235,18 @@ export const SequencerCanvas = forwardRef( } return ( -
+
{/* Time ruler */}
-
+
{Array.from({ length: Math.ceil(duration) + 1 }).map((_, i) => (
{/* Time markers */} @@ -278,10 +278,10 @@ export const SequencerCanvas = forwardRef( {/* Tracks */}
-
+
{tracks.map((track) => ( Date: Wed, 3 Sep 2025 16:52:14 +0200 Subject: [PATCH 12/24] feat: refactor fetchSounds to use useCallback and remove mock data for improved API integration --- src/components/sequencer/SoundLibrary.tsx | 74 +++++------------------ 1 file changed, 15 insertions(+), 59 deletions(-) diff --git a/src/components/sequencer/SoundLibrary.tsx b/src/components/sequencer/SoundLibrary.tsx index aa387c4..42c3353 100644 --- a/src/components/sequencer/SoundLibrary.tsx +++ b/src/components/sequencer/SoundLibrary.tsx @@ -19,7 +19,7 @@ import { Volume2, X } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' interface DraggableSoundProps { @@ -114,70 +114,26 @@ export function SoundLibrary() { return () => clearTimeout(handler) }, [searchQuery]) - const fetchSounds = async () => { + const fetchSounds = useCallback(async () => { try { setLoading(true) setError(null) - // Mock sounds for testing drag functionality - const mockSounds: Sound[] = [ - { - id: 1, - filename: 'kick.mp3', - 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) - ) - } - + // Build API params + const params: { types?: string[]; search?: string } = {} + // Filter by type if (soundType !== 'all') { - filteredSounds = filteredSounds.filter(sound => sound.type === soundType) + params.types = [soundType] } - setSounds(filteredSounds) + // 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) @@ -185,11 +141,11 @@ export function SoundLibrary() { } finally { setLoading(false) } - } + }, [debouncedSearchQuery, soundType]) useEffect(() => { fetchSounds() - }, [debouncedSearchQuery, soundType]) + }, [fetchSounds]) const renderContent = () => { if (loading) { From 7982a2eb6d52ace4add9818478b5ed0a7183453e Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 16:55:21 +0200 Subject: [PATCH 13/24] feat: update DraggableSound component to use break-words for sound name display --- src/components/sequencer/SoundLibrary.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/sequencer/SoundLibrary.tsx b/src/components/sequencer/SoundLibrary.tsx index 42c3353..6a4fe9d 100644 --- a/src/components/sequencer/SoundLibrary.tsx +++ b/src/components/sequencer/SoundLibrary.tsx @@ -77,7 +77,7 @@ function DraggableSound({ sound }: DraggableSoundProps) { {sound.type === 'EXT' && }
-
+
{sound.name || sound.filename}
From aa11ec379db9a0afa82ebdff1cc8d53586861357 Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 16:57:55 +0200 Subject: [PATCH 14/24] feat: add DragOverlay to SequencerPage for improved drag-and-drop feedback --- src/components/sequencer/SoundLibrary.tsx | 8 +++---- src/pages/SequencerPage.tsx | 26 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/components/sequencer/SoundLibrary.tsx b/src/components/sequencer/SoundLibrary.tsx index 6a4fe9d..a22d014 100644 --- a/src/components/sequencer/SoundLibrary.tsx +++ b/src/components/sequencer/SoundLibrary.tsx @@ -31,7 +31,6 @@ function DraggableSound({ sound }: DraggableSoundProps) { attributes, listeners, setNodeRef, - transform, isDragging, } = useDraggable({ id: `sound-${sound.id}`, @@ -41,9 +40,8 @@ function DraggableSound({ sound }: DraggableSoundProps) { }, }) - const style = transform ? { - transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, - } : undefined + // Don't apply transform to prevent layout shift - DragOverlay handles the visual feedback + const style = undefined const formatDuration = (ms: number): string => { const seconds = Math.floor(ms / 1000) @@ -66,7 +64,7 @@ function DraggableSound({ sound }: DraggableSoundProps) { className={` group cursor-grab active:cursor-grabbing p-3 border rounded-lg bg-card hover:bg-accent/50 transition-colors - ${isDragging ? 'opacity-50 shadow-lg' : ''} + ${isDragging ? 'opacity-30' : ''} `} title={`Drag to add "${sound.name || sound.filename}" to a track`} > diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index ba04585..79f3041 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -2,7 +2,7 @@ import { TrackControls } from '@/components/sequencer/TrackControls' import { TimelineControls } from '@/components/sequencer/TimelineControls' import { SoundLibrary } from '@/components/sequencer/SoundLibrary' import { SequencerCanvas } from '@/components/sequencer/SequencerCanvas' -import { DndContext, type DragEndEvent, type DragStartEvent, PointerSensor, useSensors, useSensor } from '@dnd-kit/core' +import { DndContext, DragOverlay, type DragEndEvent, type DragStartEvent, PointerSensor, useSensors, useSensor } from '@dnd-kit/core' import { useState, useRef, useCallback, useEffect } from 'react' export interface Track { @@ -350,6 +350,30 @@ export function SequencerPage() {
+ + {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} +
From dba08e2ec0b82b54fa6da611a6316613b2c34489 Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 17:03:04 +0200 Subject: [PATCH 15/24] feat: implement sound removal functionality in SequencerPage and update SequencerCanvas props --- src/components/sequencer/SequencerCanvas.tsx | 8 ++++++-- src/pages/SequencerPage.tsx | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index f04f11e..8b4f5fc 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -13,6 +13,7 @@ interface SequencerCanvasProps { 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 } interface TrackRowProps { @@ -23,6 +24,7 @@ interface TrackRowProps { currentTime: number draggedItem?: any // Current dragged item dragOverInfo?: {trackId: string, x: number} | null // Drag over position info + onRemoveSound: (soundId: string, trackId: string) => void } interface PlacedSoundItemProps { @@ -107,7 +109,7 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProp ) } -function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, dragOverInfo }: TrackRowProps) { +function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, dragOverInfo, onRemoveSound }: TrackRowProps) { const totalWidth = duration * zoom const playheadPosition = currentTime * zoom @@ -120,7 +122,7 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, }) const handleRemoveSound = (soundId: string) => { - console.log('Remove sound:', soundId, 'from track:', track.id) + onRemoveSound(soundId, track.id) } return ( @@ -208,6 +210,7 @@ export const SequencerCanvas = forwardRef( onScroll, draggedItem, dragOverInfo, + onRemoveSound, }, ref) => { const totalWidth = duration * zoom const timelineRef = useRef(null) @@ -292,6 +295,7 @@ export const SequencerCanvas = forwardRef( currentTime={currentTime} draggedItem={draggedItem} dragOverInfo={dragOverInfo} + onRemoveSound={onRemoveSound} /> ))}
diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index 79f3041..f5674ea 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -258,6 +258,17 @@ export function SequencerPage() { 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 @@ -346,6 +357,7 @@ export function SequencerPage() { onScroll={handleVerticalScroll} draggedItem={draggedItem} dragOverInfo={dragOverInfo} + onRemoveSound={handleRemoveSound} />
From 1ba6f2399904074870b3f747406aa4884fc56c3e Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 17:17:19 +0200 Subject: [PATCH 16/24] Improves sound placement and preview logic Refines the sound placement logic in the sequencer to ensure sounds are placed correctly within track boundaries. It restricts sound placement to the track duration, preventing sounds from being placed out of bounds. Enhances the drag preview by visually indicating invalid placement positions with a red border and "Invalid" label. Also extracts duration and size formatting into separate utility functions for better code organization. --- src/components/sequencer/SequencerCanvas.tsx | 50 ++++--- src/components/sequencer/SoundLibrary.tsx | 16 +-- src/pages/SequencerPage.tsx | 136 ++++++++++--------- 3 files changed, 104 insertions(+), 98 deletions(-) diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index 8b4f5fc..d623489 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -157,26 +157,38 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem,
{/* Precise drag preview */} - {draggedItem && dragOverInfo && dragOverInfo.trackId === track.id && ( -
-
- {draggedItem.type === 'sound' - ? (draggedItem.sound.name || draggedItem.sound.filename) - : draggedItem.name - } + {draggedItem && dragOverInfo && dragOverInfo.trackId === track.id && (() => { + const soundDuration = draggedItem.type === 'sound' + ? draggedItem.sound.duration / 1000 // Convert ms to seconds + : draggedItem.duration + const startTime = dragOverInfo.x / zoom + const endTime = startTime + soundDuration + const isValidPosition = startTime >= 0 && endTime <= duration + + return ( +
+
+ {draggedItem.type === 'sound' + ? (draggedItem.sound.name || draggedItem.sound.filename) + : draggedItem.name + } + {!isValidPosition && ' (Invalid)'} +
-
- )} + ) + })()} {/* Playhead */} {isPlaying && ( diff --git a/src/components/sequencer/SoundLibrary.tsx b/src/components/sequencer/SoundLibrary.tsx index a22d014..051b88a 100644 --- a/src/components/sequencer/SoundLibrary.tsx +++ b/src/components/sequencer/SoundLibrary.tsx @@ -21,6 +21,8 @@ import { } 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 @@ -43,18 +45,6 @@ function DraggableSound({ sound }: DraggableSoundProps) { // Don't apply transform to prevent layout shift - DragOverlay handles the visual feedback const style = 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 (
{formatDuration(sound.duration)} - {formatFileSize(sound.size)} + {formatSize(sound.size)} {sound.play_count > 0 && ( <> diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index f5674ea..1c3f1c8 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -119,85 +119,89 @@ export function SequencerPage() { 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, - } + const soundDuration = dragData.sound.duration / 1000 // Convert from ms to seconds + + // Restrict placement to within track duration + const maxStartTime = Math.max(0, state.duration - soundDuration) + startTime = Math.min(startTime, maxStartTime) - setState(prev => ({ - ...prev, - tracks: prev.tracks.map(track => - track.id === overData.trackId - ? { ...track, sounds: [...track.sounds, newPlacedSound] } - : track - ), - })) + // 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') { - 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) + // Restrict placement to within track duration + const maxStartTime = Math.max(0, state.duration - dragData.duration) + startTime = Math.min(startTime, maxStartTime) - 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, + // 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 = { + ...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) + 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 = { + ...dragData, + startTime, + trackId: targetTrackId, + } + return { + ...track, + sounds: [...track.sounds, updatedSound], + } } - 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 - }), - })) + return track + }), + })) + } } // Clear state From 37c932fe751d0943a12e80011883ae7215155eb0 Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 20:22:59 +0200 Subject: [PATCH 17/24] feat: convert duration and startTime to milliseconds in Sequencer components for consistency --- src/components/sequencer/SequencerCanvas.tsx | 36 ++++++++++--------- src/components/sequencer/TimelineControls.tsx | 27 +++++++------- src/pages/SequencerPage.tsx | 28 +++++++-------- 3 files changed, 47 insertions(+), 44 deletions(-) diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index d623489..824ec91 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -54,8 +54,8 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProp transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, } : undefined - const width = sound.duration * zoom - const left = sound.startTime * zoom + const width = (sound.duration / 1000) * zoom // Convert ms to seconds for zoom calculation + const left = (sound.startTime / 1000) * zoom // Convert ms to seconds for zoom calculation const formatTime = (seconds: number): string => { const mins = Math.floor(seconds / 60) @@ -84,7 +84,7 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProp group transition-colors ${isDragging ? 'opacity-50 z-10' : 'z-0'} `} - title={`${sound.name} (${formatTime(sound.duration)})`} + title={`${sound.name} (${formatTime(sound.duration / 1000)})`} >
@@ -110,8 +110,8 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProp } function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, dragOverInfo, onRemoveSound }: TrackRowProps) { - const totalWidth = duration * zoom - const playheadPosition = currentTime * zoom + const totalWidth = (duration / 1000) * zoom // Convert ms to seconds for zoom calculation + const playheadPosition = (currentTime / 1000) * zoom // Convert ms to seconds for zoom calculation const { isOver, setNodeRef: setDropRef } = useDroppable({ id: `track-${track.id}`, @@ -139,7 +139,7 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, > {/* Grid lines for time markers */}
- {Array.from({ length: Math.ceil(duration) + 1 }).map((_, i) => ( + {Array.from({ length: Math.ceil(duration / 1000) + 1 }).map((_, i) => (
))} {/* Major grid lines every 10 seconds */} - {Array.from({ length: Math.floor(duration / 10) + 1 }).map((_, i) => ( + {Array.from({ length: Math.floor(duration / 10000) + 1 }).map((_, i) => (
{ - const soundDuration = draggedItem.type === 'sound' - ? draggedItem.sound.duration / 1000 // Convert ms to seconds - : draggedItem.duration - const startTime = dragOverInfo.x / zoom - const endTime = startTime + soundDuration - const isValidPosition = startTime >= 0 && endTime <= duration + const soundDurationMs = draggedItem.type === 'sound' + ? draggedItem.sound.duration // Already in ms + : draggedItem.duration // Already in ms + const soundDurationSeconds = soundDurationMs / 1000 + const startTimeSeconds = dragOverInfo.x / zoom + const endTimeSeconds = startTimeSeconds + soundDurationSeconds + const durationSeconds = duration / 1000 + const isValidPosition = startTimeSeconds >= 0 && endTimeSeconds <= durationSeconds return (
( dragOverInfo, onRemoveSound, }, ref) => { - const totalWidth = duration * zoom + const totalWidth = (duration / 1000) * zoom // Convert ms to seconds for zoom calculation const timelineRef = useRef(null) const containerRef = useRef(null) @@ -262,7 +264,7 @@ export const SequencerCanvas = forwardRef( }} >
- {Array.from({ length: Math.ceil(duration) + 1 }).map((_, i) => ( + {Array.from({ length: Math.ceil(duration / 1000) + 1 }).map((_, i) => (
{/* Time markers */} {i % 5 === 0 && ( @@ -283,7 +285,7 @@ export const SequencerCanvas = forwardRef( {isPlaying && (
)}
diff --git a/src/components/sequencer/TimelineControls.tsx b/src/components/sequencer/TimelineControls.tsx index c01631e..ab46dea 100644 --- a/src/components/sequencer/TimelineControls.tsx +++ b/src/components/sequencer/TimelineControls.tsx @@ -6,9 +6,9 @@ import { Minus, Plus, ZoomIn, ZoomOut } from 'lucide-react' import { useState } from 'react' interface TimelineControlsProps { - duration: number + duration: number // in milliseconds zoom: number - onDurationChange: (duration: number) => void + onDurationChange: (duration: number) => void // expects milliseconds onZoomChange: (zoom: number) => void minZoom: number maxZoom: number @@ -22,33 +22,34 @@ export function TimelineControls({ minZoom, maxZoom, }: TimelineControlsProps) { - const [durationInput, setDurationInput] = useState(duration.toString()) + 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) + onDurationChange(numValue * 1000) // Convert to milliseconds } } const handleDurationInputBlur = () => { const numValue = parseFloat(durationInput) if (isNaN(numValue) || numValue <= 0) { - setDurationInput(duration.toString()) + setDurationInput(durationInSeconds.toString()) } } const increaseDuration = () => { - const newDuration = Math.min(600, duration + 10) - onDurationChange(newDuration) - setDurationInput(newDuration.toString()) + const newDurationSeconds = Math.min(600, durationInSeconds + 10) + onDurationChange(newDurationSeconds * 1000) // Convert to milliseconds + setDurationInput(newDurationSeconds.toString()) } const decreaseDuration = () => { - const newDuration = Math.max(10, duration - 10) - onDurationChange(newDuration) - setDurationInput(newDuration.toString()) + const newDurationSeconds = Math.max(5, durationInSeconds - 10) + onDurationChange(newDurationSeconds * 1000) // Convert to milliseconds + setDurationInput(newDurationSeconds.toString()) } const increaseZoom = () => { @@ -102,7 +103,7 @@ export function TimelineControls({
- seconds ({formatTime(duration)}) + seconds ({formatTime(duration / 1000)})
@@ -148,7 +149,7 @@ export function TimelineControls({ {/* Timeline Info */}
- Total width: {Math.round(duration * zoom)}px + Total width: {Math.round((duration / 1000) * zoom)}px
diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index 1c3f1c8..b0bf5fa 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -15,20 +15,20 @@ export interface PlacedSound { id: string soundId: number name: string - duration: number // in seconds - startTime: number // in seconds + duration: number // in milliseconds + startTime: number // in milliseconds trackId: string } interface SequencerState { tracks: Track[] - duration: number - zoom: number - currentTime: number + duration: number // in milliseconds + zoom: number // pixels per second + currentTime: number // in milliseconds isPlaying: boolean } -const INITIAL_DURATION = 30 // 30 seconds +const INITIAL_DURATION = 30000 // 30 seconds in milliseconds const INITIAL_ZOOM = 40 // 40 pixels per second const MIN_ZOOM = 10 const MAX_ZOOM = 200 @@ -113,15 +113,15 @@ export function SequencerPage() { // Handle sound drop from library to track if (dragData?.type === 'sound' && overData?.type === 'track') { - // Use precise drop position if available + // Use precise drop position if available (convert from pixels to milliseconds) let startTime = 0 if (dragOverInfo && dragOverInfo.trackId === overData.trackId) { - startTime = Math.max(0, dragOverInfo.x / state.zoom) + startTime = Math.max(0, (dragOverInfo.x / state.zoom) * 1000) // Convert seconds to milliseconds } - const soundDuration = dragData.sound.duration / 1000 // Convert from ms to seconds + const soundDuration = dragData.sound.duration // Already in milliseconds - // Restrict placement to within track duration + // Restrict placement to within track duration (all in milliseconds) const maxStartTime = Math.max(0, state.duration - soundDuration) startTime = Math.min(startTime, maxStartTime) @@ -149,13 +149,13 @@ export function SequencerPage() { // Handle moving placed sounds within tracks if (dragData?.type === 'placed-sound' && overData?.type === 'track') { - // Use precise drop position if available + // Use precise drop position if available (convert from pixels to milliseconds) let startTime = dragData.startTime || 0 if (dragOverInfo && dragOverInfo.trackId === overData.trackId) { - startTime = Math.max(0, dragOverInfo.x / state.zoom) + startTime = Math.max(0, (dragOverInfo.x / state.zoom) * 1000) // Convert seconds to milliseconds } - // Restrict placement to within track duration + // Restrict placement to within track duration (all in milliseconds) const maxStartTime = Math.max(0, state.duration - dragData.duration) startTime = Math.min(startTime, maxStartTime) @@ -287,7 +287,7 @@ export function SequencerPage() { if (state.isPlaying) { const interval = setInterval(() => { setState(prev => { - const newTime = prev.currentTime + 0.1 + const newTime = prev.currentTime + 100 // Add 100ms every 100ms if (newTime >= prev.duration) { return { ...prev, currentTime: prev.duration, isPlaying: false } } From d4b87aafe3b44209785de9695fe93ec374211aab Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 20:51:14 +0200 Subject: [PATCH 18/24] feat: enhance time interval calculation for zoom level in SequencerCanvas --- src/components/sequencer/SequencerCanvas.tsx | 153 ++++++++++++++++--- 1 file changed, 131 insertions(+), 22 deletions(-) diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index 824ec91..77f2385 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -125,6 +125,52 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, onRemoveSound(soundId, track.id) } + // Calculate logical time intervals based on zoom level (same as main component) + const getTimeIntervals = (zoom: number, duration: number) => { + const durationSeconds = duration / 1000 + const minorIntervals: number[] = [] + const majorIntervals: number[] = [] + + // Define logical interval progressions + const intervalSets = [ + { 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 } + } + + const { minorIntervals, majorIntervals } = getTimeIntervals(zoom, duration) + return (
{/* Grid lines for time markers */}
- {Array.from({ length: Math.ceil(duration / 1000) + 1 }).map((_, i) => ( + {/* Minor grid lines */} + {minorIntervals.map((time) => (
))} - {/* Major grid lines every 10 seconds */} - {Array.from({ length: Math.floor(duration / 10000) + 1 }).map((_, i) => ( + {/* Major grid lines */} + {majorIntervals.map((time) => (
))}
@@ -228,7 +275,6 @@ export const SequencerCanvas = forwardRef( }, ref) => { const totalWidth = (duration / 1000) * zoom // Convert ms to seconds for zoom calculation const timelineRef = useRef(null) - const containerRef = useRef(null) // Add a fallback droppable for the entire canvas area const { setNodeRef: setCanvasDropRef } = useDroppable({ @@ -238,6 +284,53 @@ export const SequencerCanvas = forwardRef( }, }) + // Calculate logical time intervals based on zoom level + const getTimeIntervals = (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 } + } + + const { minorIntervals, majorIntervals } = getTimeIntervals(zoom, duration) + const handleTracksScroll = (e: React.UIEvent) => { // Sync timeline horizontal scroll with tracks if (timelineRef.current) { @@ -264,23 +357,39 @@ export const SequencerCanvas = forwardRef( }} >
- {Array.from({ length: Math.ceil(duration / 1000) + 1 }).map((_, i) => ( -
- {/* Time markers */} - {i % 5 === 0 && ( - <> -
-
- {Math.floor(i / 60)}:{(i % 60).toString().padStart(2, '0')} -
- - )} - {i % 5 !== 0 && ( -
- )} + {/* 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 && (
Date: Wed, 3 Sep 2025 21:03:28 +0200 Subject: [PATCH 19/24] feat: implement time snapping to 100ms intervals for improved sound placement accuracy --- src/components/sequencer/SequencerCanvas.tsx | 17 +++++++-- src/pages/SequencerPage.tsx | 36 +++++++++++++++----- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index 77f2385..daebda3 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -54,8 +54,18 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProp transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, } : undefined + // Helper function to snap time to 100ms intervals + const snapToGrid = (timeInSeconds: number): number => { + const snapIntervalMs = 100 // 100ms snap interval + const timeInMs = timeInSeconds * 1000 + const snappedMs = Math.round(timeInMs / snapIntervalMs) * snapIntervalMs + return snappedMs / 1000 // Convert back to seconds + } + const width = (sound.duration / 1000) * zoom // Convert ms to seconds for zoom calculation - const left = (sound.startTime / 1000) * zoom // Convert ms to seconds for zoom calculation + // Ensure placed sounds are positioned at snapped locations + const snappedStartTime = snapToGrid(sound.startTime / 1000) + const left = snappedStartTime * zoom const formatTime = (seconds: number): string => { const mins = Math.floor(seconds / 60) @@ -110,7 +120,6 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProp } function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, dragOverInfo, onRemoveSound }: TrackRowProps) { - const totalWidth = (duration / 1000) * zoom // Convert ms to seconds for zoom calculation const playheadPosition = (currentTime / 1000) * zoom // Convert ms to seconds for zoom calculation const { isOver, setNodeRef: setDropRef } = useDroppable({ @@ -203,12 +212,14 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, ))}
- {/* Precise drag preview */} + {/* 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 diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index b0bf5fa..931535f 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -79,6 +79,14 @@ export function SequencerPage() { } }, []) + // Helper function to snap time to 100ms intervals with improved precision + 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')) { @@ -93,8 +101,12 @@ export function SequencerPage() { currentMousePos.y >= rect.top && currentMousePos.y <= rect.bottom ) { - const x = currentMousePos.x - rect.left - setDragOverInfo({ trackId: track.id, x: Math.max(0, x) }) + const rawX = currentMousePos.x - rect.left + // Apply snapping to the drag over position for consistency + const rawTimeSeconds = rawX / state.zoom + const snappedTimeSeconds = snapToGrid(rawTimeSeconds) + const snappedX = snappedTimeSeconds * state.zoom + setDragOverInfo({ trackId: track.id, x: Math.max(0, snappedX) }) return } } @@ -104,7 +116,7 @@ export function SequencerPage() { } else { setDragOverInfo(null) } - }, [draggedItem, currentMousePos, state.tracks]) + }, [draggedItem, currentMousePos, state.tracks, state.zoom, snapToGrid]) const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event @@ -113,10 +125,10 @@ export function SequencerPage() { // Handle sound drop from library to track if (dragData?.type === 'sound' && overData?.type === 'track') { - // Use precise drop position if available (convert from pixels to milliseconds) + // 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 seconds to milliseconds + startTime = Math.max(0, (dragOverInfo.x / state.zoom) * 1000) // Convert to milliseconds } const soundDuration = dragData.sound.duration // Already in milliseconds @@ -149,10 +161,10 @@ export function SequencerPage() { // Handle moving placed sounds within tracks if (dragData?.type === 'placed-sound' && overData?.type === 'track') { - // Use precise drop position if available (convert from pixels to milliseconds) + // 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 seconds to milliseconds + startTime = Math.max(0, (dragOverInfo.x / state.zoom) * 1000) // Convert to milliseconds } // Restrict placement to within track duration (all in milliseconds) @@ -170,7 +182,10 @@ export function SequencerPage() { if (track.id === sourceTrackId && sourceTrackId === targetTrackId) { // Moving within the same track - just update position const updatedSound: PlacedSound = { - ...dragData, + id: dragData.id, + soundId: dragData.soundId, + name: dragData.name, + duration: dragData.duration, startTime, trackId: targetTrackId, } @@ -189,7 +204,10 @@ export function SequencerPage() { } else if (track.id === targetTrackId) { // Add to target track (different track move) const updatedSound: PlacedSound = { - ...dragData, + id: dragData.id, + soundId: dragData.soundId, + name: dragData.name, + duration: dragData.duration, startTime, trackId: targetTrackId, } From 92444fb02314905168046b2c63d443c91e91f476 Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 21:35:42 +0200 Subject: [PATCH 20/24] feat: enhance time snapping and interval calculation for improved sound placement in Sequencer --- src/components/sequencer/SequencerCanvas.tsx | 118 +++---------------- src/pages/SequencerPage.tsx | 63 ++++++++-- 2 files changed, 68 insertions(+), 113 deletions(-) diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index daebda3..719c092 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -14,6 +14,7 @@ interface SequencerCanvasProps { 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} } interface TrackRowProps { @@ -25,6 +26,7 @@ interface TrackRowProps { 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 { @@ -32,9 +34,10 @@ interface PlacedSoundItemProps { zoom: number trackId: string onRemove: (soundId: string) => void + minorInterval: number } -function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProps) { +function PlacedSoundItem({ sound, zoom, trackId, onRemove, minorInterval }: PlacedSoundItemProps) { const { attributes, listeners, @@ -54,18 +57,11 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProp transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, } : undefined - // Helper function to snap time to 100ms intervals - const snapToGrid = (timeInSeconds: number): number => { - const snapIntervalMs = 100 // 100ms snap interval - const timeInMs = timeInSeconds * 1000 - const snappedMs = Math.round(timeInMs / snapIntervalMs) * snapIntervalMs - return snappedMs / 1000 // Convert back to seconds - } - const width = (sound.duration / 1000) * zoom // Convert ms to seconds for zoom calculation - // Ensure placed sounds are positioned at snapped locations - const snappedStartTime = snapToGrid(sound.startTime / 1000) - const left = snappedStartTime * zoom + // Ensure placed sounds are positioned at snapped locations using current minor interval + const startTimeSeconds = sound.startTime / 1000 + const snappedStartTime = Math.round(startTimeSeconds / minorInterval) * minorInterval + const left = Math.max(0, snappedStartTime) * zoom const formatTime = (seconds: number): string => { const mins = Math.floor(seconds / 60) @@ -119,7 +115,7 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProp ) } -function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, dragOverInfo, onRemoveSound }: TrackRowProps) { +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({ @@ -134,51 +130,7 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, onRemoveSound(soundId, track.id) } - // Calculate logical time intervals based on zoom level (same as main component) - const getTimeIntervals = (zoom: number, duration: number) => { - const durationSeconds = duration / 1000 - const minorIntervals: number[] = [] - const majorIntervals: number[] = [] - - // Define logical interval progressions - const intervalSets = [ - { 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 } - } - - const { minorIntervals, majorIntervals } = getTimeIntervals(zoom, duration) + const { minorIntervals, majorIntervals } = timeIntervals return (
@@ -266,6 +218,7 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, zoom={zoom} trackId={track.id} onRemove={handleRemoveSound} + minorInterval={timeIntervals.minorInterval} /> ))}
@@ -283,6 +236,7 @@ export const SequencerCanvas = forwardRef( draggedItem, dragOverInfo, onRemoveSound, + timeIntervals, }, ref) => { const totalWidth = (duration / 1000) * zoom // Convert ms to seconds for zoom calculation const timelineRef = useRef(null) @@ -295,52 +249,7 @@ export const SequencerCanvas = forwardRef( }, }) - // Calculate logical time intervals based on zoom level - const getTimeIntervals = (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 } - } - - const { minorIntervals, majorIntervals } = getTimeIntervals(zoom, duration) + const { minorIntervals, majorIntervals } = timeIntervals const handleTracksScroll = (e: React.UIEvent) => { // Sync timeline horizontal scroll with tracks @@ -430,6 +339,7 @@ export const SequencerCanvas = forwardRef( draggedItem={draggedItem} dragOverInfo={dragOverInfo} onRemoveSound={onRemoveSound} + timeIntervals={timeIntervals} /> ))}
diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index 931535f..81d8b29 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -79,14 +79,58 @@ export function SequencerPage() { } }, []) - // Helper function to snap time to 100ms intervals with improved precision - 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 + // 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 the current minor interval + const snapToGrid = useCallback((timeInSeconds: number, zoom: number, duration: number): number => { + const { minorInterval } = getTimeIntervals(zoom, duration) + const snappedTime = Math.round(timeInSeconds / minorInterval) * minorInterval + return Math.max(0, snappedTime) // Ensure non-negative + }, [getTimeIntervals]) + // Update drag over info based on current mouse position and over target useEffect(() => { if (draggedItem && currentMousePos && (draggedItem.type === 'sound' || draggedItem.type === 'placed-sound')) { @@ -102,9 +146,9 @@ export function SequencerPage() { currentMousePos.y <= rect.bottom ) { const rawX = currentMousePos.x - rect.left - // Apply snapping to the drag over position for consistency + // Apply adaptive snapping to the drag over position const rawTimeSeconds = rawX / state.zoom - const snappedTimeSeconds = snapToGrid(rawTimeSeconds) + const snappedTimeSeconds = snapToGrid(rawTimeSeconds, state.zoom, state.duration) const snappedX = snappedTimeSeconds * state.zoom setDragOverInfo({ trackId: track.id, x: Math.max(0, snappedX) }) return @@ -116,7 +160,7 @@ export function SequencerPage() { } else { setDragOverInfo(null) } - }, [draggedItem, currentMousePos, state.tracks, state.zoom, snapToGrid]) + }, [draggedItem, currentMousePos, state.tracks, state.zoom, state.duration, snapToGrid]) const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event @@ -380,6 +424,7 @@ export function SequencerPage() { draggedItem={draggedItem} dragOverInfo={dragOverInfo} onRemoveSound={handleRemoveSound} + timeIntervals={getTimeIntervals(state.zoom, state.duration)} />
From 2babeba49efac0a3ffe6c8700bb7fac81f9cb3c2 Mon Sep 17 00:00:00 2001 From: JSC Date: Sat, 13 Sep 2025 22:29:37 +0200 Subject: [PATCH 21/24] feat: implement 100ms snapping for sound placement and enhance zoom controls in Sequencer --- src/components/sequencer/SequencerCanvas.tsx | 67 +++++++++++++++++-- src/components/sequencer/TimelineControls.tsx | 4 +- src/pages/SequencerPage.tsx | 67 +++++++++++++------ 3 files changed, 111 insertions(+), 27 deletions(-) diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index 719c092..ab8037e 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -2,7 +2,7 @@ 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 { useState, forwardRef, useRef, useEffect } from 'react' +import { forwardRef, useRef, useEffect } from 'react' interface SequencerCanvasProps { tracks: Track[] @@ -15,6 +15,9 @@ interface SequencerCanvasProps { 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 { @@ -34,10 +37,9 @@ interface PlacedSoundItemProps { zoom: number trackId: string onRemove: (soundId: string) => void - minorInterval: number } -function PlacedSoundItem({ sound, zoom, trackId, onRemove, minorInterval }: PlacedSoundItemProps) { +function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProps) { const { attributes, listeners, @@ -58,9 +60,10 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove, minorInterval }: Plac } : undefined const width = (sound.duration / 1000) * zoom // Convert ms to seconds for zoom calculation - // Ensure placed sounds are positioned at snapped locations using current minor interval + // Ensure placed sounds are positioned at 100ms snapped locations const startTimeSeconds = sound.startTime / 1000 - const snappedStartTime = Math.round(startTimeSeconds / minorInterval) * minorInterval + 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 => { @@ -218,7 +221,6 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, zoom={zoom} trackId={track.id} onRemove={handleRemoveSound} - minorInterval={timeIntervals.minorInterval} /> ))}
@@ -237,6 +239,9 @@ export const SequencerCanvas = forwardRef( 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) @@ -264,6 +269,54 @@ export const SequencerCanvas = forwardRef( 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 */} @@ -275,6 +328,7 @@ export const SequencerCanvas = forwardRef( scrollbarWidth: 'none', msOverflowStyle: 'none' }} + data-scroll-container >
{/* Minor time markers */} @@ -326,6 +380,7 @@ export const SequencerCanvas = forwardRef( ref={ref} className="flex-1 overflow-auto" onScroll={handleTracksScroll} + data-scroll-container >
{tracks.map((track) => ( diff --git a/src/components/sequencer/TimelineControls.tsx b/src/components/sequencer/TimelineControls.tsx index ab46dea..46f6fac 100644 --- a/src/components/sequencer/TimelineControls.tsx +++ b/src/components/sequencer/TimelineControls.tsx @@ -53,11 +53,11 @@ export function TimelineControls({ } const increaseZoom = () => { - onZoomChange(Math.min(maxZoom, zoom + 10)) + onZoomChange(Math.min(maxZoom, zoom + 5)) } const decreaseZoom = () => { - onZoomChange(Math.max(minZoom, zoom - 10)) + onZoomChange(Math.max(minZoom, zoom - 5)) } const formatTime = (seconds: number): string => { diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index 81d8b29..f2bee81 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -30,7 +30,7 @@ interface SequencerState { const INITIAL_DURATION = 30000 // 30 seconds in milliseconds const INITIAL_ZOOM = 40 // 40 pixels per second -const MIN_ZOOM = 10 +const MIN_ZOOM = 5 const MAX_ZOOM = 200 export function SequencerPage() { @@ -124,12 +124,13 @@ export function SequencerPage() { return { minorIntervals, majorIntervals, minorInterval: selectedIntervals.minor, majorInterval: selectedIntervals.major } }, []) - // Helper function to snap time to the current minor interval - const snapToGrid = useCallback((timeInSeconds: number, zoom: number, duration: number): number => { - const { minorInterval } = getTimeIntervals(zoom, duration) - const snappedTime = Math.round(timeInSeconds / minorInterval) * minorInterval - return Math.max(0, snappedTime) // Ensure non-negative - }, [getTimeIntervals]) + // 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(() => { @@ -146,9 +147,9 @@ export function SequencerPage() { currentMousePos.y <= rect.bottom ) { const rawX = currentMousePos.x - rect.left - // Apply adaptive snapping to the drag over position + // Apply 100ms snapping to the drag over position const rawTimeSeconds = rawX / state.zoom - const snappedTimeSeconds = snapToGrid(rawTimeSeconds, state.zoom, state.duration) + const snappedTimeSeconds = snapToGrid(rawTimeSeconds) const snappedX = snappedTimeSeconds * state.zoom setDragOverInfo({ trackId: track.id, x: Math.max(0, snappedX) }) return @@ -160,7 +161,7 @@ export function SequencerPage() { } else { setDragOverInfo(null) } - }, [draggedItem, currentMousePos, state.tracks, state.zoom, state.duration, snapToGrid]) + }, [draggedItem, currentMousePos, state.tracks, state.zoom, snapToGrid]) const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event @@ -304,22 +305,47 @@ export function SequencerPage() { })) } - const handlePlay = () => { - setState(prev => ({ ...prev, isPlaying: !prev.isPlaying })) - } + // const handlePlay = () => { + // setState(prev => ({ ...prev, isPlaying: !prev.isPlaying })) + // } - const handleStop = () => { - setState(prev => ({ ...prev, isPlaying: false, currentTime: 0 })) - } + // const handleStop = () => { + // setState(prev => ({ ...prev, isPlaying: false, currentTime: 0 })) + // } - const handleReset = () => { - setState(prev => ({ ...prev, 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 })) } @@ -425,6 +451,9 @@ export function SequencerPage() { dragOverInfo={dragOverInfo} onRemoveSound={handleRemoveSound} timeIntervals={getTimeIntervals(state.zoom, state.duration)} + onZoomChange={handleZoomChangeWithPosition} + minZoom={MIN_ZOOM} + maxZoom={MAX_ZOOM} />
From 24cc0cc45f18091a855ac2435073f5f6bebf073a Mon Sep 17 00:00:00 2001 From: JSC Date: Sat, 13 Sep 2025 22:50:47 +0200 Subject: [PATCH 22/24] fix: add cursor pointer style to button variants for better UX --- src/components/ui/button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: { From 4fe280cf5cfe0ba4ab739b3feac6ca0b74a5f63d Mon Sep 17 00:00:00 2001 From: JSC Date: Sat, 13 Sep 2025 23:44:08 +0200 Subject: [PATCH 23/24] feat: add 'minutely' option to recurrence types in CreateTaskDialog and schedulers --- src/components/schedulers/CreateTaskDialog.tsx | 2 +- src/lib/api/services/schedulers.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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': From 4fe9251a2dcfe9902ffe9ee9410ddb471a39bdfd Mon Sep 17 00:00:00 2001 From: JSC Date: Tue, 16 Sep 2025 13:45:25 +0200 Subject: [PATCH 24/24] fix: update task cancellation messages to reflect deletion action --- src/components/schedulers/SchedulersTable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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