import { getSongForServer } from '../api/subsonicLibrary'; import { getArtistInfoForServer } from '../api/subsonicArtists'; import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Info } from 'lucide-react'; import { open as shellOpen } from '@tauri-apps/plugin-shell'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { usePlaybackServerId } from '../hooks/usePlaybackServerId'; import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown'; import CachedImage from './CachedImage'; import OverlayScrollArea from './OverlayScrollArea'; const TOUR_LIMIT = 5; const BIO_CLAMP_LINES = 4; /** * Cross-mount caches keyed by stable IDs so jumping between tracks of the same * artist / album doesn't refire the network call. Cleared on app restart. */ const artistInfoCache = new Map(); const songDetailCache = new Map(); type ArtistInfoEntry = { id: string; info: SubsonicArtistInfo | null }; type SongDetailEntry = { id: string; song: SubsonicSong | null }; 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; const month = d.toLocaleString(undefined, { month: 'short' }); const day = String(d.getDate()); const weekday = d.toLocaleString(undefined, { weekday: 'short' }); const time = d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' }); return { month, day, weekday, time }; } interface ContributorRow { role: string; names: string[]; } /** * Build credits from OpenSubsonic `contributors[]` only. The legacy * artist/albumArtist/composer fallback is intentionally dropped — it * just repeats what's already shown above the tab. */ 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); } // Drop a row that only restates the main artist under the "artist" role. const out: ContributorRow[] = []; for (const [role, names] of rows.entries()) { const list = Array.from(names); const isMainArtistOnly = role.toLowerCase().startsWith('artist') && list.length === 1 && list[0].toLowerCase() === mainLower; if (isMainArtistOnly) continue; out.push({ role, names: list }); } return out; } function queuePanelCacheKey(serverId: string, id: string): string { return serverId ? `${serverId}:${id}` : id; } export default function NowPlayingInfo() { const { t } = useTranslation(); const currentTrack = usePlayerStore(s => s.currentTrack); const enableBandsintown = useAuthStore(s => s.enableBandsintown); const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown); const subsonicServerId = usePlaybackServerId(); const subsonicReady = Boolean(subsonicServerId); const artistName = currentTrack?.artist || ''; const artistId = currentTrack?.artistId || ''; const songId = currentTrack?.id || ''; // Tuple { id, info } gates rendering on "info matches the current artistId" so // `heroImage` (from info) and `heroCacheKey` (from artistId) can never be from // different tracks. Otherwise a track switch would render one frame with a // lagging url under the new key, and CachedImage's IndexedDB would persist // the wrong blob under the new key — sticky "previous track" image (#…). const [artistInfoEntry, setArtistInfoEntry] = useState(() => { if (!artistId || !subsonicServerId) return null; const cached = artistInfoCache.get(queuePanelCacheKey(subsonicServerId, artistId)); return cached === undefined ? null : { id: artistId, info: cached }; }); const [songDetailEntry, setSongDetailEntry] = useState(() => { if (!songId || !subsonicServerId) return null; const cached = songDetailCache.get(queuePanelCacheKey(subsonicServerId, songId)); return cached === undefined ? null : { id: songId, song: cached }; }); const [tourEvents, setTourEvents] = useState([]); const [tourLoading, setTourLoading] = useState(false); const [bioExpanded, setBioExpanded] = useState(false); const [bioOverflows, setBioOverflows] = useState(false); const [showAllTours, setShowAllTours] = useState(false); const bioRef = useRef(null); // Reset per-track UI state when the track changes useEffect(() => { setBioExpanded(false); setShowAllTours(false); }, [artistId, songId]); // Artist bio + image useEffect(() => { if (!subsonicReady || !subsonicServerId || !artistId) { setArtistInfoEntry(null); return; } const cacheKey = queuePanelCacheKey(subsonicServerId, artistId); const cached = artistInfoCache.get(cacheKey); if (cached !== undefined) { setArtistInfoEntry({ id: artistId, info: cached }); return; } setArtistInfoEntry(null); let cancelled = false; getArtistInfoForServer(subsonicServerId, artistId) .then(info => { if (!cancelled) { artistInfoCache.set(cacheKey, info ?? null); setArtistInfoEntry({ id: artistId, info: info ?? null }); } }) .catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, info: null }); } }); return () => { cancelled = true; }; }, [subsonicReady, subsonicServerId, artistId]); // Song detail (for OpenSubsonic contributors[]) useEffect(() => { if (!subsonicReady || !subsonicServerId || !songId) { setSongDetailEntry(null); return; } const cacheKey = queuePanelCacheKey(subsonicServerId, songId); const cached = songDetailCache.get(cacheKey); if (cached !== undefined) { setSongDetailEntry({ id: songId, song: cached }); return; } setSongDetailEntry(null); let cancelled = false; getSongForServer(subsonicServerId, songId) .then(song => { if (!cancelled) { songDetailCache.set(cacheKey, song ?? null); setSongDetailEntry({ id: songId, song: song ?? null }); } }) .catch(() => { if (!cancelled) { songDetailCache.set(cacheKey, null); setSongDetailEntry({ id: songId, song: null }); } }); return () => { cancelled = true; }; }, [subsonicReady, subsonicServerId, songId]); // Bandsintown — only when opt-in toggle is on useEffect(() => { if (!enableBandsintown || !artistName) { setTourEvents([]); return; } let cancelled = false; setTourLoading(true); fetchBandsintownEvents(artistName) .then(events => { if (!cancelled) setTourEvents(events); }) .finally(() => { if (!cancelled) setTourLoading(false); }); return () => { cancelled = true; }; }, [enableBandsintown, artistName]); // Only consume info that belongs to the current track — never render with a // stale entry from the previous track. const matchedArtistInfo = artistInfoEntry && artistInfoEntry.id === artistId ? artistInfoEntry.info : null; const matchedSongDetail = songDetailEntry && songDetailEntry.id === songId ? songDetailEntry.song : null; // Detect whether the (clamped) bio actually overflows so we hide the toggle // when it would do nothing. const bio = matchedArtistInfo?.biography?.trim() || ''; const bioClean = bio.replace(/]*>.*?<\/a>\.?/gi, '').trim(); useLayoutEffect(() => { const el = bioRef.current; if (!el) { setBioOverflows(false); return; } setBioOverflows(el.scrollHeight - el.clientHeight > 1); }, [bioClean]); const contributorRows = useMemo( () => buildContributorRows(matchedSongDetail, artistName), [matchedSongDetail, artistName], ); if (!currentTrack) { return (
{t('nowPlayingInfo.empty', 'Play something to see info')}
); } const heroImage = matchedArtistInfo?.largeImageUrl || matchedArtistInfo?.mediumImageUrl || ''; const heroCacheKey = matchedArtistInfo && artistId ? `artistInfo:${artistId}:hero` : ''; const visibleTours = showAllTours ? tourEvents : tourEvents.slice(0, TOUR_LIMIT); const hiddenTourCount = Math.max(0, tourEvents.length - visibleTours.length); return ( {/* Artist card */}
{heroImage && heroCacheKey && (
)}
{t('nowPlayingInfo.artist', 'Artist')}
{artistName || t('common.unknownArtist', 'Unknown artist')}
{bioClean && ( <>

{bioClean}

{(bioOverflows || bioExpanded) && ( )} )}
{/* Song info / contributors — only when OpenSubsonic provided real credits */} {contributorRows.length > 0 && (
{t('nowPlayingInfo.songInfo', 'Song info')}
    {contributorRows.map(row => (
  • {row.names.join(', ')} {t(`nowPlayingInfo.role.${row.role}`, row.role)}
  • ))}
)} {/* Tour: prompt to opt-in when off, list when on */} {!enableBandsintown ? (
{t('nowPlayingInfo.enableBandsintownPrompt', 'See upcoming tour dates?')}
{t('nowPlayingInfo.enableBandsintownPromptDesc', 'Optional. Loads concerts for the current artist via Bandsintown.')}
) : (
{t('nowPlayingInfo.onTour', 'On tour')}
{tourLoading && tourEvents.length === 0 && (
{t('nowPlayingInfo.tourLoading', 'Loading…')}
)} {!tourLoading && tourEvents.length === 0 && (
{t('nowPlayingInfo.noTourEvents', 'No upcoming shows')}
)} {visibleTours.length > 0 && (
    {visibleTours.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}
  • ); })}
)} {(hiddenTourCount > 0 || (showAllTours && tourEvents.length > TOUR_LIMIT)) && ( )}
{t('nowPlayingInfo.poweredByBandsintown', 'Tour data via Bandsintown')}
)}
); }