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:
parent
4694db4f95
commit
02278f4cf8
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue