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,29 @@
import { describe, expect, it } from 'vitest';
import { nowPlayingPresence, NOW_PLAYING_IDLE_MINUTES } from '@/features/nowPlaying/api/nowPlayingPresence';
import type { SubsonicNowPlaying, PlaybackReportState } from '@/api/subsonicTypes';
// The function only reads `state` and `minutesAgo`; cast a minimal fixture.
function entry(partial: { state?: PlaybackReportState; minutesAgo?: number }): SubsonicNowPlaying {
return { minutesAgo: 0, ...partial } as SubsonicNowPlaying;
}
describe('nowPlayingPresence', () => {
it('maps live playbackReport state authoritatively', () => {
expect(nowPlayingPresence(entry({ state: 'playing' }))).toBe('playing');
expect(nowPlayingPresence(entry({ state: 'starting' }))).toBe('playing');
expect(nowPlayingPresence(entry({ state: 'paused' }))).toBe('paused');
expect(nowPlayingPresence(entry({ state: 'stopped' }))).toBe('idle');
});
it('live state wins over a stale minutesAgo', () => {
// A paused session last reported minutes ago is still "paused", not idle.
expect(nowPlayingPresence(entry({ state: 'paused', minutesAgo: 99 }))).toBe('paused');
expect(nowPlayingPresence(entry({ state: 'playing', minutesAgo: 99 }))).toBe('playing');
});
it('falls back to recency for legacy entries without a state', () => {
expect(nowPlayingPresence(entry({ minutesAgo: 0 }))).toBe('playing');
expect(nowPlayingPresence(entry({ minutesAgo: NOW_PLAYING_IDLE_MINUTES }))).toBe('playing');
expect(nowPlayingPresence(entry({ minutesAgo: NOW_PLAYING_IDLE_MINUTES + 1 }))).toBe('idle');
});
});
@@ -0,0 +1,34 @@
import type { SubsonicNowPlaying } from '@/api/subsonicTypes';
/**
* Derived liveness of a now-playing entry, surfaced as the indicator dot in the
* "Who is listening?" popover. Unifies the two sources the server can provide:
* the OpenSubsonic `playbackReport` transport state (Navidrome ≥ 0.62, when
* present) and the classic `getNowPlaying` `minutesAgo` recency (legacy
* fallback). This replaces rendering the raw "Nm ago" line.
*/
export type NowPlayingPresence = 'playing' | 'paused' | 'idle';
/**
* Minutes since last activity beyond which a legacy entry (one without
* playbackReport transport state) is treated as idle rather than actively
* playing. Entries that carry a live `state` ignore this entirely.
*/
export const NOW_PLAYING_IDLE_MINUTES = 5;
/** Resolve the liveness an entry should display. Live `state` is authoritative;
* otherwise recency (`minutesAgo`) decides playing vs idle. */
export function nowPlayingPresence(entry: SubsonicNowPlaying): NowPlayingPresence {
// playbackReport extension: trust the reported transport state.
switch (entry.state) {
case 'playing':
case 'starting':
return 'playing';
case 'paused':
return 'paused';
case 'stopped':
return 'idle';
}
// Legacy getNowPlaying: only recency is known. Recent = playing, stale = idle.
return entry.minutesAgo > NOW_PLAYING_IDLE_MINUTES ? 'idle' : 'playing';
}
@@ -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;
@@ -0,0 +1,265 @@
/**
* Regression tests for the id-gating tuple pattern in `useNowPlayingFetchers`.
*
* Each id-keyed slot (`artistInfo`, `songMeta`, `albumData`, `discography`) is
* held as a `{ id, value }` tuple internally and gated on id-match at the
* return statement. This guarantees that consumers building a `cacheKey` from
* the current id can never receive a value paired with a previously-current
* id — the bug that PR #732 fixed inside `NowPlayingInfo.tsx` and that this
* hook would otherwise leak into every other consumer (e.g. ArtistCard on the
* NowPlaying page).
*/
import { renderHook, act, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicArtistInfo, SubsonicSong, SubsonicAlbum, SubsonicArtist } from '@/api/subsonicTypes';
vi.mock('@/api/subsonicArtists');
vi.mock('@/api/subsonicLibrary');
vi.mock('@/api/bandsintown');
vi.mock('@/utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForServer: vi.fn(() => true),
}));
import { shouldAttemptSubsonicForServer } from '@/utils/network/subsonicNetworkGuard';
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '@/api/subsonicArtists';
import { getAlbumForServer, getSongForServer } from '@/api/subsonicLibrary';
import { fetchBandsintownEvents } from '@/api/bandsintown';
import { useNowPlayingFetchers, type NowPlayingFetchersDeps } from '@/features/nowPlaying/hooks/useNowPlayingFetchers';
// Resolved return shapes of the mocked API calls — used to cast deliberately
// partial test fixtures without `any`.
type ArtistForServer = Awaited<ReturnType<typeof getArtistForServer>>;
type AlbumForServer = Awaited<ReturnType<typeof getAlbumForServer>>;
// The real getArtistInfo signature returns `Promise<SubsonicArtistInfo>`, but
// the hook treats `null` as the "no info available" case and stores it as
// such in its tuple. The tests mirror that — cast to a nullable-returning
// shape so we can mock the empty case without `as any` at every site.
const mockArtistInfo = vi.mocked(getArtistInfoForServer) as unknown as {
mockImplementation: (impl: (serverId: string, id: string) => Promise<SubsonicArtistInfo | null>) => void;
mockResolvedValue: (v: SubsonicArtistInfo | null) => void;
};
const baseDeps: NowPlayingFetchersDeps = {
songId: undefined,
artistId: undefined,
albumId: undefined,
artistName: '',
enableBandsintown: false,
audiomuseNavidromeEnabled: false,
enrichmentKey: '',
currentTrack: null,
subsonicServerId: 'srv1',
fetchEnabled: true,
};
beforeEach(() => {
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [] } as unknown as ArtistForServer);
vi.mocked(fetchBandsintownEvents).mockResolvedValue([]);
});
afterEach(() => {
vi.clearAllMocks();
});
/** Deferred promise helper — lets a test step the fetch resolution manually. */
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e?: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
describe('useNowPlayingFetchers — id-gated artistInfo', () => {
it('returns null artistInfo while the previously-resolved info belongs to a different artistId', async () => {
const a = deferred<SubsonicArtistInfo | null>();
const b = deferred<SubsonicArtistInfo | null>();
mockArtistInfo.mockImplementation(async (_sid, id) => {
if (id === 'art-A') return a.promise;
if (id === 'art-B') return b.promise;
return null;
});
const { result, rerender } = renderHook(
({ artistId }: { artistId: string }) =>
useNowPlayingFetchers({ ...baseDeps, artistId, artistName: artistId }),
{ initialProps: { artistId: 'art-A' } },
);
// Before any resolve, no info yet.
expect(result.current.artistInfo).toBeNull();
// Resolve A — artistInfo becomes A's info.
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
await waitFor(() => {
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'A.jpg' });
});
// Switch to artistId B. Without id-gating, artistInfo would still be A's
// info paired with the new B id — the bug that PR #732 fixed in the queue
// info panel. With gating, artistInfo flips to null immediately.
rerender({ artistId: 'art-B' });
expect(result.current.artistInfo).toBeNull();
// Resolve B — artistInfo now becomes B's info, never paired with A.
await act(async () => { b.resolve({ largeImageUrl: 'B.jpg' } as SubsonicArtistInfo); });
await waitFor(() => {
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'B.jpg' });
});
});
it('does not leak a late-arriving resolve for a stale artistId', async () => {
// Race: artist A's fetch resolves AFTER the consumer switched to B.
const a = deferred<SubsonicArtistInfo | null>();
mockArtistInfo.mockImplementation(async (_sid, id) => {
if (id === 'art-A') return a.promise;
if (id === 'art-B') return { largeImageUrl: 'B.jpg' } as SubsonicArtistInfo;
return null;
});
const { result, rerender } = renderHook(
({ artistId }: { artistId: string }) =>
useNowPlayingFetchers({ ...baseDeps, artistId, artistName: artistId }),
{ initialProps: { artistId: 'art-A' } },
);
// Switch to B before A resolves.
rerender({ artistId: 'art-B' });
await waitFor(() => {
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'B.jpg' });
});
// Late A resolve must not overwrite the displayed value for B.
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'B.jpg' });
});
});
describe('useNowPlayingFetchers — id-gated songMeta / albumData / discography', () => {
it('gates songMeta on songId match', async () => {
const s1 = deferred<SubsonicSong | null>();
const s2 = deferred<SubsonicSong | null>();
vi.mocked(getSongForServer).mockImplementation(async (_sid, id) => {
if (id === 's1') return s1.promise;
if (id === 's2') return s2.promise;
return null;
});
const { result, rerender } = renderHook(
({ songId }: { songId: string }) =>
useNowPlayingFetchers({ ...baseDeps, songId }),
{ initialProps: { songId: 's1' } },
);
await act(async () => { s1.resolve({ id: 's1', title: 'Track 1' } as SubsonicSong); });
await waitFor(() => expect(result.current.songMeta?.title).toBe('Track 1'));
rerender({ songId: 's2' });
expect(result.current.songMeta).toBeNull();
await act(async () => { s2.resolve({ id: 's2', title: 'Track 2' } as SubsonicSong); });
await waitFor(() => expect(result.current.songMeta?.title).toBe('Track 2'));
});
it('gates albumData on albumId match', async () => {
const al1 = deferred<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
const al2 = deferred<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
vi.mocked(getAlbumForServer).mockImplementation(async (_sid, id) => {
if (id === 'alb1') return al1.promise as unknown as AlbumForServer;
if (id === 'alb2') return al2.promise as unknown as AlbumForServer;
return null as unknown as AlbumForServer;
});
const { result, rerender } = renderHook(
({ albumId }: { albumId: string }) =>
useNowPlayingFetchers({ ...baseDeps, albumId }),
{ initialProps: { albumId: 'alb1' } },
);
await act(async () => { al1.resolve({ album: { id: 'alb1', name: 'A1' } as SubsonicAlbum, songs: [] }); });
await waitFor(() => expect(result.current.albumData?.album.name).toBe('A1'));
rerender({ albumId: 'alb2' });
expect(result.current.albumData).toBeNull();
await act(async () => { al2.resolve({ album: { id: 'alb2', name: 'A2' } as SubsonicAlbum, songs: [] }); });
await waitFor(() => expect(result.current.albumData?.album.name).toBe('A2'));
});
it('gates discography on artistId match (empty fallback while gated)', async () => {
const d1 = deferred<{ artist: Partial<SubsonicArtist>; albums: SubsonicAlbum[] }>();
const d2 = deferred<{ artist: Partial<SubsonicArtist>; albums: SubsonicAlbum[] }>();
vi.mocked(getArtistForServer).mockImplementation(async (_sid, id) => {
if (id === 'art-D1') return d1.promise as unknown as ArtistForServer;
if (id === 'art-D2') return d2.promise as unknown as ArtistForServer;
return { albums: [] } as unknown as ArtistForServer;
});
mockArtistInfo.mockResolvedValue(null);
const { result, rerender } = renderHook(
({ artistId }: { artistId: string }) =>
useNowPlayingFetchers({ ...baseDeps, artistId, artistName: artistId }),
{ initialProps: { artistId: 'art-D1' } },
);
await act(async () => { d1.resolve({ artist: {}, albums: [{ id: 'al-D1' } as SubsonicAlbum] }); });
await waitFor(() => expect(result.current.discography.map(a => a.id)).toEqual(['al-D1']));
rerender({ artistId: 'art-D2' });
expect(result.current.discography).toEqual([]);
await act(async () => { d2.resolve({ artist: {}, albums: [{ id: 'al-D2' } as SubsonicAlbum] }); });
await waitFor(() => expect(result.current.discography.map(a => a.id)).toEqual(['al-D2']));
});
});
describe('useNowPlayingFetchers — local-playback metadata', () => {
// Regression: the metadata gate must never pass the playing track id, or the
// guard's `psysonic-local://` skip would blank every Subsonic card whenever
// the track plays from hot-cache / offline bytes. Guard is called with the
// server id only.
// Keep the shared guard mock at its permissive default after the behaviour
// case below swaps in a trackId-sensitive implementation.
afterEach(() => {
vi.mocked(shouldAttemptSubsonicForServer).mockImplementation(() => true);
});
it('queries the network guard without a trackId', async () => {
const guard = vi.mocked(shouldAttemptSubsonicForServer);
renderHook(() => useNowPlayingFetchers({ ...baseDeps, songId: 'song-1', albumId: 'al-1', artistId: 'art-1', artistName: 'Artist' }));
await waitFor(() => expect(guard).toHaveBeenCalled());
for (const call of guard.mock.calls) {
expect(call).toHaveLength(1);
expect(call[1]).toBeUndefined();
}
});
it('still loads album / discography / top songs when the playback bytes are local', async () => {
// Mirror the real guard: a byte-style call (with a trackId resolving to
// psysonic-local://) is blocked, but the metadata gate (server id only) is
// allowed. If the hook ever passed the trackId again, every fetch below
// would be gated off and the cards would blank — exactly the #1042 bug.
// Ids are unique to this test so the shared module caches don't short-circuit it.
vi.mocked(shouldAttemptSubsonicForServer).mockImplementation(
(_serverId, trackId) => trackId === undefined,
);
vi.mocked(getSongForServer).mockResolvedValue({ id: 'np-song', title: 'Local Track' } as SubsonicSong);
vi.mocked(getAlbumForServer).mockResolvedValue(
{ album: { id: 'np-al', name: 'Album' } as SubsonicAlbum, songs: [] },
);
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [{ id: 'np-al' } as SubsonicAlbum] } as unknown as ArtistForServer);
vi.mocked(getTopSongsForServer).mockResolvedValue([{ id: 'np-top' } as unknown as SubsonicSong]);
const { result } = renderHook(() =>
useNowPlayingFetchers({ ...baseDeps, songId: 'np-song', albumId: 'np-al', artistId: 'np-art', artistName: 'NP Artist' }),
);
await waitFor(() => expect(getAlbumForServer).toHaveBeenCalledWith('srv1', 'np-al'));
await waitFor(() => expect(getArtistForServer).toHaveBeenCalledWith('srv1', 'np-art'));
await waitFor(() => expect(getTopSongsForServer).toHaveBeenCalledWith('srv1', 'NP Artist'));
await waitFor(() => expect(result.current.albumData?.album.id).toBe('np-al'));
await waitFor(() => expect(result.current.discography.map(a => a.id)).toEqual(['np-al']));
await waitFor(() => expect(result.current.topSongs.map(s => s.id)).toEqual(['np-top']));
});
});
@@ -0,0 +1,353 @@
import { useEffect, useState } from 'react';
import { getArtistInfoForServer } from '@/api/subsonicArtists';
import type { SubsonicAlbum, SubsonicArtistInfo, SubsonicSong } from '@/api/subsonicTypes';
import { resolveNpAlbum, resolveNpDiscography, resolveNpSongMeta, resolveNpTopSongs } from '@/features/nowPlaying/utils/nowPlayingMetadataResolve';
import { fetchBandsintownEvents, type BandsintownEvent } from '@/api/bandsintown';
import type { ArtistStats, TrackStats } from '@/music-network';
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
import { makeCache } from '@/utils/cache/nowPlayingCache';
import { shouldAttemptSubsonicForServer } from '@/utils/network/subsonicNetworkGuard';
import { useConnectionStatus } from '@/hooks/useConnectionStatus';
// Module-level TTL caches (shared across mounts)
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 networkTrackCache = makeCache<TrackStats | null>();
const networkArtistCache = makeCache<ArtistStats | null>();
export interface NowPlayingFetchersDeps {
songId: string | undefined;
artistId: string | undefined;
albumId: string | undefined;
artistName: string;
enableBandsintown: boolean;
audiomuseNavidromeEnabled: boolean;
enrichmentKey: string;
currentTrack: { artist: string; title: string } | null;
/** Subsonic server for API calls — must match the playing queue server. */
subsonicServerId: string;
/**
* Caller intent / prerequisites only (e.g. "we have a playback server id").
* The network reachability decision — online, server reachable, and no
* trackId so local-cache playback still loads metadata — is made here via
* `shouldAttemptSubsonicForServer`; callers must not pre-apply that guard.
*/
fetchEnabled?: boolean;
}
export interface NowPlayingFetchersResult {
songMeta: SubsonicSong | null;
artistInfo: SubsonicArtistInfo | null;
albumData: { album: SubsonicAlbum; songs: SubsonicSong[] } | null;
topSongs: SubsonicSong[];
tourEvents: BandsintownEvent[];
tourLoading: boolean;
discography: SubsonicAlbum[];
networkTrack: TrackStats | null;
networkArtist: ArtistStats | null;
}
function subsonicCacheKey(serverId: string, id: string): string {
return serverId ? `${serverId}:${id}` : id;
}
// id-keyed slots are held as `{ id, value }` tuples and gated on id-match in
// the return statement. Without the gate, a track switch renders one frame
// with the previous track's value paired with the new id — consumers that
// build a cacheKey from the new id (e.g. CachedImage) would persist a
// mismatched blob in IndexedDB and never recover. See PR #732 for the same
// fix inside `NowPlayingInfo.tsx`.
type IdSlot<T> = { id: string; value: T } | null;
type KeySlot<T> = { key: string; value: T } | null;
function seedSlot<T>(id: string, lookup: (id: string) => T | undefined): IdSlot<T> {
if (!id) return null;
const cached = lookup(id);
return cached === undefined ? null : { id, value: cached };
}
function seedKeySlot<T>(key: string, lookup: (key: string) => T | undefined): KeySlot<T> {
if (!key) return null;
const cached = lookup(key);
return cached === undefined ? null : { key, value: cached };
}
export async function prewarmNowPlayingFetchers(
deps: NowPlayingFetchersDeps,
): Promise<void> {
const {
songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled,
enrichmentKey, currentTrack, subsonicServerId, fetchEnabled = true,
} = deps;
if (!fetchEnabled || !subsonicServerId) return;
// Index-first resolvers run whenever there's a server id (offline included) —
// each guards its own network fallback. artistInfo below is the one
// network-only job, so it keeps the reachability gate.
const jobs: Array<Promise<unknown>> = [];
if (songId) {
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
if (songMetaCache.get(cacheKey) === undefined) {
jobs.push(
resolveNpSongMeta(subsonicServerId, songId)
.then(v => songMetaCache.set(cacheKey, v ?? null))
.catch(() => songMetaCache.set(cacheKey, null)),
);
}
}
if (artistId) {
const artistKey = subsonicCacheKey(subsonicServerId, artistId);
// artistInfo (bio/similar) is network-only — keep the reachability gate.
if (shouldAttemptSubsonicForServer(subsonicServerId) && artistInfoCache.get(artistKey) === undefined) {
jobs.push(
getArtistInfoForServer(subsonicServerId, artistId, {
similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined,
})
.then(v => artistInfoCache.set(artistKey, v ?? null))
.catch(() => artistInfoCache.set(artistKey, null)),
);
}
if (discographyCache.get(artistKey) === undefined) {
jobs.push(
resolveNpDiscography(subsonicServerId, artistId)
.then(albums => discographyCache.set(artistKey, albums))
.catch(() => discographyCache.set(artistKey, [])),
);
}
}
if (albumId) {
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
if (albumCache.get(cacheKey) === undefined) {
jobs.push(
resolveNpAlbum(subsonicServerId, albumId)
.then(v => albumCache.set(cacheKey, v))
.catch(() => albumCache.set(cacheKey, null)),
);
}
}
if (artistName) {
const cacheKey = subsonicCacheKey(subsonicServerId, artistName);
if (topSongsCache.get(cacheKey) === undefined) {
jobs.push(
resolveNpTopSongs(subsonicServerId, artistId, artistName)
.then(v => topSongsCache.set(cacheKey, v))
.catch(() => topSongsCache.set(cacheKey, [])),
);
}
if (enableBandsintown && tourCache.get(artistName) === undefined) {
jobs.push(
fetchBandsintownEvents(artistName)
.then(v => tourCache.set(artistName, v))
.catch(() => tourCache.set(artistName, [])),
);
}
}
const prewarmRuntime = getMusicNetworkRuntimeOrNull();
if (prewarmRuntime?.getEnrichmentPrimaryId() && currentTrack) {
const trackKey = `${currentTrack.artist} ${currentTrack.title} ${enrichmentKey}`;
if (networkTrackCache.get(trackKey) === undefined) {
jobs.push(
prewarmRuntime.getTrackStats({ title: currentTrack.title, artist: currentTrack.artist })
.then(v => networkTrackCache.set(trackKey, v))
.catch(() => networkTrackCache.set(trackKey, null)),
);
}
const artistKey = `${currentTrack.artist} ${enrichmentKey}`;
if (networkArtistCache.get(artistKey) === undefined) {
jobs.push(
prewarmRuntime.getArtistStats(currentTrack.artist)
.then(v => networkArtistCache.set(artistKey, v))
.catch(() => networkArtistCache.set(artistKey, null)),
);
}
}
await Promise.allSettled(jobs);
}
export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingFetchersResult {
const {
songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled,
enrichmentKey, currentTrack, subsonicServerId, fetchEnabled = true,
} = deps;
// id-keyed entity state — seeded from TTL cache so same-artist song switches
// are instant. Held as `{ id, value }` tuples and gated below.
const [songMetaEntry, setSongMetaEntry] = useState<IdSlot<SubsonicSong | null>>(() =>
seedSlot(songId && subsonicServerId ? songId : '', k => songMetaCache.get(subsonicCacheKey(subsonicServerId, k))));
const [artistInfoEntry, setArtistInfoEntry] = useState<IdSlot<SubsonicArtistInfo | null>>(() =>
seedSlot(artistId && subsonicServerId ? artistId : '', k => artistInfoCache.get(subsonicCacheKey(subsonicServerId, k))));
const [albumDataEntry, setAlbumDataEntry] = useState<IdSlot<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>>(() =>
seedSlot(albumId && subsonicServerId ? albumId : '', k => albumCache.get(subsonicCacheKey(subsonicServerId, k))));
const [discographyEntry, setDiscographyEntry] = useState<IdSlot<SubsonicAlbum[]>>(() =>
seedSlot(artistId && subsonicServerId ? artistId : '', k => discographyCache.get(subsonicCacheKey(subsonicServerId, k))));
// Name-keyed / global state — no cacheKey/persistence hazard, kept as plain state.
const topSongsKey = artistName && subsonicServerId ? subsonicCacheKey(subsonicServerId, artistName) : '';
const tourKey = enableBandsintown && artistName ? artistName : '';
const [topSongsEntry, setTopSongsEntry] = useState<KeySlot<SubsonicSong[]>>(() =>
seedKeySlot(topSongsKey, k => topSongsCache.get(k)));
const [tourEventsEntry, setTourEventsEntry] = useState<KeySlot<BandsintownEvent[]>>(() =>
seedKeySlot(tourKey, k => tourCache.get(k)));
const [tourLoading, setTourLoading] = useState(false);
const networkTrackKey = currentTrack ? `${currentTrack.artist} ${currentTrack.title} ${enrichmentKey}` : '';
const networkArtistKey = artistName ? `${artistName} ${enrichmentKey}` : '';
const [networkTrackEntry, setNetworkTrackEntry] = useState<KeySlot<TrackStats | null>>(() =>
seedKeySlot(networkTrackKey, k => networkTrackCache.get(k)));
const [networkArtistEntry, setNetworkArtistEntry] = useState<KeySlot<ArtistStats | null>>(() =>
seedKeySlot(networkArtistKey, k => networkArtistCache.get(k)));
const { status: connStatus } = useConnectionStatus();
// Gate split (PR #1049): index-first resolvers run whenever there's a server id
// — they read SQLite even when the server is unreachable (the offline win) and
// guard their own network fallback. Only artistInfo (bio/similar, no index) is
// network-only, so it keeps the reachability gate.
const indexFetchAllowed = fetchEnabled && !!subsonicServerId;
const networkOnlyAllowed = indexFetchAllowed && shouldAttemptSubsonicForServer(subsonicServerId);
// Fetch batch per entity change (not per song switch — same-artist songs share artist/top/tour fetches)
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 (!indexFetchAllowed || !songId) { setSongMetaEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
const cached = songMetaCache.get(cacheKey);
if (cached !== undefined) { setSongMetaEntry({ id: songId, value: cached }); return; }
setSongMetaEntry(null);
let cancelled = false;
resolveNpSongMeta(subsonicServerId, songId)
.then(v => { if (!cancelled) { songMetaCache.set(cacheKey, v ?? null); setSongMetaEntry({ id: songId, value: v ?? null }); } })
.catch(() => { if (!cancelled) { songMetaCache.set(cacheKey, null); setSongMetaEntry({ id: songId, value: null }); } });
return () => { cancelled = true; };
}, [indexFetchAllowed, subsonicServerId, songId, connStatus]);
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 (!networkOnlyAllowed || !artistId) { setArtistInfoEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
const cached = artistInfoCache.get(cacheKey);
if (cached !== undefined) { setArtistInfoEntry({ id: artistId, value: cached }); return; }
setArtistInfoEntry(null);
let cancelled = false;
getArtistInfoForServer(subsonicServerId, artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
.then(v => { if (!cancelled) { artistInfoCache.set(cacheKey, v ?? null); setArtistInfoEntry({ id: artistId, value: v ?? null }); } })
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, value: null }); } });
return () => { cancelled = true; };
}, [networkOnlyAllowed, subsonicServerId, artistId, audiomuseNavidromeEnabled, connStatus]);
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 (!indexFetchAllowed || !albumId) { setAlbumDataEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
const cached = albumCache.get(cacheKey);
if (cached !== undefined) { setAlbumDataEntry({ id: albumId, value: cached }); return; }
setAlbumDataEntry(null);
let cancelled = false;
resolveNpAlbum(subsonicServerId, albumId)
.then(v => { if (!cancelled) { albumCache.set(cacheKey, v); setAlbumDataEntry({ id: albumId, value: v }); } })
.catch(() => { if (!cancelled) { albumCache.set(cacheKey, null); setAlbumDataEntry({ id: albumId, value: null }); } });
return () => { cancelled = true; };
}, [indexFetchAllowed, subsonicServerId, albumId, connStatus]);
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 (!indexFetchAllowed || !topSongsKey) { setTopSongsEntry(null); return; }
const cached = topSongsCache.get(topSongsKey);
if (cached !== undefined) { setTopSongsEntry({ key: topSongsKey, value: cached }); return; }
setTopSongsEntry(null);
let cancelled = false;
resolveNpTopSongs(subsonicServerId, artistId, artistName)
.then(v => { if (!cancelled) { topSongsCache.set(topSongsKey, v); setTopSongsEntry({ key: topSongsKey, value: v }); } })
.catch(() => { if (!cancelled) { topSongsCache.set(topSongsKey, []); setTopSongsEntry({ key: topSongsKey, value: [] }); } });
return () => { cancelled = true; };
}, [indexFetchAllowed, topSongsKey, subsonicServerId, artistId, artistName, connStatus]);
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 (!tourKey) { setTourEventsEntry(null); setTourLoading(false); return; }
const cached = tourCache.get(tourKey);
if (cached !== undefined) { setTourEventsEntry({ key: tourKey, value: cached }); setTourLoading(false); return; }
let cancelled = false;
setTourLoading(true);
setTourEventsEntry(null);
fetchBandsintownEvents(artistName)
.then(v => { if (!cancelled) { tourCache.set(tourKey, v); setTourEventsEntry({ key: tourKey, value: v }); } })
.finally(() => { if (!cancelled) setTourLoading(false); });
return () => { cancelled = true; };
}, [tourKey, artistName]);
// Discography via getArtist
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 (!indexFetchAllowed || !artistId) { setDiscographyEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
const cached = discographyCache.get(cacheKey);
if (cached !== undefined) { setDiscographyEntry({ id: artistId, value: cached }); return; }
setDiscographyEntry(null);
let cancelled = false;
resolveNpDiscography(subsonicServerId, artistId)
.then(albums => { if (!cancelled) { discographyCache.set(cacheKey, albums); setDiscographyEntry({ id: artistId, value: albums }); } })
.catch(() => { if (!cancelled) { discographyCache.set(cacheKey, []); setDiscographyEntry({ id: artistId, value: [] }); } });
return () => { cancelled = true; };
}, [indexFetchAllowed, subsonicServerId, artistId, connStatus]);
// Enrichment track stats (per-track, from the enrichment primary)
useEffect(() => {
const runtime = getMusicNetworkRuntimeOrNull();
// 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 (!runtime?.getEnrichmentPrimaryId() || !currentTrack || !networkTrackKey) { setNetworkTrackEntry(null); return; }
const cached = networkTrackCache.get(networkTrackKey);
if (cached !== undefined) { setNetworkTrackEntry({ key: networkTrackKey, value: cached }); return; }
setNetworkTrackEntry(null);
let cancelled = false;
runtime.getTrackStats({ title: currentTrack.title, artist: currentTrack.artist })
.then(v => { if (!cancelled) { networkTrackCache.set(networkTrackKey, v); setNetworkTrackEntry({ key: networkTrackKey, value: v }); } })
.catch(() => { if (!cancelled) { networkTrackCache.set(networkTrackKey, null); setNetworkTrackEntry({ key: networkTrackKey, value: null }); } });
return () => { cancelled = true; };
}, [networkTrackKey, currentTrack, enrichmentKey]);
// Enrichment artist stats (per-artist — shared across same-artist tracks)
useEffect(() => {
const runtime = getMusicNetworkRuntimeOrNull();
// 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 (!runtime?.getEnrichmentPrimaryId() || !artistName || !networkArtistKey) { setNetworkArtistEntry(null); return; }
const cached = networkArtistCache.get(networkArtistKey);
if (cached !== undefined) { setNetworkArtistEntry({ key: networkArtistKey, value: cached }); return; }
setNetworkArtistEntry(null);
let cancelled = false;
runtime.getArtistStats(artistName)
.then(v => { if (!cancelled) { networkArtistCache.set(networkArtistKey, v); setNetworkArtistEntry({ key: networkArtistKey, value: v }); } })
.catch(() => { if (!cancelled) { networkArtistCache.set(networkArtistKey, null); setNetworkArtistEntry({ key: networkArtistKey, value: null }); } });
return () => { cancelled = true; };
}, [networkArtistKey, artistName, enrichmentKey]);
// Gate id-keyed slots on id-match so consumers never see a value paired
// with the wrong id, even on the single render between an id change and
// the next effect run.
const songMeta = songMetaEntry && songMetaEntry.id === songId ? songMetaEntry.value : null;
const artistInfo = artistInfoEntry && artistInfoEntry.id === artistId ? artistInfoEntry.value : null;
const albumData = albumDataEntry && albumDataEntry.id === albumId ? albumDataEntry.value : null;
const discography = discographyEntry && discographyEntry.id === artistId ? discographyEntry.value : [];
const topSongs = topSongsEntry && topSongsEntry.key === topSongsKey ? topSongsEntry.value : [];
const tourEvents = tourEventsEntry && tourEventsEntry.key === tourKey ? tourEventsEntry.value : [];
const networkTrack = networkTrackEntry && networkTrackEntry.key === networkTrackKey ? networkTrackEntry.value : null;
const networkArtist = networkArtistEntry && networkArtistEntry.key === networkArtistKey ? networkArtistEntry.value : null;
return { songMeta, artistInfo, albumData, topSongs, tourEvents, tourLoading, discography, networkTrack, networkArtist };
}
@@ -0,0 +1,109 @@
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { coverCacheEnsure, coverCachePeekBatch } from '@/api/coverCache';
import { coverIndexKeyFromRef } from '@/cover/storageKeys';
import { useNowPlayingPrewarm } from '@/features/nowPlaying/hooks/useNowPlayingPrewarm';
import { prewarmNowPlayingFetchers } from '@/features/nowPlaying/hooks/useNowPlayingFetchers';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/store/playerStore';
import { makeTrack } from '@/test/helpers/factories';
import { resetAllStores } from '@/test/helpers/storeReset';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
vi.mock('@/api/coverCache', async importOriginal => {
const actual = await importOriginal<typeof import('@/api/coverCache')>();
return {
...actual,
coverCachePeekBatch: vi.fn(async () => ({})),
coverCacheEnsure: vi.fn(async () => ({ hit: false, path: '', tier: 800 })),
};
});
vi.mock('@/features/nowPlaying/hooks/useNowPlayingFetchers', () => ({
prewarmNowPlayingFetchers: vi.fn(async () => undefined),
}));
function seedServers(): { active: string; playback: string } {
const active = useAuthStore.getState().addServer({
name: 'Active',
url: 'https://active.test',
username: 'active-user',
password: 'active-pass',
});
const playback = useAuthStore.getState().addServer({
name: 'Playback',
url: 'https://playback.test',
username: 'play-user',
password: 'play-pass',
});
useAuthStore.getState().setActiveServer(active);
return { active, playback };
}
describe('useNowPlayingPrewarm', () => {
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
});
it('prewarms track data and artwork with playback scope', async () => {
const { playback } = seedServers();
const track = makeTrack({
id: 'song-1',
artistId: 'artist-1',
albumId: 'album-1',
artist: 'Artist One',
coverArt: 'cover-1',
});
usePlayerStore.setState({
queueItems: toQueueItemRefs(playback, [track]),
queueIndex: 0,
queueServerId: playback,
currentTrack: track,
});
renderHook(() => useNowPlayingPrewarm());
await waitFor(() => {
expect(prewarmNowPlayingFetchers).toHaveBeenCalledTimes(1);
expect(coverCachePeekBatch).toHaveBeenCalledTimes(1);
});
const peekRef = vi.mocked(coverCachePeekBatch).mock.calls[0]?.[0]?.[0];
const playbackProfile = useAuthStore.getState().servers.find(s => s.id === playback);
expect(playbackProfile).toBeDefined();
expect(peekRef && coverIndexKeyFromRef(peekRef)).toBe('playback.test');
const ensureRef = vi.mocked(coverCacheEnsure).mock.calls[0]?.[0];
expect(ensureRef?.serverScope.kind).toBe('server');
});
it('prewarms radio artwork with active scope (not playback queue scope)', async () => {
const { active, playback } = seedServers();
const track = makeTrack({ id: 'song-2', coverArt: 'cover-2' });
usePlayerStore.setState({
queueItems: toQueueItemRefs(playback, [track]),
queueIndex: 0,
queueServerId: playback,
currentTrack: null,
currentRadio: {
id: 'radio-1',
name: 'Radio 1',
streamUrl: 'https://radio.test/stream',
coverArt: 'https://radio.test/art.jpg',
},
});
renderHook(() => useNowPlayingPrewarm());
await waitFor(() => {
expect(coverCachePeekBatch).toHaveBeenCalledTimes(1);
});
const peekRef = vi.mocked(coverCachePeekBatch).mock.calls[0]?.[0]?.[0];
const activeProfile = useAuthStore.getState().servers.find(s => s.id === active);
expect(activeProfile).toBeDefined();
expect(peekRef && coverIndexKeyFromRef(peekRef)).toBe('active.test');
const ensureRef = vi.mocked(coverCacheEnsure).mock.calls[0]?.[0];
expect(ensureRef?.serverScope).toEqual({ kind: 'active' });
});
});
@@ -0,0 +1,106 @@
import { useEffect } from 'react';
import { coverCacheEnsure, coverCachePeekBatch } from '@/api/coverCache';
import { albumCoverRef } from '@/cover/ref';
import { resolvePlaybackCoverScope } from '@/cover/ref';
import { resolveTrackCoverRefFromLibrary } from '@/cover/resolveEntryLibrary';
import { getDiskSrc, rememberDiskSrc } from '@/cover/diskSrcCache';
import { coverStorageKeyFromRef } from '@/cover/storageKeys';
import { resolveCoverDisplayTier } from '@/cover/tiers';
import { coverArtIdFromRadio } from '@/cover/ids';
import type { CoverArtRef } from '@/cover/types';
import { prewarmNowPlayingFetchers } from '@/features/nowPlaying/hooks/useNowPlayingFetchers';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/store/playerStore';
import { usePlaybackServerId } from '@/hooks/usePlaybackServerId';
import { primaryTrackArtistRef } from '@/utils/playback/trackArtistRefs';
const NOW_PLAYING_COVER_CSS_PX = 800;
async function prewarmCoverRef(ref: CoverArtRef): Promise<void> {
if (!ref.fetchCoverArtId) return;
const tier = resolveCoverDisplayTier(NOW_PLAYING_COVER_CSS_PX, { surface: 'sparse' });
const storageKey = coverStorageKeyFromRef(ref, tier);
if (getDiskSrc(storageKey)) return;
const hits = await coverCachePeekBatch([ref], tier);
const hitPath = hits[storageKey];
if (hitPath) {
rememberDiskSrc(storageKey, hitPath);
return;
}
const ensured = await coverCacheEnsure(ref, tier, 'high');
if (ensured.hit && ensured.path) {
rememberDiskSrc(storageKey, ensured.path);
}
}
/**
* Warm the Now Playing data + key artwork as soon as the playing track changes,
* so opening `/now-playing` shows track-correct content instantly.
*/
export function useNowPlayingPrewarm(): void {
const currentTrack = usePlayerStore(s => s.currentTrack);
const currentRadio = usePlayerStore(s => s.currentRadio);
const playbackServerId = usePlaybackServerId();
const enableBandsintown = useAuthStore(s => s.enableBandsintown);
const activeServerId = useAuthStore(s => s.activeServerId);
const audiomuseNavidromeEnabled = useAuthStore(
s => (playbackServerId ? Boolean(s.audiomuseNavidromeByServer[playbackServerId]) : false),
);
const enrichmentKey = useAuthStore(s => s.enrichmentPrimaryId ?? '');
useEffect(() => {
if (!currentTrack || !playbackServerId) return;
const primary = primaryTrackArtistRef(currentTrack);
void prewarmNowPlayingFetchers({
songId: currentTrack.id,
artistId: primary.id,
albumId: currentTrack.albumId,
artistName: primary.name ?? currentTrack.artist,
enableBandsintown,
audiomuseNavidromeEnabled,
enrichmentKey,
currentTrack,
subsonicServerId: playbackServerId,
// No `fetchEnabled` / no trackId: prewarmNowPlayingFetchers owns the single
// reachability gate, and metadata must warm even when the track's audio
// plays from local cache.
});
if (currentTrack.albumId && currentTrack.id) {
void resolveTrackCoverRefFromLibrary(
{
id: currentTrack.id,
albumId: currentTrack.albumId,
coverArt: currentTrack.coverArt,
discNumber: (currentTrack as { discNumber?: number }).discNumber,
},
resolvePlaybackCoverScope(),
).then(ref => {
if (ref) void prewarmCoverRef(ref);
});
}
// Keyed on currentTrack?.id; depending on the `currentTrack` object would
// re-prewarm on every render when its identity changes but its id does not.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
currentTrack?.id,
currentTrack?.artistId,
currentTrack?.artists,
currentTrack?.albumId,
currentTrack?.coverArt,
currentTrack?.artist,
playbackServerId,
enableBandsintown,
audiomuseNavidromeEnabled,
enrichmentKey,
]);
useEffect(() => {
if (!currentRadio?.coverArt || !activeServerId) return;
const radioCoverArtId = coverArtIdFromRadio(currentRadio.id);
void prewarmCoverRef(albumCoverRef(radioCoverArtId, radioCoverArtId, { kind: 'active' }));
}, [currentRadio?.id, currentRadio?.coverArt, activeServerId]);
}
@@ -0,0 +1,50 @@
import { useCallback, useEffect, useState } from 'react';
import { queueSongStar } from '@/store/pendingStarSync';
import type { SubsonicSong } from '@/api/subsonicTypes';
import type { Track } from '@/store/playerStoreTypes';
import type { TrackStats } from '@/music-network';
import { getMusicNetworkRuntime } from '@/music-network';
export interface NowPlayingStarLoveDeps {
currentTrack: Pick<Track, 'id' | 'title' | 'artist' | 'serverId'> | null;
songMeta: SubsonicSong | null;
networkTrack: TrackStats | null;
networkLoveEnabled: boolean;
}
export interface NowPlayingStarLoveResult {
starred: boolean;
networkLoved: boolean;
toggleStar: () => Promise<void>;
toggleNetworkLove: () => Promise<void>;
}
export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingStarLoveResult {
const { currentTrack, songMeta, networkTrack, networkLoveEnabled } = deps;
// Star
const [starred, setStarred] = useState(false);
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setStarred(!!songMeta?.starred); }, [songMeta]);
const toggleStar = useCallback(async () => {
if (!currentTrack) return;
const next = !starred;
setStarred(next); // local view; helper owns the override + retried server sync (no rollback)
queueSongStar(currentTrack.id, next, currentTrack.serverId);
}, [currentTrack, starred]);
// Love (enrichment primary; seeded from track.getInfo, toggle via love/unlove)
const [networkLoved, setNetworkLoved] = useState(false);
// 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(() => { setNetworkLoved(!!networkTrack?.userLoved); }, [networkTrack]);
const toggleNetworkLove = useCallback(async () => {
if (!currentTrack || !networkLoveEnabled) return;
const track = { title: currentTrack.title, artist: currentTrack.artist };
if (networkLoved) { await getMusicNetworkRuntime().setTrackLoved(track, false); setNetworkLoved(false); }
else { await getMusicNetworkRuntime().setTrackLoved(track, true); setNetworkLoved(true); }
}, [currentTrack, networkLoved, networkLoveEnabled]);
return { starred, networkLoved, toggleStar, toggleNetworkLove };
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Now Playing feature — the Now Playing page (album/artist/credits/discography/
* tour cards, hero), its layout store and index-first metadata resolvers, the
* topbar "Who is listening?" dropdown, and the queue-side track info panel.
*
* The page itself is lazy-loaded via the deep path `pages/NowPlaying`.
* `ArtistCard` is also reused by the Artist Detail page.
*/
export { default as NowPlayingDropdown } from './components/NowPlayingDropdown';
export { default as NowPlayingInfo } from './components/NowPlayingInfo';
export { default as ArtistCard } from './components/ArtistCard';
export { useNowPlayingPrewarm } from './hooks/useNowPlayingPrewarm';
@@ -0,0 +1,463 @@
import { useCoverArt } from '@/cover/useCoverArt';
import { albumCoverRef } from '@/cover/ref';
import { usePlaybackTrackCoverRef } from '@/cover/useLibraryCoverRef';
import { coverArtIdFromRadio } from '@/cover/ids';
import type { SubsonicArtistInfo, SubsonicSong } from '@/api/subsonicTypes';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
import { usePlaybackServerId } from '@/hooks/usePlaybackServerId';
import { useTranslation } from 'react-i18next';
import { Music, EyeOff, LayoutGrid, RotateCcw, Eye } from 'lucide-react';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { useLyricsStore } from '@/store/lyricsStore';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useRadioMetadata } from '@/features/radio';
import { useDragDrop } from '@/contexts/DragDropContext';
import OverlayScrollArea from '@/ui/OverlayScrollArea';
import {
useNpLayoutStore, NP_CARD_IDS,
type NpCardId, type NpColumn,
} from '@/features/nowPlaying/store/nowPlayingLayoutStore';
// ─── Helpers ──────────────────────────────────────────────────────────────────
import {
buildContributorRows,
} from '@/utils/componentHelpers/nowPlayingHelpers';
import NpCardWrap from '@/features/nowPlaying/components/NpCardWrap';
import NpColumnEl from '@/features/nowPlaying/components/NpColumnEl';
import RadioView from '@/features/nowPlaying/components/RadioView';
import Hero from '@/features/nowPlaying/components/Hero';
import ArtistCard from '@/features/nowPlaying/components/ArtistCard';
import AlbumCard from '@/features/nowPlaying/components/AlbumCard';
import TopSongsCard from '@/features/nowPlaying/components/TopSongsCard';
import CreditsCard from '@/features/nowPlaying/components/CreditsCard';
import TourCard from '@/features/nowPlaying/components/TourCard';
import DiscographyCard from '@/features/nowPlaying/components/DiscographyCard';
import { useNowPlayingFetchers } from '@/features/nowPlaying/hooks/useNowPlayingFetchers';
import { useNowPlayingStarLove } from '@/features/nowPlaying/hooks/useNowPlayingStarLove';
import { useArtistInfoBatch } from '@/hooks/useArtistInfoBatch';
import { primaryTrackArtistRef, resolveTrackArtistRefs } from '@/utils/playback/trackArtistRefs';
import type { ArtistCardTab } from '@/features/nowPlaying/components/ArtistCard';
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function NowPlaying() {
const { t } = useTranslation();
const stableNavigate = usePlaybackLibraryNavigate();
const playbackServerId = usePlaybackServerId();
const audiomuseNavidromeByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
const audiomuseNavidromeEnabled = Boolean(
playbackServerId && audiomuseNavidromeByServer[playbackServerId],
);
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 enableBandsintown = useAuthStore(s => s.enableBandsintown);
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
const enrichmentPrimaryId = useAuthStore(s => s.enrichmentPrimaryId);
const playTrackFn = usePlayerStore(s => s.playTrack);
const radioMeta = useRadioMetadata(currentRadio ?? null);
const songId = currentTrack?.id;
const albumId = currentTrack?.albumId;
const trackArtistRefs = useMemo(
() => (currentTrack ? resolveTrackArtistRefs(currentTrack) : []),
[currentTrack],
);
const primaryArtist = useMemo(
() => (currentTrack ? primaryTrackArtistRef(currentTrack) : null),
[currentTrack],
);
const artistId = primaryArtist?.id;
const artistName = primaryArtist?.name ?? currentTrack?.artist ?? '';
// Entity fetchers (8 cached useEffects + their state)
const {
songMeta, artistInfo, albumData, topSongs,
tourEvents, tourLoading, discography,
networkTrack, networkArtist,
} = useNowPlayingFetchers({
songId, artistId, albumId, artistName,
enableBandsintown, audiomuseNavidromeEnabled,
enrichmentKey: enrichmentPrimaryId ?? '', currentTrack,
subsonicServerId: playbackServerId,
// `fetchEnabled` = "we have a playback server id". The reachability decision
// (online / server reachable, no trackId so local-cache playback still loads
// metadata) lives in one place inside the hook — see its JSDoc.
fetchEnabled: Boolean(playbackServerId),
});
// Star + enrichment love + their toggle callbacks
const networkLoveEnabled = enrichmentPrimaryId !== null;
const { starred, networkLoved, toggleStar, toggleNetworkLove } = useNowPlayingStarLove({
currentTrack, songMeta, networkTrack, networkLoveEnabled,
});
const openLyrics = useCallback(() => {
if (!isQueueVisible) toggleQueue();
showLyrics();
}, [isQueueVisible, toggleQueue, showLyrics]);
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
const radioCoverArtId = currentRadio?.coverArt ? coverArtIdFromRadio(currentRadio.id) : undefined;
const radioCover = useCoverArt(
radioCoverArtId ? albumCoverRef(radioCoverArtId, radioCoverArtId) : null,
800,
{ surface: 'sparse' },
);
const resolvedRadioCover = radioCover.src;
const contributorRows = useMemo(
() => buildContributorRows(songMeta, currentTrack?.artist ?? ''),
[songMeta, currentTrack?.artist],
);
// Merge Subsonic artistInfo with the enrichment fallback: if Subsonic has no
// bio, use the enrichment primary's artist bio so the card isn't empty.
const effectiveArtistInfo = useMemo<SubsonicArtistInfo | null>(() => {
if (!artistInfo && !networkArtist?.bio) return null;
if (artistInfo?.biography) return artistInfo;
if (!networkArtist?.bio) return artistInfo;
return {
...(artistInfo ?? {}),
biography: networkArtist.bio,
};
}, [artistInfo, networkArtist]);
const artistInfoById = useArtistInfoBatch(
playbackServerId,
trackArtistRefs,
audiomuseNavidromeEnabled ? 24 : undefined,
);
const artistTabs = useMemo((): ArtistCardTab[] | undefined => {
const tabs = trackArtistRefs.filter(r => r.id?.trim());
if (tabs.length <= 1) return undefined;
return tabs.map((ref, index) => {
const id = ref.id!.trim();
const info = index === 0
? (effectiveArtistInfo ?? artistInfoById[id] ?? null)
: (artistInfoById[id] ?? null);
return {
id,
name: ref.name ?? id,
artistInfo: info,
};
});
}, [trackArtistRefs, artistInfoById, effectiveArtistInfo]);
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)
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
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);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
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">
<OverlayScrollArea
className="np-main"
viewportClassName="np-main__viewport"
railInset="panel"
measureDeps={[
!!currentTrack,
!!currentRadio,
layoutCards,
enableBandsintown,
tourEvents.length,
discography.length,
topSongs.length,
]}
>
{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,
}}
artistRefs={trackArtistRefs.length > 0 ? trackArtistRefs : undefined}
genre={songMeta?.genre ?? undefined}
playCount={(songMeta as (SubsonicSong & { playCount?: number }) | null)?.playCount}
userRatingOverride={userRatingOverrides[currentTrack.id]}
networkTrack={networkTrack}
networkArtist={networkArtist}
starred={starred}
networkLoved={networkLoved}
networkLoveEnabled={networkLoveEnabled}
activeLyricsTab={activeTab === 'lyrics' && isQueueVisible}
coverRef={playbackCoverRef}
onNavigate={stableNavigate}
onToggleStar={toggleStar}
onToggleNetworkLove={toggleNetworkLove}
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}
artistTabs={artistTabs}
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>
)}
</OverlayScrollArea>
</div>
);
}
@@ -0,0 +1,76 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type NpColumn = 'left' | 'right';
export type NpCardId =
| 'album'
| 'topSongs'
| 'credits'
| 'artist'
| 'discography'
| 'tour';
export interface NpCardConfig {
id: NpCardId;
column: NpColumn;
visible: boolean;
}
export const NP_CARD_IDS: NpCardId[] = ['album', 'topSongs', 'credits', 'artist', 'discography', 'tour'];
export const DEFAULT_NP_LAYOUT: NpCardConfig[] = [
{ id: 'album', column: 'left', visible: true },
{ id: 'topSongs', column: 'left', visible: true },
{ id: 'credits', column: 'left', visible: true },
{ id: 'artist', column: 'right', visible: true },
{ id: 'discography', column: 'right', visible: true },
{ id: 'tour', column: 'right', visible: true },
];
interface NpLayoutStore {
cards: NpCardConfig[];
/** Move a card to a column at a given insertion index (0-based within that column). */
moveCard: (id: NpCardId, toColumn: NpColumn, toIndex: number) => void;
setVisible: (id: NpCardId, visible: boolean) => void;
reset: () => void;
}
export const useNpLayoutStore = create<NpLayoutStore>()(
persist(
(set) => ({
cards: DEFAULT_NP_LAYOUT,
moveCard: (id, toColumn, toIndex) => set((s) => {
const target = s.cards.find(c => c.id === id);
if (!target) return s;
const without = s.cards.filter(c => c.id !== id);
const left = without.filter(c => c.column === 'left');
const right = without.filter(c => c.column === 'right');
const moved: NpCardConfig = { ...target, column: toColumn };
const targetBucket = toColumn === 'left' ? left : right;
const clamped = Math.max(0, Math.min(toIndex, targetBucket.length));
targetBucket.splice(clamped, 0, moved);
return { cards: [...left, ...right] };
}),
setVisible: (id, visible) => set((s) => ({
cards: s.cards.map(c => c.id === id ? { ...c, visible } : c),
})),
reset: () => set({ cards: DEFAULT_NP_LAYOUT }),
}),
{
name: 'psysonic_np_layout',
onRehydrateStorage: () => (state) => {
if (!state) return;
const safe = (state.cards ?? []).filter((c): c is NpCardConfig =>
c != null && typeof c.id === 'string' && NP_CARD_IDS.includes(c.id as NpCardId)
);
const known = new Set(safe.map(c => c.id));
const missing = DEFAULT_NP_LAYOUT.filter(c => !known.has(c.id));
state.cards = missing.length > 0 ? [...safe, ...missing] : safe;
},
},
),
);
@@ -0,0 +1,192 @@
/**
* Index-first behaviour matrix for the Now Playing metadata resolvers (#1046).
* Index hit → no Subsonic call; index miss → network fallback (when reachable);
* index off → network fallback; unreachable → index still runs, no network call
* (PR #1049 gate split). The byte-style guard inside `getSongForServer` is
* exercised by useNowPlayingFetchers.test.ts.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import type { LibraryAdvancedSearchResponse } from '@/api/library';
import * as subsonicArtists from '@/api/subsonicArtists';
import * as subsonicLibrary from '@/api/subsonicLibrary';
// Network reachability is decided by the guard; mock it so we can test both arms.
vi.mock('@/utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForServer: vi.fn(() => true),
}));
import { shouldAttemptSubsonicForServer } from '@/utils/network/subsonicNetworkGuard';
import {
resolveNpAlbum,
resolveNpDiscography,
resolveNpTopSongs,
resolveNpSongMeta,
} from '@/features/nowPlaying/utils/nowPlayingMetadataResolve';
const guard = vi.mocked(shouldAttemptSubsonicForServer);
const ready = () =>
onInvoke('library_get_status', () => ({
serverId: 's1', libraryScope: '', syncPhase: 'ready',
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
}));
const search = (over: Partial<LibraryAdvancedSearchResponse>): LibraryAdvancedSearchResponse => ({
artists: [], albums: [], tracks: [],
totals: { artists: 0, albums: 0, tracks: 0 },
appliedFilters: [], source: 'local', ...over,
});
beforeEach(() => {
useLibraryIndexStore.setState({ masterEnabled: true });
vi.restoreAllMocks();
guard.mockReturnValue(true);
});
describe('resolveNpAlbum', () => {
it('index hit → no getAlbumForServer call', async () => {
ready();
onInvoke('library_get_tracks_by_album', () => [
{ serverId: 's1', id: 't1', title: 'Track', album: 'Alb', albumId: 'al1', artistId: 'ar1', durationSec: 100, syncedAt: 0, rawJson: {} },
]);
onInvoke('library_advanced_search', () => search({ albums: [{ serverId: 's1', id: 'al1', name: 'Alb', artistId: 'ar1', syncedAt: 0, rawJson: {} }] }));
const spy = vi.spyOn(subsonicLibrary, 'getAlbumForServer');
const res = await resolveNpAlbum('s1', 'al1');
expect(spy).not.toHaveBeenCalled();
expect(res?.album.id).toBe('al1');
});
it('index miss + reachable → getAlbumForServer fallback', async () => {
ready();
onInvoke('library_get_tracks_by_album', () => []);
const spy = vi.spyOn(subsonicLibrary, 'getAlbumForServer')
.mockResolvedValue({ album: { id: 'al1', name: 'Net' } as never, songs: [] });
const res = await resolveNpAlbum('s1', 'al1');
expect(spy).toHaveBeenCalledWith('s1', 'al1');
expect(res?.album.id).toBe('al1');
});
it('index off → getAlbumForServer fallback', async () => {
useLibraryIndexStore.setState({ masterEnabled: false });
const spy = vi.spyOn(subsonicLibrary, 'getAlbumForServer')
.mockResolvedValue({ album: { id: 'al1', name: 'Net' } as never, songs: [] });
await resolveNpAlbum('s1', 'al1');
expect(spy).toHaveBeenCalledWith('s1', 'al1');
});
it('unreachable → index runs, no network fallback', async () => {
guard.mockReturnValue(false);
ready();
onInvoke('library_get_tracks_by_album', () => []); // index miss
const spy = vi.spyOn(subsonicLibrary, 'getAlbumForServer');
const res = await resolveNpAlbum('s1', 'al1');
expect(spy).not.toHaveBeenCalled();
expect(res).toBeNull();
});
});
describe('resolveNpDiscography', () => {
it('index hit → no getArtistForServer call', async () => {
ready();
onInvoke('library_advanced_search', () => search({
albums: [
{ serverId: 's1', id: 'al1', name: 'A1', artistId: 'ar1', syncedAt: 0, rawJson: {} },
{ serverId: 's1', id: 'al2', name: 'A2', artistId: 'other', syncedAt: 0, rawJson: {} },
],
}));
const spy = vi.spyOn(subsonicArtists, 'getArtistForServer');
const albums = await resolveNpDiscography('s1', 'ar1');
expect(spy).not.toHaveBeenCalled();
expect(albums.map(a => a.id)).toEqual(['al1']);
});
it('index empty + reachable → getArtistForServer fallback', async () => {
ready();
onInvoke('library_advanced_search', () => search({ albums: [] }));
const spy = vi.spyOn(subsonicArtists, 'getArtistForServer')
.mockResolvedValue({ albums: [{ id: 'al9' }] } as never);
const albums = await resolveNpDiscography('s1', 'ar1');
expect(spy).toHaveBeenCalledWith('s1', 'ar1');
expect(albums.map(a => a.id)).toEqual(['al9']);
});
it('unreachable + index empty → no network, empty list', async () => {
guard.mockReturnValue(false);
ready();
onInvoke('library_advanced_search', () => search({ albums: [] }));
const spy = vi.spyOn(subsonicArtists, 'getArtistForServer');
const albums = await resolveNpDiscography('s1', 'ar1');
expect(spy).not.toHaveBeenCalled();
expect(albums).toEqual([]);
});
});
describe('resolveNpTopSongs', () => {
// Index path: artist's discography albums → their tracks → sort by play_count.
it('index hit → top tracks from the artist albums, by play count', async () => {
ready();
onInvoke('library_advanced_search', () => search({
albums: [{ serverId: 's1', id: 'al1', name: 'A1', artistId: 'ar1', syncedAt: 0, rawJson: {} }],
}));
onInvoke('library_get_tracks_by_album', () => [
{ serverId: 's1', id: 't-lo', title: 'Low', album: 'A1', artistId: 'ar1', durationSec: 1, playCount: 2, syncedAt: 0, rawJson: {} },
{ serverId: 's1', id: 't-hi', title: 'High', album: 'A1', artistId: 'ar1', durationSec: 1, playCount: 9, syncedAt: 0, rawJson: {} },
]);
const spy = vi.spyOn(subsonicArtists, 'getTopSongsForServer');
const songs = await resolveNpTopSongs('s1', 'ar1', 'Artist One');
expect(spy).not.toHaveBeenCalled();
expect(songs.map(s => s.id)).toEqual(['t-hi', 't-lo']); // play_count desc
});
it('index has no albums + reachable → getTopSongsForServer fallback', async () => {
ready();
onInvoke('library_advanced_search', () => search({ albums: [] }));
const spy = vi.spyOn(subsonicArtists, 'getTopSongsForServer')
.mockResolvedValue([{ id: 'net1' } as never]);
const songs = await resolveNpTopSongs('s1', 'ar1', 'Artist One');
expect(spy).toHaveBeenCalledWith('s1', 'Artist One');
expect(songs.map(s => s.id)).toEqual(['net1']);
});
it('unreachable + no index albums → no network, empty', async () => {
guard.mockReturnValue(false);
ready();
onInvoke('library_advanced_search', () => search({ albums: [] }));
const spy = vi.spyOn(subsonicArtists, 'getTopSongsForServer');
const songs = await resolveNpTopSongs('s1', 'ar1', 'Artist One');
expect(spy).not.toHaveBeenCalled();
expect(songs).toEqual([]);
});
});
describe('resolveNpSongMeta', () => {
it('index hit → no getSongForServer call', async () => {
ready();
onInvoke('library_get_track', () => ({
serverId: 's1', id: 't1', title: 'Local', album: 'Alb', artistId: 'ar1', durationSec: 100,
genre: 'Doom', playCount: 5, syncedAt: 0, rawJson: {},
}));
const spy = vi.spyOn(subsonicLibrary, 'getSongForServer');
const song = await resolveNpSongMeta('s1', 't1');
expect(spy).not.toHaveBeenCalled();
expect(song?.genre).toBe('Doom');
});
it('index miss → getSongForServer fallback', async () => {
ready();
onInvoke('library_get_track', () => null);
const spy = vi.spyOn(subsonicLibrary, 'getSongForServer')
.mockResolvedValue({ id: 't1', title: 'Net' } as never);
const song = await resolveNpSongMeta('s1', 't1');
expect(spy).toHaveBeenCalledWith('s1', 't1');
expect(song?.title).toBe('Net');
});
it('index off → getSongForServer fallback', async () => {
useLibraryIndexStore.setState({ masterEnabled: false });
const spy = vi.spyOn(subsonicLibrary, 'getSongForServer').mockResolvedValue(null);
await resolveNpSongMeta('s1', 't1');
expect(spy).toHaveBeenCalledWith('s1', 't1');
});
});
@@ -0,0 +1,108 @@
/**
* Index-first metadata resolvers for the Now Playing page (issue #1046).
*
* The local library index is first-class: when SQLite has the row, Now Playing
* reads it there; Subsonic/network is fallback only on index miss / index off /
* not ready. This mirrors the in-tree index-first family (`queueTrackResolver`,
* `offlineLibraryIndexLoad`, `useQueueTrackEnrichment`) rather than adding a
* fourth always-network path.
*
* Gate split (PR #1049 review): the index arm runs whenever there is a playback
* server id — including when the server is unreachable, the whole point of
* index-first for offline-pinned playback. The reachability guard
* (`shouldAttemptSubsonicForServer`, no trackId) lives only in each resolver's
* **network fallback arm**, so offline reads still succeed from SQLite.
*
* `artistInfo` (bio / similar) has no index source and stays network-only — it
* is intentionally absent here.
*/
import { libraryGetTrack, libraryGetTracksByAlbum } from '@/api/library';
import { getArtistForServer, getTopSongsForServer } from '@/api/subsonicArtists';
import { getAlbumForServer, getSongForServer } from '@/api/subsonicLibrary';
import type { SubsonicAlbum, SubsonicSong } from '@/api/subsonicTypes';
import { shouldAttemptSubsonicForServer } from '@/utils/network/subsonicNetworkGuard';
import { loadAlbumFromLibraryIndex, loadArtistFromLibraryIndex } from '@/utils/offline/offlineLibraryIndexLoad';
import { trackToSong } from '@/utils/library/advancedSearchLocal';
import { libraryIsReady } from '@/utils/library/libraryReady';
const TOP_SONGS_LIMIT = 5;
/** Album card — index `loadAlbumFromLibraryIndex`, else `getAlbumForServer`. */
export async function resolveNpAlbum(
serverId: string,
albumId: string,
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> {
if (await libraryIsReady(serverId)) {
try {
const hit = await loadAlbumFromLibraryIndex(serverId, albumId);
if (hit) return hit;
} catch { /* index error → network fallback */ }
}
if (!shouldAttemptSubsonicForServer(serverId)) return null;
return getAlbumForServer(serverId, albumId);
}
/** Discography — index `loadArtistFromLibraryIndex().albums`, else `getArtistForServer().albums`. */
export async function resolveNpDiscography(
serverId: string,
artistId: string,
): Promise<SubsonicAlbum[]> {
if (await libraryIsReady(serverId)) {
try {
const hit = await loadArtistFromLibraryIndex(serverId, artistId);
// Empty albums == miss: the index may not carry this artist's albums yet;
// let the network arm try before settling on an empty discography.
if (hit && hit.albums.length > 0) return hit.albums;
} catch { /* index error → network fallback */ }
}
if (!shouldAttemptSubsonicForServer(serverId)) return [];
const artist = await getArtistForServer(serverId, artistId);
return artist.albums;
}
/**
* Most played — derive from the artist's own discography albums (same bucket the
* discography card uses), sorted by play_count. This is deterministic: it can't
* pull the wrong artist's tracks the way an FTS-on-name query could. Network
* `getTopSongsForServer` is the fallback on index miss / off / unreachable.
*/
export async function resolveNpTopSongs(
serverId: string,
artistId: string | undefined,
artistName: string,
): Promise<SubsonicSong[]> {
if (artistId && await libraryIsReady(serverId)) {
try {
const hit = await loadArtistFromLibraryIndex(serverId, artistId);
if (hit && hit.albums.length > 0) {
const perAlbum = await Promise.all(
hit.albums.map(a => libraryGetTracksByAlbum(serverId, a.id).catch(() => [])),
);
const songs = perAlbum
.flat()
.map(trackToSong)
.sort((a, b) => (b.playCount ?? 0) - (a.playCount ?? 0))
.slice(0, TOP_SONGS_LIMIT);
if (songs.length > 0) return songs;
}
} catch { /* index error → network fallback */ }
}
if (!shouldAttemptSubsonicForServer(serverId)) return [];
return getTopSongsForServer(serverId, artistName);
}
/** Song-level meta — index `libraryGetTrack` → `trackToSong`, else `getSongForServer`. */
export async function resolveNpSongMeta(
serverId: string,
songId: string,
): Promise<SubsonicSong | null> {
if (await libraryIsReady(serverId)) {
try {
const dto = await libraryGetTrack(serverId, songId);
if (dto) return trackToSong(dto);
} catch { /* index error → network fallback */ }
}
// Network arm keeps its own byte-style guard (`shouldAttemptSubsonicForServer`
// with the trackId + psysonic-local:// skip) — unchanged from #1042.
return getSongForServer(serverId, songId);
}