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:
@@ -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={
|
||||
|
||||
290
src/components/sequencer/SequencerCanvas.tsx
Normal file
290
src/components/sequencer/SequencerCanvas.tsx
Normal 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'
|
||||
269
src/components/sequencer/SoundLibrary.tsx
Normal file
269
src/components/sequencer/SoundLibrary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
156
src/components/sequencer/TimelineControls.tsx
Normal file
156
src/components/sequencer/TimelineControls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
src/components/sequencer/TrackControls.tsx
Normal file
124
src/components/sequencer/TrackControls.tsx
Normal 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'
|
||||
335
src/pages/SequencerPage.tsx
Normal file
335
src/pages/SequencerPage.tsx
Normal file
@@ -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<SequencerState>({
|
||||
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<string | null>(null)
|
||||
const [activeDragData, setActiveDragData] = useState<Record<string, any> | null>(null)
|
||||
|
||||
// Refs for scroll synchronization
|
||||
const trackControlsScrollRef = useRef<HTMLDivElement>(null)
|
||||
const sequencerCanvasScrollRef = useRef<HTMLDivElement>(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 (
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Sequencer' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4 h-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Sequencer</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create sequences by dragging sounds onto tracks
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={togglePlayback}
|
||||
className="w-20"
|
||||
>
|
||||
{state.isPlaying ? (
|
||||
<>
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
Stop
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Play
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetSequencer}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-12 gap-4 h-[calc(100vh-200px)]">
|
||||
{/* Sound Library Panel */}
|
||||
<div className="col-span-3 bg-card rounded-lg border p-4 overflow-hidden">
|
||||
<SoundLibrary />
|
||||
</div>
|
||||
|
||||
{/* Main Sequencer Area */}
|
||||
<div className="col-span-9 bg-card rounded-lg border overflow-hidden flex flex-col">
|
||||
{/* Timeline Controls */}
|
||||
<div className="border-b p-4">
|
||||
<TimelineControls
|
||||
duration={state.duration}
|
||||
zoom={state.zoom}
|
||||
onDurationChange={updateDuration}
|
||||
onZoomChange={updateZoom}
|
||||
minZoom={MIN_ZOOM}
|
||||
maxZoom={MAX_ZOOM}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sequencer Content */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* Track Controls */}
|
||||
<div className="w-48 border-r bg-muted/30">
|
||||
<TrackControls
|
||||
ref={trackControlsScrollRef}
|
||||
tracks={state.tracks}
|
||||
onAddTrack={addTrack}
|
||||
onRemoveTrack={removeTrack}
|
||||
onUpdateTrackName={updateTrackName}
|
||||
onScroll={handleTrackControlsScroll}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sequencer Canvas */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<SequencerCanvas
|
||||
ref={sequencerCanvasScrollRef}
|
||||
tracks={state.tracks}
|
||||
duration={state.duration}
|
||||
zoom={state.zoom}
|
||||
currentTime={state.currentTime}
|
||||
isPlaying={state.isPlaying}
|
||||
onScroll={handleSequencerCanvasScroll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay>
|
||||
{activeId && activeDragData ? (
|
||||
<div className="bg-primary/20 border-2 border-primary rounded px-3 py-2 text-sm font-medium opacity-80">
|
||||
{activeDragData.type === 'sound'
|
||||
? activeDragData.sound?.name || activeDragData.sound?.filename
|
||||
: activeDragData.name}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user