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

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=f0f3d5a
NEXT_PUBLIC_COMMIT_SHA=3ca162e

View File

@@ -1,10 +1,13 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Album, Artist } from '@/lib/navidrome';
import { Album, Artist, Song } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { AlbumArtwork } from '@/app/components/album-artwork';
import { PopularSongs } from '@/app/components/PopularSongs';
import { SimilarArtists } from '@/app/components/SimilarArtists';
import { ArtistBio } from '@/app/components/ArtistBio';
import Image from 'next/image';
import { Button } from '@/components/ui/button';
import { Heart, Play } from 'lucide-react';
@@ -17,6 +20,7 @@ export default function ArtistPage() {
const { artist: artistId } = useParams();
const [isStarred, setIsStarred] = useState(false);
const [artistAlbums, setArtistAlbums] = useState<Album[]>([]);
const [popularSongs, setPopularSongs] = useState<Song[]>([]);
const [loading, setLoading] = useState(true);
const [artist, setArtist] = useState<Artist | null>(null);
const [isPlayingArtist, setIsPlayingArtist] = useState(false);
@@ -29,11 +33,19 @@ export default function ArtistPage() {
const fetchArtistData = async () => {
setLoading(true);
try {
if (artistId) {
if (artistId && api) {
const artistData = await getArtist(artistId as string);
setArtist(artistData.artist);
setArtistAlbums(artistData.albums);
setIsStarred(!!artistData.artist.starred);
// Fetch popular songs for the artist
try {
const songs = await api.getArtistTopSongs(artistData.artist.name, 10);
setPopularSongs(songs);
} catch (error) {
console.error('Failed to fetch popular songs:', error);
}
}
} catch (error) {
console.error('Failed to fetch artist data:', error);
@@ -42,7 +54,7 @@ export default function ArtistPage() {
};
fetchArtistData();
}, [artistId, getArtist]);
}, [artistId, getArtist, api]);
const handleStar = async () => {
if (!artist) return;
@@ -135,10 +147,18 @@ export default function ArtistPage() {
</div>
</div>
</div>
{/* About Section */}
<ArtistBio artistName={artist.name} />
{/* Popular Songs Section */}
{popularSongs.length > 0 && (
<PopularSongs songs={popularSongs} artistName={artist.name} />
)}
{/* Albums Section */}
<div className="space-y-4">
<h2 className="text-2xl font-semibold tracking-tight">Albums</h2>
<h2 className="text-2xl font-semibold tracking-tight">Discography</h2>
<ScrollArea>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4 pb-4">
{artistAlbums.map((album) => (
@@ -155,6 +175,12 @@ export default function ArtistPage() {
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
{/* Similar Artists Section */}
<SimilarArtists artistName={artist.name} />
</div>
</div>
);

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

View File

@@ -443,37 +443,6 @@ const SettingsPage = () => {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaCog className="w-5 h-5" />
Application Settings
</CardTitle>
<CardDescription>
General application preferences and setup
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Label>First-Time Setup</Label>
<Button
variant="outline"
onClick={() => {
localStorage.removeItem('onboarding-completed');
window.location.reload();
}}
className="w-full sm:w-auto"
>
<Settings className="w-4 h-4 mr-2" />
Run Setup Wizard Again
</Button>
<p className="text-sm text-muted-foreground">
Re-run the initial setup wizard to configure your preferences from scratch
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">

154
lib/lastfm-api.ts Normal file
View File

@@ -0,0 +1,154 @@
interface LastFmCredentials {
apiKey: string;
apiSecret: string;
sessionKey?: string;
username?: string;
}
interface LastFmArtistInfo {
name: string;
bio?: {
summary: string;
content: string;
};
stats?: {
listeners: string;
playcount: string;
};
similar?: {
artist: Array<{
name: string;
url: string;
image: Array<{
'#text': string;
size: string;
}>;
}>;
};
tags?: {
tag: Array<{
name: string;
url: string;
}>;
};
image?: Array<{
'#text': string;
size: string;
}>;
}
interface LastFmTopTracks {
track: Array<{
name: string;
playcount: string;
listeners: string;
artist: {
name: string;
mbid: string;
url: string;
};
image: Array<{
'#text': string;
size: string;
}>;
'@attr': {
rank: string;
};
}>;
}
export class LastFmAPI {
private baseUrl = 'https://ws.audioscrobbler.com/2.0/';
private getCredentials(): LastFmCredentials | null {
if (typeof window === 'undefined') return null;
const stored = localStorage.getItem('lastfm-credentials');
if (!stored) return null;
try {
return JSON.parse(stored);
} catch {
return null;
}
}
private async makeRequest(method: string, params: Record<string, string>): Promise<any> {
const credentials = this.getCredentials();
if (!credentials?.apiKey) {
throw new Error('No Last.fm API key available');
}
const url = new URL(this.baseUrl);
url.searchParams.append('method', method);
url.searchParams.append('api_key', credentials.apiKey);
url.searchParams.append('format', 'json');
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`Last.fm API error: ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.message || 'Last.fm API error');
}
return data;
}
async getArtistInfo(artistName: string): Promise<LastFmArtistInfo | null> {
try {
const data = await this.makeRequest('artist.getInfo', {
artist: artistName,
autocorrect: '1'
});
return data.artist || null;
} catch (error) {
console.error('Failed to fetch artist info from Last.fm:', error);
return null;
}
}
async getArtistTopTracks(artistName: string, limit: number = 10): Promise<LastFmTopTracks | null> {
try {
const data = await this.makeRequest('artist.getTopTracks', {
artist: artistName,
limit: limit.toString(),
autocorrect: '1'
});
return data.toptracks || null;
} catch (error) {
console.error('Failed to fetch artist top tracks from Last.fm:', error);
return null;
}
}
async getSimilarArtists(artistName: string, limit: number = 6): Promise<LastFmArtistInfo['similar'] | null> {
try {
const data = await this.makeRequest('artist.getSimilar', {
artist: artistName,
limit: limit.toString(),
autocorrect: '1'
});
return data.similarartists || null;
} catch (error) {
console.error('Failed to fetch similar artists from Last.fm:', error);
return null;
}
}
isAvailable(): boolean {
const credentials = this.getCredentials();
return !!credentials?.apiKey;
}
}
export const lastFmAPI = new LastFmAPI();

View File

@@ -503,6 +503,26 @@ class NavidromeAPI {
return [];
}
}
async getArtistTopSongs(artistName: string, limit: number = 10): Promise<Song[]> {
try {
// Search for songs by the artist and return them sorted by play count
const searchResult = await this.search2(artistName, 0, 0, limit * 3);
// Filter songs that are actually by this artist (exact match)
const artistSongs = searchResult.songs.filter(song =>
song.artist.toLowerCase() === artistName.toLowerCase()
);
// Sort by play count (descending) and limit results
return artistSongs
.sort((a, b) => (b.playCount || 0) - (a.playCount || 0))
.slice(0, limit);
} catch (error) {
console.error('Failed to get artist top songs:', error);
return [];
}
}
}
// Singleton instance management