Compare commits
26 Commits
43b03e61bd
...
2281993edb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2281993edb | ||
|
|
4fe9251a2d | ||
|
|
4fe280cf5c | ||
|
|
24cc0cc45f | ||
|
|
f7bfd3de73 | ||
|
|
2babeba49e | ||
|
|
92444fb023 | ||
|
|
cd7af24831 | ||
|
|
d4b87aafe3 | ||
|
|
37c932fe75 | ||
|
|
1ba6f23999 | ||
|
|
dba08e2ec0 | ||
|
|
aa11ec379d | ||
|
|
7982a2eb6d | ||
|
|
5afb761d3c | ||
|
|
9603daa5ce | ||
|
|
2ec58ea268 | ||
|
|
df60b5ce93 | ||
|
|
80a18575a1 | ||
|
|
74dfec2e29 | ||
|
|
282ba9446d | ||
|
|
d7b1d97a28 | ||
|
|
7e03189fc4 | ||
|
|
a0d5840166 | ||
|
|
25eacbc85f | ||
|
|
28faf9b149 |
@@ -13,6 +13,7 @@ import { PlaylistEditPage } from './pages/PlaylistEditPage'
|
|||||||
import { PlaylistsPage } from './pages/PlaylistsPage'
|
import { PlaylistsPage } from './pages/PlaylistsPage'
|
||||||
import { RegisterPage } from './pages/RegisterPage'
|
import { RegisterPage } from './pages/RegisterPage'
|
||||||
import { SchedulersPage } from './pages/SchedulersPage'
|
import { SchedulersPage } from './pages/SchedulersPage'
|
||||||
|
import { SequencerPage } from './pages/SequencerPage'
|
||||||
import { SoundsPage } from './pages/SoundsPage'
|
import { SoundsPage } from './pages/SoundsPage'
|
||||||
import { SettingsPage } from './pages/admin/SettingsPage'
|
import { SettingsPage } from './pages/admin/SettingsPage'
|
||||||
import { UsersPage } from './pages/admin/UsersPage'
|
import { UsersPage } from './pages/admin/UsersPage'
|
||||||
@@ -111,6 +112,14 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/sequencer"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SequencerPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/schedulers"
|
path="/schedulers"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
PlayCircle,
|
PlayCircle,
|
||||||
Settings,
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
|
AudioLines,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { CreditsNav } from './nav/CreditsNav'
|
import { CreditsNav } from './nav/CreditsNav'
|
||||||
import { NavGroup } from './nav/NavGroup'
|
import { NavGroup } from './nav/NavGroup'
|
||||||
@@ -48,6 +49,7 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
|||||||
<NavItem href="/" icon={Home} title="Dashboard" />
|
<NavItem href="/" icon={Home} title="Dashboard" />
|
||||||
<NavItem href="/sounds" icon={Music} title="Sounds" />
|
<NavItem href="/sounds" icon={Music} title="Sounds" />
|
||||||
<NavItem href="/playlists" icon={PlayCircle} title="Playlists" />
|
<NavItem href="/playlists" icon={PlayCircle} title="Playlists" />
|
||||||
|
<NavItem href="/sequencer" icon={AudioLines} title="Sequencer" />
|
||||||
<NavItem href="/extractions" icon={Download} title="Extractions" />
|
<NavItem href="/extractions" icon={Download} title="Extractions" />
|
||||||
<NavItem href="/schedulers" icon={CalendarClock} title="Schedulers" />
|
<NavItem href="/schedulers" icon={CalendarClock} title="Schedulers" />
|
||||||
</NavGroup>
|
</NavGroup>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ interface CreateTaskDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TASK_TYPES: TaskType[] = ['credit_recharge', 'play_sound', 'play_playlist']
|
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({
|
export function CreateTaskDialog({
|
||||||
open,
|
open,
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ export function SchedulersTable({ tasks, onTaskUpdated, onTaskDeleted }: Schedul
|
|||||||
|
|
||||||
await schedulersService.cancelTask(task.id)
|
await schedulersService.cancelTask(task.id)
|
||||||
onTaskDeleted?.(task.id)
|
onTaskDeleted?.(task.id)
|
||||||
toast.success('Task cancelled successfully')
|
toast.success('Task deleted successfully')
|
||||||
} catch (error) {
|
} 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)
|
toast.error(message)
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingActions(prev => ({ ...prev, [task.id]: false }))
|
setLoadingActions(prev => ({ ...prev, [task.id]: false }))
|
||||||
@@ -144,7 +144,7 @@ export function SchedulersTable({ tasks, onTaskUpdated, onTaskDeleted }: Schedul
|
|||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Square className="h-4 w-4 mr-2" />
|
<Square className="h-4 w-4 mr-2" />
|
||||||
Cancel Task
|
Delete Task
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
406
src/components/sequencer/SequencerCanvas.tsx
Normal file
406
src/components/sequencer/SequencerCanvas.tsx
Normal 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'
|
||||||
262
src/components/sequencer/SoundLibrary.tsx
Normal file
262
src/components/sequencer/SoundLibrary.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
src/components/sequencer/TimelineControls.tsx
Normal file
157
src/components/sequencer/TimelineControls.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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', 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'
|
||||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export type TaskType = 'credit_recharge' | 'play_sound' | 'play_playlist'
|
|||||||
|
|
||||||
export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
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
|
// Task interfaces
|
||||||
export interface ScheduledTask {
|
export interface ScheduledTask {
|
||||||
@@ -166,6 +166,8 @@ export function getRecurrenceTypeLabel(recurrenceType: RecurrenceType): string {
|
|||||||
switch (recurrenceType) {
|
switch (recurrenceType) {
|
||||||
case 'none':
|
case 'none':
|
||||||
return 'None'
|
return 'None'
|
||||||
|
case 'minutely':
|
||||||
|
return 'Minutely'
|
||||||
case 'hourly':
|
case 'hourly':
|
||||||
return 'Hourly'
|
return 'Hourly'
|
||||||
case 'daily':
|
case 'daily':
|
||||||
|
|||||||
489
src/pages/SequencerPage.tsx
Normal file
489
src/pages/SequencerPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user