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:
Jeff Emmett 2026-03-30 21:16:12 -07:00
parent a54e003196
commit 1bdb5e50ef
3 changed files with 197 additions and 6 deletions

View File

@ -1,6 +1,66 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { navidromeGet } from '@/lib/navidrome' 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( export async function POST(
request: Request, request: Request,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }

View File

@ -6,9 +6,16 @@ import { Badge } from '@/components/ui/badge'
import { JefflixLogo } from '@/components/jefflix-logo' import { JefflixLogo } from '@/components/jefflix-logo'
import { SongRow } from '@/components/music/search-results' import { SongRow } from '@/components/music/search-results'
import { useMusicPlayer, type Track } from '@/components/music/music-provider' 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' import Link from 'next/link'
interface Playlist {
id: string
name: string
songCount: number
coverArt: string
}
interface SlskdResult { interface SlskdResult {
username: string username: string
freeSlots: number freeSlots: number
@ -30,6 +37,13 @@ export default function MusicPage() {
const [searchError, setSearchError] = useState('') const [searchError, setSearchError] = useState('')
const debounceRef = useRef<NodeJS.Timeout>(null) 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 // Soulseek state
const [slskMode, setSlskMode] = useState(false) const [slskMode, setSlskMode] = useState(false)
const [slskSearchId, setSlskSearchId] = useState<string | null>(null) const [slskSearchId, setSlskSearchId] = useState<string | null>(null)
@ -38,6 +52,33 @@ export default function MusicPage() {
const [downloading, setDownloading] = useState<string | null>(null) const [downloading, setDownloading] = useState<string | null>(null)
const pollRef = useRef<NodeJS.Timeout>(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 // Debounced Navidrome search
useEffect(() => { useEffect(() => {
if (slskMode) return 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 */} {/* Info */}
<div className="mt-12 p-6 bg-muted/50 rounded-lg space-y-3"> <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> <h3 className="font-bold mb-2">How does this work?</h3>

View File

@ -17,6 +17,7 @@ interface Playlist {
id: string id: string
name: string name: string
songCount: number songCount: number
coverArt: string
} }
export function PlaylistPicker({ export function PlaylistPicker({
@ -102,16 +103,30 @@ export function PlaylistPicker({
key={p.id} key={p.id}
onClick={() => addToPlaylist(p.id)} onClick={() => addToPlaylist(p.id)}
disabled={adding !== null} 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="flex-shrink-0 w-10 h-10 rounded overflow-hidden bg-muted">
<div className="font-medium text-sm">{p.name}</div> {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 className="text-xs text-muted-foreground">{p.songCount} songs</div>
</div> </div>
{adding === p.id ? ( {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 ? ( ) : 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} ) : null}
</button> </button>
))} ))}