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, Info, Headphones, Calendar, Disc3, TrendingUp, 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 { buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar, 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'; import { useDragSource, useDragDrop } from '../contexts/DragDropContext'; import OverlayScrollArea from '../components/OverlayScrollArea'; import { useNpLayoutStore, NP_CARD_IDS, type NpCardId, type NpColumn, } from '../store/nowPlayingLayoutStore'; // ─── Helpers ────────────────────────────────────────────────────────────────── function formatTime(s: number): string { if (!s || isNaN(s)) return '0:00'; const m = Math.floor(s / 60); 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'); doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove()); doc.querySelectorAll('*').forEach(el => { Array.from(el.attributes).forEach(attr => { const name = attr.name.toLowerCase(); const val = attr.value.toLowerCase().trim(); if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) { el.removeAttribute(attr.name); } }); }); // 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 => ( ))}
); } // ─── Module-level TTL caches (shared across mounts) ─────────────────────────── const CACHE_TTL_MS = 5 * 60 * 1000; interface CacheEntry { value: T; ts: number; } 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() }); }, }; } 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 (
{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' })} )}
)}
)}
); }); interface ArtistCardProps { artistName: string; artistId?: string; artistInfo: SubsonicArtistInfo | null; onNavigate: (path: string) => void; } const ArtistCard = memo(function ArtistCard({ artistName, artistId, artistInfo, onNavigate }: ArtistCardProps) { const { t } = useTranslation(); 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.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' })} )}
{visibleSongs.map(track => { const isActive = track.id === currentTrackId; return (
{isActive ? : track.track ?? '—'} {track.title} {formatTime(track.duration)}
); })}
{songs.length > ALBUM_TRACK_LIMIT && ( )}
); }); interface TopSongsCardProps { artistName: string; artistId?: string; songs: SubsonicSong[]; currentTrackId: string; onNavigate: (path: string) => void; onPlay: (song: SubsonicSong) => void; } const TopSongsCard = memo(function TopSongsCard({ artistName, artistId, songs, currentTrackId, onNavigate, onPlay }: TopSongsCardProps) { const { t } = useTranslation(); const top = songs.slice(0, 8); if (top.length === 0) return null; 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}}' })}
); }); interface CreditsCardProps { rows: ContributorRow[]; } 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(', ')}
  • ))}
); }); 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 && ( )}
); }); // ─── Widget wrapper (drag source via psyDnD) ───────────────────────────────── interface NpCardWrapProps { id: NpCardId; label: string; isDraggingThis: boolean; children: React.ReactNode; } function NpCardWrap({ id, label, isDraggingThis, children }: NpCardWrapProps) { const dragProps = useDragSource(() => ({ data: JSON.stringify({ kind: 'np-card', id }), label, })); return (
{children}
); } // ─── Column (drop target via psy-drop + global mousemove) ──────────────────── interface NpColumnProps { col: NpColumn; children: React.ReactNode; empty: boolean; emptyLabel: string; isDndActive: boolean; draggingCardId: NpCardId | null; onHover: (col: NpColumn, idx: number) => void; isOverHere: boolean; } function NpColumnEl({ col, children, empty, emptyLabel, isDndActive, draggingCardId, onHover, isOverHere }: NpColumnProps) { const ref = useRef(null); useEffect(() => { if (!draggingCardId) return; const el = ref.current; if (!el) return; const onMove = (e: MouseEvent) => { const rect = el.getBoundingClientRect(); // Use only the x-axis to decide "which column". This keeps the whole // vertical strip above / below the last card part of the drop zone, // so the user can drop "at the very bottom" of either column. if (e.clientX < rect.left || e.clientX > rect.right) return; const wrappers = Array.from(el.querySelectorAll('[data-np-wrapper]')) .filter(w => w.getAttribute('data-np-card-id') !== draggingCardId); let idx = wrappers.length; for (let i = 0; i < wrappers.length; i++) { const r = wrappers[i].getBoundingClientRect(); if (e.clientY < r.top + r.height / 2) { idx = i; break; } } onHover(col, idx); }; document.addEventListener('mousemove', onMove); return () => document.removeEventListener('mousemove', onMove); }, [draggingCardId, col, onHover]); return (
{children} {empty &&
{emptyLabel}
}
); } 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 (
{currentRadio.name}
{radioMeta.currentTitle && (
{radioMeta.currentArtist && (<>{radioMeta.currentArtist}·)} {radioMeta.currentTitle} {radioMeta.currentAlbum && (<>·{radioMeta.currentAlbum})}
)}
{t('radio.live')} {radioMeta.source === 'azuracast' && AzuraCast} {radioMeta.listeners != null && ( {t('radio.listenerCount', { count: radioMeta.listeners })} )}
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 && (
{formatTime(radioMeta.elapsed)}
{formatTime(radioMeta.duration)}
)}
{resolvedCover ? {currentRadio.name} : radioMeta.currentArt ? { (e.target as HTMLImageElement).style.display = 'none'; }} /> :
}
{radioMeta.nextSong && (

{t('radio.upNext')}

{radioMeta.nextSong.art && ( { (e.target as HTMLImageElement).style.display = 'none'; }} /> )}
{radioMeta.nextSong.title} {radioMeta.nextSong.artist && {radioMeta.nextSong.artist}}
)} {radioMeta.history.length > 0 && (

{t('radio.recentlyPlayed')}

{radioMeta.history.map((item, idx) => (
{item.song.art && ( { (e.target as HTMLImageElement).style.display = 'none'; }} /> )} {item.song.artist ? `${item.song.artist} — ${item.song.title}` : item.song.title}
))}
)}
); }); // ─── 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')}

)}
); }