145 lines
3.9 KiB
TypeScript
145 lines
3.9 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
|
|
export type QueueItem = {
|
|
slug: string
|
|
title: string
|
|
artist: string
|
|
album_slug: string | null
|
|
duration: number | null
|
|
}
|
|
|
|
type QueueListProps = {
|
|
apiRoot: string
|
|
queue: QueueItem[]
|
|
order: number[]
|
|
playingOrigIdx: number
|
|
scrollSignal: number
|
|
onPlay: (origIdx: number) => void
|
|
onRemove: (origIdx: number) => void
|
|
onMove: (fromPos: number, toPos: number) => void
|
|
}
|
|
|
|
function pad(n: number) {
|
|
return String(n).padStart(2, '0')
|
|
}
|
|
|
|
function fmt(secs: number) {
|
|
if (!secs || Number.isNaN(secs)) return '0:00'
|
|
const s = Math.floor(secs)
|
|
const m = Math.floor(s / 60)
|
|
const h = Math.floor(m / 60)
|
|
if (h > 0) return `${h}:${pad(m % 60)}:${pad(s % 60)}`
|
|
return `${m}:${pad(s % 60)}`
|
|
}
|
|
|
|
function Cover({ src }: { src: string }) {
|
|
const [errored, setErrored] = useState(false)
|
|
useEffect(() => {
|
|
setErrored(false)
|
|
}, [src])
|
|
|
|
if (errored) return <>🎵</>
|
|
return <img src={src} alt="" onError={() => setErrored(true)} />
|
|
}
|
|
|
|
export function QueueList({
|
|
apiRoot,
|
|
queue,
|
|
order,
|
|
playingOrigIdx,
|
|
scrollSignal,
|
|
onPlay,
|
|
onRemove,
|
|
onMove,
|
|
}: QueueListProps) {
|
|
const playingRef = useRef<HTMLDivElement | null>(null)
|
|
const [draggingPos, setDraggingPos] = useState<number | null>(null)
|
|
const [dragOverPos, setDragOverPos] = useState<number | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (playingRef.current) {
|
|
playingRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
}
|
|
}, [playingOrigIdx, scrollSignal])
|
|
|
|
if (!queue.length) {
|
|
return (
|
|
<div className="queue-empty">
|
|
<div className="empty-icon">🎵</div>
|
|
<div>Select an album to start</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{order.map((origIdx, pos) => {
|
|
const t = queue[origIdx]
|
|
if (!t) return null
|
|
|
|
const isPlaying = origIdx === playingOrigIdx
|
|
const coverSrc = t.album_slug ? `${apiRoot}/tracks/${t.slug}/cover` : ''
|
|
const dur = t.duration ? fmt(t.duration) : ''
|
|
const isDragging = draggingPos === pos
|
|
const isDragOver = dragOverPos === pos
|
|
|
|
return (
|
|
<div
|
|
key={`${t.slug}:${pos}`}
|
|
ref={isPlaying ? playingRef : null}
|
|
className={`queue-item${isPlaying ? ' playing' : ''}${isDragging ? ' dragging' : ''}${
|
|
isDragOver ? ' drag-over' : ''
|
|
}`}
|
|
draggable
|
|
onClick={() => onPlay(origIdx)}
|
|
onDragStart={(e) => {
|
|
setDraggingPos(pos)
|
|
e.dataTransfer?.setData('text/plain', String(pos))
|
|
}}
|
|
onDragEnd={() => {
|
|
setDraggingPos(null)
|
|
setDragOverPos(null)
|
|
}}
|
|
onDragOver={(e) => {
|
|
e.preventDefault()
|
|
}}
|
|
onDragEnter={() => {
|
|
setDragOverPos(pos)
|
|
}}
|
|
onDragLeave={() => {
|
|
setDragOverPos((cur) => (cur === pos ? null : cur))
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault()
|
|
setDragOverPos(null)
|
|
const from = parseInt(e.dataTransfer?.getData('text/plain') ?? '', 10)
|
|
if (!Number.isNaN(from)) onMove(from, pos)
|
|
setDraggingPos(null)
|
|
}}
|
|
>
|
|
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
|
|
<div className="qi-cover">
|
|
{coverSrc ? <Cover src={coverSrc} /> : <>🎵</>}
|
|
</div>
|
|
<div className="qi-info">
|
|
<div className="qi-title">{t.title}</div>
|
|
<div className="qi-artist">{t.artist || ''}</div>
|
|
</div>
|
|
<span className="qi-dur">{dur}</span>
|
|
<button
|
|
className="qi-remove"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onRemove(origIdx)
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|