From 1bdb5e50ef8c23bc74d42942c5813c4af23e735b Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 30 Mar 2026 21:16:12 -0700 Subject: [PATCH] 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 --- app/api/music/playlist/[id]/route.ts | 60 ++++++++++++++ app/music/page.tsx | 118 ++++++++++++++++++++++++++- components/music/playlist-picker.tsx | 25 ++++-- 3 files changed, 197 insertions(+), 6 deletions(-) diff --git a/app/api/music/playlist/[id]/route.ts b/app/api/music/playlist/[id]/route.ts index 0d6edda..0982ac7 100644 --- a/app/api/music/playlist/[id]/route.ts +++ b/app/api/music/playlist/[id]/route.ts @@ -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('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 }> } diff --git a/app/music/page.tsx b/app/music/page.tsx index fdc2ec4..1e42f6f 100644 --- a/app/music/page.tsx +++ b/app/music/page.tsx @@ -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(null) + // Playlist browsing state + const [playlists, setPlaylists] = useState([]) + const [playlistsLoading, setPlaylistsLoading] = useState(true) + const [expandedPlaylist, setExpandedPlaylist] = useState(null) + const [playlistSongs, setPlaylistSongs] = useState([]) + const [playlistSongsLoading, setPlaylistSongsLoading] = useState(false) + // Soulseek state const [slskMode, setSlskMode] = useState(false) const [slskSearchId, setSlskSearchId] = useState(null) @@ -38,6 +52,33 @@ export default function MusicPage() { const [downloading, setDownloading] = useState(null) const pollRef = useRef(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 && ( +
+

+ + Playlists +

+ + {playlistsLoading ? ( +
+ +
+ ) : playlists.length === 0 ? ( +

+ No playlists yet +

+ ) : ( +
+ {playlists.map((p) => ( +
+ + + {expandedPlaylist === p.id && ( +
+ {playlistSongsLoading ? ( +
+ +
+ ) : playlistSongs.length === 0 ? ( +

+ Empty playlist +

+ ) : ( +
+ {playlistSongs.map((song, i) => ( + + ))} +
+ )} +
+ )} +
+ ))} +
+ )} +
+ )} + {/* Info */}

How does this work?

diff --git a/components/music/playlist-picker.tsx b/components/music/playlist-picker.tsx index df50e56..68ef70a 100644 --- a/components/music/playlist-picker.tsx +++ b/components/music/playlist-picker.tsx @@ -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" > -
-
{p.name}
+
+ {p.coverArt ? ( + {p.name} + ) : ( +
+ +
+ )} +
+
+
{p.name}
{p.songCount} songs
{adding === p.id ? ( - + ) : added === p.id ? ( - + ) : null} ))}