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:
parent
345fbca1a9
commit
4694db4f95
|
|
@ -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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue