From b4f31e095475b19dafc03849b270aacd5f9a3f91 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher Date: Wed, 22 Apr 2026 20:30:09 +0200 Subject: [PATCH] feat(now-playing): redesign page as info dashboard (#266) Replace the flat Now Playing page with a two-column dashboard: hero (cover, metadata, badges, Last.fm stats + love button, release-age tagline), and a grid of themed cards (Album tracklist with sliding window, most-played-by-artist, credits, about-the-artist with Last.fm bio fallback, compact discography contact-sheet with expand, upcoming tours via Bandsintown). Adds lastfmGetTrackInfo + lastfmGetArtistStats for global listener counts + userplaycount, plus Last.fm love/unlove wired to the new hero button. Module-level TTL caches per entity keep same-artist track switches instant. Artist-image guard filters the well-known Last.fm no-image placeholder so the card collapses cleanly when no real image exists. Co-authored-by: Psychotoxical Co-authored-by: Claude Opus 4.7 (1M context) --- src/api/lastfm.ts | 76 +++ src/locales/de.ts | 24 + src/locales/en.ts | 24 + src/locales/es.ts | 24 + src/locales/fr.ts | 24 + src/locales/nb.ts | 24 + src/locales/nl.ts | 24 + src/locales/ru.ts | 28 + src/locales/zh.ts | 24 + src/pages/NowPlaying.tsx | 1241 +++++++++++++++++++++++++++---------- src/styles/components.css | 483 +++++++++++++++ 11 files changed, 1671 insertions(+), 325 deletions(-) diff --git a/src/api/lastfm.ts b/src/api/lastfm.ts index bc40b112..46fb1690 100644 --- a/src/api/lastfm.ts +++ b/src/api/lastfm.ts @@ -300,3 +300,79 @@ export async function lastfmScrobble( // best effort } } + +export interface LastfmTrackInfo { + listeners: number; + playcount: number; + userPlaycount: number | null; + userLoved: boolean; + tags: string[]; + url: string | null; +} + +export async function lastfmGetTrackInfo( + artist: string, + track: string, + username?: string, +): Promise { + try { + const params: Record = { method: 'track.getInfo', artist, track }; + if (username) params.username = username; + const data = await call(params, false, true); + const t = data?.track; + if (!t) return null; + const rawTags = t.toptags?.tag; + const tags = rawTags + ? (Array.isArray(rawTags) ? rawTags : [rawTags]).map((tg: any) => String(tg.name)).slice(0, 5) + : []; + const userPc = t.userplaycount != null ? Number(t.userplaycount) : null; + return { + listeners: Number(t.listeners) || 0, + playcount: Number(t.playcount) || 0, + userPlaycount: Number.isFinite(userPc) ? userPc : null, + userLoved: t.userloved === '1' || t.userloved === 1, + tags, + url: t.url ?? null, + }; + } catch { + return null; + } +} + +export interface LastfmArtistStats { + listeners: number; + playcount: number; + userPlaycount: number | null; + tags: string[]; + url: string | null; + bio: string | null; +} + +export async function lastfmGetArtistStats( + artist: string, + username?: string, +): Promise { + try { + const params: Record = { method: 'artist.getInfo', artist }; + if (username) params.username = username; + const data = await call(params, false, true); + const a = data?.artist; + if (!a) return null; + const rawTags = a.tags?.tag; + const tags = rawTags + ? (Array.isArray(rawTags) ? rawTags : [rawTags]).map((tg: any) => String(tg.name)).slice(0, 5) + : []; + const userPc = a.stats?.userplaycount != null ? Number(a.stats.userplaycount) : null; + const bioRaw = (a.bio?.content || a.bio?.summary || '').trim(); + return { + listeners: Number(a.stats?.listeners) || 0, + playcount: Number(a.stats?.playcount) || 0, + userPlaycount: Number.isFinite(userPc) ? userPc : null, + tags, + url: a.url ?? null, + bio: bioRaw || null, + }; + } catch { + return null; + } +} diff --git a/src/locales/de.ts b/src/locales/de.ts index 51e89aa4..f7a30ae4 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -93,6 +93,30 @@ export const deTranslation = { showLess: 'Weniger anzeigen', genreInfo: 'Genre', trackInfo: 'Track-Info', + topSongs: 'Meistgespielt von diesem Künstler', + topSongsCredit: 'Top-Tracks von {{name}}', + trackPosition: 'Track {{pos}}', + playsCount_one: '{{count}} Wiedergabe', + playsCount_other: '{{count}} Wiedergaben', + releasedYearsAgo_one: 'vor {{count}} Jahr', + releasedYearsAgo_other: 'vor {{count}} Jahren', + discography: 'Diskografie', + lastfmStats: 'Last.fm-Statistik', + thisTrack: 'Dieser Titel', + thisArtist: 'Dieser Künstler', + listeners: 'Hörer', + scrobbles: 'Scrobbles', + yourScrobbles: 'von dir', + listenersN: '{{n}} Hörer', + scrobblesN: '{{n}} Scrobbles', + playsByYouN: '{{n}}× von dir gespielt', + openTrackOnLastfm: 'Track auf Last.fm', + openArtistOnLastfm: 'Künstler auf Last.fm', + rgTrackTooltip: 'ReplayGain (Track)', + rgAlbumTooltip: 'ReplayGain (Album)', + rgAutoTooltip: 'ReplayGain (Auto)', + showMoreTracks: '{{count}} weitere anzeigen', + showLessTracks: 'Weniger anzeigen', }, contextMenu: { playNow: 'Direkt abspielen', diff --git a/src/locales/en.ts b/src/locales/en.ts index 739356c3..cdb8090e 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -94,6 +94,30 @@ export const enTranslation = { showLess: 'Show less', genreInfo: 'Genre', trackInfo: 'Track Info', + topSongs: 'Most played by this artist', + topSongsCredit: 'Top tracks from {{name}}', + trackPosition: 'Track {{pos}}', + playsCount_one: '{{count}} play', + playsCount_other: '{{count}} plays', + releasedYearsAgo_one: '{{count}} year ago', + releasedYearsAgo_other: '{{count}} years ago', + discography: 'Discography', + lastfmStats: 'Last.fm stats', + thisTrack: 'This track', + thisArtist: 'This artist', + listeners: 'listeners', + scrobbles: 'scrobbles', + yourScrobbles: 'by you', + listenersN: '{{n}} listeners', + scrobblesN: '{{n}} scrobbles', + playsByYouN: 'played {{n}}× by you', + openTrackOnLastfm: 'Track on Last.fm', + openArtistOnLastfm: 'Artist on Last.fm', + rgTrackTooltip: 'ReplayGain (track)', + rgAlbumTooltip: 'ReplayGain (album)', + rgAutoTooltip: 'ReplayGain (auto)', + showMoreTracks: 'Show {{count}} more', + showLessTracks: 'Show less', }, contextMenu: { playNow: 'Play Now', diff --git a/src/locales/es.ts b/src/locales/es.ts index 5312bb44..aab7380a 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -94,6 +94,30 @@ export const esTranslation = { showLess: 'Mostrar menos', genreInfo: 'Género', trackInfo: 'Información de la Pista', + topSongs: 'Más reproducidas de este artista', + topSongsCredit: 'Temas principales de {{name}}', + trackPosition: 'Pista {{pos}}', + playsCount_one: '{{count}} reproducción', + playsCount_other: '{{count}} reproducciones', + releasedYearsAgo_one: 'hace {{count}} año', + releasedYearsAgo_other: 'hace {{count}} años', + discography: 'Discografía', + lastfmStats: 'Estadísticas de Last.fm', + thisTrack: 'Este tema', + thisArtist: 'Este artista', + listeners: 'oyentes', + scrobbles: 'scrobbles', + yourScrobbles: 'por ti', + listenersN: '{{n}} oyentes', + scrobblesN: '{{n}} scrobbles', + playsByYouN: 'reproducido {{n}}× por ti', + openTrackOnLastfm: 'Tema en Last.fm', + openArtistOnLastfm: 'Artista en Last.fm', + rgTrackTooltip: 'ReplayGain (pista)', + rgAlbumTooltip: 'ReplayGain (álbum)', + rgAutoTooltip: 'ReplayGain (auto)', + showMoreTracks: 'Mostrar {{count}} más', + showLessTracks: 'Mostrar menos', }, contextMenu: { playNow: 'Reproducir Ahora', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 971ac814..9214065e 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -93,6 +93,30 @@ export const frTranslation = { showLess: 'Réduire', genreInfo: 'Genre', trackInfo: 'Infos piste', + topSongs: 'Le plus joué de cet artiste', + topSongsCredit: 'Top titres de {{name}}', + trackPosition: 'Piste {{pos}}', + playsCount_one: '{{count}} écoute', + playsCount_other: '{{count}} écoutes', + releasedYearsAgo_one: 'il y a {{count}} an', + releasedYearsAgo_other: 'il y a {{count}} ans', + discography: 'Discographie', + lastfmStats: 'Statistiques Last.fm', + thisTrack: 'Ce titre', + thisArtist: 'Cet artiste', + listeners: 'auditeurs', + scrobbles: 'scrobbles', + yourScrobbles: 'par vous', + listenersN: '{{n}} auditeurs', + scrobblesN: '{{n}} scrobbles', + playsByYouN: 'écouté {{n}}× par vous', + openTrackOnLastfm: 'Titre sur Last.fm', + openArtistOnLastfm: 'Artiste sur Last.fm', + rgTrackTooltip: 'ReplayGain (piste)', + rgAlbumTooltip: 'ReplayGain (album)', + rgAutoTooltip: 'ReplayGain (auto)', + showMoreTracks: 'Afficher {{count}} de plus', + showLessTracks: 'Réduire', }, contextMenu: { playNow: 'Lire maintenant', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 2e9d7042..d08f2ccc 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -93,6 +93,30 @@ export const nbTranslation = { showLess: 'Vis mindre', genreInfo: 'Sjanger', trackInfo: 'Sporinfo', + topSongs: 'Mest spilt av denne artisten', + topSongsCredit: 'Toppspor fra {{name}}', + trackPosition: 'Spor {{pos}}', + playsCount_one: '{{count}} avspilling', + playsCount_other: '{{count}} avspillinger', + releasedYearsAgo_one: 'for {{count}} år siden', + releasedYearsAgo_other: 'for {{count}} år siden', + discography: 'Diskografi', + lastfmStats: 'Last.fm-statistikk', + thisTrack: 'Dette sporet', + thisArtist: 'Denne artisten', + listeners: 'lyttere', + scrobbles: 'scrobbles', + yourScrobbles: 'av deg', + listenersN: '{{n}} lyttere', + scrobblesN: '{{n}} scrobbles', + playsByYouN: 'spilt {{n}}× av deg', + openTrackOnLastfm: 'Spor på Last.fm', + openArtistOnLastfm: 'Artist på Last.fm', + rgTrackTooltip: 'ReplayGain (spor)', + rgAlbumTooltip: 'ReplayGain (album)', + rgAutoTooltip: 'ReplayGain (auto)', + showMoreTracks: 'Vis {{count}} til', + showLessTracks: 'Vis mindre', }, contextMenu: { playNow: 'Spill nå', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 4a004d84..799cc248 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -93,6 +93,30 @@ export const nlTranslation = { showLess: 'Minder tonen', genreInfo: 'Genre', trackInfo: 'Trackinfo', + topSongs: 'Meest afgespeeld van deze artiest', + topSongsCredit: 'Topnummers van {{name}}', + trackPosition: 'Track {{pos}}', + playsCount_one: '{{count}} keer afgespeeld', + playsCount_other: '{{count}} keer afgespeeld', + releasedYearsAgo_one: '{{count}} jaar geleden', + releasedYearsAgo_other: '{{count}} jaar geleden', + discography: 'Discografie', + lastfmStats: 'Last.fm-statistieken', + thisTrack: 'Dit nummer', + thisArtist: 'Deze artiest', + listeners: 'luisteraars', + scrobbles: 'scrobbles', + yourScrobbles: 'door jou', + listenersN: '{{n}} luisteraars', + scrobblesN: '{{n}} scrobbles', + playsByYouN: '{{n}}× door jou afgespeeld', + openTrackOnLastfm: 'Nummer op Last.fm', + openArtistOnLastfm: 'Artiest op Last.fm', + rgTrackTooltip: 'ReplayGain (track)', + rgAlbumTooltip: 'ReplayGain (album)', + rgAutoTooltip: 'ReplayGain (auto)', + showMoreTracks: '{{count}} meer tonen', + showLessTracks: 'Minder tonen', }, contextMenu: { playNow: 'Nu afspelen', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 4b2dbf3e..f0be1266 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -94,6 +94,34 @@ export const ruTranslation = { showLess: 'Свернуть', genreInfo: 'Жанр', trackInfo: 'О треке', + topSongs: 'Самое популярное у этого исполнителя', + topSongsCredit: 'Топ-треки исполнителя {{name}}', + trackPosition: 'Трек {{pos}}', + playsCount_one: '{{count}} прослушивание', + playsCount_few: '{{count}} прослушивания', + playsCount_many: '{{count}} прослушиваний', + playsCount_other: '{{count}} прослушиваний', + releasedYearsAgo_one: '{{count}} год назад', + releasedYearsAgo_few: '{{count}} года назад', + releasedYearsAgo_many: '{{count}} лет назад', + releasedYearsAgo_other: '{{count}} лет назад', + discography: 'Дискография', + lastfmStats: 'Статистика Last.fm', + thisTrack: 'Этот трек', + thisArtist: 'Этот исполнитель', + listeners: 'слушателей', + scrobbles: 'прослушиваний', + yourScrobbles: 'вами', + listenersN: '{{n}} слушателей', + scrobblesN: '{{n}} прослушиваний', + playsByYouN: 'прослушано вами {{n}}×', + openTrackOnLastfm: 'Трек на Last.fm', + openArtistOnLastfm: 'Исполнитель на Last.fm', + rgTrackTooltip: 'ReplayGain (трек)', + rgAlbumTooltip: 'ReplayGain (альбом)', + rgAutoTooltip: 'ReplayGain (авто)', + showMoreTracks: 'Показать ещё {{count}}', + showLessTracks: 'Свернуть', }, contextMenu: { playNow: 'Играть сейчас', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index ce9c13ac..30621c9a 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -93,6 +93,30 @@ export const zhTranslation = { showLess: '收起', genreInfo: '流派', trackInfo: '曲目信息', + topSongs: '该艺术家最常播放', + topSongsCredit: '{{name}} 的热门曲目', + trackPosition: '第 {{pos}} 首', + playsCount_one: '播放 {{count}} 次', + playsCount_other: '播放 {{count}} 次', + releasedYearsAgo_one: '{{count}} 年前', + releasedYearsAgo_other: '{{count}} 年前', + discography: '专辑列表', + lastfmStats: 'Last.fm 统计', + thisTrack: '这首歌', + thisArtist: '这位艺术家', + listeners: '听众', + scrobbles: '播放记录', + yourScrobbles: '你', + listenersN: '{{n}} 位听众', + scrobblesN: '{{n}} 次播放', + playsByYouN: '你播放了 {{n}} 次', + openTrackOnLastfm: '在 Last.fm 上查看', + openArtistOnLastfm: '在 Last.fm 上查看艺术家', + rgTrackTooltip: 'ReplayGain (曲目)', + rgAlbumTooltip: 'ReplayGain (专辑)', + rgAutoTooltip: 'ReplayGain (自动)', + showMoreTracks: '显示另外 {{count}} 首', + showLessTracks: '收起', }, contextMenu: { playNow: '立即播放', diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index d45d538e..786d81ea 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -1,16 +1,27 @@ -import React, { useState, useRef, useEffect, useCallback, memo } from 'react'; +import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useMemo, memo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward } from 'lucide-react'; +import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward, Info, Headphones, Calendar, Disc3, TrendingUp, Play } from 'lucide-react'; +import { open as shellOpen } from '@tauri-apps/plugin-shell'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useLyricsStore } from '../store/lyricsStore'; import { buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar, - getAlbum, getArtistInfo, - SubsonicSong, SubsonicArtistInfo, + getAlbum, getArtist, getArtistInfo, getTopSongs, + SubsonicSong, SubsonicArtistInfo, SubsonicAlbum, } from '../api/subsonic'; +import { songToTrack } from '../store/playerStore'; +import { + lastfmIsConfigured, + lastfmGetTrackInfo, lastfmGetArtistStats, + lastfmLoveTrack, lastfmUnloveTrack, + type LastfmTrackInfo, type LastfmArtistStats, +} from '../api/lastfm'; +import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown'; import { useCachedUrl } from '../components/CachedImage'; +import CachedImage from '../components/CachedImage'; +import LastfmIcon from '../components/LastfmIcon'; import { useRadioMetadata } from '../hooks/useRadioMetadata'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -21,6 +32,22 @@ function formatTime(s: number): string { return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`; } +function formatCompact(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`; + return String(n); +} + +function formatTotalDuration(s: number): string { + if (!s || isNaN(s)) return '—'; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = Math.floor(s % 60); + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${sec}s`; + return `${sec}s`; +} + function sanitizeHtml(html: string): string { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); @@ -34,13 +61,61 @@ function sanitizeHtml(html: string): string { } }); }); - return doc.body.innerHTML; + // Strip trailing "Read more on Last.fm" style links for cleaner clamped bios. + return doc.body.innerHTML.replace(/]*>.*?<\/a>\.?\s*$/i, '').trim(); +} + +function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return { + month: d.toLocaleString(undefined, { month: 'short' }), + day: String(d.getDate()), + weekday: d.toLocaleString(undefined, { weekday: 'short' }), + time: d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' }), + }; +} + +interface ContributorRow { role: string; names: string[]; } + +function buildContributorRows(song: SubsonicSong | null | undefined, mainArtistName: string): ContributorRow[] { + if (!song?.contributors || song.contributors.length === 0) return []; + const mainLower = mainArtistName.trim().toLowerCase(); + const rows = new Map>(); + for (const c of song.contributors) { + const role = c.role?.trim(); + const name = c.artist?.name?.trim(); + if (!role || !name) continue; + const label = c.subRole ? `${role} • ${c.subRole}` : role; + let bucket = rows.get(label); + if (!bucket) { bucket = new Set(); rows.set(label, bucket); } + bucket.add(name); + } + const out: ContributorRow[] = []; + for (const [role, names] of rows.entries()) { + const list = Array.from(names); + if (role.toLowerCase().startsWith('artist') && list.length === 1 && list[0].toLowerCase() === mainLower) continue; + out.push({ role, names: list }); + } + return out; +} + +/** + * Filter out the well-known Last.fm "no image" placeholder that Subsonic + * backends aggregate into `largeImageUrl`/`mediumImageUrl` when no real + * artist image exists. The placeholder MD5 is fixed and documented. + */ +function isRealArtistImage(url?: string): boolean { + if (!url) return false; + if (url.includes('2a96cbd8b46e442fc41c2b86b821562f')) return false; + return true; } function renderStars(rating?: number) { if (!rating) return null; return ( -
+
{[1, 2, 3, 4, 5].map(i => ( ([]); - const heights = useRef(Array.from({ length: BAR_COUNT }, () => 0.08)); - const targets = useRef(Array.from({ length: BAR_COUNT }, () => Math.random() * 0.5 + 0.1)); - const speeds = useRef(Array.from({ length: BAR_COUNT }, () => 0.06 + Math.random() * 0.08)); - const rafRef = useRef(); +interface CacheEntry { value: T; ts: number; } - const animate = useCallback(() => { - heights.current = heights.current.map((h, i) => { - const t = targets.current[i]; - const newH = h + (t - h) * speeds.current[i]; - if (Math.abs(newH - t) < 0.015) { - targets.current[i] = Math.random() * 0.88 + 0.06; - speeds.current[i] = 0.05 + Math.random() * 0.10; - } - return newH; - }); - barsRef.current.forEach((bar, i) => { - if (bar) bar.style.height = `${Math.round(heights.current[i] * 100)}%`; - }); - rafRef.current = requestAnimationFrame(animate); - }, []); +function makeCache() { + const map = new Map>(); + return { + get(key: string): T | undefined { + const e = map.get(key); + if (!e) return undefined; + if (Date.now() - e.ts > CACHE_TTL_MS) { map.delete(key); return undefined; } + return e.value; + }, + set(key: string, value: T) { map.set(key, { value, ts: Date.now() }); }, + }; +} - useEffect(() => { - if (isPlaying) { - rafRef.current = requestAnimationFrame(animate); - } else { - if (rafRef.current) cancelAnimationFrame(rafRef.current); - // Settle bars to a low resting height - heights.current = heights.current.map(() => 0.08); - barsRef.current.forEach(bar => { - if (bar) bar.style.height = '8%'; - }); - } - return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; - }, [isPlaying, animate]); +const songMetaCache = makeCache(); +const artistInfoCache = makeCache(); +const albumCache = makeCache<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>(); +const topSongsCache = makeCache(); +const tourCache = makeCache(); +const discographyCache = makeCache(); +const lfmTrackCache = makeCache(); +const lfmArtistCache = makeCache(); + +// ─── Subcomponents (all memoized) ───────────────────────────────────────────── + +interface HeroProps { + track: { title: string; artist: string; album: string; year?: number; + duration: number; suffix?: string; bitRate?: number; samplingRate?: number; + bitDepth?: number; artistId?: string; albumId?: string; id: string; + userRating?: number; }; + genre?: string; + playCount?: number; + userRatingOverride?: number; + lfmTrack: LastfmTrackInfo | null; + lfmArtist: LastfmArtistStats | null; + starred: boolean; + lfmLoved: boolean; + lfmLoveEnabled: boolean; + activeLyricsTab: boolean; + coverUrl: string; + onNavigate: (path: string) => void; + onToggleStar: () => void; + onToggleLfmLove: () => void; + onOpenLyrics: () => void; +} + +const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverUrl, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) { + const { t } = useTranslation(); + const rating = userRatingOverride ?? track.userRating; + const hiRes = (track.bitDepth && track.bitDepth > 16) || (track.samplingRate && track.samplingRate > 48000); + const releaseAge = track.year ? new Date().getFullYear() - track.year : 0; return ( -
-
- {Array.from({ length: BAR_COUNT }).map((_, i) => ( -
{ barsRef.current[i] = el; }} - /> - ))} +
+
+ {coverUrl + ? + :
} +
+
+
{track.title}
+
+ track.artistId && onNavigate(`/artist/${track.artistId}`)} + style={{ cursor: track.artistId ? 'pointer' : 'default' }}> + {track.artist} + + · + track.albumId && onNavigate(`/album/${track.albumId}`)} + style={{ cursor: track.albumId ? 'pointer' : 'default' }}> + {track.album} + + {track.year && <>·{track.year}} + {releaseAge > 0 && ( + <>· + + {t('nowPlaying.releasedYearsAgo', { count: releaseAge, defaultValue: '{{count}} years ago' })} + + )} +
+ +
+ {genre && {genre}} + {track.suffix && {track.suffix.toUpperCase()}} + {track.bitRate && {track.bitRate} kbps} + {track.samplingRate && {(track.samplingRate / 1000).toFixed(1)} kHz} + {track.bitDepth && {track.bitDepth}-bit} + {hiRes && Hi-Res} + {track.duration > 0 && {formatTime(track.duration)}} +
+ +
+ + {lfmLoveEnabled && ( + + )} + + {rating && renderStars(rating)} +
+ + {(playCount != null && playCount > 0) && ( +
+ + {t('nowPlaying.playsCount', { count: playCount, defaultValue: '{{count}} plays' })} +
+ )} + + {(lfmTrack || lfmArtist) && ( +
+
+ Last.fm +
+ {lfmTrack && ( +
+ {t('nowPlaying.thisTrack', 'This track')} + + {t('nowPlaying.listenersN', { n: lfmTrack.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })} + · + {t('nowPlaying.scrobblesN', { n: lfmTrack.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })} + {lfmTrack.userPlaycount != null && ( + <> + · + + {t('nowPlaying.playsByYouN', { n: lfmTrack.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })} + + + )} +
+ )} + {lfmArtist && ( +
+ {t('nowPlaying.thisArtist', 'This artist')} + + {t('nowPlaying.listenersN', { n: lfmArtist.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })} + · + {t('nowPlaying.scrobblesN', { n: lfmArtist.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })} + {lfmArtist.userPlaycount != null && ( + <> + · + + {t('nowPlaying.playsByYouN', { n: lfmArtist.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })} + + + )} +
+ )} +
+ )}
); }); -// ─── Tag Cloud ──────────────────────────────────────────────────────────────── - -interface TagCloudProps { - similarArtists: Array<{ id: string; name: string }>; - onArtistClick: (id: string) => void; -} - -function strHash(s: string): number { - let h = 0; - for (const c of s) h = (h * 31 + c.charCodeAt(0)) & 0xffff; - return h; -} - -function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) { - const { t } = useTranslation(); - if (similarArtists.length === 0) return null; - - const getTagStyle = (name: string, idx: number): React.CSSProperties => { - const h = strHash(name); - const sizePool = [10, 11, 12, 13, 14, 15, 16]; - const size = sizePool[(h + idx * 7) % sizePool.length]; - const weight = size >= 15 ? 600 : size >= 13 ? 500 : 400; - const pad = size >= 15 ? '5px 10px' : '4px 8px'; - const opacity = 0.6 + ((h % 5) * 0.08); - const verticals = [-5, -3, -1, 0, 2, 4, 5, -4, 2, -2, 4, 0, 3, -3, 1]; - const ty = verticals[(h + idx * 4) % verticals.length]; - return { fontSize: `${size}px`, fontWeight: weight, padding: pad, opacity, transform: `translateY(${ty}px)` }; - }; - - return ( -
-
{t('artistDetail.similarArtists')}
- {([similarArtists.slice(0, 3), similarArtists.slice(3, 6)] as const).map((row, rowIdx) => ( -
- {row.map((a, i) => ( - onArtistClick(a.id)} - data-tooltip={t('nowPlaying.goToArtist')} - > - {a.name} - - ))} -
- ))} -
- ); -} - - -// ─── Album Tracklist ────────────────────────────────────────────────────────── - -interface NpTrackListProps { - albumTracks: SubsonicSong[]; - currentTrackId: string; - album: string; - albumId?: string; +interface ArtistCardProps { + artistName: string; + artistId?: string; + artistInfo: SubsonicArtistInfo | null; onNavigate: (path: string) => void; } -const NpTrackList = memo(function NpTrackList({ albumTracks, currentTrackId, album, albumId, onNavigate }: NpTrackListProps) { +const ArtistCard = memo(function ArtistCard({ artistName, artistId, artistInfo, onNavigate }: ArtistCardProps) { const { t } = useTranslation(); - if (albumTracks.length === 0) return null; + const [bioExpanded, setBioExpanded] = useState(false); + const [bioOverflows, setBioOverflows] = useState(false); + const bioRef = useRef(null); + + useEffect(() => { setBioExpanded(false); }, [artistId]); + + const bioHtml = useMemo(() => artistInfo?.biography ? sanitizeHtml(artistInfo.biography) : '', [artistInfo?.biography]); + + useLayoutEffect(() => { + const el = bioRef.current; + if (!el) { setBioOverflows(false); return; } + setBioOverflows(el.scrollHeight - el.clientHeight > 1); + }, [bioHtml]); + + const similar = artistInfo?.similarArtist ?? []; + const rawLarge = artistInfo?.largeImageUrl; + const rawMed = artistInfo?.mediumImageUrl; + const heroImage = isRealArtistImage(rawLarge) + ? rawLarge! + : isRealArtistImage(rawMed) ? rawMed! : ''; + const heroCacheKey = artistId ? `artistInfo:${artistId}:hero` : ''; + + if (!bioHtml && similar.length === 0 && !heroImage) return null; + return ( -
+
-

{t('nowPlaying.fromAlbum')}: {album}

+

{t('nowPlaying.aboutArtist')}

+ {artistId && ( + + )} +
+ +
+ {heroImage && heroCacheKey && ( + { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} + /> + )} +
+
{artistName}
+ {bioHtml && ( + <> +
+ {(bioOverflows || bioExpanded) && ( + + )} + + )} +
+
+ + {similar.length > 0 && ( +
+
+ {similar.slice(0, 12).map(a => ( + a.id && onNavigate(`/artist/${a.id}`)} + data-tooltip={t('nowPlaying.goToArtist')}> + {a.name} + + ))} +
+
+ )} +
+ ); +}); + +interface AlbumCardProps { + album: SubsonicAlbum | null; + songs: SubsonicSong[]; + currentTrackId: string; + albumName: string; + albumId?: string; + albumYear?: number; + onNavigate: (path: string) => void; +} + +const ALBUM_TRACK_LIMIT = 10; + +const AlbumCard = memo(function AlbumCard({ album, songs, currentTrackId, albumName, albumId, albumYear, onNavigate }: AlbumCardProps) { + const { t } = useTranslation(); + const [showAll, setShowAll] = useState(false); + useEffect(() => { setShowAll(false); }, [albumId]); + + if (songs.length === 0) return null; + + const totalDur = songs.reduce((sum, s) => sum + (s.duration || 0), 0); + const currentIdx = songs.findIndex(s => s.id === currentTrackId); + const position = currentIdx >= 0 ? `${currentIdx + 1} / ${songs.length}` : `${songs.length}`; + + // Sliding window anchored at the current track: when the running track sits + // beyond position N, show the N tracks ending with (and including) it. + // "Show all" expands to the full list. + let visibleSongs: SubsonicSong[]; + if (showAll) { + visibleSongs = songs; + } else if (currentIdx < ALBUM_TRACK_LIMIT) { + visibleSongs = songs.slice(0, ALBUM_TRACK_LIMIT); + } else { + const end = currentIdx + 1; + visibleSongs = songs.slice(end - ALBUM_TRACK_LIMIT, end); + } + const hiddenCount = Math.max(0, songs.length - visibleSongs.length); + + return ( +
+
+

+ + {t('nowPlaying.fromAlbum')} +

{albumId && ( )}
+
+ {albumName} + + {albumYear && {albumYear}} + {albumYear && ·} + {t('nowPlaying.trackPosition', { pos: position, defaultValue: 'Track {{pos}}' })} + · + {formatTotalDuration(totalDur)} + {album?.playCount != null && album.playCount > 0 && ( + <>·{t('nowPlaying.playsCount', { count: album.playCount, defaultValue: '{{count}} plays' })} + )} + +
- {albumTracks.map(track => { + {visibleSongs.map(track => { const isActive = track.id === currentTrackId; return (
albumId && onNavigate(`/album/${albumId}`)} - > + className={`np-album-track${isActive ? ' active' : ''}`}> {isActive ? - : track.track ?? '—' - } + : track.track ?? '—'} {track.title} {formatTime(track.duration)} @@ -202,155 +467,312 @@ const NpTrackList = memo(function NpTrackList({ albumTracks, currentTrackId, alb ); })}
+ {songs.length > ALBUM_TRACK_LIMIT && ( + + )}
); }); -// ─── Main Page ──────────────────────────────────────────────────────────────── +interface TopSongsCardProps { + artistName: string; + artistId?: string; + songs: SubsonicSong[]; + currentTrackId: string; + onNavigate: (path: string) => void; + onPlay: (song: SubsonicSong) => void; +} -export default function NowPlaying() { +const TopSongsCard = memo(function TopSongsCard({ artistName, artistId, songs, currentTrackId, onNavigate, onPlay }: TopSongsCardProps) { const { t } = useTranslation(); - const navigate = useNavigate(); + const top = songs.slice(0, 8); + if (top.length === 0) return null; - const currentTrack = usePlayerStore(s => s.currentTrack); - const currentRadio = usePlayerStore(s => s.currentRadio); - const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); - const isPlaying = usePlayerStore(s => s.isPlaying); - const showLyrics = useLyricsStore(s => s.showLyrics); - const activeTab = useLyricsStore(s => s.activeTab); - const isQueueVisible = usePlayerStore(s => s.isQueueVisible); - const toggleQueue = usePlayerStore(s => s.toggleQueue); - const audiomuseNavidromeEnabled = useAuthStore( - s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]), + return ( +
+
+

+ + {t('nowPlaying.topSongs', { defaultValue: 'Most played by this artist' })} +

+ {artistId && ( + + )} +
+
+ {top.map((s, idx) => { + const isActive = s.id === currentTrackId; + return ( +
onPlay(s)} + data-tooltip={t('contextMenu.playNow')}> + {idx + 1} +
+ {s.title} + {s.album && {s.album}} +
+ {formatTime(s.duration)} + +
+ ); + })} +
+
{t('nowPlaying.topSongsCredit', { name: artistName, defaultValue: 'Top tracks from {{name}}' })}
+
); +}); - const stableNavigate = useCallback((path: string) => navigate(path), [navigate]); +interface CreditsCardProps { rows: ContributorRow[]; } - // Radio metadata (ICY or AzuraCast) - const radioMeta = useRadioMetadata(currentRadio ?? null); +const CreditsCard = memo(function CreditsCard({ rows }: CreditsCardProps) { + const { t } = useTranslation(); + if (rows.length === 0) return null; + return ( +
+
+

{t('nowPlayingInfo.songInfo', 'Song info')}

+
+
    + {rows.map(row => ( +
  • + {t(`nowPlayingInfo.role.${row.role}`, row.role)} + {row.names.join(', ')} +
  • + ))} +
+
+ ); +}); - // Extra song metadata - const [songMeta, setSongMeta] = useState(null); - useEffect(() => { - if (!currentTrack) { setSongMeta(null); return; } - getSong(currentTrack.id).then(setSongMeta); - }, [currentTrack?.id]); +interface TourCardProps { + artistName: string; + enabled: boolean; + loading: boolean; + events: BandsintownEvent[]; + onEnable: () => void; +} - // Artist info (bio + similar artists) - const [artistInfo, setArtistInfo] = useState(null); - useEffect(() => { - if (!currentTrack?.artistId) { setArtistInfo(null); return; } - getArtistInfo(currentTrack.artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined }) - .then(setArtistInfo) - .catch(() => setArtistInfo(null)); - }, [currentTrack?.artistId, audiomuseNavidromeEnabled]); +const TourCard = memo(function TourCard({ artistName, enabled, loading, events, onEnable }: TourCardProps) { + const { t } = useTranslation(); + const [showAll, setShowAll] = useState(false); + useEffect(() => { setShowAll(false); }, [artistName]); + const TOUR_LIMIT = 5; + const visible = showAll ? events : events.slice(0, TOUR_LIMIT); + const hidden = Math.max(0, events.length - visible.length); - // Album tracks - const [albumTracks, setAlbumTracks] = useState([]); - useEffect(() => { - if (!currentTrack?.albumId) { setAlbumTracks([]); return; } - getAlbum(currentTrack.albumId).then(d => setAlbumTracks(d.songs)).catch(() => setAlbumTracks([])); - }, [currentTrack?.albumId]); + return ( +
+
+

