Compare commits

...

26 Commits

Author SHA1 Message Date
JSC
2281993edb Merge branch 'sequencer2'
Some checks failed
Frontend CI / lint (push) Failing after 18s
Frontend CI / build (push) Has been skipped
2025-09-17 18:31:18 +02:00
JSC
4fe9251a2d fix: update task cancellation messages to reflect deletion action 2025-09-16 13:45:25 +02:00
JSC
4fe280cf5c feat: add 'minutely' option to recurrence types in CreateTaskDialog and schedulers 2025-09-13 23:44:08 +02:00
JSC
24cc0cc45f fix: add cursor pointer style to button variants for better UX 2025-09-13 22:50:47 +02:00
JSC
f7bfd3de73 Merge branch 'main' into sequencer2 2025-09-13 22:42:29 +02:00
JSC
2babeba49e feat: implement 100ms snapping for sound placement and enhance zoom controls in Sequencer 2025-09-13 22:29:37 +02:00
JSC
92444fb023 feat: enhance time snapping and interval calculation for improved sound placement in Sequencer 2025-09-03 21:35:42 +02:00
JSC
cd7af24831 feat: implement time snapping to 100ms intervals for improved sound placement accuracy 2025-09-03 21:03:28 +02:00
JSC
d4b87aafe3 feat: enhance time interval calculation for zoom level in SequencerCanvas 2025-09-03 20:51:14 +02:00
JSC
37c932fe75 feat: convert duration and startTime to milliseconds in Sequencer components for consistency 2025-09-03 20:22:59 +02:00
JSC
1ba6f23999 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.
2025-09-03 17:17:19 +02:00
JSC
dba08e2ec0 feat: implement sound removal functionality in SequencerPage and update SequencerCanvas props 2025-09-03 17:03:04 +02:00
JSC
aa11ec379d feat: add DragOverlay to SequencerPage for improved drag-and-drop feedback 2025-09-03 16:57:55 +02:00
JSC
7982a2eb6d feat: update DraggableSound component to use break-words for sound name display 2025-09-03 16:55:21 +02:00
JSC
5afb761d3c feat: refactor fetchSounds to use useCallback and remove mock data for improved API integration 2025-09-03 16:52:14 +02:00
JSC
9603daa5ce refactor: remove noPadding prop from AppLayout and simplify class names in SequencerCanvas 2025-09-03 16:45:34 +02:00
JSC
2ec58ea268 feat: update link text in SequencerPage header from "Home" to "Dashboard" 2025-09-03 16:39:22 +02:00
JSC
df60b5ce93 feat: enhance AppLayout and SequencerPage for improved layout and responsiveness 2025-09-03 16:32:05 +02:00
JSC
80a18575a1 feat: update SequencerPage layout for full height and improved responsiveness 2025-09-03 15:55:35 +02:00
JSC
74dfec2e29 feat: add Sequencer navigation item to AppSidebar and wrap SequencerPage in AppLayout for improved structure 2025-09-03 15:30:46 +02:00
JSC
282ba9446d feat: reduce width of TrackControls for improved layout 2025-09-03 15:20:46 +02:00
JSC
d7b1d97a28 feat: add padding to bottom of SequencerCanvas and TrackControls for improved layout 2025-09-03 15:17:52 +02:00
JSC
7e03189fc4 feat: adjust styling of PlacedSoundItem for improved positioning and visual consistency 2025-09-03 15:11:23 +02:00
JSC
a0d5840166 feat: improve layout of SequencerCanvas and SequencerPage for better responsiveness and overflow handling 2025-09-03 15:06:38 +02:00
JSC
25eacbc85f feat: enhance SequencerPage and SequencerCanvas with drag-and-drop functionality for sound placement and improved track management 2025-09-03 14:46:28 +02:00
JSC
28faf9b149 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
2025-09-03 00:23:59 +02:00
11 changed files with 1457 additions and 6 deletions

View File

