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
This commit is contained in:
JSC
2025-09-03 00:23:59 +02:00
parent 851738f04f
commit 28faf9b149
6 changed files with 1183 additions and 0 deletions

View File

@@ -0,0 +1,290 @@
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'
interface SequencerCanvasProps {
tracks: Track[]
duration: number
zoom: number
currentTime: number
isPlaying: boolean
onScroll?: () => 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 (
<div
ref={setNodeRef}
style={{
...style,
position: 'absolute',
left: `${left}px`,
width: `${width}px`,
}}
{...listeners}
{...attributes}
className={`
h-12 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)})`}
>
<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 }: 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<number | null>(null)
const handleMouseMove = (e: React.MouseEvent) => {
if (isOver) {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
setDragOverX(x)
}
}
const handleMouseLeave = () => {
setDragOverX(null)
}
const handleRemoveSound = (soundId: string) => {
// 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 (
<div className="relative" style={{ height: '80px' }}>
<div
ref={setDropRef}
className={`
w-full h-full border-b border-border/50
relative overflow-hidden
${isOver ? 'bg-accent/30' : 'bg-muted/10'}
transition-colors
`}
style={{ minWidth: `${totalWidth}px` }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
{/* Grid lines for time markers */}
<div className="absolute inset-0 pointer-events-none">
{Array.from({ length: Math.ceil(duration) + 1 }).map((_, i) => (
<div
key={i}
className="absolute top-0 bottom-0 w-px bg-border/30"
style={{ left: `${i * zoom}px` }}
/>
))}
{/* Major grid lines every 10 seconds */}
{Array.from({ length: Math.floor(duration / 10) + 1 }).map((_, i) => (
<div
key={`major-${i}`}
className="absolute top-0 bottom-0 w-px bg-border/60"
style={{ left: `${i * 10 * zoom}px` }}
/>
))}
</div>
{/* Drop indicator */}
{isOver && dragOverX !== null && (
<div
className="absolute top-0 bottom-0 w-0.5 bg-primary/60 pointer-events-none z-20"
style={{ left: `${dragOverX}px` }}
/>
)}
{/* 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,
}, ref) => {
const totalWidth = duration * zoom
const timelineRef = useRef<HTMLDivElement>(null)
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?.()
}
return (
<div className="h-full flex flex-col">
{/* 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'
}}
>
<div className="relative h-full" style={{ width: `${totalWidth}px` }}>
{Array.from({ length: Math.ceil(duration) + 1 }).map((_, i) => (
<div key={i} className="absolute top-0 bottom-0" style={{ left: `${i * zoom}px` }}>
{/* Time markers */}
{i % 5 === 0 && (
<>
<div className="absolute top-0 w-px h-3 bg-border/60" />
<div className="absolute top-4 text-xs text-muted-foreground font-mono">
{Math.floor(i / 60)}:{(i % 60).toString().padStart(2, '0')}
</div>
</>
)}
{i % 5 !== 0 && (
<div className="absolute top-0 w-px h-2 bg-border/40" />
)}
</div>
))}
{/* Playhead in ruler */}
{isPlaying && (
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-30"
style={{ left: `${currentTime * zoom}px` }}
/>
)}
</div>
</div>
</div>
{/* Tracks */}
<div className="flex-1 overflow-hidden">
<div
ref={ref}
className="w-full h-full overflow-auto"
onScroll={handleTracksScroll}
>
<div style={{ minWidth: `${totalWidth}px` }}>
{tracks.map((track) => (
<TrackRow
key={track.id}
track={track}
duration={duration}
zoom={zoom}
isPlaying={isPlaying}
currentTime={currentTime}
/>
))}
</div>
</div>
</div>
</div>
)
})
SequencerCanvas.displayName = 'SequencerCanvas'

View File

@@ -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 (
<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-50 shadow-lg' : ''}
`}
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 truncate">
{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>{formatFileSize(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 = 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 (
<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,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 (
<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)})
</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 * 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' }}
>
{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'