+ + {t('nowPlayingInfo.onTour', 'On tour')} +

+
- // Bio expand toggle - const [bioExpanded, setBioExpanded] = useState(false); - useEffect(() => { setBioExpanded(false); }, [currentTrack?.artistId]); + {!enabled ? ( +
+
+ {t('nowPlayingInfo.enableBandsintownPrompt', 'See upcoming tour dates?')} + + + +
+
+ {t('nowPlayingInfo.enableBandsintownPromptDesc', 'Optional. Loads concerts for the current artist via Bandsintown.')} +
+ +
+ ) : ( + <> + {loading && events.length === 0 && ( +
{t('nowPlayingInfo.tourLoading', 'Loading…')}
+ )} + {!loading && events.length === 0 && ( +
{t('nowPlayingInfo.noTourEvents', 'No upcoming shows')}
+ )} + {visible.length > 0 && ( +
    + {visible.map((ev, idx) => { + const parts = isoToParts(ev.datetime); + const place = [ev.venueCity, ev.venueRegion, ev.venueCountry].filter(Boolean).join(', '); + return ( +
  • ev.url && shellOpen(ev.url).catch(() => {})} + role={ev.url ? 'button' : undefined} + tabIndex={ev.url ? 0 : undefined}> + {parts && ( +
    +
    {parts.month}
    +
    {parts.day}
    +
    + )} +
    +
    {ev.venueName || place}
    +
    + {parts && {parts.weekday}, {parts.time}} + {parts && place && } + {place} +
    +
    +
  • + ); + })} +
+ )} + {(hidden > 0 || (showAll && events.length > TOUR_LIMIT)) && ( + + )} +
{t('nowPlayingInfo.poweredByBandsintown', 'Tour data via Bandsintown')}
+ + )} +
+ ); +}); - // Favorite - const [starred, setStarred] = useState(false); - useEffect(() => { setStarred(!!songMeta?.starred); }, [songMeta]); - const toggleStar = async () => { - if (!currentTrack) return; - if (starred) { await unstar(currentTrack.id, 'song'); setStarred(false); } - else { await star(currentTrack.id, 'song'); setStarred(true); } - }; +// ─── Radio view (unchanged from previous implementation) ────────────────────── - // Cover - const coverFetchUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; - const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; - const resolvedCover = useCachedUrl(coverFetchUrl, coverKey); +// ─── Discography card ──────────────────────────────────────────────────────── - // Radio cover - const radioCoverFetchUrl = currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 800) : ''; - const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 800) : ''; - const resolvedRadioCover = useCachedUrl(radioCoverFetchUrl, radioCoverKey); +interface DiscographyCardProps { + artistId?: string; + albums: SubsonicAlbum[]; + currentAlbumId?: string; + onNavigate: (path: string) => void; +} - const similarArtists = artistInfo?.similarArtist ?? []; +const DISC_GRID_COLS = 10; +const DISC_INITIAL_ROWS = 2; +const DISC_INITIAL = DISC_GRID_COLS * DISC_INITIAL_ROWS; - // ── Radio now-playing section ──────────────────────────────────────────────── - const radioNowPlaying = currentRadio && !currentTrack && ( +const DiscographyCard = memo(function DiscographyCard({ artistId, albums, currentAlbumId, onNavigate }: DiscographyCardProps) { + const { t } = useTranslation(); + const [showAll, setShowAll] = useState(false); + useEffect(() => { setShowAll(false); }, [artistId]); + + if (albums.length === 0) return null; + + // Chronological sort, newest first. Always clamp to initial rows; expansion is explicit. + const ordered = [...albums].sort((a, b) => (b.year ?? 0) - (a.year ?? 0)); + const visible = showAll ? ordered : ordered.slice(0, DISC_INITIAL); + const hiddenCount = Math.max(0, ordered.length - visible.length); + + return ( +
+
+

