241 lines
9.6 KiB
TypeScript
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>
|
|
)
|
|
}
|