From 6f8dd734483acb970e2e14492b242b4ec818265f Mon Sep 17 00:00:00 2001
From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com>
Date: Wed, 13 May 2026 14:46:31 +0200
Subject: [PATCH] =?UTF-8?q?refactor(now-playing):=20G.73=20=E2=80=94=20ext?=
=?UTF-8?q?ract=20TourCard=20+=20DiscographyCard=20+=20useNowPlayingFetche?=
=?UTF-8?q?rs=20+=20useNowPlayingStarLove=20(cluster)=20(#640)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four-cut cluster closing out the NowPlaying decomposition. 715 → 391
LOC (−324).
TourCard — Bandsintown tour-dates card with the privacy-prompt
gate (shown before the user opts in), loading state, empty state,
5-item show-more list with date / venue / place, and the
"Tour data via Bandsintown" credit footer.
DiscographyCard — chronological album grid (10×2 = 20 tiles
initial, show-more for the rest), each tile a cached cover-art
thumbnail with a tooltip and click-through to the album page.
useNowPlayingFetchers — the eight cached fetch effects that drove
the page: song meta / artist info / album / top songs / Bandsintown
tour events (+ loading state) / discography / Last.fm track stats /
Last.fm artist stats. Each effect follows the same pattern
(cache hit → seed state, cache miss → fetch + setCache + setState,
cancellation flag on cleanup). All eight makeCache<…> instances
move into the hook module too.
useNowPlayingStarLove — local starred + lfmLoved booleans seeded
from songMeta.starred and lfmTrack.userLoved respectively, plus
toggleStar (star/unstar Subsonic API) and toggleLfmLove
(lastfmLove/Unlove with the session key).
NowPlaying drops the now-unused imports: star/unstar (Subsonic),
getArtist/getArtistInfo/getTopSongs/getSong/getAlbum, the entire
'../api/lastfm' line, fetchBandsintownEvents + BandsintownEvent,
makeCache (was passing through to the page only for cache
instantiation), plus SubsonicAlbum (only the type is still used
inside the hook). The radio + dashboard JSX in the body still
references everything via the hook returns. Pure code move
otherwise.
---
src/components/nowPlaying/DiscographyCard.tsx | 74 ++++
src/components/nowPlaying/TourCard.tsx | 104 ++++++
src/hooks/useNowPlayingFetchers.ts | 155 ++++++++
src/hooks/useNowPlayingStarLove.ts | 47 +++
src/pages/NowPlaying.tsx | 334 +-----------------
5 files changed, 399 insertions(+), 315 deletions(-)
create mode 100644 src/components/nowPlaying/DiscographyCard.tsx
create mode 100644 src/components/nowPlaying/TourCard.tsx
create mode 100644 src/hooks/useNowPlayingFetchers.ts
create mode 100644 src/hooks/useNowPlayingStarLove.ts
diff --git a/src/components/nowPlaying/DiscographyCard.tsx b/src/components/nowPlaying/DiscographyCard.tsx
new file mode 100644
index 00000000..5065b88e
--- /dev/null
+++ b/src/components/nowPlaying/DiscographyCard.tsx
@@ -0,0 +1,74 @@
+import React, { memo, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Disc3, ExternalLink, Music } from 'lucide-react';
+import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
+import type { SubsonicAlbum } from '../../api/subsonicTypes';
+import CachedImage from '../CachedImage';
+
+interface DiscographyCardProps {
+ artistId?: string;
+ albums: SubsonicAlbum[];
+ currentAlbumId?: string;
+ onNavigate: (path: string) => void;
+}
+
+const DISC_GRID_COLS = 10;
+const DISC_INITIAL_ROWS = 2;
+const DISC_INITIAL = DISC_GRID_COLS * DISC_INITIAL_ROWS;
+
+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 && (
+
+ )}
+
+ );
+});
+
+export default DiscographyCard;
diff --git a/src/components/nowPlaying/TourCard.tsx b/src/components/nowPlaying/TourCard.tsx
new file mode 100644
index 00000000..e8e9028f
--- /dev/null
+++ b/src/components/nowPlaying/TourCard.tsx
@@ -0,0 +1,104 @@
+import React, { memo, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Calendar, Info } from 'lucide-react';
+import { open as shellOpen } from '@tauri-apps/plugin-shell';
+import type { BandsintownEvent } from '../../api/bandsintown';
+import { isoToParts } from '../../utils/nowPlayingHelpers';
+
+interface TourCardProps {
+ artistName: string;
+ enabled: boolean;
+ loading: boolean;
+ events: BandsintownEvent[];
+ onEnable: () => void;
+}
+
+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);
+
+ return (
+
+
+
+
+ {t('nowPlayingInfo.onTour', 'On tour')}
+
+
+
+ {!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')}
+ >
+ )}
+
+ );
+});
+
+export default TourCard;
diff --git a/src/hooks/useNowPlayingFetchers.ts b/src/hooks/useNowPlayingFetchers.ts
new file mode 100644
index 00000000..87b79718
--- /dev/null
+++ b/src/hooks/useNowPlayingFetchers.ts
@@ -0,0 +1,155 @@
+import { useEffect, useState } from 'react';
+import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
+import { getAlbum, getSong } from '../api/subsonicLibrary';
+import type { SubsonicAlbum, SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes';
+import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
+import {
+ lastfmGetArtistStats, lastfmGetTrackInfo, lastfmIsConfigured,
+ type LastfmArtistStats, type LastfmTrackInfo,
+} from '../api/lastfm';
+import { makeCache } from '../utils/nowPlayingCache';
+
+// Module-level TTL caches (shared across mounts)
+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();
+
+export interface NowPlayingFetchersDeps {
+ songId: string | undefined;
+ artistId: string | undefined;
+ albumId: string | undefined;
+ artistName: string;
+ enableBandsintown: boolean;
+ audiomuseNavidromeEnabled: boolean;
+ lastfmUsername: string;
+ currentTrack: { artist: string; title: string } | null;
+}
+
+export interface NowPlayingFetchersResult {
+ songMeta: SubsonicSong | null;
+ artistInfo: SubsonicArtistInfo | null;
+ albumData: { album: SubsonicAlbum; songs: SubsonicSong[] } | null;
+ topSongs: SubsonicSong[];
+ tourEvents: BandsintownEvent[];
+ tourLoading: boolean;
+ discography: SubsonicAlbum[];
+ lfmTrack: LastfmTrackInfo | null;
+ lfmArtist: LastfmArtistStats | null;
+}
+
+export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingFetchersResult {
+ const { songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled, lastfmUsername, currentTrack } = deps;
+
+ // 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]);
+
+ return { songMeta, artistInfo, albumData, topSongs, tourEvents, tourLoading, discography, lfmTrack, lfmArtist };
+}
diff --git a/src/hooks/useNowPlayingStarLove.ts b/src/hooks/useNowPlayingStarLove.ts
new file mode 100644
index 00000000..9f55a517
--- /dev/null
+++ b/src/hooks/useNowPlayingStarLove.ts
@@ -0,0 +1,47 @@
+import { useCallback, useEffect, useState } from 'react';
+import { star, unstar } from '../api/subsonicStarRating';
+import type { SubsonicSong } from '../api/subsonicTypes';
+import {
+ lastfmLoveTrack, lastfmUnloveTrack,
+ type LastfmTrackInfo,
+} from '../api/lastfm';
+
+export interface NowPlayingStarLoveDeps {
+ currentTrack: { id: string; title: string; artist: string } | null;
+ songMeta: SubsonicSong | null;
+ lfmTrack: LastfmTrackInfo | null;
+ lfmLoveEnabled: boolean;
+ lastfmSessionKey: string;
+}
+
+export interface NowPlayingStarLoveResult {
+ starred: boolean;
+ lfmLoved: boolean;
+ toggleStar: () => Promise;
+ toggleLfmLove: () => Promise;
+}
+
+export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingStarLoveResult {
+ const { currentTrack, songMeta, lfmTrack, lfmLoveEnabled, lastfmSessionKey } = deps;
+
+ // 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 [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]);
+
+ return { starred, lfmLoved, toggleStar, toggleLfmLove };
+}
diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx
index 990350d0..c19913cd 100644
--- a/src/pages/NowPlaying.tsx
+++ b/src/pages/NowPlaying.tsx
@@ -1,8 +1,5 @@
-import { star, unstar } from '../api/subsonicStarRating';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
-import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
-import { getSong, getAlbum } from '../api/subsonicLibrary';
-import type { SubsonicSong, SubsonicArtistInfo, SubsonicAlbum } from '../api/subsonicTypes';
+import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes';
import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -12,13 +9,6 @@ import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore';
import { songToTrack } from '../utils/songToTrack';
-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 { useRadioMetadata } from '../hooks/useRadioMetadata';
@@ -36,7 +26,6 @@ import {
buildContributorRows,
type ContributorRow,
} from '../utils/nowPlayingHelpers';
-import { makeCache } from '../utils/nowPlayingCache';
import NpCardWrap from '../components/nowPlaying/NpCardWrap';
import NpColumnEl from '../components/nowPlaying/NpColumnEl';
import RadioView from '../components/nowPlaying/RadioView';
@@ -45,186 +34,10 @@ import ArtistCard from '../components/nowPlaying/ArtistCard';
import AlbumCard from '../components/nowPlaying/AlbumCard';
import TopSongsCard from '../components/nowPlaying/TopSongsCard';
import CreditsCard from '../components/nowPlaying/CreditsCard';
-
-// ─── Module-level TTL caches (shared across mounts) ───────────────────────────
-
-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 TourCardProps {
- artistName: string;
- enabled: boolean;
- loading: boolean;
- events: BandsintownEvent[];
- onEnable: () => void;
-}
-
-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);
-
- return (
-
-
-
-
- {t('nowPlayingInfo.onTour', 'On tour')}
-
-
-
- {!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')}
- >
- )}
-
- );
-});
-
-// ─── Radio view (unchanged from previous implementation) ──────────────────────
-
-// ─── Discography card ────────────────────────────────────────────────────────
-
-interface DiscographyCardProps {
- artistId?: string;
- albums: SubsonicAlbum[];
- currentAlbumId?: string;
- onNavigate: (path: string) => void;
-}
-
-const DISC_GRID_COLS = 10;
-const DISC_INITIAL_ROWS = 2;
-const DISC_INITIAL = DISC_GRID_COLS * DISC_INITIAL_ROWS;
-
-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 && (
-
- )}
-
- );
-});
+import TourCard from '../components/nowPlaying/TourCard';
+import DiscographyCard from '../components/nowPlaying/DiscographyCard';
+import { useNowPlayingFetchers } from '../hooks/useNowPlayingFetchers';
+import { useNowPlayingStarLove } from '../hooks/useNowPlayingStarLove';
// ─── Main Page ────────────────────────────────────────────────────────────────
@@ -256,131 +69,22 @@ export default function NowPlaying() {
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);
+ // Entity fetchers (8 cached useEffects + their state)
+ const {
+ songMeta, artistInfo, albumData, topSongs,
+ tourEvents, tourLoading, discography,
+ lfmTrack, lfmArtist,
+ } = useNowPlayingFetchers({
+ songId, artistId, albumId, artistName,
+ enableBandsintown, audiomuseNavidromeEnabled,
+ lastfmUsername, currentTrack,
+ });
- // 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)
+ // Star + Last.fm love + their toggle callbacks
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 { starred, lfmLoved, toggleStar, toggleLfmLove } = useNowPlayingStarLove({
+ currentTrack, songMeta, lfmTrack, lfmLoveEnabled, lastfmSessionKey,
+ });
const openLyrics = useCallback(() => {
if (!isQueueVisible) toggleQueue();