feat: shuffle mode, queue-next fix, song switch confirmation, playlist dedup

- Add shuffle toggle to mini-player and full-screen player (Fisher-Yates
  shuffle of upcoming queue items, restores original order on toggle off)
- Fix ADD_TO_QUEUE to insert after current track instead of appending to end
- Add confirmation dialog ("Switch song, DJ Cutoff?") when clicking a
  different song while one is playing
- Dedup playlist songs by title+artist (keeps first occurrence)
- Make artist name clickable in full-screen player (navigates to search)
- Music page reads ?q= param to pre-fill search from artist links
- Add pre-cache module for upcoming tracks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 21:03:34 -07:00
parent 4694db4f95
commit 02278f4cf8
8 changed files with 208 additions and 23 deletions

View File

@ -35,18 +35,23 @@ export async function GET(
const pl = data.playlist
if (!pl) return NextResponse.json({ error: 'Playlist not found' }, { status: 404 })
const songs = (pl.entry || []).map((s) => ({
id: s.id,
title: s.title,
artist: s.artist,
album: s.album,
albumId: s.albumId,
duration: s.duration,
track: s.track,
year: s.year,
coverArt: s.coverArt,
suffix: s.suffix,
}))
// Dedup by title+artist, keeping first occurrence
const seen = new Set<string>()
const songs = (pl.entry || []).reduce<Array<{
id: string; title: string; artist: string; album: string; albumId: string;
duration: number; track: number; year: number; coverArt: string; suffix: string;
}>>((acc, s) => {
const key = `${s.title.toLowerCase().trim()}|||${s.artist.toLowerCase().trim()}`
if (!seen.has(key)) {
seen.add(key)
acc.push({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, track: s.track,
year: s.year, coverArt: s.coverArt, suffix: s.suffix,
})
}
return acc
}, [])
return NextResponse.json({
id: pl.id,

View File

@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { JefflixLogo } from '@/components/jefflix-logo'
@ -33,9 +34,18 @@ interface SlskdFile {
}
export default function MusicPage() {
return (
<Suspense>
<MusicPageInner />
</Suspense>
)
}
function MusicPageInner() {
const searchParams = useSearchParams()
const { state } = useMusicPlayer()
const { offlineIds, download: downloadTrack } = useOffline()
const [query, setQuery] = useState('')
const [query, setQuery] = useState(() => searchParams.get('q') || '')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [songs, setSongs] = useState<Track[]>([])
const [searching, setSearching] = useState(false)

View File

@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Drawer } from 'vaul'
import { useMusicPlayer } from './music-provider'
import { DownloadButton } from './download-button'
@ -21,6 +22,7 @@ import {
VolumeX,
ChevronDown,
Speaker,
Shuffle,
} from 'lucide-react'
function formatTime(secs: number) {
@ -31,7 +33,8 @@ function formatTime(secs: number) {
}
export function FullScreenPlayer() {
const { state, togglePlay, seek, setVolume, nextTrack, prevTrack, setFullScreen, outputDevices, currentOutputId, setOutputDevice } = useMusicPlayer()
const { state, togglePlay, seek, setVolume, nextTrack, prevTrack, setFullScreen, toggleShuffle, outputDevices, currentOutputId, setOutputDevice } = useMusicPlayer()
const router = useRouter()
const [lyrics, setLyrics] = useState<string | null>(null)
const [syncedLyrics, setSyncedLyrics] = useState<string | null>(null)
const [loadingLyrics, setLoadingLyrics] = useState(false)
@ -109,7 +112,15 @@ export function FullScreenPlayer() {
{/* Title / Artist */}
<div className="text-center mb-6 max-w-sm">
<h2 className="text-xl font-bold truncate">{track.title}</h2>
<p className="text-muted-foreground truncate">{track.artist}</p>
<button
className="text-muted-foreground truncate hover:text-purple-400 hover:underline transition-colors"
onClick={() => {
setFullScreen(false)
router.push(`/music?q=${encodeURIComponent(track.artist)}`)
}}
>
{track.artist}
</button>
<p className="text-sm text-muted-foreground/70 truncate">{track.album}</p>
</div>
@ -130,6 +141,13 @@ export function FullScreenPlayer() {
{/* Controls */}
<div className="flex items-center gap-6 mb-6">
<button
onClick={toggleShuffle}
className={`p-2 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'hover:bg-muted/50'}`}
title={state.shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'}
>
<Shuffle className="h-5 w-5" />
</button>
<button onClick={prevTrack} className="p-2 hover:bg-muted/50 rounded-full transition-colors">
<SkipBack className="h-6 w-6" />
</button>

View File

@ -5,7 +5,7 @@ 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, ListMusic } from 'lucide-react'
import { Play, Pause, SkipBack, SkipForward, ListMusic, Shuffle } from 'lucide-react'
function formatTime(secs: number) {
if (!secs || !isFinite(secs)) return '0:00'
@ -15,7 +15,7 @@ function formatTime(secs: number) {
}
export function MiniPlayer() {
const { state, togglePlay, seek, nextTrack, prevTrack, setFullScreen } = useMusicPlayer()
const { state, togglePlay, seek, nextTrack, prevTrack, setFullScreen, toggleShuffle } = useMusicPlayer()
const [queueOpen, setQueueOpen] = useState(false)
if (!state.currentTrack) return null
@ -69,6 +69,13 @@ export function MiniPlayer() {
{/* Controls */}
<div className="flex items-center gap-1">
<button
onClick={toggleShuffle}
className={`p-1.5 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'text-muted-foreground hover:bg-muted/50'}`}
title={state.shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'}
>
<Shuffle className="h-3.5 w-3.5" />
</button>
<button
onClick={prevTrack}
className="p-1.5 hover:bg-muted/50 rounded-full transition-colors"

View File

@ -2,6 +2,7 @@
import React, { createContext, useContext, useReducer, useRef, useEffect, useCallback, useState } from 'react'
import { getTrackBlob } from '@/lib/offline-db'
import { precacheUpcoming } from '@/lib/precache'
export interface Track {
id: string
@ -22,6 +23,8 @@ interface PlayerState {
duration: number
volume: number
isFullScreen: boolean
shuffleEnabled: boolean
originalQueue: Track[] | null
}
type PlayerAction =
@ -39,6 +42,7 @@ type PlayerAction =
| { type: 'MOVE_IN_QUEUE'; from: number; to: number }
| { type: 'JUMP_TO'; index: number }
| { type: 'CLEAR_QUEUE' }
| { type: 'TOGGLE_SHUFFLE' }
const initialState: PlayerState = {
currentTrack: null,
@ -49,6 +53,8 @@ const initialState: PlayerState = {
duration: 0,
volume: 0.8,
isFullScreen: false,
shuffleEnabled: false,
originalQueue: null,
}
function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
@ -56,7 +62,7 @@ function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
case 'PLAY_TRACK': {
const queue = action.queue || [action.track]
const index = action.index ?? 0
return { ...state, currentTrack: action.track, queue, queueIndex: index, isPlaying: true, progress: 0 }
return { ...state, currentTrack: action.track, queue, queueIndex: index, isPlaying: true, progress: 0, shuffleEnabled: false, originalQueue: null }
}
case 'TOGGLE_PLAY':
return { ...state, isPlaying: !state.isPlaying }
@ -82,8 +88,15 @@ function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
}
case 'SET_FULLSCREEN':
return { ...state, isFullScreen: action.open }
case 'ADD_TO_QUEUE':
return { ...state, queue: [...state.queue, action.track] }
case 'ADD_TO_QUEUE': {
// Insert after current track so it plays next
const insertAt = state.queueIndex + 1
const q2 = [...state.queue]
q2.splice(insertAt, 0, action.track)
// If shuffle is on, also add to originalQueue at end
const origQ = state.originalQueue ? [...state.originalQueue, action.track] : null
return { ...state, queue: q2, originalQueue: origQ }
}
case 'REMOVE_FROM_QUEUE': {
const newQueue = [...state.queue]
newQueue.splice(action.index, 1)
@ -121,6 +134,23 @@ function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
if (state.queueIndex < 0 || !state.currentTrack) return { ...state, queue: [], queueIndex: -1 }
return { ...state, queue: [state.currentTrack], queueIndex: 0 }
}
case 'TOGGLE_SHUFFLE': {
if (state.shuffleEnabled) {
// OFF: restore original order, find current track in it
if (!state.originalQueue) return { ...state, shuffleEnabled: false }
const currentId = state.currentTrack?.id
const idx = state.originalQueue.findIndex((t) => t.id === currentId)
return { ...state, shuffleEnabled: false, queue: state.originalQueue, queueIndex: idx >= 0 ? idx : 0, originalQueue: null }
} else {
// ON: save original queue, Fisher-Yates shuffle items after current index
const shuffled = [...state.queue]
for (let i = shuffled.length - 1; i > state.queueIndex + 1; i--) {
const j = state.queueIndex + 1 + Math.floor(Math.random() * (i - state.queueIndex))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return { ...state, shuffleEnabled: true, originalQueue: [...state.queue], queue: shuffled }
}
}
default:
return state
}
@ -145,6 +175,7 @@ interface MusicContextValue {
moveInQueue: (from: number, to: number) => void
jumpTo: (index: number) => void
clearQueue: () => void
toggleShuffle: () => void
outputDevices: AudioOutputDevice[]
currentOutputId: string
setOutputDevice: (deviceId: string) => void
@ -282,6 +313,21 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
navigator.mediaSession.setActionHandler('nexttrack', () => dispatch({ type: 'NEXT_TRACK' }))
}, [state.currentTrack])
// Pre-cache next 3 tracks in queue when current track changes
useEffect(() => {
if (state.queueIndex < 0 || state.queue.length <= state.queueIndex + 1) return
const ac = new AbortController()
const delay = setTimeout(() => {
precacheUpcoming(state.queue, state.queueIndex, 3, ac.signal)
}, 2000)
return () => {
clearTimeout(delay)
ac.abort()
}
}, [state.queueIndex, state.queue])
// Keyboard shortcut: Space = play/pause (only when not in input)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -350,6 +396,7 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
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' }), [])
const toggleShuffle = useCallback(() => dispatch({ type: 'TOGGLE_SHUFFLE' }), [])
return (
<MusicContext.Provider value={{
@ -366,6 +413,7 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
moveInQueue,
jumpTo,
clearQueue,
toggleShuffle,
outputDevices,
currentOutputId,
setOutputDevice,

View File

@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import { useMusicPlayer, type Track } from './music-provider'
import { DownloadButton } from './download-button'
import { SwipeableRow } from './swipeable-row'
@ -26,17 +27,47 @@ export function SongRow({
const { state, playTrack, togglePlay, addToQueue } = useMusicPlayer()
const isActive = state.currentTrack?.id === song.id
const isPlaying = isActive && state.isPlaying
const [confirmSwitch, setConfirmSwitch] = useState(false)
const handlePlay = () => {
if (isActive) {
togglePlay()
} else if (state.currentTrack && state.isPlaying) {
setConfirmSwitch(true)
} else {
playTrack(song, songs, index)
}
}
return (
<SwipeableRow onSwipeRight={() => addToQueue(song)}>
<div
className={`flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors group ${
className={`relative flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors group ${
isActive ? 'bg-primary/5' : ''
}`}
>
{/* Confirm switch overlay */}
{confirmSwitch && (
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-background/95 backdrop-blur-sm rounded px-4">
<span className="text-sm font-medium truncate mr-auto">Switch song, DJ Cutoff?</span>
<button
onClick={() => { playTrack(song, songs, index); setConfirmSwitch(false) }}
className="px-3 py-1.5 text-sm font-medium bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors"
>
Switch
</button>
<button
onClick={() => setConfirmSwitch(false)}
className="px-3 py-1.5 text-sm font-medium bg-muted hover:bg-muted/80 rounded-md transition-colors"
>
Cancel
</button>
</div>
)}
{/* Play button / track number */}
<button
onClick={() => isActive ? togglePlay() : playTrack(song, songs, index)}
onClick={handlePlay}
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full hover:bg-muted transition-colors"
>
{isPlaying ? (

View File

@ -71,6 +71,28 @@ export async function hasTrack(trackId: string): Promise<boolean> {
})
}
/** Save only the audio blob (no metadata — used for pre-caching) */
export async function saveBlob(trackId: string, blob: Blob): Promise<void> {
const db = await openDB()
const t = tx(db, AUDIO_STORE, 'readwrite')
t.objectStore(AUDIO_STORE).put(blob, trackId)
return new Promise((resolve, reject) => {
t.oncomplete = () => { db.close(); resolve() }
t.onerror = () => { db.close(); reject(t.error) }
})
}
/** Check if an audio blob exists (regardless of metadata) */
export async function hasBlob(trackId: string): Promise<boolean> {
const db = await openDB()
const t = tx(db, AUDIO_STORE)
const req = t.objectStore(AUDIO_STORE).count(trackId)
return new Promise((resolve, reject) => {
req.onsuccess = () => { db.close(); resolve(req.result > 0) }
req.onerror = () => { db.close(); reject(req.error) }
})
}
/** Remove a track from offline storage */
export async function removeTrack(trackId: string): Promise<void> {
const db = await openDB()

44
lib/precache.ts Normal file
View File

@ -0,0 +1,44 @@
import { hasBlob, saveBlob } from '@/lib/offline-db'
/**
* Pre-cache a track's audio blob into IndexedDB.
* Only stores the blob (no metadata) so pre-cached tracks don't appear
* in the offline library, but the player still finds them via getTrackBlob().
* Best-effort errors are silently swallowed.
*/
export async function precacheTrack(
trackId: string,
signal?: AbortSignal
): Promise<void> {
if (await hasBlob(trackId)) return
if (signal?.aborted) return
const res = await fetch(`/api/music/stream/${trackId}`, { signal })
if (!res.ok) return
const blob = await res.blob()
if (signal?.aborted) return
await saveBlob(trackId, blob)
}
/**
* Pre-cache the next N tracks in a queue sequentially.
* Sequential to avoid flooding bandwidth on mobile connections.
*/
export async function precacheUpcoming(
queue: { id: string }[],
currentIndex: number,
count: number,
signal: AbortSignal
): Promise<void> {
const upcoming = queue.slice(currentIndex + 1, currentIndex + 1 + count)
for (const track of upcoming) {
if (signal.aborted) return
try {
await precacheTrack(track.id, signal)
} catch {
// best-effort — continue with next track
}
}
}