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 { 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 }> }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue