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;