refactor(now-playing): G.73 — extract TourCard + DiscographyCard + useNowPlayingFetchers + useNowPlayingStarLove (cluster) (#640)

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.
This commit is contained in:
Frank Stellmacher
2026-05-13 14:46:31 +02:00
committed by GitHub
parent 2ddc2a2345
commit 6f8dd73448
5 changed files with 399 additions and 315 deletions
@@ -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 (
<div className="np-info-card np-dash-card">
<div className="np-card-header">
<h3 className="np-card-title">
<Disc3 size={13} style={{ marginRight: 5, verticalAlign: '-2px' }} />
{t('nowPlaying.discography', 'Discography')}
</h3>
{artistId && (
<button className="np-card-link" onClick={() => onNavigate(`/artist/${artistId}`)}>
{t('nowPlaying.goToArtist')} <ExternalLink size={12} />
</button>
)}
</div>
<div className="np-dash-disc-grid">
{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 (
<div key={a.id}
className={`np-dash-disc-tile${isActive ? ' active' : ''}`}
onClick={() => onNavigate(`/album/${a.id}`)}
data-tooltip={`${a.name}${a.year ? ` · ${a.year}` : ''}`}>
<div className="np-dash-disc-cover">
{fetchUrl && key
? <CachedImage src={fetchUrl} cacheKey={key} alt={a.name} className="np-dash-disc-img" />
: <div className="np-dash-disc-fallback"><Music size={18} /></div>}
</div>
</div>
);
})}
</div>
{ordered.length > DISC_INITIAL && (
<button className="np-dash-tracklist-more" onClick={() => setShowAll(v => !v)}>
{showAll
? t('nowPlaying.showLessTracks', 'Show less')
: t('nowPlaying.showMoreTracks', { defaultValue: 'Show {{count}} more', count: hiddenCount })}
</button>
)}
</div>
);
});
export default DiscographyCard;
+104
View File
@@ -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 (
<div className="np-info-card np-dash-card">
<div className="np-card-header">
<h3 className="np-card-title">
<Calendar size={13} style={{ marginRight: 5, verticalAlign: '-2px' }} />
{t('nowPlayingInfo.onTour', 'On tour')}
</h3>
</div>
{!enabled ? (
<div className="np-info-bandsintown-prompt">
<div className="np-info-bandsintown-prompt-title">
<span>{t('nowPlayingInfo.enableBandsintownPrompt', 'See upcoming tour dates?')}</span>
<span className="np-info-bandsintown-prompt-info"
data-tooltip={t('nowPlayingInfo.enableBandsintownPrivacy', 'When enabled, the current artist\'s name is sent to the Bandsintown API to fetch tour dates. No personal account information leaves your device.')}
data-tooltip-pos="bottom"
data-tooltip-wrap="true"
tabIndex={0}>
<Info size={13} />
</span>
</div>
<div className="np-info-bandsintown-prompt-desc">
{t('nowPlayingInfo.enableBandsintownPromptDesc', 'Optional. Loads concerts for the current artist via Bandsintown.')}
</div>
<button className="np-info-bandsintown-prompt-btn" onClick={onEnable}>
{t('nowPlayingInfo.enableBandsintownAction', 'Enable')}
</button>
</div>
) : (
<>
{loading && events.length === 0 && (
<div className="np-info-tour-empty">{t('nowPlayingInfo.tourLoading', 'Loading…')}</div>
)}
{!loading && events.length === 0 && (
<div className="np-info-tour-empty">{t('nowPlayingInfo.noTourEvents', 'No upcoming shows')}</div>
)}
{visible.length > 0 && (
<ul className="np-info-tour">
{visible.map((ev, idx) => {
const parts = isoToParts(ev.datetime);
const place = [ev.venueCity, ev.venueRegion, ev.venueCountry].filter(Boolean).join(', ');
return (
<li key={`${ev.datetime}-${ev.venueName}-${idx}`}
className="np-info-tour-item"
onClick={() => ev.url && shellOpen(ev.url).catch(() => {})}
role={ev.url ? 'button' : undefined}
tabIndex={ev.url ? 0 : undefined}>
{parts && (
<div className="np-info-tour-date">
<div className="np-info-tour-date-month">{parts.month}</div>
<div className="np-info-tour-date-day">{parts.day}</div>
</div>
)}
<div className="np-info-tour-meta">
<div className="np-info-tour-venue">{ev.venueName || place}</div>
<div className="np-info-tour-place">
{parts && <span className="np-info-tour-when">{parts.weekday}, {parts.time}</span>}
{parts && place && <span className="np-info-tour-sep"> </span>}
<span>{place}</span>
</div>
</div>
</li>
);
})}
</ul>
)}
{(hidden > 0 || (showAll && events.length > TOUR_LIMIT)) && (
<button className="np-info-tour-more" onClick={() => setShowAll(v => !v)}>
{showAll
? t('nowPlayingInfo.showLessTours', 'Show less')
: t('nowPlayingInfo.showMoreTours', { defaultValue: 'Show {{count}} more', count: hidden })}
</button>
)}
<div className="np-info-tour-credit">{t('nowPlayingInfo.poweredByBandsintown', 'Tour data via Bandsintown')}</div>
</>
)}
</div>
);
});
export default TourCard;