Files
Psychotoxical-psysonic/src/pages/NowPlaying.tsx
T
Frank Stellmacher db72ed9e5a feat(now-playing): draggable widget cards with per-user layout (#267)
Each dashboard card (Album, Top Songs, Credits, Artist, Discography,
On Tour) is now a widget the user grabs and drops anywhere — reorder
within a column or move across columns. Layout persists per-install
via the new nowPlayingLayoutStore (Zustand + localStorage, same shape
as sidebarStore with an onRehydrateStorage guard for unknown IDs).

Drag uses psyDnD (useDragSource / psy-drop event, the mouse-event
based system from DragDropContext) — HTML5 native DnD is a no-go on
WebKitGTK Linux where it always shows a forbidden cursor. The whole
card is the drag handle; the column listens via a document-level
mousemove for the drop indicator and via a global psy-drop listener
that reads the latest hovered column from a ref, so drops below the
last card still land correctly regardless of column height.

Visibility is toggled from a layout menu in the top-right of the
dashboard — two sections (visible / hidden) plus a reset-to-defaults
button. No hide buttons on the cards themselves (keeps the card UI
uncluttered). An equal-height grid (align-items: stretch + min-height
on columns) keeps the drop zone active for the full vertical span of
either column.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:14:13 +02:00

1371 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
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}${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<SubsonicArtistInfo | null>(() => {
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 (
<div className="np-page">
<div className="np-main">
{currentRadio && !currentTrack ? (
<RadioView radioMeta={radioMeta} currentRadio={currentRadio} resolvedCover={resolvedRadioCover} />
) : currentTrack ? (
<div className="np-dash">
<Hero
track={{
id: currentTrack.id,
title: currentTrack.title,
artist: currentTrack.artist,
album: currentTrack.album,
year: currentTrack.year,
duration: currentTrack.duration,
suffix: currentTrack.suffix,
bitRate: currentTrack.bitRate,
samplingRate: songMeta?.samplingRate,
bitDepth: songMeta?.bitDepth,
artistId: currentTrack.artistId,
albumId: currentTrack.albumId,
userRating: currentTrack.userRating,
}}
genre={songMeta?.genre ?? undefined}
playCount={(songMeta as (SubsonicSong & { playCount?: number }) | null)?.playCount}
userRatingOverride={userRatingOverrides[currentTrack.id]}
lfmTrack={lfmTrack}
lfmArtist={lfmArtist}
starred={starred}
lfmLoved={lfmLoved}
lfmLoveEnabled={lfmLoveEnabled}
activeLyricsTab={activeTab === 'lyrics' && isQueueVisible}
coverUrl={resolvedCover}
onNavigate={stableNavigate}
onToggleStar={toggleStar}
onToggleLfmLove={toggleLfmLove}
onOpenLyrics={openLyrics}
/>
{(() => {
const renderCard = (id: NpCardId): React.ReactNode => {
switch (id) {
case 'album': return (
<AlbumCard
album={albumData?.album ?? null}
songs={albumData?.songs ?? []}
currentTrackId={currentTrack.id}
albumName={currentTrack.album}
albumId={albumId}
albumYear={currentTrack.year}
onNavigate={stableNavigate}
/>
);
case 'topSongs': return (
<TopSongsCard
artistName={artistName}
artistId={artistId}
songs={topSongs}
currentTrackId={currentTrack.id}
onNavigate={stableNavigate}
onPlay={handlePlayTopSong}
/>
);
case 'credits': return <CreditsCard rows={contributorRows} />;
case 'artist': return (
<ArtistCard
artistName={artistName}
artistId={artistId}
artistInfo={effectiveArtistInfo}
onNavigate={stableNavigate}
/>
);
case 'discography': return (
<DiscographyCard
artistId={artistId}
albums={discography}
currentAlbumId={albumId}
onNavigate={stableNavigate}
/>
);
case 'tour': return (
<TourCard
artistName={artistName}
enabled={enableBandsintown}
loading={tourLoading}
events={tourEvents}
onEnable={handleEnableBandsintown}
/>
);
}
};
const cardLabel = (id: NpCardId): string => {
const k: Record<NpCardId, string> = {
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 (
<NpColumnEl
col={col}
empty={cards.length === 0}
emptyLabel={t('nowPlaying.emptyColumn', 'Drop cards here')}
isDndActive={!!draggingCardId}
draggingCardId={draggingCardId}
onHover={onColumnHover}
isOverHere={!!isOver}
>
{cards.map((c, idx) => (
<React.Fragment key={c.id}>
{isOver && dragOver.idx === idx && <div className="np-dash-drop-indicator" />}
<NpCardWrap
id={c.id}
label={cardLabel(c.id)}
isDraggingThis={draggingCardId === c.id}
>
{renderCard(c.id)}
</NpCardWrap>
</React.Fragment>
))}
{isOver && dragOver.idx === cards.length && <div className="np-dash-drop-indicator" />}
</NpColumnEl>
);
};
return (
<>
<div className="np-dash-toolbar">
<div className="np-dash-toolbar-menu-wrap">
<button
className="np-dash-toolbar-btn"
onClick={() => setLayoutMenuOpen(v => !v)}
data-tooltip={t('nowPlaying.layoutMenu', 'Layout')}
>
<LayoutGrid size={14} />
<span>{t('nowPlaying.layoutMenu', 'Layout')}</span>
{hiddenCards.length > 0 && (
<span className="np-dash-toolbar-badge">{hiddenCards.length}</span>
)}
</button>
{layoutMenuOpen && (
<div className="np-dash-toolbar-menu" role="menu">
<div className="np-dash-toolbar-section">
{t('nowPlaying.visibleCards', 'Visible cards')}
</div>
{visibleCards.map(c => (
<button
key={c.id}
className="np-dash-toolbar-item"
onClick={() => toggleCardVisible(c.id, false)}
>
<Eye size={13} /> <span className="np-dash-toolbar-item-label">{cardLabel(c.id)}</span>
</button>
))}
{hiddenCards.length > 0 && (
<>
<div className="np-dash-toolbar-section">
{t('nowPlaying.hiddenCards', 'Hidden cards')}
</div>
{hiddenCards.map(c => (
<button
key={c.id}
className="np-dash-toolbar-item is-hidden"
onClick={() => toggleCardVisible(c.id, true)}
>
<EyeOff size={13} /> <span className="np-dash-toolbar-item-label">{cardLabel(c.id)}</span>
</button>
))}
</>
)}
<div className="np-dash-toolbar-divider" />
<button
className="np-dash-toolbar-item"
onClick={() => { resetLayout(); setLayoutMenuOpen(false); }}
>
<RotateCcw size={13} /> <span className="np-dash-toolbar-item-label">{t('nowPlaying.resetLayout', 'Reset layout')}</span>
</button>
</div>
)}
</div>
</div>
<div className="np-dash-grid">
{renderColumn('left')}
{renderColumn('right')}
</div>
</>
);
})()}
</div>
) : (
<div className="np-empty-state">
<Music size={48} style={{ opacity: 0.3 }} />
<p>{t('nowPlaying.nothingPlaying')}</p>
</div>
)}
</div>
</div>
);
}