feat: queue view — view, rearrange, and manage playback queue

Adds full-screen queue panel with drag-and-drop reordering, remove,
jump-to, clear upcoming, and add-songs navigation. Accessible from
both the mini player bar and full-screen player actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 20:25:56 -07:00
parent 345fbca1a9
commit 4694db4f95
4 changed files with 313 additions and 2 deletions

View File

@ -6,6 +6,7 @@ import { useMusicPlayer } from './music-provider'
import { DownloadButton } from './download-button'
import { PlaylistPicker } from './playlist-picker'
import { SyncedLyrics } from './synced-lyrics'
import { QueueView } from './queue-view'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
import {
@ -14,6 +15,7 @@ import {
SkipBack,
SkipForward,
ListPlus,
ListMusic,
Share2,
Volume2,
VolumeX,
@ -34,6 +36,7 @@ export function FullScreenPlayer() {
const [syncedLyrics, setSyncedLyrics] = useState<string | null>(null)
const [loadingLyrics, setLoadingLyrics] = useState(false)
const [playlistOpen, setPlaylistOpen] = useState(false)
const [queueOpen, setQueueOpen] = useState(false)
const track = state.currentTrack
@ -173,7 +176,11 @@ export function FullScreenPlayer() {
)}
{/* Actions */}
<div className="flex gap-3 mb-8">
<div className="flex gap-3 mb-8 flex-wrap justify-center">
<Button variant="outline" size="sm" onClick={() => setQueueOpen(true)}>
<ListMusic className="h-4 w-4 mr-1.5" />
Queue
</Button>
<DownloadButton track={track} size="md" />
<Button variant="outline" size="sm" onClick={() => setPlaylistOpen(true)}>
<ListPlus className="h-4 w-4 mr-1.5" />
@ -209,6 +216,7 @@ export function FullScreenPlayer() {
</Drawer.Root>
<PlaylistPicker open={playlistOpen} onOpenChange={setPlaylistOpen} />
<QueueView open={queueOpen} onClose={() => setQueueOpen(false)} />
</>
)
}

View File

@ -1,9 +1,11 @@
'use client'
import { useState } from 'react'
import { useMusicPlayer } from './music-provider'
import { FullScreenPlayer } from './full-screen-player'
import { QueueView } from './queue-view'
import { Slider } from '@/components/ui/slider'
import { Play, Pause, SkipBack, SkipForward } from 'lucide-react'
import { Play, Pause, SkipBack, SkipForward, ListMusic } from 'lucide-react'
function formatTime(secs: number) {
if (!secs || !isFinite(secs)) return '0:00'
@ -14,6 +16,7 @@ function formatTime(secs: number) {
export function MiniPlayer() {
const { state, togglePlay, seek, nextTrack, prevTrack, setFullScreen } = useMusicPlayer()
const [queueOpen, setQueueOpen] = useState(false)
if (!state.currentTrack) return null
@ -84,11 +87,18 @@ export function MiniPlayer() {
>
<SkipForward className="h-4 w-4" />
</button>
<button
onClick={() => setQueueOpen(true)}
className="p-1.5 hover:bg-muted/50 rounded-full transition-colors text-muted-foreground"
>
<ListMusic className="h-4 w-4" />
</button>
</div>
</div>
</div>
<FullScreenPlayer />
<QueueView open={queueOpen} onClose={() => setQueueOpen(false)} />
</>
)
}

View File

@ -35,6 +35,10 @@ type PlayerAction =
| { type: 'PREV_TRACK' }
| { type: 'SET_FULLSCREEN'; open: boolean }
| { type: 'ADD_TO_QUEUE'; track: Track }
| { type: 'REMOVE_FROM_QUEUE'; index: number }
| { type: 'MOVE_IN_QUEUE'; from: number; to: number }
| { type: 'JUMP_TO'; index: number }
| { type: 'CLEAR_QUEUE' }
const initialState: PlayerState = {
currentTrack: null,
@ -80,6 +84,43 @@ function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
return { ...state, isFullScreen: action.open }
case 'ADD_TO_QUEUE':
return { ...state, queue: [...state.queue, action.track] }
case 'REMOVE_FROM_QUEUE': {
const newQueue = [...state.queue]
newQueue.splice(action.index, 1)
let newIndex = state.queueIndex
if (action.index < state.queueIndex) {
newIndex--
} else if (action.index === state.queueIndex) {
// Removing current track: play next (or clamp)
const clamped = Math.min(newIndex, newQueue.length - 1)
if (clamped < 0) return { ...state, queue: [], queueIndex: -1, currentTrack: null, isPlaying: false }
return { ...state, queue: newQueue, queueIndex: clamped, currentTrack: newQueue[clamped], progress: 0 }
}
return { ...state, queue: newQueue, queueIndex: newIndex }
}
case 'MOVE_IN_QUEUE': {
const q = [...state.queue]
const [moved] = q.splice(action.from, 1)
q.splice(action.to, 0, moved)
// Track the currently-playing song's new position
let idx = state.queueIndex
if (action.from === idx) {
idx = action.to
} else {
if (action.from < idx) idx--
if (action.to <= idx) idx++
}
return { ...state, queue: q, queueIndex: idx }
}
case 'JUMP_TO': {
if (action.index < 0 || action.index >= state.queue.length) return state
return { ...state, currentTrack: state.queue[action.index], queueIndex: action.index, isPlaying: true, progress: 0 }
}
case 'CLEAR_QUEUE': {
// Keep current track, remove everything after it
if (state.queueIndex < 0 || !state.currentTrack) return { ...state, queue: [], queueIndex: -1 }
return { ...state, queue: [state.currentTrack], queueIndex: 0 }
}
default:
return state
}
@ -100,6 +141,10 @@ interface MusicContextValue {
prevTrack: () => void
setFullScreen: (open: boolean) => void
addToQueue: (track: Track) => void
removeFromQueue: (index: number) => void
moveInQueue: (from: number, to: number) => void
jumpTo: (index: number) => void
clearQueue: () => void
outputDevices: AudioOutputDevice[]
currentOutputId: string
setOutputDevice: (deviceId: string) => void
@ -301,6 +346,10 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
const prevTrack = useCallback(() => dispatch({ type: 'PREV_TRACK' }), [])
const setFullScreen = useCallback((open: boolean) => dispatch({ type: 'SET_FULLSCREEN', open }), [])
const addToQueue = useCallback((track: Track) => dispatch({ type: 'ADD_TO_QUEUE', track }), [])
const removeFromQueue = useCallback((index: number) => dispatch({ type: 'REMOVE_FROM_QUEUE', index }), [])
const moveInQueue = useCallback((from: number, to: number) => dispatch({ type: 'MOVE_IN_QUEUE', from, to }), [])
const jumpTo = useCallback((index: number) => dispatch({ type: 'JUMP_TO', index }), [])
const clearQueue = useCallback(() => dispatch({ type: 'CLEAR_QUEUE' }), [])
return (
<MusicContext.Provider value={{
@ -313,6 +362,10 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
prevTrack,
setFullScreen,
addToQueue,
removeFromQueue,
moveInQueue,
jumpTo,
clearQueue,
outputDevices,
currentOutputId,
setOutputDevice,

View File

@ -0,0 +1,240 @@
'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>
)
}