mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
9ad0f8af6d
* feat(ui): unify queue toggle handle behavior Show the queue toggle in the header when the queue is collapsed and use a seam-aligned drag handle when it is open. Hide the seam handle while the main content is actively scrolling to reduce accidental interactions. * feat(ui): add adaptive header search collapse behavior Collapse header search to a magnifier when top controls get crowded and expand it as an overlay only while active. Use measured header space with hysteresis to avoid flicker and keep neighboring controls stable. * chore(ui): remove leftover search prototype artifacts Drop an unused icon import from live search and remove an unused header container-type rule left from an earlier layout experiment. * feat(ui): persist sidebar and queue visibility state Save left sidebar collapse and right queue open/closed visibility in local storage helpers so both panel modes are restored after app restart. * feat(ui): unify player overflow menu behavior Use a single overflow menu for click and wheel interactions, with a volume-only mode that keeps the same layout and volume controls as the full menu. * feat(ui): add wheel seek controls to waveform Apply 10-second wheel seek steps with trailing 1-second debounce and keep the waveform preview stable so the playhead moves smoothly during rapid scroll input. * fix(now-playing): stabilize narrow dashboard layout Switch now-playing responsiveness to container-based breakpoints and prevent stacked widgets from overlapping when width is constrained. * fix(search): reduce collapse jitter and avoid header overlap Add a short collapse-state cooldown to prevent threshold flicker and hide conflicting header controls while collapsed search expands as an overlay. * fix(i18n): localize player overflow controls across locales Replace hardcoded player overflow labels with translation keys and add the missing keys for all shipped locale files. * fix(search): keep advanced control clickable in collapsed mode Prevent focus loss on the advanced search button in collapsed overlay mode so its click handler consistently runs. * fix(i18n): restore queue translation in offline library Use the existing queue.appendToQueue key for the offline enqueue button tooltip and label instead of a missing key and hardcoded English text. * fix(ui): apply overlay scrollbar to right-panel text tabs Switch now-playing content, lyrics, and info panes to OverlayScrollArea and harden tour-item layout so long concert metadata stays within panel bounds. * fix(ui): add unread indicator for new releases and guard sidebar drag clicks Track unread new-release IDs per server/library scope and clear the badge when opening the New Releases page. Also prevent click-through navigation after sidebar drag release and keep related i18n/responsive sidebar-adjacent refinements in this snapshot. * fix(ui): stabilize live dropdown layering and unread reset flow Render the topbar Live dropdown via a portal so it consistently overlays sidebar layers. Rework new-releases unread tracking to handle library scope baselines, ignore stale refresh races, and mark items as seen after a 5-second stay on the New Releases page. * feat(ui): add localized New badges for recently added albums Show a theme-consistent New badge on album cards and album detail for albums created within the last 48 hours. Localize the badge label across all supported locales and centralize recency logic in a shared utility to avoid duplication. * fix(album): prevent tracklist jump when entering multiselect Move bulk selection actions from the tracklist body into the album toolbar next to the track filter. Keep selection controls stable in the header area so enabling multiselect no longer shifts the tracklist content downward. * fix(tray): add playback-state badge and finalize queue handle tooltip Show play/pause/stop icons in the Linux tray now-playing entry and persist state safely in Tauri managed state. Also switch the queue-resize handle tooltip to the dedicated localized key across all locales. * fix(header): prioritize search collapse before Live/Orbit labels Make topbar compression deterministic by collapsing search first and compacting Live/Orbit labels only in sustained low-space mode. Add sticky hysteresis-based header compact state to prevent oscillation while resizing. * fix(ui): stabilize header compaction and show tray state icons Prevent topbar flicker in the narrow-width range by tightening compact-mode thresholds, gating on real overflow, and removing width transitions from live search. Also include playback state icons in tray tooltip text across platforms while preserving tooltip length limits. * fix(tray): keep tooltip iconization Windows-only Revert Linux tray tooltip/title fallback attempts and keep state icons only in Windows tray tooltips, while Linux continues to show playback state in the now-playing menu entry. * fix(ui): restore queue resize response after overlay scroll interactions Hide the queue handle while scrolling on both the main route viewport and the now-playing viewport, and clear stale thumb-drag state before starting queue resize. Also ignore inactive/faded overlay thumbs in resizer suppression so horizontal pointer transitions no longer leave the queue seam unresponsive. * docs(changelog): summarize ui-refinements branch features Document the branch-level feature additions in 1.45.0 as separate changelog sections and group remaining branch-local fixes under a single polish entry. * docs(changelog): add PR #397 references for ui-refinements Attach PR metadata to the new 1.45.0 ui-refinement sections and the polish entry so release notes map directly to the merged branch discussion.
1385 lines
60 KiB
TypeScript
1385 lines
60 KiB
TypeScript
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 [^>]*>.*?<\/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<string, Set<string>>();
|
||
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 (
|
||
<div className="np-stars-inline">
|
||
{[1, 2, 3, 4, 5].map(i => (
|
||
<Star key={i} size={13}
|
||
fill={i <= rating ? 'var(--ctp-yellow)' : 'none'}
|
||
color={i <= rating ? 'var(--ctp-yellow)' : 'var(--ctp-overlay1)'}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Module-level TTL caches (shared across mounts) ───────────────────────────
|
||
|
||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||
|
||
interface CacheEntry<T> { value: T; ts: number; }
|
||
|
||
function makeCache<T>() {
|
||
const map = new Map<string, CacheEntry<T>>();
|
||
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<SubsonicSong | null>();
|
||
const artistInfoCache = makeCache<SubsonicArtistInfo | null>();
|
||
const albumCache = makeCache<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
|
||
const topSongsCache = makeCache<SubsonicSong[]>();
|
||
const tourCache = makeCache<BandsintownEvent[]>();
|
||
const discographyCache = makeCache<SubsonicAlbum[]>();
|
||
const lfmTrackCache = makeCache<LastfmTrackInfo | null>();
|
||
const lfmArtistCache = makeCache<LastfmArtistStats | null>();
|
||
|
||
// ─── 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 (
|
||
<div className="np-dash-hero">
|
||
<div className="np-dash-hero-cover">
|
||
{coverUrl
|
||
? <img src={coverUrl} alt="" className="np-cover" />
|
||
: <div className="np-cover np-cover-fallback"><Music size={64} /></div>}
|
||
</div>
|
||
<div className="np-dash-hero-body">
|
||
<div className="np-dash-hero-title">{track.title}</div>
|
||
<div className="np-dash-hero-sub">
|
||
<span className="np-link"
|
||
onClick={() => track.artistId && onNavigate(`/artist/${track.artistId}`)}
|
||
style={{ cursor: track.artistId ? 'pointer' : 'default' }}>
|
||
{track.artist}
|
||
</span>
|
||
<span className="np-sep">·</span>
|
||
<span className="np-link"
|
||
onClick={() => track.albumId && onNavigate(`/album/${track.albumId}`)}
|
||
style={{ cursor: track.albumId ? 'pointer' : 'default' }}>
|
||
{track.album}
|
||
</span>
|
||
{track.year && <><span className="np-sep">·</span><span>{track.year}</span></>}
|
||
{releaseAge > 0 && (
|
||
<><span className="np-sep">·</span>
|
||
<span className="np-dash-hero-age">
|
||
{t('nowPlaying.releasedYearsAgo', { count: releaseAge, defaultValue: '{{count}} years ago' })}
|
||
</span></>
|
||
)}
|
||
</div>
|
||
|
||
<div className="np-dash-hero-badges">
|
||
{genre && <span className="np-badge">{genre}</span>}
|
||
{track.suffix && <span className="np-badge">{track.suffix.toUpperCase()}</span>}
|
||
{track.bitRate && <span className="np-badge">{track.bitRate} kbps</span>}
|
||
{track.samplingRate && <span className="np-badge">{(track.samplingRate / 1000).toFixed(1)} kHz</span>}
|
||
{track.bitDepth && <span className="np-badge">{track.bitDepth}-bit</span>}
|
||
{hiRes && <span className="np-badge np-badge-hires">Hi-Res</span>}
|
||
{track.duration > 0 && <span className="np-badge">{formatTime(track.duration)}</span>}
|
||
</div>
|
||
|
||
<div className="np-dash-hero-actions">
|
||
<button onClick={onToggleStar} className="np-dash-icon-btn"
|
||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}>
|
||
<Heart size={18} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'currentColor'} />
|
||
</button>
|
||
{lfmLoveEnabled && (
|
||
<button onClick={onToggleLfmLove}
|
||
className={`np-dash-icon-btn np-dash-lfm-btn${lfmLoved ? ' is-loved' : ''}`}
|
||
data-tooltip={lfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}>
|
||
<LastfmIcon size={18} />
|
||
</button>
|
||
)}
|
||
<button className="np-dash-icon-btn"
|
||
onClick={onOpenLyrics}
|
||
data-tooltip={t('player.lyrics')}
|
||
style={{ color: activeLyricsTab ? 'var(--accent)' : undefined }}>
|
||
<MicVocal size={18} />
|
||
</button>
|
||
{rating && renderStars(rating)}
|
||
</div>
|
||
|
||
{(playCount != null && playCount > 0) && (
|
||
<div className="np-dash-hero-stat">
|
||
<Headphones size={13} />
|
||
<span>{t('nowPlaying.playsCount', { count: playCount, defaultValue: '{{count}} plays' })}</span>
|
||
</div>
|
||
)}
|
||
|
||
{(lfmTrack || lfmArtist) && (
|
||
<div className="np-dash-hero-lfm">
|
||
<div className="np-dash-hero-lfm-heading">
|
||
<span className="np-dash-hero-lfm-badge">Last.fm</span>
|
||
</div>
|
||
{lfmTrack && (
|
||
<div className="np-dash-hero-lfm-row">
|
||
<span className="np-dash-hero-lfm-scope">{t('nowPlaying.thisTrack', 'This track')}</span>
|
||
<span className="np-dash-hero-lfm-sep">—</span>
|
||
<span>{t('nowPlaying.listenersN', { n: lfmTrack.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
|
||
<span className="np-dash-hero-lfm-dot">·</span>
|
||
<span>{t('nowPlaying.scrobblesN', { n: lfmTrack.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
|
||
{lfmTrack.userPlaycount != null && (
|
||
<>
|
||
<span className="np-dash-hero-lfm-dot">·</span>
|
||
<span className="np-dash-hero-lfm-you">
|
||
{t('nowPlaying.playsByYouN', { n: lfmTrack.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
{lfmArtist && (
|
||
<div className="np-dash-hero-lfm-row">
|
||
<span className="np-dash-hero-lfm-scope">{t('nowPlaying.thisArtist', 'This artist')}</span>
|
||
<span className="np-dash-hero-lfm-sep">—</span>
|
||
<span>{t('nowPlaying.listenersN', { n: lfmArtist.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
|
||
<span className="np-dash-hero-lfm-dot">·</span>
|
||
<span>{t('nowPlaying.scrobblesN', { n: lfmArtist.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
|
||
{lfmArtist.userPlaycount != null && (
|
||
<>
|
||
<span className="np-dash-hero-lfm-dot">·</span>
|
||
<span className="np-dash-hero-lfm-you">
|
||
{t('nowPlaying.playsByYouN', { n: lfmArtist.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
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<HTMLDivElement | null>(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 (
|
||
<div className="np-info-card np-dash-card">
|
||
<div className="np-card-header">
|
||
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
|
||
{artistId && (
|
||
<button className="np-card-link" onClick={() => onNavigate(`/artist/${artistId}`)}>
|
||
{t('nowPlaying.goToArtist')} <ExternalLink size={12} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="np-dash-artist-body">
|
||
{heroImage && heroCacheKey && (
|
||
<CachedImage
|
||
src={heroImage}
|
||
cacheKey={heroCacheKey}
|
||
alt={artistName}
|
||
className="np-dash-artist-image"
|
||
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||
/>
|
||
)}
|
||
<div className="np-dash-artist-text">
|
||
<div className="np-dash-artist-name">{artistName}</div>
|
||
{bioHtml && (
|
||
<>
|
||
<div
|
||
ref={bioRef}
|
||
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
|
||
dangerouslySetInnerHTML={{ __html: bioHtml }}
|
||
/>
|
||
{(bioOverflows || bioExpanded) && (
|
||
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
|
||
{bioExpanded ? t('nowPlayingInfo.bioReadLess', 'Show less') : t('nowPlayingInfo.bioReadMore', 'Read more')}
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{similar.length > 0 && (
|
||
<div className="np-dash-similar">
|
||
<div className="np-dash-chip-row">
|
||
{similar.slice(0, 12).map(a => (
|
||
<span key={a.id} className="np-chip"
|
||
onClick={() => a.id && onNavigate(`/artist/${a.id}`)}
|
||
data-tooltip={t('nowPlaying.goToArtist')}>
|
||
{a.name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
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 (
|
||
<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.fromAlbum')}
|
||
</h3>
|
||
{albumId && (
|
||
<button className="np-card-link" onClick={() => onNavigate(`/album/${albumId}`)}>
|
||
{t('nowPlaying.viewAlbum')} <ExternalLink size={12} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="np-dash-album-meta">
|
||
<span className="np-dash-album-name">{albumName}</span>
|
||
<span className="np-dash-album-stats">
|
||
{albumYear && <span>{albumYear}</span>}
|
||
{albumYear && <span className="np-sep">·</span>}
|
||
<span>{t('nowPlaying.trackPosition', { pos: position, defaultValue: 'Track {{pos}}' })}</span>
|
||
<span className="np-sep">·</span>
|
||
<span>{formatTotalDuration(totalDur)}</span>
|
||
{album?.playCount != null && album.playCount > 0 && (
|
||
<><span className="np-sep">·</span><span>{t('nowPlaying.playsCount', { count: album.playCount, defaultValue: '{{count}} plays' })}</span></>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div className="np-album-tracklist">
|
||
{visibleSongs.map(track => {
|
||
const isActive = track.id === currentTrackId;
|
||
return (
|
||
<div key={track.id}
|
||
className={`np-album-track${isActive ? ' active' : ''}`}>
|
||
<span className="np-album-track-num">
|
||
{isActive
|
||
? <Star size={10} fill="var(--accent)" color="var(--accent)" />
|
||
: track.track ?? '—'}
|
||
</span>
|
||
<span className="np-album-track-title truncate">{track.title}</span>
|
||
<span className="np-album-track-dur">{formatTime(track.duration)}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{songs.length > ALBUM_TRACK_LIMIT && (
|
||
<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>
|
||
);
|
||
});
|
||
|
||
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 (
|
||
<div className="np-info-card np-dash-card">
|
||
<div className="np-card-header">
|
||
<h3 className="np-card-title">
|
||
<TrendingUp size={13} style={{ marginRight: 5, verticalAlign: '-2px' }} />
|
||
{t('nowPlaying.topSongs', { defaultValue: 'Most played by this artist' })}
|
||
</h3>
|
||
{artistId && (
|
||
<button className="np-card-link" onClick={() => onNavigate(`/artist/${artistId}`)}>
|
||
{t('nowPlaying.goToArtist')} <ExternalLink size={12} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="np-dash-top-list">
|
||
{top.map((s, idx) => {
|
||
const isActive = s.id === currentTrackId;
|
||
return (
|
||
<div key={s.id}
|
||
className={`np-dash-top-row${isActive ? ' active' : ''}`}
|
||
onClick={() => onPlay(s)}
|
||
data-tooltip={t('contextMenu.playNow')}>
|
||
<span className="np-dash-top-rank">{idx + 1}</span>
|
||
<div className="np-dash-top-body">
|
||
<span className="np-dash-top-title truncate">{s.title}</span>
|
||
{s.album && <span className="np-dash-top-sub truncate">{s.album}</span>}
|
||
</div>
|
||
<span className="np-dash-top-dur">{formatTime(s.duration)}</span>
|
||
<Play size={14} className="np-dash-top-play" />
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="np-dash-top-credit">{t('nowPlaying.topSongsCredit', { name: artistName, defaultValue: 'Top tracks from {{name}}' })}</div>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
interface CreditsCardProps { rows: ContributorRow[]; }
|
||
|
||
const CreditsCard = memo(function CreditsCard({ rows }: CreditsCardProps) {
|
||
const { t } = useTranslation();
|
||
if (rows.length === 0) return null;
|
||
return (
|
||
<div className="np-info-card np-dash-card">
|
||
<div className="np-card-header">
|
||
<h3 className="np-card-title">{t('nowPlayingInfo.songInfo', 'Song info')}</h3>
|
||
</div>
|
||
<ul className="np-info-credits">
|
||
{rows.map(row => (
|
||
<li key={row.role} className="np-info-credit-row">
|
||
<span className="np-info-credit-role">{t(`nowPlayingInfo.role.${row.role}`, row.role)}</span>
|
||
<span className="np-info-credit-names">{row.names.join(', ')}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
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>
|
||
);
|
||
});
|
||
|
||
// ─── 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 (
|
||
<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>
|
||
);
|
||
});
|
||
|
||
// ─── 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 (
|
||
<div
|
||
data-np-wrapper
|
||
data-np-card-id={id}
|
||
className={`np-dash-card-wrap${isDraggingThis ? ' is-dragging' : ''}`}
|
||
{...dragProps}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 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<HTMLDivElement>(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<HTMLElement>('[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 (
|
||
<div
|
||
ref={ref}
|
||
className={`np-dash-col${isOverHere ? ' is-drop-target' : ''}${isDndActive ? ' is-dnd-active' : ''}`}
|
||
>
|
||
{children}
|
||
{empty && <div className="np-dash-col-empty">{emptyLabel}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type NonNullStoreField<K extends keyof ReturnType<typeof usePlayerStore.getState>> =
|
||
NonNullable<ReturnType<typeof usePlayerStore.getState>[K]>;
|
||
|
||
interface RadioViewProps {
|
||
radioMeta: ReturnType<typeof useRadioMetadata>;
|
||
currentRadio: NonNullStoreField<'currentRadio'>;
|
||
resolvedCover: string;
|
||
}
|
||
|
||
const RadioView = memo(function RadioView({ radioMeta, currentRadio, resolvedCover }: RadioViewProps) {
|
||
const { t } = useTranslation();
|
||
return (
|
||
<div className="np-radio-section">
|
||
<div className="np-hero-card">
|
||
<div className="np-hero-left">
|
||
<div className="np-hero-info">
|
||
<div className="np-title" style={{ color: 'var(--accent)' }}>{currentRadio.name}</div>
|
||
{radioMeta.currentTitle && (
|
||
<div className="np-artist-album">
|
||
{radioMeta.currentArtist && (<><span className="np-link">{radioMeta.currentArtist}</span><span className="np-sep">·</span></>)}
|
||
<span>{radioMeta.currentTitle}</span>
|
||
{radioMeta.currentAlbum && (<><span className="np-sep">·</span><span style={{ opacity: 0.6 }}>{radioMeta.currentAlbum}</span></>)}
|
||
</div>
|
||
)}
|
||
<div className="np-tech-row">
|
||
<span className="np-badge np-badge-live"><Radio size={10} style={{ marginRight: 3 }} />{t('radio.live')}</span>
|
||
{radioMeta.source === 'azuracast' && <span className="np-badge np-badge-azuracast">AzuraCast</span>}
|
||
{radioMeta.listeners != null && (
|
||
<span className="np-badge"><Users size={10} style={{ marginRight: 3 }} />{t('radio.listenerCount', { count: radioMeta.listeners })}</span>
|
||
)}
|
||
</div>
|
||
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 && (
|
||
<div className="np-radio-progress-wrap">
|
||
<span className="np-radio-time">{formatTime(radioMeta.elapsed)}</span>
|
||
<div className="np-radio-progress-bar">
|
||
<div className="np-radio-progress-fill" style={{ width: `${Math.min(100, (radioMeta.elapsed / radioMeta.duration) * 100)}%` }} />
|
||
</div>
|
||
<span className="np-radio-time">{formatTime(radioMeta.duration)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="np-hero-cover-wrap">
|
||
{resolvedCover
|
||
? <img src={resolvedCover} alt={currentRadio.name} className="np-cover" />
|
||
: radioMeta.currentArt
|
||
? <img src={radioMeta.currentArt} alt="" className="np-cover" onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||
: <div className="np-cover np-cover-fallback"><Cast size={52} /></div>}
|
||
</div>
|
||
<div style={{ flex: 1 }} />
|
||
</div>
|
||
|
||
{radioMeta.nextSong && (
|
||
<div className="np-info-card">
|
||
<div className="np-card-header">
|
||
<h3 className="np-card-title"><SkipForward size={13} style={{ marginRight: 5 }} />{t('radio.upNext')}</h3>
|
||
</div>
|
||
<div className="np-radio-next-track">
|
||
{radioMeta.nextSong.art && (
|
||
<img src={radioMeta.nextSong.art} alt="" className="np-radio-track-art"
|
||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||
)}
|
||
<div className="np-radio-track-info">
|
||
<span className="np-radio-track-title">{radioMeta.nextSong.title}</span>
|
||
{radioMeta.nextSong.artist && <span className="np-radio-track-artist">{radioMeta.nextSong.artist}</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{radioMeta.history.length > 0 && (
|
||
<div className="np-info-card">
|
||
<div className="np-card-header">
|
||
<h3 className="np-card-title"><Clock size={13} style={{ marginRight: 5 }} />{t('radio.recentlyPlayed')}</h3>
|
||
</div>
|
||
<div className="np-album-tracklist">
|
||
{radioMeta.history.map((item, idx) => (
|
||
<div key={idx} className="np-album-track">
|
||
{item.song.art && (
|
||
<img src={item.song.art} alt="" className="np-radio-track-art np-radio-track-art--sm"
|
||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||
)}
|
||
<span className="np-album-track-title truncate">
|
||
{item.song.artist ? `${item.song.artist} — ${item.song.title}` : item.song.title}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
// ─── 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<SubsonicSong | null>(() => songId ? songMetaCache.get(songId) ?? null : null);
|
||
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(() => 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<SubsonicSong[]>(() => artistName ? topSongsCache.get(artistName) ?? [] : []);
|
||
const [tourEvents, setTourEvents] = useState<BandsintownEvent[]>(() => artistName ? tourCache.get(artistName) ?? [] : []);
|
||
const [tourLoading, setTourLoading] = useState(false);
|
||
const [discography, setDiscography] = useState<SubsonicAlbum[]>(() => artistId ? discographyCache.get(artistId) ?? [] : []);
|
||
const [lfmTrack, setLfmTrack] = useState<LastfmTrackInfo | null>(null);
|
||
const [lfmArtist, setLfmArtist] = useState<LastfmArtistStats | null>(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} |