jefflix-website/components/music/queue-view.tsx

241 lines
9.6 KiB
TypeScript

'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import { useMusicPlayer } from './music-provider'
import { X, GripVertical, ArrowLeft, Search, Music } from 'lucide-react'
import { useRouter } from 'next/navigation'
interface QueueViewProps {
open: boolean
onClose: () => void
}
export function QueueView({ open, onClose }: QueueViewProps) {
const { state, removeFromQueue, moveInQueue, jumpTo, clearQueue } = useMusicPlayer()
const router = useRouter()
// Drag state
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [overIndex, setOverIndex] = useState<number | null>(null)
const dragStartY = useRef(0)
const dragNodeRef = useRef<HTMLElement | null>(null)
const listRef = useRef<HTMLDivElement>(null)
const upcomingStart = state.queueIndex + 1
const upcoming = state.queue.slice(upcomingStart)
// Map upcoming array index to absolute queue index
const toAbsolute = (i: number) => upcomingStart + i
const handleDragStart = useCallback((e: React.MouseEvent | React.TouchEvent, index: number) => {
e.preventDefault()
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
dragStartY.current = clientY
setDragIndex(index)
setOverIndex(index)
dragNodeRef.current = (e.target as HTMLElement).closest('[data-queue-row]') as HTMLElement
}, [])
const handleDragMove = useCallback((e: MouseEvent | TouchEvent) => {
if (dragIndex === null || !listRef.current) return
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
// Find which row we're over
const rows = listRef.current.querySelectorAll<HTMLElement>('[data-queue-row]')
for (let i = 0; i < rows.length; i++) {
const rect = rows[i].getBoundingClientRect()
if (clientY >= rect.top && clientY <= rect.bottom) {
setOverIndex(i)
break
}
}
}, [dragIndex])
const handleDragEnd = useCallback(() => {
if (dragIndex !== null && overIndex !== null && dragIndex !== overIndex) {
moveInQueue(toAbsolute(dragIndex), toAbsolute(overIndex))
}
setDragIndex(null)
setOverIndex(null)
dragNodeRef.current = null
}, [dragIndex, overIndex, moveInQueue, upcomingStart]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (dragIndex === null) return
const onMove = (e: MouseEvent | TouchEvent) => handleDragMove(e)
const onEnd = () => handleDragEnd()
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onEnd)
window.addEventListener('touchmove', onMove, { passive: false })
window.addEventListener('touchend', onEnd)
return () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onEnd)
window.removeEventListener('touchmove', onMove)
window.removeEventListener('touchend', onEnd)
}
}, [dragIndex, handleDragMove, handleDragEnd])
// Prevent body scroll when open
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}
}, [open])
if (!open) return null
const currentTrack = state.currentTrack
const hasUpcoming = upcoming.length > 0
return (
<div className="fixed inset-0 z-[250] bg-background flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border flex-shrink-0">
<button onClick={onClose} className="p-1.5 hover:bg-muted/50 rounded-full transition-colors">
<ArrowLeft className="h-5 w-5" />
</button>
<h2 className="text-lg font-semibold">Queue</h2>
{hasUpcoming ? (
<button
onClick={clearQueue}
className="text-sm text-muted-foreground hover:text-foreground transition-colors px-2 py-1"
>
Clear
</button>
) : (
<div className="w-12" />
)}
</div>
<div className="flex-1 overflow-y-auto">
{/* Now Playing */}
{currentTrack && (
<div className="px-4 pt-4 pb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Now Playing</p>
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/10 border border-primary/20">
<div className="w-10 h-10 rounded-md overflow-hidden bg-muted flex-shrink-0">
{currentTrack.coverArt ? (
<img
src={`/api/music/cover/${currentTrack.coverArt}?size=80`}
alt={currentTrack.album}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<Music className="h-5 w-5" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{currentTrack.title}</div>
<div className="text-xs text-muted-foreground truncate">{currentTrack.artist}</div>
</div>
</div>
</div>
)}
{/* Next Up */}
{hasUpcoming && (
<div className="px-4 pt-4 pb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Next Up</p>
<div ref={listRef}>
{upcoming.map((track, i) => {
const isDragging = dragIndex === i
const showIndicator = overIndex !== null && dragIndex !== null && overIndex !== dragIndex && overIndex === i
return (
<div key={`${track.id}-${i}`} data-queue-row>
{/* Drop indicator line */}
{showIndicator && dragIndex !== null && dragIndex > i && (
<div className="h-0.5 bg-primary rounded-full mx-3 -mb-0.5" />
)}
<div
className={`flex items-center gap-2 px-1 py-2 rounded-lg transition-colors ${
isDragging ? 'opacity-50 bg-muted/30' : 'hover:bg-muted/50'
}`}
style={{ willChange: isDragging ? 'transform' : 'auto' }}
>
{/* Drag handle */}
<button
className="p-1.5 touch-none cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
onMouseDown={(e) => handleDragStart(e, i)}
onTouchStart={(e) => handleDragStart(e, i)}
>
<GripVertical className="h-4 w-4" />
</button>
{/* Track info — tap to jump */}
<button
className="flex items-center gap-3 flex-1 min-w-0 text-left"
onClick={() => {
jumpTo(toAbsolute(i))
onClose()
}}
>
<div className="w-10 h-10 rounded-md overflow-hidden bg-muted flex-shrink-0">
{track.coverArt ? (
<img
src={`/api/music/cover/${track.coverArt}?size=80`}
alt={track.album}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<Music className="h-5 w-5" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{track.title}</div>
<div className="text-xs text-muted-foreground truncate">{track.artist}</div>
</div>
</button>
{/* Remove button */}
<button
className="p-1.5 hover:bg-muted rounded-full transition-colors text-muted-foreground hover:text-foreground flex-shrink-0"
onClick={() => removeFromQueue(toAbsolute(i))}
>
<X className="h-4 w-4" />
</button>
</div>
{/* Drop indicator line (below) */}
{showIndicator && dragIndex !== null && dragIndex < i && (
<div className="h-0.5 bg-primary rounded-full mx-3 -mt-0.5" />
)}
</div>
)
})}
</div>
</div>
)}
{/* Empty state */}
{!currentTrack && !hasUpcoming && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Music className="h-12 w-12 mb-4 opacity-40" />
<p className="text-lg font-medium mb-1">Queue is empty</p>
<p className="text-sm">Search for songs to start listening</p>
</div>
)}
{/* Add Songs link */}
<div className="px-4 py-6">
<button
onClick={() => {
onClose()
router.push('/music')
}}
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
>
<Search className="h-4 w-4" />
Add Songs
</button>
</div>
</div>
</div>
)
}