feat: add playlist browsing with cover art on music page
Playlists now display as expandable cards with cover art thumbnails on the /music page when no search is active. Tapping a playlist reveals its songs with cover art via the SongRow component. Also added GET handler for /api/music/playlist/[id] and improved playlist-picker dialog with cover art. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a54e003196
commit
1bdb5e50ef
|
|
@ -1,6 +1,66 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { navidromeGet } from '@/lib/navidrome'
|
||||
|
||||
interface SubsonicSong {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
albumId: string
|
||||
duration: number
|
||||
track: number
|
||||
year: number
|
||||
coverArt: string
|
||||
suffix: string
|
||||
}
|
||||
|
||||
interface PlaylistResult {
|
||||
playlist?: {
|
||||
id: string
|
||||
name: string
|
||||
songCount: number
|
||||
coverArt: string
|
||||
entry?: SubsonicSong[]
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const data = await navidromeGet<PlaylistResult>('getPlaylist.view', { id })
|
||||
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,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
id: pl.id,
|
||||
name: pl.name,
|
||||
songCount: pl.songCount,
|
||||
coverArt: pl.coverArt,
|
||||
songs,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get playlist error:', error)
|
||||
return NextResponse.json({ error: 'Failed to load playlist' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
|
|
|
|||
|
|
@ -6,9 +6,16 @@ import { Badge } from '@/components/ui/badge'
|
|||
import { JefflixLogo } from '@/components/jefflix-logo'
|
||||
import { SongRow } from '@/components/music/search-results'
|
||||
import { useMusicPlayer, type Track } from '@/components/music/music-provider'
|
||||
import { Search, Music, Download, Loader2, ArrowLeft, AlertCircle } from 'lucide-react'
|
||||
import { Search, Music, Download, Loader2, ArrowLeft, AlertCircle, ListMusic, ChevronRight, ChevronDown } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Playlist {
|
||||
id: string
|
||||
name: string
|
||||
songCount: number
|
||||
coverArt: string
|
||||
}
|
||||
|
||||
interface SlskdResult {
|
||||
username: string
|
||||
freeSlots: number
|
||||
|
|
@ -30,6 +37,13 @@ export default function MusicPage() {
|
|||
const [searchError, setSearchError] = useState('')
|
||||
const debounceRef = useRef<NodeJS.Timeout>(null)
|
||||
|
||||
// Playlist browsing state
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||
const [playlistsLoading, setPlaylistsLoading] = useState(true)
|
||||
const [expandedPlaylist, setExpandedPlaylist] = useState<string | null>(null)
|
||||
const [playlistSongs, setPlaylistSongs] = useState<Track[]>([])
|
||||
const [playlistSongsLoading, setPlaylistSongsLoading] = useState(false)
|
||||
|
||||
// Soulseek state
|
||||
const [slskMode, setSlskMode] = useState(false)
|
||||
const [slskSearchId, setSlskSearchId] = useState<string | null>(null)
|
||||
|
|
@ -38,6 +52,33 @@ export default function MusicPage() {
|
|||
const [downloading, setDownloading] = useState<string | null>(null)
|
||||
const pollRef = useRef<NodeJS.Timeout>(null)
|
||||
|
||||
// Fetch playlists on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/music/playlists')
|
||||
.then((r) => r.json())
|
||||
.then((d) => setPlaylists(d.playlists || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setPlaylistsLoading(false))
|
||||
}, [])
|
||||
|
||||
const togglePlaylist = async (id: string) => {
|
||||
if (expandedPlaylist === id) {
|
||||
setExpandedPlaylist(null)
|
||||
setPlaylistSongs([])
|
||||
return
|
||||
}
|
||||
setExpandedPlaylist(id)
|
||||
setPlaylistSongsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/music/playlist/${id}`)
|
||||
const d = await res.json()
|
||||
setPlaylistSongs(d.songs || [])
|
||||
} catch {
|
||||
setPlaylistSongs([])
|
||||
}
|
||||
setPlaylistSongsLoading(false)
|
||||
}
|
||||
|
||||
// Debounced Navidrome search
|
||||
useEffect(() => {
|
||||
if (slskMode) return
|
||||
|
|
@ -306,6 +347,81 @@ export default function MusicPage() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Playlists - shown when not searching */}
|
||||
{!slskMode && !debouncedQuery && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2 mb-4">
|
||||
<ListMusic className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
Playlists
|
||||
</h2>
|
||||
|
||||
{playlistsLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-purple-600" />
|
||||
</div>
|
||||
) : playlists.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No playlists yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
|
||||
{playlists.map((p) => (
|
||||
<div key={p.id}>
|
||||
<button
|
||||
onClick={() => togglePlaylist(p.id)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded overflow-hidden bg-muted">
|
||||
{p.coverArt ? (
|
||||
<img
|
||||
src={`/api/music/cover/${p.coverArt}?size=96`}
|
||||
alt={p.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ListMusic className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{p.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{p.songCount} songs</div>
|
||||
</div>
|
||||
{expandedPlaylist === p.id ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedPlaylist === p.id && (
|
||||
<div className="bg-muted/30 border-t border-border">
|
||||
{playlistSongsLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : playlistSongs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
Empty playlist
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{playlistSongs.map((song, i) => (
|
||||
<SongRow key={song.id} song={song} songs={playlistSongs} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-12 p-6 bg-muted/50 rounded-lg space-y-3">
|
||||
<h3 className="font-bold mb-2">How does this work?</h3>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface Playlist {
|
|||
id: string
|
||||
name: string
|
||||
songCount: number
|
||||
coverArt: string
|
||||
}
|
||||
|
||||
export function PlaylistPicker({
|
||||
|
|
@ -102,16 +103,30 @@ export function PlaylistPicker({
|
|||
key={p.id}
|
||||
onClick={() => addToPlaylist(p.id)}
|
||||
disabled={adding !== null}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 rounded-md hover:bg-muted/50 transition-colors text-left"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{p.name}</div>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded overflow-hidden bg-muted">
|
||||
{p.coverArt ? (
|
||||
<img
|
||||
src={`/api/music/cover/${p.coverArt}?size=80`}
|
||||
alt={p.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ListPlus className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{p.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{p.songCount} songs</div>
|
||||
</div>
|
||||
{adding === p.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin flex-shrink-0" />
|
||||
) : added === p.id ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Reference in New Issue