@@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/sequencer"
element={
<ProtectedRoute>
<SequencerPage />
</ProtectedRoute>
}
/>
<Route
path="/schedulers"
element={

View File

@@ -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) {
<NavItem href="/" icon={Home} title="Dashboard" />
<NavItem href="/sounds" icon={Music} title="Sounds" />
<NavItem href="/playlists" icon={PlayCircle} title="Playlists" />
<NavItem href="/sequencer" icon={AudioLines} title="Sequencer" />
<NavItem href="/extractions" icon={Download} title="Extractions" />
<NavItem href="/schedulers" icon={CalendarClock} title="Schedulers" />
</NavGroup>

View File

@@ -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,

View File

@@ -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"
>
<Square className="h-4 w-4 mr-2" />
Cancel Task
Delete Task
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -0,0 +1,406 @@
import { useDroppable, useDraggable } from '@dnd-kit/core'
import type { Track, PlacedSound } from '@/pages/SequencerPage'
import { Button } from '@/components/ui/button'
import { Trash2, Volume2 } from 'lucide-react'
import { forwardRef, useRef, useEffect } from 'react'
interface SequencerCanvasProps {
tracks: Track[]
duration: number
zoom: number
currentTime: number
isPlaying: boolean
onScroll?: () => void
draggedItem?: any // Current dragged item from parent
dragOverInfo?: {trackId: string, x: number} | null // Drag over position info
onRemoveSound: (soundId: string, trackId: string) => void
timeIntervals: {minorIntervals: number[], majorIntervals: number[], minorInterval: number, majorInterval: number}
onZoomChange?: (newZoom: number, mouseX?: number) => void
minZoom?: number
maxZoom?: number
}
interface TrackRowProps {
track: Track
duration: number
zoom: number
isPlaying: boolean
currentTime: number
draggedItem?: any // Current dragged item
dragOverInfo?: {trackId: string, x: number} | null // Drag over position info
onRemoveSound: (soundId: string, trackId: string) => void
timeIntervals: {minorIntervals: number[], majorIntervals: number[], minorInterval: number, majorInterval: number}
}
interface PlacedSoundItemProps {
sound: PlacedSound
zoom: number
trackId: string
onRemove: (soundId: string) => void
}
function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
isDragging,
} = useDraggable({
id: sound.id,
data: {
type: 'placed-sound',
...sound,
trackId,
},
})
const style = transform ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
} : undefined
const width = (sound.duration / 1000) * zoom // Convert ms to seconds for zoom calculation
// Ensure placed sounds are positioned at 100ms snapped locations
const startTimeSeconds = sound.startTime / 1000
const snapIntervalMs = 100 // 100ms snap interval
const snappedStartTime = Math.round((startTimeSeconds * 1000) / snapIntervalMs) * snapIntervalMs / 1000
const left = Math.max(0, snappedStartTime) * zoom
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
return (
<div
ref={setNodeRef}
style={{
...style,
position: 'absolute',
left: `${left}px`,
width: `${width}px`,
top: '8px',
bottom: '8px',
}}
{...listeners}
{...attributes}
className={`
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
group transition-colors
${isDragging ? 'opacity-50 z-10' : 'z-0'}
`}
title={`${sound.name} (${formatTime(sound.duration / 1000)})`}
>
<div className="flex items-center gap-1 min-w-0 flex-1">
<Volume2 className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate font-medium text-primary">
{sound.name}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
onRemove(sound.id)
}}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
title="Remove sound"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)
}
function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, dragOverInfo, onRemoveSound, timeIntervals }: TrackRowProps) {
const playheadPosition = (currentTime / 1000) * zoom // Convert ms to seconds for zoom calculation
const { isOver, setNodeRef: setDropRef } = useDroppable({
id: `track-${track.id}`,
data: {
type: 'track',
trackId: track.id,
},
})
const handleRemoveSound = (soundId: string) => {
onRemoveSound(soundId, track.id)
}
const { minorIntervals, majorIntervals } = timeIntervals
return (
<div className="relative" style={{ height: '80px' }}>
<div
ref={setDropRef}
id={`track-${track.id}`}
className={`
h-full border-b border-border/50
relative overflow-hidden
${isOver ? 'bg-accent/30' : 'bg-muted/10'}
transition-colors
`}
>
{/* Grid lines for time markers */}
<div className="absolute inset-0 pointer-events-none">
{/* Minor grid lines */}
{minorIntervals.map((time) => (
<div
key={`minor-${time}`}
className="absolute top-0 bottom-0 w-px bg-border/30"
style={{ left: `${time * zoom}px` }}
/>
))}
{/* Major grid lines */}
{majorIntervals.map((time) => (
<div
key={`major-${time}`}
className="absolute top-0 bottom-0 w-px bg-border/60"
style={{ left: `${time * zoom}px` }}
/>
))}
</div>
{/* Precise drag preview (dragOverInfo.x is already snapped) */}
{draggedItem && dragOverInfo && dragOverInfo.trackId === track.id && (() => {
const soundDurationMs = draggedItem.type === 'sound'
? draggedItem.sound.duration // Already in ms
: draggedItem.duration // Already in ms
const soundDurationSeconds = soundDurationMs / 1000
// dragOverInfo.x is already snapped in the parent component
const startTimeSeconds = dragOverInfo.x / zoom
const endTimeSeconds = startTimeSeconds + soundDurationSeconds
const durationSeconds = duration / 1000
const isValidPosition = startTimeSeconds >= 0 && endTimeSeconds <= durationSeconds
return (
<div
className={`absolute top-2 bottom-2 border-2 border-dashed rounded pointer-events-none z-10 flex items-center px-2 ${
isValidPosition
? 'border-primary/60 bg-primary/10'
: 'border-red-500/60 bg-red-500/10'
}`}
style={{
left: `${Math.max(0, dragOverInfo.x)}px`,
width: `${Math.max(60, soundDurationSeconds * zoom)}px`,
}}
>
<div className={`text-xs truncate font-medium ${
isValidPosition ? 'text-primary/80' : 'text-red-500/80'
}`}>
{draggedItem.type === 'sound'
? (draggedItem.sound.name || draggedItem.sound.filename)
: draggedItem.name
}
{!isValidPosition && ' (Invalid)'}
</div>
</div>
)
})()}
{/* Playhead */}
{isPlaying && (
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-30"
style={{ left: `${playheadPosition}px` }}
/>
)}
{/* Placed sounds */}
{track.sounds.map((sound) => (
<PlacedSoundItem
key={sound.id}
sound={sound}
zoom={zoom}
trackId={track.id}
onRemove={handleRemoveSound}
/>
))}
</div>
</div>
)
}
export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(({
tracks,
duration,
zoom,
currentTime,
isPlaying,
onScroll,
draggedItem,
dragOverInfo,
onRemoveSound,
timeIntervals,
onZoomChange,
minZoom = 10,
maxZoom = 200,
}, ref) => {
const totalWidth = (duration / 1000) * zoom // Convert ms to seconds for zoom calculation
const timelineRef = useRef<HTMLDivElement>(null)
// Add a fallback droppable for the entire canvas area
const { setNodeRef: setCanvasDropRef } = useDroppable({
id: 'sequencer-canvas',
data: {
type: 'canvas',
},
})
const { minorIntervals, majorIntervals } = timeIntervals
const handleTracksScroll = (e: React.UIEvent<HTMLDivElement>) => {
// Sync timeline horizontal scroll with tracks
if (timelineRef.current) {
const scrollLeft = e.currentTarget.scrollLeft
// Only update if different to prevent scroll fighting
if (Math.abs(timelineRef.current.scrollLeft - scrollLeft) > 1) {
timelineRef.current.scrollLeft = scrollLeft
}
}
// Call the original scroll handler for vertical sync
onScroll?.()
}
// Handle mouse wheel zoom with Ctrl key using native event listeners
useEffect(() => {
if (!onZoomChange) return
const handleWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return
e.preventDefault()
e.stopPropagation()
// Use the same discrete steps as the zoom buttons (+5/-5)
const zoomStep = 5
const delta = e.deltaY > 0 ? -zoomStep : zoomStep // Inverted for natural feel
const newZoom = Math.min(Math.max(zoom + delta, minZoom), maxZoom)
if (newZoom !== zoom) {
// Get mouse position relative to the scrollable content
const target = e.target as HTMLElement
const scrollContainer = target.closest('[data-scroll-container]') as HTMLElement
if (scrollContainer) {
const rect = scrollContainer.getBoundingClientRect()
const mouseX = e.clientX - rect.left + scrollContainer.scrollLeft
onZoomChange(newZoom, mouseX)
}
}
}
// Add wheel event listeners to both timeline and tracks
const timelineElement = timelineRef.current
const tracksElement = ref && typeof ref === 'object' && ref.current ? ref.current : null
if (timelineElement) {
timelineElement.addEventListener('wheel', handleWheel, { passive: false })
}
if (tracksElement) {
tracksElement.addEventListener('wheel', handleWheel, { passive: false })
}
return () => {
if (timelineElement) {
timelineElement.removeEventListener('wheel', handleWheel)
}
if (tracksElement) {
tracksElement.removeEventListener('wheel', handleWheel)
}
}
}, [onZoomChange, zoom, minZoom, maxZoom, ref])
return (
<div ref={setCanvasDropRef} className="h-full flex flex-col overflow-hidden">
{/* Time ruler */}
<div className="h-8 bg-muted/50 border-b border-border/50 flex-shrink-0 overflow-hidden">
<div
ref={timelineRef}
className="h-full overflow-x-auto [&::-webkit-scrollbar]:hidden"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none'
}}
data-scroll-container
>
<div className="relative h-full" style={{ width: `${totalWidth}px` }}>
{/* Minor time markers */}
{minorIntervals.map((time) => (
<div key={`ruler-minor-${time}`} className="absolute top-0 bottom-0" style={{ left: `${time * zoom}px` }}>
<div className="absolute top-0 w-px h-2 bg-border/40" />
</div>
))}
{/* 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 (
<div key={`ruler-major-${time}`} className="absolute top-0 bottom-0" style={{ left: `${time * zoom}px` }}>
<div className="absolute top-0 w-px h-3 bg-border/60" />
<div className="absolute top-4 text-xs text-muted-foreground font-mono whitespace-nowrap">
{formatTime(time)}
</div>
</div>
)
})}
{/* Playhead in ruler */}
{isPlaying && (
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-30"
style={{ left: `${(currentTime / 1000) * zoom}px` }}
/>
)}
</div>
</div>
</div>
{/* Tracks */}
<div
ref={ref}
className="flex-1 overflow-auto"
onScroll={handleTracksScroll}
data-scroll-container
>
<div style={{ width: `${totalWidth}px`, paddingBottom: '52px' }}>
{tracks.map((track) => (
<TrackRow
key={track.id}
track={track}
duration={duration}
zoom={zoom}
isPlaying={isPlaying}
currentTime={currentTime}
draggedItem={draggedItem}
dragOverInfo={dragOverInfo}
onRemoveSound={onRemoveSound}
timeIntervals={timeIntervals}
/>
))}
</div>
</div>
</div>
)
})
SequencerCanvas.displayName = 'SequencerCanvas'

