diff --git a/components/music/full-screen-player.tsx b/components/music/full-screen-player.tsx index 6bcf9c6..c65c1c7 100644 --- a/components/music/full-screen-player.tsx +++ b/components/music/full-screen-player.tsx @@ -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(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 */} -
+
+ +
+ setQueueOpen(false)} /> ) } diff --git a/components/music/music-provider.tsx b/components/music/music-provider.tsx index 2a519e6..60830b0 100644 --- a/components/music/music-provider.tsx +++ b/components/music/music-provider.tsx @@ -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 ( void +} + +export function QueueView({ open, onClose }: QueueViewProps) { + const { state, removeFromQueue, moveInQueue, jumpTo, clearQueue } = useMusicPlayer() + const router = useRouter() + + // Drag state + const [dragIndex, setDragIndex] = useState(null) + const [overIndex, setOverIndex] = useState(null) + const dragStartY = useRef(0) + const dragNodeRef = useRef(null) + const listRef = useRef(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('[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 ( +
+ {/* Header */} +
+ +

Queue

+ {hasUpcoming ? ( + + ) : ( +
+ )} +
+ +
+ {/* Now Playing */} + {currentTrack && ( +
+

Now Playing

+
+
+ {currentTrack.coverArt ? ( + {currentTrack.album} + ) : ( +
+ +
+ )} +
+
+
{currentTrack.title}
+
{currentTrack.artist}
+
+
+
+ )} + + {/* Next Up */} + {hasUpcoming && ( +
+

Next Up

+
+ {upcoming.map((track, i) => { + const isDragging = dragIndex === i + const showIndicator = overIndex !== null && dragIndex !== null && overIndex !== dragIndex && overIndex === i + + return ( +
+ {/* Drop indicator line */} + {showIndicator && dragIndex !== null && dragIndex > i && ( +
+ )} +
+ {/* Drag handle */} + + + {/* Track info — tap to jump */} + + + {/* Remove button */} + +
+ {/* Drop indicator line (below) */} + {showIndicator && dragIndex !== null && dragIndex < i && ( +
+ )} +
+ ) + })} +
+
+ )} + + {/* Empty state */} + {!currentTrack && !hasUpcoming && ( +
+ +

Queue is empty

+

Search for songs to start listening

+
+ )} + + {/* Add Songs link */} +
+ +
+
+
+ ) +}