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 && ( + + )} + {(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();