+ + {t('nowPlaying.discography', 'Discography')} +

+ {artistId && ( + + )} +
+
+ {visible.map(a => { + const isActive = a.id === currentAlbumId; + const fetchUrl = a.coverArt ? buildCoverArtUrl(a.coverArt, 200) : ''; + const key = a.coverArt ? coverArtCacheKey(a.coverArt, 200) : ''; + return ( +
onNavigate(`/album/${a.id}`)} + data-tooltip={`${a.name}${a.year ? ` · ${a.year}` : ''}`}> +
+ {fetchUrl && key + ? + :
} +
+
+ ); + })} +
+ {ordered.length > DISC_INITIAL && ( + + )} +
+ ); +}); + +type NonNullStoreField> = + NonNullable[K]>; + +interface RadioViewProps { + radioMeta: ReturnType; + currentRadio: NonNullStoreField<'currentRadio'>; + resolvedCover: string; +} + +const RadioView = memo(function RadioView({ radioMeta, currentRadio, resolvedCover }: RadioViewProps) { + const { t } = useTranslation(); + return (
- - {/* Station hero */}
-
- {currentRadio.name} -
+
{currentRadio.name}
{radioMeta.currentTitle && (
- {radioMeta.currentArtist && ( - <>{radioMeta.currentArtist}· - )} + {radioMeta.currentArtist && (<>{radioMeta.currentArtist}·)} {radioMeta.currentTitle} - {radioMeta.currentAlbum && ( - <>·{radioMeta.currentAlbum} - )} + {radioMeta.currentAlbum && (<>·{radioMeta.currentAlbum})}
)}
- - {t('radio.live')} - - {radioMeta.source === 'azuracast' && ( - AzuraCast - )} + {t('radio.live')} + {radioMeta.source === 'azuracast' && AzuraCast} {radioMeta.listeners != null && ( - - - {t('radio.listenerCount', { count: radioMeta.listeners })} - + {t('radio.listenerCount', { count: radioMeta.listeners })} )}
- - {/* AzuraCast progress bar */} {radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 && (
{formatTime(radioMeta.elapsed)}
-
+
{formatTime(radioMeta.duration)}
)}
- - {/* Cover */}
- {resolvedRadioCover - ? {currentRadio.name} + {resolvedCover + ? {currentRadio.name} : radioMeta.currentArt ? { (e.target as HTMLImageElement).style.display = 'none'; }} /> - :
- } + :
}
- - {/* Placeholder to keep 3-column layout */}
- {/* Upcoming track */} {radioMeta.nextSong && (
-

- {t('radio.upNext')} -

+

{t('radio.upNext')}

{radioMeta.nextSong.art && ( @@ -359,21 +781,16 @@ export default function NowPlaying() { )}
{radioMeta.nextSong.title} - {radioMeta.nextSong.artist && ( - {radioMeta.nextSong.artist} - )} + {radioMeta.nextSong.artist && {radioMeta.nextSong.artist}}
)} - {/* Song history */} {radioMeta.history.length > 0 && (
-

- {t('radio.recentlyPlayed')} -

+

{t('radio.recentlyPlayed')}

{radioMeta.history.map((item, idx) => ( @@ -392,114 +809,288 @@ export default function NowPlaying() { )}
); +}); +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export default function NowPlaying() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const stableNavigate = useCallback((path: string) => navigate(path), [navigate]); + + const currentTrack = usePlayerStore(s => s.currentTrack); + const currentRadio = usePlayerStore(s => s.currentRadio); + const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); + const showLyrics = useLyricsStore(s => s.showLyrics); + const activeTab = useLyricsStore(s => s.activeTab); + const isQueueVisible = usePlayerStore(s => s.isQueueVisible); + const toggleQueue = usePlayerStore(s => s.toggleQueue); + const audiomuseNavidromeEnabled = useAuthStore( + s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]), + ); + const enableBandsintown = useAuthStore(s => s.enableBandsintown); + const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown); + const lastfmUsername = useAuthStore(s => s.lastfmUsername); + const lastfmSessionKey = useAuthStore(s => s.lastfmSessionKey); + const playTrackFn = usePlayerStore(s => s.playTrack); + + const radioMeta = useRadioMetadata(currentRadio ?? null); + + const songId = currentTrack?.id; + const artistId = currentTrack?.artistId; + const albumId = currentTrack?.albumId; + const artistName = currentTrack?.artist ?? ''; + + // Entity state, seeded from TTL cache so same-artist song switches are instant + const [songMeta, setSongMeta] = useState(() => songId ? songMetaCache.get(songId) ?? null : null); + const [artistInfo, setArtistInfo] = useState(() => artistId ? artistInfoCache.get(artistId) ?? null : null); + const [albumData, setAlbumData] = useState<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>(() => albumId ? albumCache.get(albumId) ?? null : null); + const [topSongs, setTopSongs] = useState(() => artistName ? topSongsCache.get(artistName) ?? [] : []); + const [tourEvents, setTourEvents] = useState(() => artistName ? tourCache.get(artistName) ?? [] : []); + const [tourLoading, setTourLoading] = useState(false); + const [discography, setDiscography] = useState(() => artistId ? discographyCache.get(artistId) ?? [] : []); + const [lfmTrack, setLfmTrack] = useState(null); + const [lfmArtist, setLfmArtist] = useState(null); + + // Fetch batch per entity change (not per song switch — same-artist songs share artist/top/tour fetches) + useEffect(() => { + if (!songId) { setSongMeta(null); return; } + const cached = songMetaCache.get(songId); + if (cached !== undefined) { setSongMeta(cached); return; } + let cancelled = false; + getSong(songId) + .then(v => { if (!cancelled) { songMetaCache.set(songId, v ?? null); setSongMeta(v ?? null); } }) + .catch(() => { if (!cancelled) { songMetaCache.set(songId, null); setSongMeta(null); } }); + return () => { cancelled = true; }; + }, [songId]); + + useEffect(() => { + if (!artistId) { setArtistInfo(null); return; } + const cached = artistInfoCache.get(artistId); + if (cached !== undefined) { setArtistInfo(cached); return; } + let cancelled = false; + getArtistInfo(artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined }) + .then(v => { if (!cancelled) { artistInfoCache.set(artistId, v ?? null); setArtistInfo(v ?? null); } }) + .catch(() => { if (!cancelled) { artistInfoCache.set(artistId, null); setArtistInfo(null); } }); + return () => { cancelled = true; }; + }, [artistId, audiomuseNavidromeEnabled]); + + useEffect(() => { + if (!albumId) { setAlbumData(null); return; } + const cached = albumCache.get(albumId); + if (cached !== undefined) { setAlbumData(cached); return; } + let cancelled = false; + getAlbum(albumId) + .then(v => { if (!cancelled) { albumCache.set(albumId, v); setAlbumData(v); } }) + .catch(() => { if (!cancelled) { albumCache.set(albumId, null); setAlbumData(null); } }); + return () => { cancelled = true; }; + }, [albumId]); + + useEffect(() => { + if (!artistName) { setTopSongs([]); return; } + const cached = topSongsCache.get(artistName); + if (cached !== undefined) { setTopSongs(cached); return; } + let cancelled = false; + getTopSongs(artistName) + .then(v => { if (!cancelled) { topSongsCache.set(artistName, v); setTopSongs(v); } }) + .catch(() => { if (!cancelled) { topSongsCache.set(artistName, []); setTopSongs([]); } }); + return () => { cancelled = true; }; + }, [artistName]); + + useEffect(() => { + if (!enableBandsintown || !artistName) { setTourEvents([]); return; } + const cached = tourCache.get(artistName); + if (cached !== undefined) { setTourEvents(cached); setTourLoading(false); return; } + let cancelled = false; + setTourLoading(true); + fetchBandsintownEvents(artistName) + .then(v => { if (!cancelled) { tourCache.set(artistName, v); setTourEvents(v); } }) + .finally(() => { if (!cancelled) setTourLoading(false); }); + return () => { cancelled = true; }; + }, [enableBandsintown, artistName]); + + // Discography via getArtist + useEffect(() => { + if (!artistId) { setDiscography([]); return; } + const cached = discographyCache.get(artistId); + if (cached !== undefined) { setDiscography(cached); return; } + let cancelled = false; + getArtist(artistId) + .then(v => { if (!cancelled) { discographyCache.set(artistId, v.albums); setDiscography(v.albums); } }) + .catch(() => { if (!cancelled) { discographyCache.set(artistId, []); setDiscography([]); } }); + return () => { cancelled = true; }; + }, [artistId]); + + // Last.fm track info (per-track) + const lfmTrackKey = currentTrack ? `${currentTrack.artist}${currentTrack.title}${lastfmUsername}` : ''; + useEffect(() => { + if (!lastfmIsConfigured() || !currentTrack) { setLfmTrack(null); return; } + const cached = lfmTrackCache.get(lfmTrackKey); + if (cached !== undefined) { setLfmTrack(cached); return; } + let cancelled = false; + lastfmGetTrackInfo(currentTrack.artist, currentTrack.title, lastfmUsername || undefined) + .then(v => { if (!cancelled) { lfmTrackCache.set(lfmTrackKey, v); setLfmTrack(v); } }) + .catch(() => { if (!cancelled) { lfmTrackCache.set(lfmTrackKey, null); setLfmTrack(null); } }); + return () => { cancelled = true; }; + }, [lfmTrackKey, currentTrack, lastfmUsername]); + + // Last.fm artist stats (per-artist — shared across same-artist tracks) + const lfmArtistKey = artistName ? `${artistName}${lastfmUsername}` : ''; + useEffect(() => { + if (!lastfmIsConfigured() || !artistName) { setLfmArtist(null); return; } + const cached = lfmArtistCache.get(lfmArtistKey); + if (cached !== undefined) { setLfmArtist(cached); return; } + let cancelled = false; + lastfmGetArtistStats(artistName, lastfmUsername || undefined) + .then(v => { if (!cancelled) { lfmArtistCache.set(lfmArtistKey, v); setLfmArtist(v); } }) + .catch(() => { if (!cancelled) { lfmArtistCache.set(lfmArtistKey, null); setLfmArtist(null); } }); + return () => { cancelled = true; }; + }, [lfmArtistKey, artistName, lastfmUsername]); + + // Star + const [starred, setStarred] = useState(false); + useEffect(() => { setStarred(!!songMeta?.starred); }, [songMeta]); + const toggleStar = useCallback(async () => { + if (!currentTrack) return; + if (starred) { await unstar(currentTrack.id, 'song'); setStarred(false); } + else { await star(currentTrack.id, 'song'); setStarred(true); } + }, [currentTrack, starred]); + + // Last.fm love (seeded from track.getInfo, toggle via love/unlove) + const lfmLoveEnabled = Boolean(lastfmUsername && lastfmSessionKey); + const [lfmLoved, setLfmLoved] = useState(false); + useEffect(() => { setLfmLoved(!!lfmTrack?.userLoved); }, [lfmTrack]); + const toggleLfmLove = useCallback(async () => { + if (!currentTrack || !lfmLoveEnabled) return; + const track = { title: currentTrack.title, artist: currentTrack.artist }; + if (lfmLoved) { await lastfmUnloveTrack(track, lastfmSessionKey); setLfmLoved(false); } + else { await lastfmLoveTrack (track, lastfmSessionKey); setLfmLoved(true); } + }, [currentTrack, lfmLoved, lfmLoveEnabled, lastfmSessionKey]); + + const openLyrics = useCallback(() => { + if (!isQueueVisible) toggleQueue(); + showLyrics(); + }, [isQueueVisible, toggleQueue, showLyrics]); + + // Cover + const coverFetchUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; + const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; + const resolvedCover = useCachedUrl(coverFetchUrl, coverKey); + + const radioCoverFetchUrl = currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 800) : ''; + const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 800) : ''; + const resolvedRadioCover = useCachedUrl(radioCoverFetchUrl, radioCoverKey); + + const contributorRows = useMemo( + () => buildContributorRows(songMeta, artistName), + [songMeta, artistName], + ); + + // Merge Subsonic artistInfo with Last.fm fallback: if Subsonic has no bio, + // use Last.fm's artist bio so the card doesn't show up empty. + const effectiveArtistInfo = useMemo(() => { + if (!artistInfo && !lfmArtist?.bio) return null; + if (artistInfo?.biography) return artistInfo; + if (!lfmArtist?.bio) return artistInfo; + return { + ...(artistInfo ?? {}), + biography: lfmArtist.bio, + }; + }, [artistInfo, lfmArtist]); + + const handleEnableBandsintown = useCallback(() => setEnableBandsintown(true), [setEnableBandsintown]); + + const handlePlayTopSong = useCallback((song: SubsonicSong) => { + if (topSongs.length === 0) return; + const queue = topSongs.map(songToTrack); + const hit = queue.find(q => q.id === song.id); + if (hit) playTrackFn(hit, queue); + }, [topSongs, playTrackFn]); + + // ── Render ──────────────────────────────────────────────────────────────── return (
-
- {radioNowPlaying ? ( - radioNowPlaying + {currentRadio && !currentTrack ? ( + ) : currentTrack ? ( - <> - {/* ── Hero Card ── */} -
- - {/* Left: meta info */} -
-
-
{currentTrack.title}
-
- currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)} - style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }} - >{currentTrack.artist} - · - currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)} - style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }} - >{currentTrack.album} - {currentTrack.year && <>·{currentTrack.year}} -
-
- {songMeta?.genre && {songMeta.genre}} - {currentTrack.suffix && {currentTrack.suffix.toUpperCase()}} - {currentTrack.bitRate && {currentTrack.bitRate} kbps} - {currentTrack.duration && {formatTime(currentTrack.duration)}} - {renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)} - - -
-
-
- - {/* Center: cover */} -
- {resolvedCover - ? - :
- } -
- - {/* Right: tag cloud */} - navigate(`/artist/${id}`)} - /> - -
- - {/* ── About the Artist ── */} - {artistInfo?.biography && ( -
-
-

{t('nowPlaying.aboutArtist')}

- {currentTrack.artistId && ( - - )} -
-
- {artistInfo.largeImageUrl && ( - {currentTrack.artist} { (e.target as HTMLImageElement).style.display = 'none'; }} - /> - )} -
-
- -
-
-
- )} - - + - + +
+
+ + + +
+
+ + + +
+
+
) : (
diff --git a/src/styles/components.css b/src/styles/components.css index b62ca146..53319145 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -10187,3 +10187,486 @@ html[data-app-hidden="true"] *::after { transition: filter 0.12s; } .np-info-bandsintown-prompt-btn:hover { filter: brightness(1.08); } + +/* ────────────────────────────────────────────────────────────────────────── */ +/* Now Playing Dashboard (full-page redesign) */ +/* ────────────────────────────────────────────────────────────────────────── */ + +.np-dash { + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; +} + +.np-dash-hero { + display: grid; + grid-template-columns: auto 1fr; + gap: 28px; + padding: 28px; + background: rgba(0, 0, 0, 0.30); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-radius: var(--radius-lg); + border: 1px solid rgba(255, 255, 255, 0.08); + align-items: center; +} + +.np-dash-hero-cover { + width: 260px; + height: 260px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); +} +.np-dash-hero-cover .np-cover { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.np-dash-hero-body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.np-dash-hero-title { + font-size: 30px; + font-weight: 700; + letter-spacing: -0.015em; + line-height: 1.15; + color: var(--accent); + overflow-wrap: anywhere; +} + +.np-dash-hero-sub { + font-size: 15px; + color: rgba(255, 255, 255, 0.78); + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: baseline; +} +.np-dash-hero-sub .np-link { color: inherit; } +.np-dash-hero-sub .np-link:hover { color: var(--accent); } + +.np-dash-hero-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.np-badge-hires { + background: color-mix(in srgb, var(--ctp-yellow) 30%, transparent); + color: var(--ctp-yellow); + font-weight: 700; + letter-spacing: 0.05em; +} + +.np-dash-hero-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 2px; +} + +.np-dash-icon-btn { + background: none; + border: none; + cursor: pointer; + padding: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.65); + border-radius: var(--radius-sm); + transition: color 0.15s, background 0.15s; +} +.np-dash-icon-btn:hover { + color: var(--accent); + background: rgba(255, 255, 255, 0.06); +} +.np-dash-lfm-btn.is-loved { + color: var(--accent); +} + +.np-stars-inline { + display: inline-flex; + gap: 3px; + align-items: center; + margin-left: 4px; +} + +.np-dash-hero-stat { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); + opacity: 0.85; +} + +/* Last.fm stats pulled up into the hero */ +.np-dash-hero-lfm { + display: flex; + flex-direction: column; + gap: 5px; + margin-top: 8px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.04); + border-radius: var(--radius-md); + border-left: 3px solid var(--accent); +} +.np-dash-hero-lfm-heading { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 2px; +} +.np-dash-hero-lfm-badge { + font-size: 10px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); +} +.np-dash-hero-lfm-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; + font-size: 13px; + color: rgba(255, 255, 255, 0.78); + font-variant-numeric: tabular-nums; +} +.np-dash-hero-lfm-scope { + font-weight: 600; + color: rgba(255, 255, 255, 0.92); +} +.np-dash-hero-lfm-sep { + color: rgba(255, 255, 255, 0.35); + padding: 0 2px; +} +.np-dash-hero-lfm-dot { + color: rgba(255, 255, 255, 0.30); + padding: 0 3px; +} +.np-dash-hero-lfm-you { + color: var(--accent); + font-weight: 600; +} + +/* Two flex columns — left stack, right stack */ +.np-dash-grid { + display: flex; + gap: 18px; + align-items: flex-start; +} + +.np-dash-col { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 18px; +} + +.np-dash-card { + min-width: 0; +} + +/* Expanded bio grows the card naturally instead of turning into a scroll box */ +.np-dash-artist-text .np-bio-text.expanded { + max-height: none; + overflow: visible; +} + +/* Artist card */ +.np-dash-artist-body { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.np-dash-artist-image { + width: 92px; + height: 92px; + border-radius: var(--radius-md); + object-fit: cover; + flex-shrink: 0; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); +} + +.np-dash-artist-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.np-dash-artist-name { + font-size: 16px; + font-weight: 600; + color: rgba(255, 255, 255, 0.92); +} + +.np-dash-similar { + margin-top: 4px; +} + +.np-dash-chip-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.np-chip { + font-size: 12px; + font-weight: 500; + padding: 4px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.75); + cursor: pointer; + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} +.np-chip:hover { + background: color-mix(in srgb, var(--accent) 30%, transparent); + color: white; +} + +/* Album card meta line */ +.np-dash-album-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + font-size: 12.5px; + color: rgba(255, 255, 255, 0.70); +} +.np-dash-album-name { + font-weight: 600; + color: rgba(255, 255, 255, 0.88); +} +.np-dash-album-stats { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + align-items: baseline; +} + +/* Top songs card */ +.np-dash-top-list { + display: flex; + flex-direction: column; + gap: 2px; +} +.np-dash-top-row { + display: grid; + grid-template-columns: 28px 1fr auto 16px; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.12s; +} +.np-dash-top-row:hover { + background: rgba(255, 255, 255, 0.07); +} +.np-dash-top-row.active { + background: rgba(255, 255, 255, 0.10); +} +.np-dash-top-rank { + font-size: 12px; + font-weight: 700; + color: rgba(255, 255, 255, 0.45); + font-variant-numeric: tabular-nums; + text-align: right; +} +.np-dash-top-row.active .np-dash-top-rank { + color: var(--accent); +} +.np-dash-top-body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} +.np-dash-top-title { + font-size: 13px; + color: rgba(255, 255, 255, 0.88); +} +.np-dash-top-row.active .np-dash-top-title { + color: var(--accent); + font-weight: 600; +} +.np-dash-top-sub { + font-size: 11px; + color: rgba(255, 255, 255, 0.50); +} +.np-dash-top-dur { + font-size: 12px; + font-variant-numeric: tabular-nums; + color: rgba(255, 255, 255, 0.60); +} +.np-dash-top-play { + color: rgba(255, 255, 255, 0.35); + transition: color 0.15s; +} +.np-dash-top-row:hover .np-dash-top-play { + color: var(--accent); +} +.np-dash-top-credit { + font-size: 11px; + color: rgba(255, 255, 255, 0.40); + margin-top: 2px; +} + +/* Info-only tracklist inside the dashboard: no cursor, no hover background */ +.np-dash-card .np-album-track { cursor: default; } +.np-dash-card .np-album-track:hover:not(.active) { background: transparent; } + +/* Tracklist expand toggle */ +.np-dash-tracklist-more { + background: none; + border: none; + cursor: pointer; + font-size: 12px; + color: var(--accent); + padding: 6px 8px; + align-self: flex-start; + opacity: 0.85; + transition: opacity 0.15s; +} +.np-dash-tracklist-more:hover { opacity: 1; } + +/* Hero age + ReplayGain badge */ +.np-dash-hero-age { + font-style: italic; + opacity: 0.65; +} + +/* Discography grid — compact contact-sheet of covers, title/year shown on hover via tooltip */ +.np-dash-disc-grid { + display: grid; + grid-template-columns: repeat(10, minmax(0, 1fr)); + gap: 6px; +} +.np-dash-disc-tile { + cursor: pointer; + min-width: 0; + transition: transform 0.15s; +} +.np-dash-disc-tile:hover { transform: translateY(-2px); } +.np-dash-disc-cover { + aspect-ratio: 1 / 1; + border-radius: var(--radius-sm); + overflow: hidden; + background: rgba(255, 255, 255, 0.04); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.35); + position: relative; +} +.np-dash-disc-tile.active .np-dash-disc-cover { + box-shadow: 0 0 0 2px var(--accent), 0 3px 10px rgba(0, 0, 0, 0.4); +} +.np-dash-disc-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.np-dash-disc-fallback { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.30); +} + +/* Last.fm stats */ +.np-dash-stats-row { + display: flex; + flex-direction: column; + gap: 6px; +} +.np-dash-stats-row + .np-dash-stats-row { + margin-top: 4px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} +.np-dash-stats-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.45); +} +.np-dash-stats-values { + display: flex; + gap: 22px; + flex-wrap: wrap; +} +.np-dash-stat { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 60px; +} +.np-dash-stat-value { + font-size: 20px; + font-weight: 700; + color: rgba(255, 255, 255, 0.92); + letter-spacing: -0.01em; + font-variant-numeric: tabular-nums; +} +.np-dash-stat-you .np-dash-stat-value { + color: var(--accent); +} +.np-dash-stat-sub { + font-size: 11px; + color: rgba(255, 255, 255, 0.50); + text-transform: lowercase; +} +.np-dash-stats-links { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-top: 2px; +} + +/* Responsive: single column on narrow viewports */ +@media (max-width: 900px) { + .np-dash-grid { flex-direction: column; } + .np-dash-hero { grid-template-columns: 1fr; } + .np-dash-hero-cover { width: 180px; height: 180px; margin: 0 auto; } + .np-dash-hero-title { font-size: 24px; } +} + +.app-shell[data-mobile] .np-dash-grid { flex-direction: column; } +.app-shell[data-mobile] .np-dash-hero { grid-template-columns: 1fr; } +.app-shell[data-mobile] .np-dash-hero-cover { width: 160px; height: 160px; margin: 0 auto; } +.app-shell[data-mobile] .np-dash-hero-title { font-size: 22px; } +.app-shell[data-mobile] .np-dash-artist-body { flex-direction: column; } +.app-shell[data-mobile] .np-dash-artist-image { width: 72px; height: 72px; } +.app-shell[data-mobile] .np-dash-disc-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } + +@media (max-width: 1100px) { + .np-dash-disc-grid { grid-template-columns: repeat(8, minmax(0, 1fr)); } +} +@media (max-width: 520px) { + .np-dash-disc-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } + .np-dash-stats-values { gap: 16px; } + .np-dash-stat-value { font-size: 18px; } +}