feat: enhance artist page with popular songs and artist bio sections, update Last.fm API integration

This commit is contained in:
2025-07-01 17:45:39 +00:00
committed by GitHub
parent 3ca162e188
commit bc159ac20a
10 changed files with 553 additions and 38 deletions

View File

@@ -0,0 +1,106 @@
'use client';
import { useState, useEffect } from 'react';
import { lastFmAPI } from '@/lib/lastfm-api';
import { Button } from '@/components/ui/button';
import { ChevronDown, ChevronUp, ExternalLink } from 'lucide-react';
interface ArtistBioProps {
artistName: string;
}
export function ArtistBio({ artistName }: ArtistBioProps) {
const [bio, setBio] = useState<string>('');
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(false);
const [lastFmUrl, setLastFmUrl] = useState<string>('');
useEffect(() => {
const fetchArtistInfo = async () => {
if (!lastFmAPI.isAvailable()) return;
setLoading(true);
try {
const artistInfo = await lastFmAPI.getArtistInfo(artistName);
if (artistInfo?.bio?.summary) {
// Clean up the bio text (remove HTML tags and Last.fm links)
let cleanBio = artistInfo.bio.summary
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
// Remove the "Read more on Last.fm" part
cleanBio = cleanBio.replace(/Read more on Last\.fm.*$/i, '').trim();
setBio(cleanBio);
setLastFmUrl(`https://www.last.fm/music/${encodeURIComponent(artistName)}`);
}
} catch (error) {
console.error('Failed to fetch artist bio:', error);
} finally {
setLoading(false);
}
};
fetchArtistInfo();
}, [artistName]);
if (!lastFmAPI.isAvailable() || loading || !bio) {
return null;
}
const shouldTruncate = bio.length > 300;
const displayBio = shouldTruncate && !expanded ? bio.substring(0, 300) + '...' : bio;
return (
<div className="space-y-4">
<h2 className="text-2xl font-semibold tracking-tight">About</h2>
<div className="space-y-3">
<p className="text-sm text-muted-foreground leading-relaxed">
{displayBio}
</p>
<div className="flex items-center gap-2">
{shouldTruncate && (
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded(!expanded)}
className="text-xs h-7 px-2"
>
{expanded ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Show less
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Show more
</>
)}
</Button>
)}
{lastFmUrl && (
<Button
variant="ghost"
size="sm"
asChild
className="text-xs h-7 px-2"
>
<a
href={lastFmUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center"
>
<ExternalLink className="h-3 w-3 mr-1" />
Last.fm
</a>
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import { Song } from '@/lib/navidrome';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Button } from '@/components/ui/button';
import { Play, Heart } from 'lucide-react';
import { useState } from 'react';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { getNavidromeAPI } from '@/lib/navidrome';
interface PopularSongsProps {
songs: Song[];
artistName: string;
}
export function PopularSongs({ songs, artistName }: PopularSongsProps) {
const { playTrack } = useAudioPlayer();
const { starItem, unstarItem } = useNavidrome();
const [songStates, setSongStates] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
songs.forEach(song => {
initial[song.id] = !!song.starred;
});
return initial;
});
const api = getNavidromeAPI();
const songToTrack = (song: Song) => {
if (!api) {
throw new Error('Navidrome API not configured');
}
return {
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId
};
};
const handlePlaySong = async (song: Song) => {
try {
const track = songToTrack(song);
playTrack(track, true);
} catch (error) {
console.error('Failed to play song:', error);
}
};
const handleToggleStar = async (song: Song) => {
try {
const isStarred = songStates[song.id];
if (isStarred) {
await unstarItem(song.id, 'song');
setSongStates(prev => ({ ...prev, [song.id]: false }));
} else {
await starItem(song.id, 'song');
setSongStates(prev => ({ ...prev, [song.id]: true }));
}
} catch (error) {
console.error('Failed to star/unstar song:', error);
}
};
const formatDuration = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
if (songs.length === 0) {
return null;
}
return (
<div className="space-y-4">
<h2 className="text-2xl font-semibold tracking-tight">Popular Songs</h2>
<div className="space-y-2">
{songs.map((song, index) => (
<div
key={song.id}
className="flex items-center space-x-4 p-3 rounded-lg hover:bg-muted/50 group"
>
{/* Rank */}
<div className="w-8 text-sm text-muted-foreground text-center">
{index + 1}
</div>
{/* Album Art */}
<div className="relative w-12 h-12 bg-muted rounded-md overflow-hidden flex-shrink-0">
{song.coverArt && api && (
<img
src={api.getCoverArtUrl(song.coverArt, 96)}
alt={song.album}
className="w-full h-full object-cover"
/>
)}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 text-white hover:bg-white/20"
onClick={() => handlePlaySong(song)}
>
<Play className="h-4 w-4" />
</Button>
</div>
</div>
{/* Song Info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{song.title}</div>
<div className="text-xs text-muted-foreground truncate">{song.album}</div>
</div>
{/* Play Count */}
{song.playCount && song.playCount > 0 && (
<div className="text-xs text-muted-foreground">
{song.playCount.toLocaleString()} plays
</div>
)}
{/* Duration */}
<div className="text-xs text-muted-foreground w-12 text-right">
{formatDuration(song.duration)}
</div>
{/* Star Button */}
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleToggleStar(song)}
>
<Heart
className={`h-4 w-4 ${songStates[song.id] ? 'text-red-500 fill-red-500' : 'text-muted-foreground'}`}
/>
</Button>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { useState, useEffect } from 'react';
import { lastFmAPI } from '@/lib/lastfm-api';
import { Button } from '@/components/ui/button';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import Link from 'next/link';
interface SimilarArtist {
name: string;
url: string;
image?: Array<{
'#text': string;
size: string;
}>;
}
interface SimilarArtistsProps {
artistName: string;
}
export function SimilarArtists({ artistName }: SimilarArtistsProps) {
const [similarArtists, setSimilarArtists] = useState<SimilarArtist[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchSimilarArtists = async () => {
if (!lastFmAPI.isAvailable()) return;
setLoading(true);
try {
const similar = await lastFmAPI.getSimilarArtists(artistName, 6);
if (similar?.artist) {
setSimilarArtists(similar.artist);
}
} catch (error) {
console.error('Failed to fetch similar artists:', error);
} finally {
setLoading(false);
}
};
fetchSimilarArtists();
}, [artistName]);
const getArtistImage = (artist: SimilarArtist): string => {
if (!artist.image || artist.image.length === 0) {
return '/default-user.jpg';
}
// Try to get medium or large image
const mediumImage = artist.image.find(img => img.size === 'medium' || img.size === 'large');
const anyImage = artist.image[artist.image.length - 1]; // Fallback to last image
return mediumImage?.['#text'] || anyImage?.['#text'] || '/default-user.jpg';
};
if (!lastFmAPI.isAvailable() || loading || similarArtists.length === 0) {
return null;
}
return (
<div className="space-y-4">
<h2 className="text-2xl font-semibold tracking-tight">Fans also like</h2>
<ScrollArea>
<div className="flex space-x-4 pb-4">
{similarArtists.map((artist) => (
<Link
key={artist.name}
href={`/artist/${encodeURIComponent(artist.name)}`}
className="flex-shrink-0"
>
<div className="w-32 space-y-2 group cursor-pointer">
<div className="relative w-32 h-32 bg-muted rounded-full overflow-hidden">
<img
src={getArtistImage(artist)}
alt={artist.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
</div>
<div className="text-center">
<p className="text-sm font-medium truncate group-hover:text-primary transition-colors">
{artist.name}
</p>
</div>
</div>
</Link>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
);
}

View File

@@ -23,7 +23,7 @@ const CHANGELOG = [
'Improved search and browsing experience',
'Added history tracking for played songs',
'New Library Artist Page',
'Enhanced audio player with better controls',
'Artist page with top songs and albums',
'Added settings page for customization options',
'Introduced Whats New popup for version updates',
'Improved UI consistency with new Badge component',

View File

@@ -55,7 +55,7 @@ export function Sidebar({ className, playlists, collapsed = false, onToggle }: S
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</Button>
<div className="space-y-4 py-4">
<div className={`space-y-4 py-4 ${collapsed ? "pt-6" : "" }`}>
<div className="px-3 py-2">
<p className={cn("mb-2 px-4 text-lg font-semibold tracking-tight", collapsed && "sr-only")}>
Discover