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,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;
|
||||
Reference in New Issue
Block a user