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 React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Music, ExternalLink, Cast, Users, Radio, Clock, SkipForward, Info, Calendar, Disc3, Play, EyeOff, LayoutGrid, RotateCcw, Eye } 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 { 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'; import { useDragSource, useDragDrop } from '../contexts/DragDropContext'; import OverlayScrollArea from '../components/OverlayScrollArea'; import { useNpLayoutStore, NP_CARD_IDS, type NpCardId, type NpColumn, } from '../store/nowPlayingLayoutStore'; // ─── Helpers ────────────────────────────────────────────────────────────────── import { formatTime, formatCompact, isoToParts, 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'; import Hero from '../components/nowPlaying/Hero'; 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 && ( )}
); }); // ─── 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]); // ── Widget layout (drag-to-reorder, hide/show, reset) ──────────────────── const layoutCards = useNpLayoutStore(s => s.cards); const moveCard = useNpLayoutStore(s => s.moveCard); const setCardVisible = useNpLayoutStore(s => s.setVisible); const resetLayout = useNpLayoutStore(s => s.reset); const { isDragging: dndActive, payload: dndPayload } = useDragDrop(); const [dragOver, setDragOver] = useState<{ col: NpColumn; idx: number } | null>(null); const [layoutMenuOpen, setLayoutMenuOpen] = useState(false); // Parse the current drag payload to know whether it's an np-card drag const draggingCardId: NpCardId | null = useMemo(() => { if (!dndActive || !dndPayload) return null; try { const parsed = JSON.parse(dndPayload.data); if (parsed?.kind === 'np-card' && NP_CARD_IDS.includes(parsed.id)) return parsed.id as NpCardId; } catch { /* not a card payload */ } return null; }, [dndActive, dndPayload]); // Clear the drop indicator when the drag ends (no psy-drop on our target) useEffect(() => { if (!draggingCardId) setDragOver(null); }, [draggingCardId]); const toggleCardVisible = useCallback((id: NpCardId, next: boolean) => { setCardVisible(id, next); }, [setCardVisible]); const onColumnHover = useCallback((col: NpColumn, idx: number) => { setDragOver(prev => (prev && prev.col === col && prev.idx === idx) ? prev : { col, idx }); }, []); // Ref mirror of dragOver so the document-level psy-drop handler always sees // the latest hovered column/index regardless of closure timing. const dragOverRef = useRef(dragOver); dragOverRef.current = dragOver; // Global psy-drop listener: catches drops anywhere on the page (even below a // column when the cursor left the column bounds), then uses dragOverRef to // decide which column/index the user actually meant. useEffect(() => { if (!draggingCardId) return; const onPsyDrop = (evt: Event) => { const ce = evt as CustomEvent<{ data: string }>; try { const parsed = JSON.parse(ce.detail?.data ?? ''); if (parsed?.kind !== 'np-card' || !NP_CARD_IDS.includes(parsed.id)) return; const over = dragOverRef.current; if (over) { moveCard(parsed.id as NpCardId, over.col, over.idx); } } catch { /* ignore non-card drops */ } setDragOver(null); }; document.addEventListener('psy-drop', onPsyDrop as EventListener); return () => document.removeEventListener('psy-drop', onPsyDrop as EventListener); }, [draggingCardId, moveCard]); // Close layout menu on outside click useEffect(() => { if (!layoutMenuOpen) return; const onDoc = (e: MouseEvent) => { const el = e.target as HTMLElement | null; if (!el?.closest('.np-dash-toolbar-menu-wrap')) setLayoutMenuOpen(false); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [layoutMenuOpen]); // ── Render ──────────────────────────────────────────────────────────────── return (
{currentRadio && !currentTrack ? ( ) : currentTrack ? (
{(() => { const renderCard = (id: NpCardId): React.ReactNode => { switch (id) { case 'album': return ( ); case 'topSongs': return ( ); case 'credits': return ; case 'artist': return ( ); case 'discography': return ( ); case 'tour': return ( ); } }; const cardLabel = (id: NpCardId): string => { const k: Record = { album: 'nowPlaying.fromAlbum', topSongs: 'nowPlaying.topSongs', credits: 'nowPlayingInfo.songInfo', artist: 'nowPlaying.aboutArtist', discography: 'nowPlaying.discography', tour: 'nowPlayingInfo.onTour', }; return t(k[id]); }; const visibleCards = layoutCards.filter(c => c.visible); const hiddenCards = layoutCards.filter(c => !c.visible); const renderColumn = (col: NpColumn) => { const cards = layoutCards.filter(c => c.column === col && c.visible && c.id !== draggingCardId, ); const isOver = dragOver?.col === col; return ( {cards.map((c, idx) => ( {isOver && dragOver.idx === idx &&
} {renderCard(c.id)} ))} {isOver && dragOver.idx === cards.length &&
} ); }; return ( <>
{layoutMenuOpen && (
{t('nowPlaying.visibleCards', 'Visible cards')}
{visibleCards.map(c => ( ))} {hiddenCards.length > 0 && ( <>
{t('nowPlaying.hiddenCards', 'Hidden cards')}
{hiddenCards.map(c => ( ))} )}
)}
{renderColumn('left')} {renderColumn('right')}
); })()}
) : (

{t('nowPlaying.nothingPlaying')}

)}
); }