mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(nowPlaying): co-locate now playing feature into features/nowPlaying
This commit is contained in:
@@ -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;
|
||||
@@ -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 effect’s 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 };
|
||||
}
|
||||
@@ -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 effect’s 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);
|
||||
}
|
||||
Reference in New Issue
Block a user