diff --git a/src/components/nowPlaying/AlbumCard.tsx b/src/components/nowPlaying/AlbumCard.tsx
new file mode 100644
index 00000000..1fb00d2b
--- /dev/null
+++ b/src/components/nowPlaying/AlbumCard.tsx
@@ -0,0 +1,98 @@
+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 { formatTime, formatTotalDuration } from '../../utils/nowPlayingHelpers';
+
+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 (
+
+
+
+
+ {t('nowPlaying.fromAlbum')}
+
+ {albumId && (
+
+ )}
+
+
+ {albumName}
+
+ {albumYear && {albumYear}}
+ {albumYear && ·}
+ {t('nowPlaying.trackPosition', { pos: position, defaultValue: 'Track {{pos}}' })}
+ ·
+ {formatTotalDuration(totalDur)}
+ {album?.playCount != null && album.playCount > 0 && (
+ <>·{t('nowPlaying.playsCount', { count: album.playCount, defaultValue: '{{count}} plays' })}>
+ )}
+
+
+
+ {visibleSongs.map(track => {
+ const isActive = track.id === currentTrackId;
+ return (
+
+
+ {isActive
+ ?
+ : track.track ?? '—'}
+
+ {track.title}
+ {formatTime(track.duration)}
+
+ );
+ })}
+
+ {songs.length > ALBUM_TRACK_LIMIT && (
+
+ )}
+
+ );
+});
+
+export default AlbumCard;
diff --git a/src/components/nowPlaying/ArtistCard.tsx b/src/components/nowPlaying/ArtistCard.tsx
new file mode 100644
index 00000000..a449ef40
--- /dev/null
+++ b/src/components/nowPlaying/ArtistCard.tsx
@@ -0,0 +1,98 @@
+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/nowPlayingHelpers';
+import CachedImage from '../CachedImage';
+
+interface ArtistCardProps {
+ artistName: string;
+ artistId?: string;
+ artistInfo: SubsonicArtistInfo | null;
+ onNavigate: (path: string) => void;
+}
+
+const ArtistCard = memo(function ArtistCard({ artistName, artistId, artistInfo, onNavigate }: ArtistCardProps) {
+ const { t } = useTranslation();
+ const [bioExpanded, setBioExpanded] = useState(false);
+ const [bioOverflows, setBioOverflows] = useState(false);
+ const bioRef = useRef(null);
+
+ useEffect(() => { setBioExpanded(false); }, [artistId]);
+
+ const bioHtml = useMemo(() => artistInfo?.biography ? sanitizeHtml(artistInfo.biography) : '', [artistInfo?.biography]);
+
+ useLayoutEffect(() => {
+ const el = bioRef.current;
+ if (!el) { setBioOverflows(false); return; }
+ setBioOverflows(el.scrollHeight - el.clientHeight > 1);
+ }, [bioHtml]);
+
+ const similar = artistInfo?.similarArtist ?? [];
+ const rawLarge = artistInfo?.largeImageUrl;
+ const rawMed = artistInfo?.mediumImageUrl;
+ const heroImage = isRealArtistImage(rawLarge)
+ ? rawLarge!
+ : isRealArtistImage(rawMed) ? rawMed! : '';
+ const heroCacheKey = artistId ? `artistInfo:${artistId}:hero` : '';
+
+ if (!bioHtml && similar.length === 0 && !heroImage) return null;
+
+ return (
+
+
+
{t('nowPlaying.aboutArtist')}
+ {artistId && (
+
+ )}
+
+
+
+ {heroImage && heroCacheKey && (
+
{ (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
+ />
+ )}
+
+
{artistName}
+ {bioHtml && (
+ <>
+
+ {(bioOverflows || bioExpanded) && (
+
+ )}
+ >
+ )}
+
+
+
+ {similar.length > 0 && (
+
+
+ {similar.slice(0, 12).map(a => (
+ a.id && onNavigate(`/artist/${a.id}`)}
+ data-tooltip={t('nowPlaying.goToArtist')}>
+ {a.name}
+
+ ))}
+
+
+ )}
+
+ );
+});
+
+export default ArtistCard;
diff --git a/src/components/nowPlaying/CreditsCard.tsx b/src/components/nowPlaying/CreditsCard.tsx
new file mode 100644
index 00000000..98c11982
--- /dev/null
+++ b/src/components/nowPlaying/CreditsCard.tsx
@@ -0,0 +1,27 @@
+import React, { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { ContributorRow } from '../../utils/nowPlayingHelpers';
+
+interface CreditsCardProps { rows: ContributorRow[]; }
+
+const CreditsCard = memo(function CreditsCard({ rows }: CreditsCardProps) {
+ const { t } = useTranslation();
+ if (rows.length === 0) return null;
+ return (
+
+
+
{t('nowPlayingInfo.songInfo', 'Song info')}
+
+
+ {rows.map(row => (
+ -
+ {t(`nowPlayingInfo.role.${row.role}`, row.role)}
+ {row.names.join(', ')}
+
+ ))}
+
+
+ );
+});
+
+export default CreditsCard;
diff --git a/src/components/nowPlaying/Hero.tsx b/src/components/nowPlaying/Hero.tsx
new file mode 100644
index 00000000..54a6590f
--- /dev/null
+++ b/src/components/nowPlaying/Hero.tsx
@@ -0,0 +1,163 @@
+import React, { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Headphones, Heart, MicVocal, Music, Star } from 'lucide-react';
+import type { LastfmArtistStats, LastfmTrackInfo } from '../../api/lastfm';
+import LastfmIcon from '../LastfmIcon';
+import { formatTime } from '../../utils/nowPlayingHelpers';
+
+interface HeroProps {
+ track: { title: string; artist: string; album: string; year?: number;
+ duration: number; suffix?: string; bitRate?: number; samplingRate?: number;
+ bitDepth?: number; artistId?: string; albumId?: string; id: string;
+ userRating?: number; };
+ genre?: string;
+ playCount?: number;
+ userRatingOverride?: number;
+ lfmTrack: LastfmTrackInfo | null;
+ lfmArtist: LastfmArtistStats | null;
+ starred: boolean;
+ lfmLoved: boolean;
+ lfmLoveEnabled: boolean;
+ activeLyricsTab: boolean;
+ coverUrl: string;
+ onNavigate: (path: string) => void;
+ onToggleStar: () => void;
+ onToggleLfmLove: () => void;
+ onOpenLyrics: () => void;
+}
+
+function renderStars(rating?: number) {
+ if (!rating) return null;
+ return (
+
+ {[1, 2, 3, 4, 5].map(i => (
+
+ ))}
+
+ );
+}
+
+const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverUrl, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) {
+ const { t } = useTranslation();
+ const rating = userRatingOverride ?? track.userRating;
+ const hiRes = (track.bitDepth && track.bitDepth > 16) || (track.samplingRate && track.samplingRate > 48000);
+ const releaseAge = track.year ? new Date().getFullYear() - track.year : 0;
+
+ return (
+
+
+ {coverUrl
+ ?

+ :
}
+
+
+
{track.title}
+
+ track.artistId && onNavigate(`/artist/${track.artistId}`)}
+ style={{ cursor: track.artistId ? 'pointer' : 'default' }}>
+ {track.artist}
+
+ ·
+ track.albumId && onNavigate(`/album/${track.albumId}`)}
+ style={{ cursor: track.albumId ? 'pointer' : 'default' }}>
+ {track.album}
+
+ {track.year && <>·{track.year}>}
+ {releaseAge > 0 && (
+ <>·
+
+ {t('nowPlaying.releasedYearsAgo', { count: releaseAge, defaultValue: '{{count}} years ago' })}
+ >
+ )}
+
+
+
+ {genre && {genre}}
+ {track.suffix && {track.suffix.toUpperCase()}}
+ {track.bitRate && {track.bitRate} kbps}
+ {track.samplingRate && {(track.samplingRate / 1000).toFixed(1)} kHz}
+ {track.bitDepth && {track.bitDepth}-bit}
+ {hiRes && Hi-Res}
+ {track.duration > 0 && {formatTime(track.duration)}}
+
+
+
+
+ {lfmLoveEnabled && (
+
+ )}
+
+ {rating && renderStars(rating)}
+
+
+ {(playCount != null && playCount > 0) && (
+
+
+ {t('nowPlaying.playsCount', { count: playCount, defaultValue: '{{count}} plays' })}
+
+ )}
+
+ {(lfmTrack || lfmArtist) && (
+
+
+ Last.fm
+
+ {lfmTrack && (
+
+ {t('nowPlaying.thisTrack', 'This track')}
+ —
+ {t('nowPlaying.listenersN', { n: lfmTrack.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}
+ ·
+ {t('nowPlaying.scrobblesN', { n: lfmTrack.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}
+ {lfmTrack.userPlaycount != null && (
+ <>
+ ·
+
+ {t('nowPlaying.playsByYouN', { n: lfmTrack.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
+
+ >
+ )}
+
+ )}
+ {lfmArtist && (
+
+ {t('nowPlaying.thisArtist', 'This artist')}
+ —
+ {t('nowPlaying.listenersN', { n: lfmArtist.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}
+ ·
+ {t('nowPlaying.scrobblesN', { n: lfmArtist.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}
+ {lfmArtist.userPlaycount != null && (
+ <>
+ ·
+
+ {t('nowPlaying.playsByYouN', { n: lfmArtist.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
+
+ >
+ )}
+
+ )}
+
+ )}
+
+
+ );
+});
+
+export default Hero;
diff --git a/src/components/nowPlaying/TopSongsCard.tsx b/src/components/nowPlaying/TopSongsCard.tsx
new file mode 100644
index 00000000..da322bb4
--- /dev/null
+++ b/src/components/nowPlaying/TopSongsCard.tsx
@@ -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 { formatTime } from '../../utils/nowPlayingHelpers';
+
+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 (
+
+
+
+
+ {t('nowPlaying.topSongs', { defaultValue: 'Most played by this artist' })}
+
+ {artistId && (
+
+ )}
+
+
+ {top.map((s, idx) => {
+ const isActive = s.id === currentTrackId;
+ return (
+
onPlay(s)}
+ data-tooltip={t('contextMenu.playNow')}>
+
{idx + 1}
+
+ {s.title}
+ {s.album && {s.album}}
+
+
{formatTime(s.duration)}
+
+
+ );
+ })}
+
+
{t('nowPlaying.topSongsCredit', { name: artistName, defaultValue: 'Top tracks from {{name}}' })}
+
+ );
+});
+
+export default TopSongsCard;
diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx
index f63367a3..990350d0 100644
--- a/src/pages/NowPlaying.tsx
+++ b/src/pages/NowPlaying.tsx
@@ -3,10 +3,10 @@ import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
import { getSong, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicSong, SubsonicArtistInfo, SubsonicAlbum } from '../api/subsonicTypes';
-import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useMemo, memo } from 'react';
+import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward, Info, Headphones, Calendar, Disc3, TrendingUp, Play, EyeOff, LayoutGrid, RotateCcw, Eye } from 'lucide-react';
+import { Music, ExternalLink, Cast, Users, Radio, Clock, SkipForward, Info, Calendar, Disc3, Play, EyeOff, LayoutGrid, RotateCcw, Eye } from 'lucide-react';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -21,7 +21,6 @@ import {
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
import { useCachedUrl } from '../components/CachedImage';
import CachedImage from '../components/CachedImage';
-import LastfmIcon from '../components/LastfmIcon';
import { useRadioMetadata } from '../hooks/useRadioMetadata';
import { useDragSource, useDragDrop } from '../contexts/DragDropContext';
import OverlayScrollArea from '../components/OverlayScrollArea';
@@ -33,28 +32,19 @@ import {
// ─── Helpers ──────────────────────────────────────────────────────────────────
import {
- formatTime, formatCompact, formatTotalDuration, sanitizeHtml, isoToParts,
- buildContributorRows, isRealArtistImage,
+ formatTime, formatCompact, isoToParts,
+ buildContributorRows,
type ContributorRow,
} from '../utils/nowPlayingHelpers';
import { makeCache } from '../utils/nowPlayingCache';
import NpCardWrap from '../components/nowPlaying/NpCardWrap';
import NpColumnEl from '../components/nowPlaying/NpColumnEl';
import RadioView from '../components/nowPlaying/RadioView';
-
-function renderStars(rating?: number) {
- if (!rating) return null;
- return (
-
- {[1, 2, 3, 4, 5].map(i => (
-
- ))}
-
- );
-}
+import Hero from '../components/nowPlaying/Hero';
+import ArtistCard from '../components/nowPlaying/ArtistCard';
+import AlbumCard from '../components/nowPlaying/AlbumCard';
+import TopSongsCard from '../components/nowPlaying/TopSongsCard';
+import CreditsCard from '../components/nowPlaying/CreditsCard';
// ─── Module-level TTL caches (shared across mounts) ───────────────────────────
@@ -69,400 +59,6 @@ const lfmArtistCache = makeCache();
// ─── Subcomponents (all memoized) ─────────────────────────────────────────────
-interface HeroProps {
- track: { title: string; artist: string; album: string; year?: number;
- duration: number; suffix?: string; bitRate?: number; samplingRate?: number;
- bitDepth?: number; artistId?: string; albumId?: string; id: string;
- userRating?: number; };
- genre?: string;
- playCount?: number;
- userRatingOverride?: number;
- lfmTrack: LastfmTrackInfo | null;
- lfmArtist: LastfmArtistStats | null;
- starred: boolean;
- lfmLoved: boolean;
- lfmLoveEnabled: boolean;
- activeLyricsTab: boolean;
- coverUrl: string;
- onNavigate: (path: string) => void;
- onToggleStar: () => void;
- onToggleLfmLove: () => void;
- onOpenLyrics: () => void;
-}
-
-const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverUrl, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) {
- const { t } = useTranslation();
- const rating = userRatingOverride ?? track.userRating;
- const hiRes = (track.bitDepth && track.bitDepth > 16) || (track.samplingRate && track.samplingRate > 48000);
- const releaseAge = track.year ? new Date().getFullYear() - track.year : 0;
-
- return (
-
-
- {coverUrl
- ?

- :
}
-
-
-
{track.title}
-
- track.artistId && onNavigate(`/artist/${track.artistId}`)}
- style={{ cursor: track.artistId ? 'pointer' : 'default' }}>
- {track.artist}
-
- ·
- track.albumId && onNavigate(`/album/${track.albumId}`)}
- style={{ cursor: track.albumId ? 'pointer' : 'default' }}>
- {track.album}
-
- {track.year && <>·{track.year}>}
- {releaseAge > 0 && (
- <>·
-
- {t('nowPlaying.releasedYearsAgo', { count: releaseAge, defaultValue: '{{count}} years ago' })}
- >
- )}
-
-
-
- {genre && {genre}}
- {track.suffix && {track.suffix.toUpperCase()}}
- {track.bitRate && {track.bitRate} kbps}
- {track.samplingRate && {(track.samplingRate / 1000).toFixed(1)} kHz}
- {track.bitDepth && {track.bitDepth}-bit}
- {hiRes && Hi-Res}
- {track.duration > 0 && {formatTime(track.duration)}}
-
-
-
-
- {lfmLoveEnabled && (
-
- )}
-
- {rating && renderStars(rating)}
-
-
- {(playCount != null && playCount > 0) && (
-
-
- {t('nowPlaying.playsCount', { count: playCount, defaultValue: '{{count}} plays' })}
-
- )}
-
- {(lfmTrack || lfmArtist) && (
-
-
- Last.fm
-
- {lfmTrack && (
-
- {t('nowPlaying.thisTrack', 'This track')}
- —
- {t('nowPlaying.listenersN', { n: lfmTrack.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}
- ·
- {t('nowPlaying.scrobblesN', { n: lfmTrack.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}
- {lfmTrack.userPlaycount != null && (
- <>
- ·
-
- {t('nowPlaying.playsByYouN', { n: lfmTrack.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
-
- >
- )}
-
- )}
- {lfmArtist && (
-
- {t('nowPlaying.thisArtist', 'This artist')}
- —
- {t('nowPlaying.listenersN', { n: lfmArtist.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}
- ·
- {t('nowPlaying.scrobblesN', { n: lfmArtist.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}
- {lfmArtist.userPlaycount != null && (
- <>
- ·
-
- {t('nowPlaying.playsByYouN', { n: lfmArtist.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
-
- >
- )}
-
- )}
-
- )}
-
-
- );
-});
-
-interface ArtistCardProps {
- artistName: string;
- artistId?: string;
- artistInfo: SubsonicArtistInfo | null;
- onNavigate: (path: string) => void;
-}
-
-const ArtistCard = memo(function ArtistCard({ artistName, artistId, artistInfo, onNavigate }: ArtistCardProps) {
- const { t } = useTranslation();
- const [bioExpanded, setBioExpanded] = useState(false);
- const [bioOverflows, setBioOverflows] = useState(false);
- const bioRef = useRef(null);
-
- useEffect(() => { setBioExpanded(false); }, [artistId]);
-
- const bioHtml = useMemo(() => artistInfo?.biography ? sanitizeHtml(artistInfo.biography) : '', [artistInfo?.biography]);
-
- useLayoutEffect(() => {
- const el = bioRef.current;
- if (!el) { setBioOverflows(false); return; }
- setBioOverflows(el.scrollHeight - el.clientHeight > 1);
- }, [bioHtml]);
-
- const similar = artistInfo?.similarArtist ?? [];
- const rawLarge = artistInfo?.largeImageUrl;
- const rawMed = artistInfo?.mediumImageUrl;
- const heroImage = isRealArtistImage(rawLarge)
- ? rawLarge!
- : isRealArtistImage(rawMed) ? rawMed! : '';
- const heroCacheKey = artistId ? `artistInfo:${artistId}:hero` : '';
-
- if (!bioHtml && similar.length === 0 && !heroImage) return null;
-
- return (
-
-
-
{t('nowPlaying.aboutArtist')}
- {artistId && (
-
- )}
-
-
-
- {heroImage && heroCacheKey && (
-
{ (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
- />
- )}
-
-
{artistName}
- {bioHtml && (
- <>
-
- {(bioOverflows || bioExpanded) && (
-
- )}
- >
- )}
-
-
-
- {similar.length > 0 && (
-
-
- {similar.slice(0, 12).map(a => (
- a.id && onNavigate(`/artist/${a.id}`)}
- data-tooltip={t('nowPlaying.goToArtist')}>
- {a.name}
-
- ))}
-
-
- )}
-
- );
-});
-
-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 (
-
-
-
-
- {t('nowPlaying.fromAlbum')}
-
- {albumId && (
-
- )}
-
-
- {albumName}
-
- {albumYear && {albumYear}}
- {albumYear && ·}
- {t('nowPlaying.trackPosition', { pos: position, defaultValue: 'Track {{pos}}' })}
- ·
- {formatTotalDuration(totalDur)}
- {album?.playCount != null && album.playCount > 0 && (
- <>·{t('nowPlaying.playsCount', { count: album.playCount, defaultValue: '{{count}} plays' })}>
- )}
-
-
-
- {visibleSongs.map(track => {
- const isActive = track.id === currentTrackId;
- return (
-
-
- {isActive
- ?
- : track.track ?? '—'}
-
- {track.title}
- {formatTime(track.duration)}
-
- );
- })}
-
- {songs.length > ALBUM_TRACK_LIMIT && (
-
- )}
-
- );
-});
-
-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 (
-
-
-
-
- {t('nowPlaying.topSongs', { defaultValue: 'Most played by this artist' })}
-
- {artistId && (
-
- )}
-
-
- {top.map((s, idx) => {
- const isActive = s.id === currentTrackId;
- return (
-
onPlay(s)}
- data-tooltip={t('contextMenu.playNow')}>
-
{idx + 1}
-
- {s.title}
- {s.album && {s.album}}
-
-
{formatTime(s.duration)}
-
-
- );
- })}
-
-
{t('nowPlaying.topSongsCredit', { name: artistName, defaultValue: 'Top tracks from {{name}}' })}
-
- );
-});
-
-interface CreditsCardProps { rows: ContributorRow[]; }
-
-const CreditsCard = memo(function CreditsCard({ rows }: CreditsCardProps) {
- const { t } = useTranslation();
- if (rows.length === 0) return null;
- return (
-
-
-
{t('nowPlayingInfo.songInfo', 'Song info')}
-
-
- {rows.map(row => (
- -
- {t(`nowPlayingInfo.role.${row.role}`, row.role)}
- {row.names.join(', ')}
-
- ))}
-
-
- );
-});
interface TourCardProps {
artistName: string;