View File

@@ -0,0 +1,262 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { useDraggable } from '@dnd-kit/core'
import { soundsService, type Sound } from '@/lib/api/services/sounds'
import {
AlertCircle,
Music,
RefreshCw,
Search,
Volume2,
X
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { formatDuration } from '@/utils/format-duration'
import { formatSize } from '@/utils/format-size'
interface DraggableSoundProps {
sound: Sound
}
function DraggableSound({ sound }: DraggableSoundProps) {
const {
attributes,
listeners,
setNodeRef,
isDragging,
} = useDraggable({
id: `sound-${sound.id}`,
data: {
type: 'sound',
sound,
},
})
// Don't apply transform to prevent layout shift - DragOverlay handles the visual feedback
const style = undefined
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`
group cursor-grab active:cursor-grabbing
p-3 border rounded-lg bg-card hover:bg-accent/50 transition-colors
${isDragging ? 'opacity-30' : ''}
`}
title={`Drag to add "${sound.name || sound.filename}" to a track`}
>
<div className="flex items-start gap-2">
<div className="flex-shrink-0 mt-0.5">
{sound.type === 'SDB' && <Volume2 className="h-4 w-4 text-blue-500" />}
{sound.type === 'TTS' && <span className="text-xs font-bold text-green-500">TTS</span>}
{sound.type === 'EXT' && <Music className="h-4 w-4 text-purple-500" />}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm break-words">
{sound.name || sound.filename}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
<span>{formatDuration(sound.duration)}</span>
<span></span>
<span>{formatSize(sound.size)}</span>
{sound.play_count > 0 && (
<>
<span></span>
<span>{sound.play_count} plays</span>
</>
)}
</div>
</div>
</div>
</div>
)
}
export function SoundLibrary() {
const [sounds, setSounds] = useState<Sound[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [soundType, setSoundType] = useState<'all' | 'SDB' | 'TTS' | 'EXT'>('all')
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
// Debounce search query
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery)
}, 300)
return () => clearTimeout(handler)
}, [searchQuery])
const fetchSounds = useCallback(async () => {
try {
setLoading(true)
setError(null)
// Build API params
const params: { types?: string[]; search?: string } = {}
// Filter by type
if (soundType !== 'all') {
params.types = [soundType]
}
// Filter by search query
if (debouncedSearchQuery.trim()) {
params.search = debouncedSearchQuery.trim()
}
const fetchedSounds = await soundsService.getSounds(params)
setSounds(fetchedSounds)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch sounds'
setError(errorMessage)
toast.error(errorMessage)
} finally {
setLoading(false)
}
}, [debouncedSearchQuery, soundType])
useEffect(() => {
fetchSounds()
}, [fetchSounds])
const renderContent = () => {
if (loading) {
return (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-16 w-full rounded-lg" />
</div>
))}
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-8 w-8 text-muted-foreground mb-3" />
<h3 className="font-semibold mb-2">Failed to load sounds</h3>
<p className="text-sm text-muted-foreground mb-4">{error}</p>
<Button variant="outline" size="sm" onClick={fetchSounds}>
Try again
</Button>
</div>
)
}
if (sounds.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
<Music className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="font-semibold mb-2">No sounds found</h3>
<p className="text-sm text-muted-foreground">
{searchQuery
? `No sounds match "${searchQuery}"`
: 'No sounds available in your library'}
</p>
</div>
)
}
return (
<ScrollArea className="h-full">
<div className="space-y-2">
{sounds.map((sound) => (
<DraggableSound key={sound.id} sound={sound} />
))}
</div>
</ScrollArea>
)
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="pb-4 border-b">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold">Sound Library</h3>
<Button
variant="ghost"
size="sm"
onClick={fetchSounds}
disabled={loading}
className="h-8 w-8 p-0"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* Search */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search sounds..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-9 h-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
onClick={() => setSearchQuery('')}
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{/* Type Filter */}
<Select
value={soundType}
onValueChange={(value: 'all' | 'SDB' | 'TTS' | 'EXT') => setSoundType(value)}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="SDB">Soundboard (SDB)</SelectItem>
<SelectItem value="TTS">Text-to-Speech (TTS)</SelectItem>
<SelectItem value="EXT">Extracted (EXT)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Content */}
<div className="flex-1 pt-4 min-h-0">
{renderContent()}
</div>
{/* Footer */}
{!loading && !error && (
<div className="pt-3 border-t">
<div className="text-xs text-muted-foreground text-center">
{sounds.length} sound{sounds.length !== 1 ? 's' : ''}
{searchQuery && ` matching "${searchQuery}"`}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,157 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Minus, Plus, ZoomIn, ZoomOut } from 'lucide-react'
import { useState } from 'react'
interface TimelineControlsProps {
duration: number // in milliseconds
zoom: number
onDurationChange: (duration: number) => void // expects milliseconds
onZoomChange: (zoom: number) => void
minZoom: number
maxZoom: number
}
export function TimelineControls({
duration,
zoom,
onDurationChange,
onZoomChange,
minZoom,
maxZoom,
}: TimelineControlsProps) {
const durationInSeconds = duration / 1000
const [durationInput, setDurationInput] = useState(durationInSeconds.toString())
const handleDurationInputChange = (value: string) => {
setDurationInput(value)
const numValue = parseFloat(value)
if (!isNaN(numValue) && numValue > 0 && numValue <= 600) { // Max 10 minutes
onDurationChange(numValue * 1000) // Convert to milliseconds
}
}
const handleDurationInputBlur = () => {
const numValue = parseFloat(durationInput)
if (isNaN(numValue) || numValue <= 0) {
setDurationInput(durationInSeconds.toString())
}
}
const increaseDuration = () => {
const newDurationSeconds = Math.min(600, durationInSeconds + 10)
onDurationChange(newDurationSeconds * 1000) // Convert to milliseconds
setDurationInput(newDurationSeconds.toString())
}
const decreaseDuration = () => {
const newDurationSeconds = Math.max(5, durationInSeconds - 10)
onDurationChange(newDurationSeconds * 1000) // Convert to milliseconds
setDurationInput(newDurationSeconds.toString())
}
const increaseZoom = () => {
onZoomChange(Math.min(maxZoom, zoom + 5))
}
const decreaseZoom = () => {
onZoomChange(Math.max(minZoom, zoom - 5))
}
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
return (
<div className="flex items-center gap-6">
{/* Duration Controls */}
<div className="flex items-center gap-2">
<Label htmlFor="duration" className="text-sm font-medium whitespace-nowrap">
Duration:
</Label>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={decreaseDuration}
className="h-8 w-8 p-0"
>
<Minus className="h-3 w-3" />
</Button>
<Input
id="duration"
type="number"
min="10"
max="600"
step="0.1"
value={durationInput}
onChange={(e) => handleDurationInputChange(e.target.value)}
onBlur={handleDurationInputBlur}
className="h-8 w-16 text-center"
/>
<Button
variant="outline"
size="sm"
onClick={increaseDuration}
className="h-8 w-8 p-0"
>
<Plus className="h-3 w-3" />
</Button>
</div>
<span className="text-sm text-muted-foreground">
seconds ({formatTime(duration / 1000)})
</span>
</div>
{/* Zoom Controls */}
<div className="flex items-center gap-2">
<Label htmlFor="zoom" className="text-sm font-medium whitespace-nowrap">
Zoom:
</Label>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={decreaseZoom}
className="h-8 w-8 p-0"
>
<ZoomOut className="h-3 w-3" />
</Button>
<div className="w-32">
<Slider
id="zoom"
min={minZoom}
max={maxZoom}
step={5}
value={[zoom]}
onValueChange={([value]) => onZoomChange(value)}
className="w-full"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={increaseZoom}
className="h-8 w-8 p-0"
>
<ZoomIn className="h-3 w-3" />
</Button>
</div>
<span className="text-sm text-muted-foreground">
{zoom}px/s
</span>
</div>
{/* Timeline Info */}
<div className="flex items-center gap-4 ml-auto text-sm text-muted-foreground">
<div>
Total width: {Math.round((duration / 1000) * zoom)}px
</div>
</div>
</div>
)
}

View File

@@ -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<HTMLDivElement, TrackControlsProps>(({
tracks,
onAddTrack,
onRemoveTrack,
onUpdateTrackName,
onScroll,
}, ref) => {
const [editingTrackId, setEditingTrackId] = useState<string | null>(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 (
<div className="h-full flex flex-col">
{/* Header - matches time ruler height of h-8 (32px) */}
<div className="h-8 px-3 border-b bg-muted/50 flex items-center justify-between flex-shrink-0">
<h3 className="text-sm font-medium">Tracks</h3>
<Button
variant="outline"
size="sm"
onClick={onAddTrack}
className="h-6 w-6 p-0"
>
<Plus className="h-3 w-3" />
</Button>
</div>
{/* Track List */}
<div
ref={ref}
className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:hidden"
onScroll={onScroll}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none', paddingBottom: '52px' }}
>
{tracks.map((track) => (
<div
key={track.id}
className="flex items-center justify-between p-3 border-b hover:bg-muted/30 group"
style={{ height: '80px' }} // Match track height in canvas
>
<div className="flex-1 min-w-0">
{editingTrackId === track.id ? (
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onBlur={handleFinishEditing}
onKeyDown={handleKeyDown}
className="h-8 text-sm"
autoFocus
/>
) : (
<div
className="text-sm font-medium truncate cursor-pointer hover:bg-muted/50 p-1 rounded"
onClick={() => handleStartEditing(track)}
title={`Click to rename track: ${track.name}`}
>
{track.name}
</div>
)}
<div className="text-xs text-muted-foreground mt-1">
{track.sounds.length} sound{track.sounds.length !== 1 ? 's' : ''}
</div>
</div>
{tracks.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => onRemoveTrack(track.id)}
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive"
title="Remove track"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
{/* Footer */}
<div className="p-3 border-t bg-muted/50">
<div className="text-xs text-muted-foreground text-center">
{tracks.length} track{tracks.length !== 1 ? 's' : ''}
</div>
</div>
</div>
)
})
TrackControls.displayName = 'TrackControls'

View File

@@ -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: {

View File

@@ -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':

489
src/pages/SequencerPage.tsx Normal file
View File

@@ -0,0 +1,489 @@
import { TrackControls } from '@/components/sequencer/TrackControls'
import { TimelineControls } from '@/components/sequencer/TimelineControls'
import { SoundLibrary } from '@/components/sequencer/SoundLibrary'
import { SequencerCanvas } from '@/components/sequencer/SequencerCanvas'
import { DndContext, DragOverlay, type DragEndEvent, type DragStartEvent, PointerSensor, useSensors, useSensor } from '@dnd-kit/core'
import { useState, useRef, useCallback, useEffect } from 'react'
export interface Track {
id: string
name: string
sounds: PlacedSound[]
}
export interface PlacedSound {
id: string
soundId: number
name: string
duration: number // in milliseconds
startTime: number // in milliseconds
trackId: string
}
interface SequencerState {
tracks: Track[]
duration: number // in milliseconds
zoom: number // pixels per second
currentTime: number // in milliseconds
isPlaying: boolean
}
const INITIAL_DURATION = 30000 // 30 seconds in milliseconds
const INITIAL_ZOOM = 40 // 40 pixels per second
const MIN_ZOOM = 5
const MAX_ZOOM = 200
export function SequencerPage() {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
)
const [state, setState] = useState<SequencerState>({
tracks: [
{
id: 'track-1',
name: 'Track 1',
sounds: [],
},
],
duration: INITIAL_DURATION,
zoom: INITIAL_ZOOM,
currentTime: 0,
isPlaying: false,
})
const [draggedItem, setDraggedItem] = useState<any>(null)
const [dragOverInfo, setDragOverInfo] = useState<{trackId: string, x: number} | null>(null)
const [currentMousePos, setCurrentMousePos] = useState<{x: number, y: number} | null>(null)
const trackControlsRef = useRef<HTMLDivElement>(null)
const sequencerCanvasRef = useRef<HTMLDivElement>(null)
const handleDragStart = useCallback((event: DragStartEvent) => {
setDraggedItem(event.active.data.current)
// Start tracking mouse position globally
const handleMouseMove = (e: MouseEvent) => {
setCurrentMousePos({ x: e.clientX, y: e.clientY })
}
document.addEventListener('mousemove', handleMouseMove)
// Store cleanup function
;(window as any).dragMouseCleanup = () => {
document.removeEventListener('mousemove', handleMouseMove)
}
}, [])
// Calculate logical time intervals based on zoom level (shared with SequencerCanvas)
const getTimeIntervals = useCallback((zoom: number, duration: number) => {
const durationSeconds = duration / 1000
const minorIntervals: number[] = []
const majorIntervals: number[] = []
// Define logical interval progressions
const intervalSets = [
{ minor: 0.1, major: 1 }, // 0.1s minor, 1s major (mega zoomed in)
{ minor: 1, major: 5 }, // 1s minor, 5s major (very zoomed in)
{ minor: 5, major: 30 }, // 5s minor, 30s major
{ minor: 10, major: 60 }, // 10s minor, 1min major
{ minor: 30, major: 300 }, // 30s minor, 5min major
{ minor: 60, major: 600 }, // 1min minor, 10min major
{ minor: 300, major: 1800 }, // 5min minor, 30min major
{ minor: 600, major: 3600 } // 10min minor, 1h major
]
// Find appropriate interval set based on zoom level
// We want major intervals to be roughly 100-200px apart
const targetMajorSpacing = 150
let selectedIntervals = intervalSets[intervalSets.length - 1] // fallback to largest
for (const intervals of intervalSets) {
if (intervals.major * zoom >= targetMajorSpacing) {
selectedIntervals = intervals
break
}
}
// Generate minor intervals (every interval)
for (let i = 0; i * selectedIntervals.minor <= durationSeconds; i++) {
const time = i * selectedIntervals.minor
minorIntervals.push(time)
}
// Generate major intervals (at major boundaries)
for (let i = 0; i * selectedIntervals.major <= durationSeconds; i++) {
const time = i * selectedIntervals.major
majorIntervals.push(time)
}
return { minorIntervals, majorIntervals, minorInterval: selectedIntervals.minor, majorInterval: selectedIntervals.major }
}, [])
// Helper function to snap time to 100ms intervals
const snapToGrid = useCallback((timeInSeconds: number): number => {
const snapIntervalMs = 100 // 100ms snap interval
const timeInMs = Math.max(0, timeInSeconds * 1000) // Ensure non-negative
const snappedMs = Math.round(timeInMs / snapIntervalMs) * snapIntervalMs
return Math.max(0, snappedMs / 1000) // Convert back to seconds, ensure non-negative
}, [])
// Update drag over info based on current mouse position and over target
useEffect(() => {
if (draggedItem && currentMousePos && (draggedItem.type === 'sound' || draggedItem.type === 'placed-sound')) {
// Find which track the mouse is currently over
for (const track of state.tracks) {
const trackElement = document.getElementById(`track-${track.id}`)
if (trackElement) {
const rect = trackElement.getBoundingClientRect()
if (
currentMousePos.x >= rect.left &&
currentMousePos.x <= rect.right &&
currentMousePos.y >= rect.top &&
currentMousePos.y <= rect.bottom
) {
const rawX = currentMousePos.x - rect.left
// Apply 100ms snapping to the drag over position
const rawTimeSeconds = rawX / state.zoom
const snappedTimeSeconds = snapToGrid(rawTimeSeconds)
const snappedX = snappedTimeSeconds * state.zoom
setDragOverInfo({ trackId: track.id, x: Math.max(0, snappedX) })
return
}
}
}
// Mouse is not over any track
setDragOverInfo(null)
} else {
setDragOverInfo(null)
}
}, [draggedItem, currentMousePos, state.tracks, state.zoom, snapToGrid])
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
const dragData = active.data.current
const overData = over?.data.current
// Handle sound drop from library to track
if (dragData?.type === 'sound' && overData?.type === 'track') {
// Use precise drop position if available (dragOverInfo.x is already snapped)
let startTime = 0
if (dragOverInfo && dragOverInfo.trackId === overData.trackId) {
startTime = Math.max(0, (dragOverInfo.x / state.zoom) * 1000) // Convert to milliseconds
}
const soundDuration = dragData.sound.duration // Already in milliseconds
// Restrict placement to within track duration (all in milliseconds)
const maxStartTime = Math.max(0, state.duration - soundDuration)
startTime = Math.min(startTime, maxStartTime)
// Only proceed if the sound can fit within the track
if (startTime >= 0 && startTime + soundDuration <= state.duration) {
const newPlacedSound: PlacedSound = {
id: `placed-${Date.now()}-${Math.random()}`,
soundId: dragData.sound.id,
name: dragData.sound.name || dragData.sound.filename,
duration: soundDuration,
startTime,
trackId: overData.trackId,
}
setState(prev => ({
...prev,
tracks: prev.tracks.map(track =>
track.id === overData.trackId
? { ...track, sounds: [...track.sounds, newPlacedSound] }
: track
),
}))
}
}
// Handle moving placed sounds within tracks
if (dragData?.type === 'placed-sound' && overData?.type === 'track') {
// Use precise drop position if available (dragOverInfo.x is already snapped)
let startTime = dragData.startTime || 0
if (dragOverInfo && dragOverInfo.trackId === overData.trackId) {
startTime = Math.max(0, (dragOverInfo.x / state.zoom) * 1000) // Convert to milliseconds
}
// Restrict placement to within track duration (all in milliseconds)
const maxStartTime = Math.max(0, state.duration - dragData.duration)
startTime = Math.min(startTime, maxStartTime)
// Only proceed if the sound can fit within the track
if (startTime >= 0 && startTime + dragData.duration <= state.duration) {
const sourceTrackId = dragData.trackId
const targetTrackId = overData.trackId
setState(prev => ({
...prev,
tracks: prev.tracks.map(track => {
if (track.id === sourceTrackId && sourceTrackId === targetTrackId) {
// Moving within the same track - just update position
const updatedSound: PlacedSound = {
id: dragData.id,
soundId: dragData.soundId,
name: dragData.name,
duration: dragData.duration,
startTime,
trackId: targetTrackId,
}
return {
...track,
sounds: track.sounds.map(s =>
s.id === dragData.id ? updatedSound : s
),
}
} else if (track.id === sourceTrackId) {
// Remove from source track (different track move)
return {
...track,
sounds: track.sounds.filter(s => s.id !== dragData.id),
}
} else if (track.id === targetTrackId) {
// Add to target track (different track move)
const updatedSound: PlacedSound = {
id: dragData.id,
soundId: dragData.soundId,
name: dragData.name,
duration: dragData.duration,
startTime,
trackId: targetTrackId,
}
return {
...track,
sounds: [...track.sounds, updatedSound],
}
}
return track
}),
}))
}
}
// Clear state
setDraggedItem(null)
setDragOverInfo(null)
setCurrentMousePos(null)
// Clean up mouse tracking
if ((window as any).dragMouseCleanup) {
(window as any).dragMouseCleanup()
delete (window as any).dragMouseCleanup
}
}, [dragOverInfo, state.zoom])
const handleAddTrack = () => {
const newTrackNumber = state.tracks.length + 1
const newTrack: Track = {
id: `track-${Date.now()}`,
name: `Track ${newTrackNumber}`,
sounds: [],
}
setState(prev => ({ ...prev, tracks: [...prev.tracks, newTrack] }))
}
const handleRemoveTrack = (trackId: string) => {
setState(prev => ({
...prev,
tracks: prev.tracks.filter(track => track.id !== trackId),
}))
}
const handleUpdateTrackName = (trackId: string, name: string) => {
setState(prev => ({
...prev,
tracks: prev.tracks.map(track =>
track.id === trackId ? { ...track, name } : track
),
}))
}
// const handlePlay = () => {
// setState(prev => ({ ...prev, isPlaying: !prev.isPlaying }))
// }
// const handleStop = () => {
// setState(prev => ({ ...prev, isPlaying: false, currentTime: 0 }))
// }
// const handleReset = () => {
// setState(prev => ({ ...prev, currentTime: 0 }))
// }
const handleZoomChange = (value: number) => {
setState(prev => ({ ...prev, zoom: value }))
}
// Handle mouse wheel zoom with position centering
const handleZoomChangeWithPosition = (newZoom: number, mouseX?: number) => {
if (mouseX !== undefined && sequencerCanvasRef.current) {
const oldZoom = state.zoom
const currentScrollLeft = sequencerCanvasRef.current.scrollLeft
// Calculate the time position that the mouse is pointing at
const timeAtMouse = (currentScrollLeft + mouseX) / oldZoom
// Calculate what the new scroll position should be to keep the same time under the mouse
const newScrollLeft = timeAtMouse * newZoom - mouseX
setState(prev => ({ ...prev, zoom: newZoom }))
// Apply the new scroll position after the zoom change
requestAnimationFrame(() => {
if (sequencerCanvasRef.current) {
sequencerCanvasRef.current.scrollLeft = Math.max(0, newScrollLeft)
}
})
} else {
setState(prev => ({ ...prev, zoom: newZoom }))
}
}
const handleDurationChange = (duration: number) => {
setState(prev => ({ ...prev, duration }))
}
const handleRemoveSound = (soundId: string, trackId: string) => {
setState(prev => ({
...prev,
tracks: prev.tracks.map(track =>
track.id === trackId
? { ...track, sounds: track.sounds.filter(sound => sound.id !== soundId) }
: track
),
}))
}
const handleVerticalScroll = useCallback(() => {
if (trackControlsRef.current && sequencerCanvasRef.current) {
const canvasScrollTop = sequencerCanvasRef.current.scrollTop
if (Math.abs(trackControlsRef.current.scrollTop - canvasScrollTop) > 1) {
trackControlsRef.current.scrollTop = canvasScrollTop
}
}
}, [])
// Simple playhead animation
useEffect(() => {
if (state.isPlaying) {
const interval = setInterval(() => {
setState(prev => {
const newTime = prev.currentTime + 100 // Add 100ms every 100ms
if (newTime >= prev.duration) {
return { ...prev, currentTime: prev.duration, isPlaying: false }
}
return { ...prev, currentTime: newTime }
})
}, 100)
return () => clearInterval(interval)
}
}, [state.isPlaying, state.duration])
return (
<div className="h-screen w-screen flex flex-col overflow-hidden">
{/* Simple Header */}
<div className="h-12 bg-background border-b flex items-center px-4 flex-shrink-0">
<a href="/" className="text-sm text-muted-foreground hover:text-foreground">Dashboard</a>
<span className="mx-2 text-muted-foreground">/</span>
<span className="text-sm font-medium">Sequencer</span>
</div>
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{/* Left Sidebar - Sound Library */}
<div className="w-64 border-r bg-muted/30 flex flex-col flex-shrink-0">
<div className="p-4 h-full">
<SoundLibrary />
</div>
</div>
{/* Center Content - Tracks */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Timeline Controls */}
<div className="p-4 border-b bg-muted/30">
<TimelineControls
duration={state.duration}
zoom={state.zoom}
onDurationChange={handleDurationChange}
onZoomChange={handleZoomChange}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
/>
</div>
{/* Track Area */}
<div className="flex-1 flex overflow-hidden">
{/* Track Controls */}
<div className="w-32 border-r flex-shrink-0">
<TrackControls
ref={trackControlsRef}
tracks={state.tracks}
onAddTrack={handleAddTrack}
onRemoveTrack={handleRemoveTrack}
onUpdateTrackName={handleUpdateTrackName}
onScroll={handleVerticalScroll}
/>
</div>
{/* Sequencer Canvas */}
<div className="flex-1 overflow-hidden">
<SequencerCanvas
ref={sequencerCanvasRef}
tracks={state.tracks}
duration={state.duration}
zoom={state.zoom}
currentTime={state.currentTime}
isPlaying={state.isPlaying}
onScroll={handleVerticalScroll}
draggedItem={draggedItem}
dragOverInfo={dragOverInfo}
onRemoveSound={handleRemoveSound}
timeIntervals={getTimeIntervals(state.zoom, state.duration)}
onZoomChange={handleZoomChangeWithPosition}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
/>
</div>
</div>
</div>
<DragOverlay>
{draggedItem?.type === 'sound' ? (
<div className="p-3 border rounded-lg bg-card shadow-lg opacity-90">
<div className="flex items-start gap-2">
<div className="flex-shrink-0 mt-0.5">
{draggedItem.sound.type === 'SDB' && <span className="text-xs font-bold text-blue-500">SDB</span>}
{draggedItem.sound.type === 'TTS' && <span className="text-xs font-bold text-green-500">TTS</span>}
{draggedItem.sound.type === 'EXT' && <span className="text-xs font-bold text-purple-500">EXT</span>}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">
{draggedItem.sound.name || draggedItem.sound.filename}
</div>
</div>
</div>
</div>
) : draggedItem?.type === 'placed-sound' ? (
<div className="p-2 border rounded bg-primary/20 border-primary/40 shadow-lg opacity-90">
<div className="font-medium text-sm text-primary truncate">
{draggedItem.name}
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
</div>
)
}