refactor(nowPlaying): co-locate now playing feature into features/nowPlaying

This commit is contained in:
Psychotoxical
2026-06-29 23:38:09 +02:00
parent e0e10e0034
commit 931c47e19e
29 changed files with 151 additions and 139 deletions
@@ -0,0 +1,99 @@
import React, { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Disc3, ExternalLink, Star } from 'lucide-react';
import type { SubsonicAlbum, SubsonicSong } from '@/api/subsonicTypes';
import { formatTotalDuration } from '@/utils/componentHelpers/nowPlayingHelpers';
import { formatTrackTime } from '@/utils/format/formatDuration';
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, idx) => {
const isActive = track.id === currentTrackId;
return (
<div key={`${track.id}-${idx}`}
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">{formatTrackTime(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>
);
});
export default AlbumCard;
@@ -0,0 +1,101 @@
/**
* `ArtistCard` characterization — covers the prop surface added when the
* card became the shared "About the Artist" component for both the
* NowPlaying page and ArtistDetail (replacing the inline bio block on
* /artist/:id and the previously-divergent rendering).
*/
import { describe, it, expect, vi } from 'vitest';
import { fireEvent } from '@testing-library/react';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import ArtistCard from '@/features/nowPlaying/components/ArtistCard';
import type { SubsonicArtistInfo } from '@/api/subsonicTypes';
const infoWithImage: SubsonicArtistInfo = {
biography: 'Some bio text here.',
largeImageUrl: 'https://example.test/a-large.jpg',
similarArtist: [{ id: 'sim-1', name: 'Sister Act' }],
} as unknown as SubsonicArtistInfo;
describe('ArtistCard — optional onNavigate', () => {
it('hides the "Go to Artist" link when onNavigate is omitted', () => {
const { container } = renderWithProviders(
<ArtistCard artistName="A" artistId="art-1" artistInfo={infoWithImage} />,
);
expect(container.querySelector('.np-card-link')).toBeNull();
});
it('renders the "Go to Artist" link when onNavigate is provided', () => {
const { container } = renderWithProviders(
<ArtistCard artistName="A" artistId="art-1" artistInfo={infoWithImage} onNavigate={vi.fn()} />,
);
expect(container.querySelector('.np-card-link')).not.toBeNull();
});
});
describe('ArtistCard — hideArtistName / hideSimilar', () => {
it('does not render the artist name row when hideArtistName is set', () => {
const { container } = renderWithProviders(
<ArtistCard artistName="A" artistId="art-1" artistInfo={infoWithImage} hideArtistName />,
);
expect(container.querySelector('.np-dash-artist-name')).toBeNull();
});
it('does not render the similar-artists chip row when hideSimilar is set', () => {
const { container } = renderWithProviders(
<ArtistCard artistName="A" artistId="art-1" artistInfo={infoWithImage} hideSimilar />,
);
expect(container.querySelector('.np-dash-similar')).toBeNull();
});
});
describe('ArtistCard — artistTabs', () => {
it('renders tabs and switches bio when a second artist tab is selected', () => {
const { container, getByRole } = renderWithProviders(
<ArtistCard
artistName="A"
artistId="a1"
artistInfo={infoWithImage}
artistTabs={[
{ id: 'a1', name: 'Dan Balan', artistInfo: { biography: 'Bio A' } as SubsonicArtistInfo },
{ id: 'a2', name: 'Katerina Begu', artistInfo: { biography: 'Bio B' } as SubsonicArtistInfo },
]}
/>,
);
expect(container.querySelectorAll('.np-artist-tab')).toHaveLength(2);
expect(container.querySelector('.np-bio-text')?.textContent).toContain('Bio A');
fireEvent.click(getByRole('tab', { name: 'Katerina Begu' }));
expect(container.querySelector('.np-bio-text')?.textContent).toContain('Bio B');
});
});
describe('ArtistCard — coverFallback', () => {
it('uses coverFallback src + cacheKey when artistInfo has no hero image', () => {
const noImageInfo = { biography: 'b', similarArtist: [] } as unknown as SubsonicArtistInfo;
const { container } = renderWithProviders(
<ArtistCard
artistName="A"
artistId="art-1"
artistInfo={noImageInfo}
coverFallback={{ src: 'https://fallback.test/cover.jpg', cacheKey: 'fb:art-1:cover' }}
/>,
);
const img = container.querySelector<HTMLImageElement>('img.np-dash-artist-image');
expect(img).not.toBeNull();
expect(img!.getAttribute('src') || '').toContain('fallback.test');
});
it('prefers info.largeImageUrl over the fallback when both are present', () => {
const { container } = renderWithProviders(
<ArtistCard
artistName="A"
artistId="art-1"
artistInfo={infoWithImage}
coverFallback={{ src: 'https://fallback.test/cover.jpg', cacheKey: 'fb:art-1:cover' }}
/>,
);
const img = container.querySelector<HTMLImageElement>('img.np-dash-artist-image');
expect(img!.getAttribute('src') || '').toContain('a-large.jpg');
});
});
@@ -0,0 +1,176 @@
import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ExternalLink } from 'lucide-react';
import type { SubsonicArtistInfo } from '@/api/subsonicTypes';
import { isRealArtistImage, sanitizeHtml } from '@/utils/componentHelpers/nowPlayingHelpers';
import CachedImage from '@/ui/CachedImage';
export interface ArtistCardTab {
id?: string;
name: string;
artistInfo: SubsonicArtistInfo | null;
}
interface ArtistCardProps {
artistName: string;
artistId?: string;
artistInfo: SubsonicArtistInfo | null;
/** When more than one entry, render picker tabs (Now Playing multi-artist tracks). */
artistTabs?: ArtistCardTab[];
/** When omitted the "Go to Artist" link and similar-artist chip click handlers do nothing — used on /artist/:id where the user is already there. */
onNavigate?: (path: string) => void;
/** Render fallback cover when artistInfo has no hero image (ArtistDetail's coverArt fallback). */
coverFallback?: { src: string; cacheKey: string };
/** Suppress the artist-name row — ArtistDetail shows the name in its hero already. */
hideArtistName?: boolean;
/** Suppress the similar-artists chip row — ArtistDetail has its own similar section. */
hideSimilar?: boolean;
}
function heroForEntry(
artistId: string | undefined,
artistInfo: SubsonicArtistInfo | null,
coverFallback?: { src: string; cacheKey: string },
): { heroImage: string; heroCacheKey: string } {
const rawLarge = artistInfo?.largeImageUrl;
const rawMed = artistInfo?.mediumImageUrl;
const heroFromInfo = isRealArtistImage(rawLarge)
? rawLarge!
: isRealArtistImage(rawMed) ? rawMed! : '';
const heroImage = heroFromInfo || coverFallback?.src || '';
const heroCacheKey = heroFromInfo
? (artistId ? `artistInfo:${artistId}:hero` : '')
: (coverFallback?.cacheKey ?? '');
return { heroImage, heroCacheKey };
}
function entryHasContent(
entry: ArtistCardTab,
hideSimilar: boolean,
coverFallback?: { src: string; cacheKey: string },
): boolean {
const bioHtml = entry.artistInfo?.biography ? sanitizeHtml(entry.artistInfo.biography) : '';
const similar = hideSimilar ? [] : (entry.artistInfo?.similarArtist ?? []);
const { heroImage } = heroForEntry(entry.id, entry.artistInfo, coverFallback);
return Boolean(bioHtml || similar.length > 0 || heroImage);
}
const ArtistCard = memo(function ArtistCard({
artistName, artistId, artistInfo, artistTabs, onNavigate, coverFallback,
hideArtistName = false, hideSimilar = false,
}: ArtistCardProps) {
const { t } = useTranslation();
const [bioExpanded, setBioExpanded] = useState(false);
const [bioOverflows, setBioOverflows] = useState(false);
const bioRef = useRef<HTMLDivElement | null>(null);
const tabs = artistTabs && artistTabs.length > 1 ? artistTabs : null;
const tabsKey = tabs?.map(tab => tab.id ?? tab.name).join('\x1e') ?? '';
const [activeTabIdx, setActiveTabIdx] = useState(0);
useEffect(() => { setActiveTabIdx(0); }, [tabsKey]);
useEffect(() => { setBioExpanded(false); }, [artistId, tabsKey, activeTabIdx]);
const activeEntry = tabs
? tabs[Math.min(activeTabIdx, tabs.length - 1)]
: { id: artistId, name: artistName, artistInfo };
const activeArtistId = activeEntry.id;
const activeArtistName = activeEntry.name;
const activeArtistInfo = activeEntry.artistInfo;
const bioHtml = useMemo(
() => activeArtistInfo?.biography ? sanitizeHtml(activeArtistInfo.biography) : '',
[activeArtistInfo?.biography],
);
useLayoutEffect(() => {
const el = bioRef.current;
if (!el) { setBioOverflows(false); return; }
setBioOverflows(el.scrollHeight - el.clientHeight > 1);
}, [bioHtml]);
const similar = hideSimilar ? [] : (activeArtistInfo?.similarArtist ?? []);
const { heroImage, heroCacheKey } = heroForEntry(activeArtistId, activeArtistInfo, coverFallback);
const visible = tabs
? tabs.some(tab => entryHasContent(tab, hideSimilar, coverFallback))
: entryHasContent({ id: artistId, name: artistName, artistInfo }, hideSimilar, coverFallback);
if (!visible) 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>
{activeArtistId && onNavigate && (
<button className="np-card-link" onClick={() => onNavigate(`/artist/${activeArtistId}`)}>
{t('nowPlaying.goToArtist')} <ExternalLink size={12} />
</button>
)}
</div>
{tabs && (
<div className="np-artist-tab-row" role="tablist" aria-label={t('nowPlaying.aboutArtist')}>
{tabs.map((tab, idx) => (
<button
key={tab.id ?? tab.name}
type="button"
role="tab"
aria-selected={idx === activeTabIdx}
className={`np-artist-tab${idx === activeTabIdx ? ' is-active' : ''}`}
onClick={() => setActiveTabIdx(idx)}
>
{tab.name}
</button>
))}
</div>
)}
<div className="np-dash-artist-body">
{heroImage && heroCacheKey && (
<CachedImage
src={heroImage}
cacheKey={heroCacheKey}
alt={activeArtistName}
className="np-dash-artist-image"
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div className="np-dash-artist-text">
{!hideArtistName && !tabs && <div className="np-dash-artist-name">{activeArtistName}</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, idx) => (
<span key={`${a.id}-${idx}`} className="np-chip"
onClick={() => a.id && onNavigate?.(`/artist/${a.id}`)}
data-tooltip={t('nowPlaying.goToArtist')}>
{a.name}
</span>
))}
</div>
</div>
)}
</div>
);
});
export default ArtistCard;
@@ -0,0 +1,27 @@
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ContributorRow } from '@/utils/componentHelpers/nowPlayingHelpers';
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>
);
});
export default CreditsCard;
@@ -0,0 +1,81 @@
import React, { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Disc3, ExternalLink, Music } from 'lucide-react';
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
import { COVER_DENSE_RAIL_CELL_CSS_PX } from '@/cover/layoutSizes';
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;
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">
{a.coverArt
? (
<AlbumCoverArtImage
albumId={a.id}
coverArt={a.coverArt}
displayCssPx={COVER_DENSE_RAIL_CELL_CSS_PX}
surface="dense"
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>
);
});
export default DiscographyCard;
+193
View File
@@ -0,0 +1,193 @@
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Headphones, Heart, MicVocal, Music, Star } from 'lucide-react';
import { CoverArtImage } from '@/cover/CoverArtImage';
import type { CoverArtRef } from '@/cover/types';
import type { ArtistStats, TrackStats } from '@/music-network';
import type { SubsonicOpenArtistRef } from '@/api/subsonicTypes';
import { OpenArtistRefInline } from '@/components/OpenArtistRefInline';
import { formatTrackTime } from '@/utils/format/formatDuration';
import { useEnrichmentPrimaryLabel } from '@/hooks/useEnrichmentPrimaryLabel';
import { useEnrichmentPrimaryIcon } from '@/hooks/useEnrichmentPrimaryIcon';
import { renderPresetIcon } from '@/components/settings/musicNetwork/presetIcon';
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; };
/** OpenSubsonic `artists` on the playing track — per-artist links in the hero subline. */
artistRefs?: SubsonicOpenArtistRef[];
genre?: string;
playCount?: number;
userRatingOverride?: number;
networkTrack: TrackStats | null;
networkArtist: ArtistStats | null;
starred: boolean;
networkLoved: boolean;
networkLoveEnabled: boolean;
activeLyricsTab: boolean;
coverRef?: CoverArtRef;
onNavigate: (path: string) => void;
onToggleStar: () => void;
onToggleNetworkLove: () => void;
onOpenLyrics: () => void;
}
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(--highlight)' : 'none'}
color={i <= rating ? 'var(--highlight)' : 'var(--border-subtle)'}
/>
))}
</div>
);
}
const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatingOverride, networkTrack, networkArtist, starred, networkLoved, networkLoveEnabled, activeLyricsTab, coverRef, onNavigate, onToggleStar, onToggleNetworkLove, onOpenLyrics }: HeroProps) {
const { t } = useTranslation();
const networkLabel = useEnrichmentPrimaryLabel() ?? '';
const networkIcon = useEnrichmentPrimaryIcon();
const rating = userRatingOverride ?? track.userRating;
const hiRes = (track.bitDepth ?? 0) > 16 || (track.samplingRate ?? 0) > 48000;
const releaseAge = track.year ? new Date().getFullYear() - track.year : 0;
return (
<div className="np-dash-hero">
<div className="np-dash-hero-cover">
{coverRef ? (
<CoverArtImage
className="np-cover"
coverRef={coverRef}
displayCssPx={280}
surface="sparse"
ensurePriority="high"
alt=""
/>
) : (
<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">
{artistRefs && artistRefs.length > 0 ? (
<OpenArtistRefInline
refs={artistRefs}
fallbackName={track.artist}
onGoArtist={id => onNavigate(`/artist/${id}`)}
as="none"
linkTag="span"
linkClassName="np-link"
/>
) : (
<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 != null && track.year > 0 && <><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 ?? 0) > 0 && <span className="np-badge">{track.bitRate} kbps</span>}
{(track.samplingRate ?? 0) > 0 && <span className="np-badge">{((track.samplingRate ?? 0) / 1000).toFixed(1)} kHz</span>}
{(track.bitDepth ?? 0) > 0 && <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">{formatTrackTime(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(--highlight)' : 'none'} color={starred ? 'var(--highlight)' : 'currentColor'} />
</button>
{networkLoveEnabled && (
<button onClick={onToggleNetworkLove}
className={`np-dash-icon-btn np-dash-network-btn${networkLoved ? ' is-loved' : ''}`}
data-tooltip={networkLoved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}>
{renderPresetIcon(networkIcon ?? 'lastfm', 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 != null && rating > 0 && 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>
)}
{(networkTrack || networkArtist) && (
<div className="np-dash-hero-network">
<div className="np-dash-hero-network-heading">
<span className="np-dash-hero-network-badge">{networkLabel}</span>
</div>
{networkTrack && (
<div className="np-dash-hero-network-row">
<span className="np-dash-hero-network-scope">{t('nowPlaying.thisTrack', 'This track')}</span>
<span className="np-dash-hero-network-sep"></span>
<span>{t('nowPlaying.listenersN', { n: networkTrack.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
<span className="np-dash-hero-network-dot">·</span>
<span>{t('nowPlaying.scrobblesN', { n: networkTrack.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
{networkTrack.userPlaycount != null && (
<>
<span className="np-dash-hero-network-dot">·</span>
<span className="np-dash-hero-network-you">
{t('nowPlaying.playsByYouN', { n: networkTrack.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
</span>
</>
)}
</div>
)}
{networkArtist && (
<div className="np-dash-hero-network-row">
<span className="np-dash-hero-network-scope">{t('nowPlaying.thisArtist', 'This artist')}</span>
<span className="np-dash-hero-network-sep"></span>
<span>{t('nowPlaying.listenersN', { n: networkArtist.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
<span className="np-dash-hero-network-dot">·</span>
<span>{t('nowPlaying.scrobblesN', { n: networkArtist.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
{networkArtist.userPlaycount != null && (
<>
<span className="np-dash-hero-network-dot">·</span>
<span className="np-dash-hero-network-you">
{t('nowPlaying.playsByYouN', { n: networkArtist.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
</span>
</>
)}
</div>
)}
</div>
)}
</div>
</div>
);
});
export default Hero;
@@ -0,0 +1,296 @@
import { TrackCoverArtImage } from '@/cover/TrackCoverArtImage';
import { getNowPlaying } from '@/api/subsonicScrobble';
import type { SubsonicNowPlaying } from '@/api/subsonicTypes';
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { PlayCircle, Pause, User, Radio, RefreshCw } from 'lucide-react';
import { nowPlayingPresence } from '@/features/nowPlaying/api/nowPlayingPresence';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/store/playerStore';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export default function NowPlayingDropdown() {
const { t } = useTranslation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [nowPlaying, setNowPlaying] = useState<SubsonicNowPlaying[]>([]);
const isPlaying = usePlayerStore(s => s.isPlaying);
const ownUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
const [loading, setLoading] = useState(false);
const [spinning, setSpinning] = useState(false);
const triggerWrapRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [panelPos, setPanelPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
// Wall-clock baseline for the last poll: between polls we extrapolate the
// position of `playing` entries locally so the progress bar glides instead
// of snapping. The server already extrapolates positionMs at fetch time.
const fetchedAtRef = useRef(0);
const [, forceTick] = useState(0);
const PANEL_WIDTH = 340;
const LIVE_POLL_OPEN_MS = 10_000;
const LIVE_POLL_BACKGROUND_MS = 30_000;
const formatClock = (totalSec: number) => {
const s = Math.max(0, Math.floor(totalSec));
const m = Math.floor(s / 60);
return `${m}:${String(s % 60).padStart(2, '0')}`;
};
// Live position in seconds: advance `playing` entries by elapsed × playbackRate
// since the last poll; freeze everything else at the reported position.
const livePositionSec = (entry: SubsonicNowPlaying): number | undefined => {
if (typeof entry.positionMs !== 'number') return undefined;
let ms = entry.positionMs;
if (entry.state === 'playing') {
const rate = entry.playbackRate && entry.playbackRate > 0 ? entry.playbackRate : 1;
// React Compiler purity rule: intentional live-timestamp read at render (Date.now()); the value is allowed to differ between renders.
// eslint-disable-next-line react-hooks/purity
ms += (Date.now() - fetchedAtRef.current) * rate;
}
const maxMs = entry.duration > 0 ? entry.duration * 1000 : ms;
return Math.min(ms, maxMs) / 1000;
};
const updatePanelPos = useCallback(() => {
const el = triggerWrapRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const margin = 8;
const top = r.bottom + 10;
const maxLeft = window.innerWidth - PANEL_WIDTH - margin;
const left = Math.max(margin, Math.min(r.right - PANEL_WIDTH, maxLeft));
setPanelPos({ top, left });
}, []);
const fetchNowPlaying = useCallback(async (opts?: { silent?: boolean }) => {
if (!opts?.silent) setLoading(true);
try {
const data = await getNowPlaying();
fetchedAtRef.current = Date.now();
setNowPlaying(data);
} catch (e) {
console.error('Failed to load Now Playing', e);
} finally {
if (!opts?.silent) setLoading(false);
}
}, []);
const handleRefresh = () => {
setSpinning(true);
fetchNowPlaying().finally(() => {
setTimeout(() => setSpinning(false), 600);
});
};
// Poll while the page is visible: every 30 s for the header badge, every 10 s
// while the popover is open so the list stays fresher.
useEffect(() => {
const poll = (silent: boolean) => {
if (document.visibilityState === 'visible') void fetchNowPlaying({ silent });
};
poll(!isOpen);
const intervalMs = isOpen ? LIVE_POLL_OPEN_MS : LIVE_POLL_BACKGROUND_MS;
const id = setInterval(() => poll(true), intervalMs);
const onVisibility = () => {
if (document.visibilityState === 'visible') poll(true);
};
document.addEventListener('visibilitychange', onVisibility);
return () => {
clearInterval(id);
document.removeEventListener('visibilitychange', onVisibility);
};
}, [isOpen, fetchNowPlaying]);
// Re-render once per second while a `playing` entry exposes a position, so the
// locally-extrapolated bar advances smoothly between polls.
const hasLivePosition = nowPlaying.some(
e => e.state === 'playing' && typeof e.positionMs === 'number',
);
useEffect(() => {
if (!isOpen || !hasLivePosition) return;
const id = setInterval(() => forceTick(v => v + 1), 1000);
return () => clearInterval(id);
}, [isOpen, hasLivePosition]);
useLayoutEffect(() => {
if (!isOpen) return;
updatePanelPos();
const onWin = () => updatePanelPos();
window.addEventListener('resize', onWin);
window.addEventListener('scroll', onWin, true);
return () => {
window.removeEventListener('resize', onWin);
window.removeEventListener('scroll', onWin, true);
};
}, [isOpen, updatePanelPos]);
// Click outside to close
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
const target = event.target as Node;
if (triggerWrapRef.current?.contains(target)) return;
if (panelRef.current?.contains(target)) return;
setIsOpen(false);
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// For the current user, trust the local player state — the server keeps stale
// "now playing" entries for minutes after playback stops.
const visible = nowPlaying.filter(entry =>
entry.username === ownUsername ? isPlaying : true
);
return (
<div className="now-playing-dropdown" ref={triggerWrapRef} style={{ position: 'relative' }}>
<button
className="btn btn-surface now-playing-dropdown__trigger"
onClick={() => setIsOpen(!isOpen)}
data-tooltip={t('nowPlaying.tooltip')}
data-tooltip-pos="bottom"
style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }}
>
<Radio size={18} className={visible.length > 0 ? 'animate-pulse' : ''} style={{ color: visible.length > 0 ? 'var(--accent)' : 'inherit' }} />
<span className="now-playing-dropdown__label">Live</span>
{visible.length > 0 && (
<span style={{
background: 'var(--accent)',
color: 'var(--text-on-accent)',
fontSize: '10px',
fontWeight: 'bold',
padding: '2px 6px',
borderRadius: '10px'
}}>
{visible.length}
</span>
)}
</button>
{isOpen && createPortal(
<div
ref={panelRef}
className="nav-library-dropdown-panel animate-fade-in"
style={{
position: 'fixed',
top: panelPos.top,
left: panelPos.left,
width: `${PANEL_WIDTH}px`,
maxHeight: '400px',
overflowY: 'auto',
padding: '1rem',
gap: '1rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-subtle)', paddingBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>{t('nowPlaying.title')}</h3>
<button
onClick={handleRefresh}
className="btn btn-ghost"
style={{ width: '28px', height: '28px', padding: 0, justifyContent: 'center' }}
>
<RefreshCw size={14} className={spinning ? 'animate-spin' : ''} />
</button>
</div>
{loading && visible.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
{t('nowPlaying.loading')}
</div>
) : visible.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
{t('nowPlaying.nobody')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{/* React Compiler refs rule: the row renderer reads a ref for latest presence state; intentional, not reactive render data. */}
{/* eslint-disable-next-line react-hooks/refs */}
{visible.map((stream, idx) => {
const presence = nowPlayingPresence(stream);
const presenceLabel = t(`nowPlaying.presence.${presence}`);
return (
<div
key={`${stream.id}-${idx}`}
onClick={() => { if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }}
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
>
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--card-placeholder-bg)' }}>
{stream.albumId && stream.coverArt ? (
<TrackCoverArtImage
song={{
id: stream.id,
albumId: stream.albumId,
coverArt: stream.coverArt,
discNumber: undefined,
}}
displayCssPx={50}
surface="sparse"
alt="Cover"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<PlayCircle size={24} style={{ margin: '12px', color: 'var(--text-muted)' }} />
)}
</div>
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', gap: '2px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
<span
className={`now-playing-led now-playing-led--${presence}`}
role="img"
aria-label={presenceLabel}
data-tooltip={presenceLabel}
data-tooltip-pos="bottom"
/>
<span className="truncate" style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>{stream.title}</span>
</div>
<div className="truncate" style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{stream.artist}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', marginTop: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0 }}>
<User size={10} style={{ flexShrink: 0 }} />
<span className="truncate">{stream.username} ({stream.playerName || 'Web'})</span>
</div>
{(() => {
const posSec = livePositionSec(stream);
if (posSec === undefined || stream.duration <= 0) return null;
const playing = stream.state === 'playing';
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0, marginTop: '1px' }}>
{stream.state === 'paused' && <Pause size={10} style={{ flexShrink: 0 }} />}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ height: '3px', borderRadius: '2px', background: 'var(--border-subtle)', overflow: 'hidden' }}>
<div style={{
width: `${Math.min(100, Math.max(0, (posSec / stream.duration) * 100))}%`,
height: '100%',
background: playing ? 'var(--accent)' : 'var(--text-muted)',
transition: playing ? 'width 1s linear' : 'none',
}} />
</div>
</div>
<span style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}>
{/* ~2ch reserve inside the current-time box (9:59→10:00), not empty gap before the bar. */}
<span style={{ display: 'inline-block', minWidth: '6ch', textAlign: 'right' }}>
{formatClock(posSec)}
</span>
{' / '}
{formatClock(stream.duration)}
</span>
</div>
);
})()}
</div>
</div>
</div>
);
})}
</div>
)}
</div>,
document.body
)}
</div>
);
}
@@ -0,0 +1,372 @@
import { getSongForServer } from '@/api/subsonicLibrary';
import { getArtistInfoForServer } from '@/api/subsonicArtists';
import type { SubsonicArtistInfo, SubsonicSong } from '@/api/subsonicTypes';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Info } from 'lucide-react';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { usePlaybackServerId } from '@/hooks/usePlaybackServerId';
import { fetchBandsintownEvents, type BandsintownEvent } from '@/api/bandsintown';
import CachedImage from '@/ui/CachedImage';
import OverlayScrollArea from '@/ui/OverlayScrollArea';
import { primaryTrackArtistRef } from '@/utils/playback/trackArtistRefs';
const TOUR_LIMIT = 5;
const BIO_CLAMP_LINES = 4;
/**
* Cross-mount caches keyed by stable IDs so jumping between tracks of the same
* artist / album doesn't refire the network call. Cleared on app restart.
*/
const artistInfoCache = new Map<string, SubsonicArtistInfo | null>();
const songDetailCache = new Map<string, SubsonicSong | null>();
type ArtistInfoEntry = { id: string; info: SubsonicArtistInfo | null };
type SongDetailEntry = { id: string; song: SubsonicSong | null };
function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
const month = d.toLocaleString(undefined, { month: 'short' });
const day = String(d.getDate());
const weekday = d.toLocaleString(undefined, { weekday: 'short' });
const time = d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' });
return { month, day, weekday, time };
}
interface ContributorRow {
role: string;
names: string[];
}
/**
* Build credits from OpenSubsonic `contributors[]` only. The legacy
* artist/albumArtist/composer fallback is intentionally dropped — it
* just repeats what's already shown above the tab.
*/
function buildContributorRows(
song: SubsonicSong | null | undefined,
mainArtistName: string,
): ContributorRow[] {
if (!song?.contributors || song.contributors.length === 0) return [];
const mainLower = mainArtistName.trim().toLowerCase();
const rows = new Map<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);
}
// Drop a row that only restates the main artist under the "artist" role.
const out: ContributorRow[] = [];
for (const [role, names] of rows.entries()) {
const list = Array.from(names);
const isMainArtistOnly =
role.toLowerCase().startsWith('artist') &&
list.length === 1 &&
list[0].toLowerCase() === mainLower;
if (isMainArtistOnly) continue;
out.push({ role, names: list });
}
return out;
}
function queuePanelCacheKey(serverId: string, id: string): string {
return serverId ? `${serverId}:${id}` : id;
}
export default function NowPlayingInfo() {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const enableBandsintown = useAuthStore(s => s.enableBandsintown);
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
const subsonicServerId = usePlaybackServerId();
const subsonicReady = Boolean(subsonicServerId);
const primaryArtist = currentTrack ? primaryTrackArtistRef(currentTrack) : null;
const artistName = primaryArtist?.name ?? currentTrack?.artist ?? '';
const artistId = primaryArtist?.id ?? '';
const songId = currentTrack?.id || '';
// Tuple { id, info } gates rendering on "info matches the current artistId" so
// `heroImage` (from info) and `heroCacheKey` (from artistId) can never be from
// different tracks. Otherwise a track switch would render one frame with a
// lagging url under the new key, and CachedImage's IndexedDB would persist
// the wrong blob under the new key — sticky "previous track" image (#…).
const [artistInfoEntry, setArtistInfoEntry] = useState<ArtistInfoEntry | null>(() => {
if (!artistId || !subsonicServerId) return null;
const cached = artistInfoCache.get(queuePanelCacheKey(subsonicServerId, artistId));
return cached === undefined ? null : { id: artistId, info: cached };
});
const [songDetailEntry, setSongDetailEntry] = useState<SongDetailEntry | null>(() => {
if (!songId || !subsonicServerId) return null;
const cached = songDetailCache.get(queuePanelCacheKey(subsonicServerId, songId));
return cached === undefined ? null : { id: songId, song: cached };
});
const [tourEvents, setTourEvents] = useState<BandsintownEvent[]>([]);
const [tourLoading, setTourLoading] = useState(false);
const [bioExpanded, setBioExpanded] = useState(false);
const [bioOverflows, setBioOverflows] = useState(false);
const [showAllTours, setShowAllTours] = useState(false);
const bioRef = useRef<HTMLParagraphElement | null>(null);
// Reset per-track UI state when the track changes
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setBioExpanded(false); setShowAllTours(false); }, [artistId, songId]);
// Artist bio + image
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!subsonicReady || !subsonicServerId || !artistId) { setArtistInfoEntry(null); return; }
const cacheKey = queuePanelCacheKey(subsonicServerId, artistId);
const cached = artistInfoCache.get(cacheKey);
if (cached !== undefined) { setArtistInfoEntry({ id: artistId, info: cached }); return; }
setArtistInfoEntry(null);
let cancelled = false;
getArtistInfoForServer(subsonicServerId, artistId)
.then(info => { if (!cancelled) { artistInfoCache.set(cacheKey, info ?? null); setArtistInfoEntry({ id: artistId, info: info ?? null }); } })
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, info: null }); } });
return () => { cancelled = true; };
}, [subsonicReady, subsonicServerId, artistId]);
// Song detail (for OpenSubsonic contributors[])
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!subsonicReady || !subsonicServerId || !songId) { setSongDetailEntry(null); return; }
const cacheKey = queuePanelCacheKey(subsonicServerId, songId);
const cached = songDetailCache.get(cacheKey);
if (cached !== undefined) { setSongDetailEntry({ id: songId, song: cached }); return; }
setSongDetailEntry(null);
let cancelled = false;
getSongForServer(subsonicServerId, songId)
.then(song => { if (!cancelled) { songDetailCache.set(cacheKey, song ?? null); setSongDetailEntry({ id: songId, song: song ?? null }); } })
.catch(() => { if (!cancelled) { songDetailCache.set(cacheKey, null); setSongDetailEntry({ id: songId, song: null }); } });
return () => { cancelled = true; };
}, [subsonicReady, subsonicServerId, songId]);
// Bandsintown — only when opt-in toggle is on
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!enableBandsintown || !artistName) { setTourEvents([]); return; }
let cancelled = false;
setTourLoading(true);
fetchBandsintownEvents(artistName)
.then(events => { if (!cancelled) setTourEvents(events); })
.finally(() => { if (!cancelled) setTourLoading(false); });
return () => { cancelled = true; };
}, [enableBandsintown, artistName]);
// Only consume info that belongs to the current track — never render with a
// stale entry from the previous track.
const matchedArtistInfo =
artistInfoEntry && artistInfoEntry.id === artistId ? artistInfoEntry.info : null;
const matchedSongDetail =
songDetailEntry && songDetailEntry.id === songId ? songDetailEntry.song : null;
// Detect whether the (clamped) bio actually overflows so we hide the toggle
// when it would do nothing.
const bio = matchedArtistInfo?.biography?.trim() || '';
const bioClean = bio.replace(/<a [^>]*>.*?<\/a>\.?/gi, '').trim();
useLayoutEffect(() => {
const el = bioRef.current;
if (!el) { setBioOverflows(false); return; }
setBioOverflows(el.scrollHeight - el.clientHeight > 1);
}, [bioClean]);
const contributorRows = useMemo(
() => buildContributorRows(matchedSongDetail, currentTrack?.artist ?? ''),
[matchedSongDetail, currentTrack?.artist],
);
if (!currentTrack) {
return (
<div className="np-info-empty">
{t('nowPlayingInfo.empty', 'Play something to see info')}
</div>
);
}
const heroImage =
matchedArtistInfo?.largeImageUrl || matchedArtistInfo?.mediumImageUrl || '';
const heroCacheKey = matchedArtistInfo && artistId ? `artistInfo:${artistId}:hero` : '';
const visibleTours = showAllTours ? tourEvents : tourEvents.slice(0, TOUR_LIMIT);
const hiddenTourCount = Math.max(0, tourEvents.length - visibleTours.length);
return (
<OverlayScrollArea
className="np-info"
viewportClassName="np-info__viewport"
railInset="panel"
measureDeps={[
currentTrack?.id,
artistId,
songId,
enableBandsintown,
tourLoading,
tourEvents.length,
showAllTours,
bioExpanded,
bioOverflows,
bioClean.length,
contributorRows.length,
]}
>
{/* Artist card */}
<section className="np-info-section np-info-artist">
{heroImage && heroCacheKey && (
<div className="np-info-artist-image-wrap">
<CachedImage
src={heroImage}
cacheKey={heroCacheKey}
alt={artistName}
className="np-info-artist-image"
/>
</div>
)}
<div className="np-info-artist-body">
<div className="np-info-section-title">{t('nowPlayingInfo.artist', 'Artist')}</div>
<div className="np-info-artist-name">{artistName || t('common.unknownArtist', 'Unknown artist')}</div>
{bioClean && (
<>
<p
ref={bioRef}
className={`np-info-artist-bio${bioExpanded ? '' : ' is-clamped'}`}
style={bioExpanded ? undefined : { WebkitLineClamp: BIO_CLAMP_LINES }}
>
{bioClean}
</p>
{(bioOverflows || bioExpanded) && (
<button
type="button"
className="np-info-link-btn"
onClick={() => setBioExpanded(v => !v)}
>
{bioExpanded
? t('nowPlayingInfo.bioReadLess', 'Show less')
: t('nowPlayingInfo.bioReadMore', 'Read more')}
</button>
)}
</>
)}
</div>
</section>
{/* Song info / contributors — only when OpenSubsonic provided real credits */}
{contributorRows.length > 0 && (
<section className="np-info-section">
<div className="np-info-section-title">{t('nowPlayingInfo.songInfo', 'Song info')}</div>
<ul className="np-info-credits">
{contributorRows.map(row => (
<li key={row.role} className="np-info-credit-row">
<span className="np-info-credit-names">{row.names.join(', ')}</span>
<span className="np-info-credit-role">{t(`nowPlayingInfo.role.${row.role}`, row.role)}</span>
</li>
))}
</ul>
</section>
)}
{/* Tour: prompt to opt-in when off, list when on */}
{!enableBandsintown ? (
<section className="np-info-section">
<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"
aria-label={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.')}
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
type="button"
className="np-info-bandsintown-prompt-btn"
onClick={() => setEnableBandsintown(true)}
>
{t('nowPlayingInfo.enableBandsintownAction', 'Enable')}
</button>
</div>
</section>
) : (
<section className="np-info-section">
<div className="np-info-section-title">{t('nowPlayingInfo.onTour', 'On tour')}</div>
{tourLoading && tourEvents.length === 0 && (
<div className="np-info-tour-empty">{t('nowPlayingInfo.tourLoading', 'Loading…')}</div>
)}
{!tourLoading && tourEvents.length === 0 && (
<div className="np-info-tour-empty">{t('nowPlayingInfo.noTourEvents', 'No upcoming shows')}</div>
)}
{visibleTours.length > 0 && (
<ul className="np-info-tour">
{visibleTours.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>
)}
{(hiddenTourCount > 0 || (showAllTours && tourEvents.length > TOUR_LIMIT)) && (
<button
type="button"
className="np-info-tour-more"
onClick={() => setShowAllTours(v => !v)}
>
{showAllTours
? t('nowPlayingInfo.showLessTours', 'Show less')
: t('nowPlayingInfo.showMoreTours', { defaultValue: 'Show {{count}} more', count: hiddenTourCount })}
</button>
)}
<div className="np-info-tour-credit">
{t('nowPlayingInfo.poweredByBandsintown', 'Tour data via Bandsintown')}
</div>
</section>
)}
</OverlayScrollArea>
);
}
@@ -0,0 +1,27 @@
import React from 'react';
import { useDragSource } from '@/contexts/DragDropContext';
import type { NpCardId } from '@/features/nowPlaying/store/nowPlayingLayoutStore';
interface NpCardWrapProps {
id: NpCardId;
label: string;
isDraggingThis: boolean;
children: React.ReactNode;
}
export default 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>
);
}
@@ -0,0 +1,52 @@
import React, { useEffect, useRef } from 'react';
import type { NpCardId, NpColumn } from '@/features/nowPlaying/store/nowPlayingLayoutStore';
interface NpColumnProps {
col: NpColumn;
children: React.ReactNode;
empty: boolean;
emptyLabel: string;
isDndActive: boolean;
draggingCardId: NpCardId | null;
onHover: (col: NpColumn, idx: number) => void;
isOverHere: boolean;
}
export default 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>
);
}
@@ -0,0 +1,102 @@
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Cast, Clock, Radio, SkipForward, Users } from 'lucide-react';
import type { useRadioMetadata } from '@/features/radio';
import { usePlayerStore } from '@/store/playerStore';
import { formatTrackTime } from '@/utils/format/formatDuration';
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">{formatTrackTime(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">{formatTrackTime(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>
);
});
export default RadioView;
@@ -0,0 +1,58 @@
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { ExternalLink, Play, TrendingUp } from 'lucide-react';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { formatTrackTime } from '@/utils/format/formatDuration';
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}-${idx}`}
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">{formatTrackTime(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>
);
});
export default TopSongsCard;
@@ -0,0 +1,104 @@
import React, { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Calendar, Info } from 'lucide-react';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import type { BandsintownEvent } from '@/api/bandsintown';
import { isoToParts } from '@/utils/componentHelpers/nowPlayingHelpers';
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>
);
});
export default TourCard;