From 48e1ff5a588716eeceb376a439584a70f02f4dec Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:57:44 -0400 Subject: [PATCH] improve artist page --- src/app/(tabs)/library/artist/[name].tsx | 575 ++++++++++++++++-- .../(tabs)/library/artist/[name]/albums.tsx | 111 ++++ .../library/artist/[name]/appearances.tsx | 117 ++++ .../(tabs)/library/artist/[name]/songs.tsx | 117 ++++ src/components/library/TrackRow.tsx | 8 +- src/library/artistDetail.ts | 116 ++++ 6 files changed, 977 insertions(+), 67 deletions(-) create mode 100644 src/app/(tabs)/library/artist/[name]/albums.tsx create mode 100644 src/app/(tabs)/library/artist/[name]/appearances.tsx create mode 100644 src/app/(tabs)/library/artist/[name]/songs.tsx create mode 100644 src/library/artistDetail.ts diff --git a/src/app/(tabs)/library/artist/[name].tsx b/src/app/(tabs)/library/artist/[name].tsx index 77aba38..93981be 100644 --- a/src/app/(tabs)/library/artist/[name].tsx +++ b/src/app/(tabs)/library/artist/[name].tsx @@ -1,40 +1,137 @@ -import { useMemo, useState } from 'react'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { useMemo, useState, type ComponentProps } from 'react'; +import { Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native'; +import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; import { FlashList } from '@shopify/flash-list'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; +import { AstraLogo } from '@/components/AstraLogo'; import { TrackRow } from '@/components/library/TrackRow'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; -import { colors, radius, spacing } from '@/theme'; +import { colors, fontSize, radius, spacing } from '@/theme'; import { useLibraryStore } from '@/stores/libraryStore'; import { usePlayerStore } from '@/stores/playerStore'; import { useSettingsStore } from '@/stores/settingsStore'; import { playTracks, shuffleTracks } from '@/audio/playbackController'; import { dbTrackToTrack } from '@/library/trackAdapter'; -import { filterTracksByArtist } from '@/library/artistGrouping'; +import { artworkUri } from '@/library/artwork'; +import { buildArtistDetail, type ArtistAlbum, type ArtistDetail } from '@/library/artistDetail'; import type { DbTrack } from '@/types/library'; +type IconName = ComponentProps['name']; +type ArtistSectionTarget = 'songs' | 'albums' | 'appearances'; + +const SONG_PREVIEW_LIMIT = 5; +const ALBUM_PREVIEW_LIMIT = 8; +const APPEARANCE_PREVIEW_LIMIT = 5; + +type ArtistPageItem = + | { key: 'hero'; type: 'hero' } + | { + key: string; + type: 'section'; + title: string; + trailing: string; + target?: ArtistSectionTarget; + } + | { key: 'albums'; type: 'albums' } + | { key: string; type: 'track'; track: DbTrack; section: 'appearances' | 'songs'; index: number } + | { key: 'empty'; type: 'empty' }; + export default function ArtistScreen() { const router = useRouter(); - const { name } = useLocalSearchParams<{ name: string }>(); + const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); + const { width } = useWindowDimensions(); const allTracks = useLibraryStore((s) => s.tracks); const groupingMode = useSettingsStore((s) => s.artistGroupingMode); const currentPath = usePlayerStore((s) => s.currentTrack?.path); const [actionTrack, setActionTrack] = useState(null); - // Match the artist list's grouping mode; store tracks are ordered - // artist/album/disc/track, so the filtered slice keeps album/track order. - const tracks = useMemo( - () => filterTracksByArtist(allTracks, name, groupingMode), + const detail = useMemo( + () => buildArtistDetail(allTracks, name, groupingMode), [allTracks, name, groupingMode] ); - const playFrom = (index: number) => { + const listItems = useMemo(() => buildListItems(detail), [detail]); + + const playTrackListFrom = (tracks: readonly DbTrack[], index: number) => { + if (tracks.length === 0) return; void playTracks(tracks.map(dbTrackToTrack), index); }; + const playArtist = () => playTrackListFrom(detail.playbackTracks, 0); + const shuffleArtist = () => { + if (detail.playbackTracks.length === 0) return; + void shuffleTracks(detail.playbackTracks.map(dbTrackToTrack)); + }; + + const openSection = (target: ArtistSectionTarget) => { + router.push({ + pathname: `/library/artist/[name]/${target}`, + params: { name }, + }); + }; + + const renderItem = ({ item }: { item: ArtistPageItem }) => { + switch (item.type) { + case 'hero': + return ( + + ); + case 'section': { + const target = item.target; + return ( + openSection(target) : undefined} + /> + ); + } + case 'albums': + return ( + + router.push({ + pathname: '/library/album/[key]', + params: { key: album.identity_key }, + }) + } + /> + ); + case 'track': { + const sourceTracks = + item.section === 'appearances' ? detail.appearanceTracks : detail.songTracks; + return ( + playTrackListFrom(sourceTracks, item.index)} + onLongPress={() => setActionTrack(item.track)} + /> + ); + } + case 'empty': + return ( + + + + No tracks found for this artist. + + + ); + } + }; + return ( router.back()} hitSlop={8}> @@ -44,45 +141,12 @@ export default function ArtistScreen() { - - - - {name} - - - {tracks.length} {tracks.length === 1 ? 'track' : 'tracks'} - - - playFrom(0)} accessibilityRole="button"> - - - Play - - - void shuffleTracks(tracks.map(dbTrackToTrack))} - accessibilityRole="button" - > - - - Shuffle - - - - String(track.id)} + data={listItems} + keyExtractor={(item) => item.key} showsVerticalScrollIndicator={false} - renderItem={({ item, index }) => ( - playFrom(index)} - onLongPress={() => setActionTrack(item)} - /> - )} + renderItem={renderItem} + contentContainerStyle={styles.listContent} /> setActionTrack(null)} /> @@ -90,47 +154,428 @@ export default function ArtistScreen() { ); } +function buildListItems(detail: ArtistDetail): ArtistPageItem[] { + const items: ArtistPageItem[] = [{ key: 'hero', type: 'hero' }]; + + if (detail.tracks.length === 0) { + items.push({ key: 'empty', type: 'empty' }); + return items; + } + + items.push({ + key: 'section-songs', + type: 'section', + title: 'Songs', + trailing: formatCount(detail.songTracks.length, 'track'), + target: 'songs', + }); + detail.songTracks.slice(0, SONG_PREVIEW_LIMIT).forEach((track, index) => { + items.push({ key: `song-${track.id}`, type: 'track', track, section: 'songs', index }); + }); + + if (detail.albums.length > 0) { + items.push({ + key: 'section-albums', + type: 'section', + title: 'Albums', + trailing: formatCount(detail.albums.length, 'album'), + target: 'albums', + }); + items.push({ key: 'albums', type: 'albums' }); + } + + if (detail.showAppearances) { + items.push({ + key: 'section-appearances', + type: 'section', + title: 'Appears On', + trailing: formatCount(detail.appearanceTracks.length, 'track'), + target: 'appearances', + }); + detail.appearanceTracks.slice(0, APPEARANCE_PREVIEW_LIMIT).forEach((track, index) => { + items.push({ + key: `appearance-${track.id}`, + type: 'track', + track, + section: 'appearances', + index, + }); + }); + } + + return items; +} + +function ArtistHero({ + artistName, + detail, + compact, + onPlay, + onShuffle, +}: { + artistName: string; + detail: ArtistDetail; + compact: boolean; + onPlay: () => void; + onShuffle: () => void; +}) { + const disabled = detail.playbackTracks.length === 0; + + return ( + + + + + {artistName} + + + {detail.albums.length > 0 ? ( + + ) : null} + + {detail.totalDuration > 0 ? ( + + ) : null} + + + + + + + + Play + + + + + + Shuffle + + + + + ); +} + +function ArtistArtwork({ hashes, compact }: { hashes: string[]; compact: boolean }) { + const useMosaic = hashes.length >= 4; + const displayHashes = useMosaic ? hashes.slice(0, 4) : hashes.slice(0, 1); + + return ( + + {displayHashes.length === 0 ? ( + + ) : useMosaic ? ( + displayHashes.map((hash) => ( + + )) + ) : ( + + )} + + ); +} + +function SectionHeader({ + title, + trailing, + onPress, +}: { + title: string; + trailing: string; + onPress?: () => void; +}) { + return ( + + + + {title} + + + {trailing} + + + {onPress ? ( + + + See all + + + + ) : null} + + ); +} + +function AlbumRail({ + albums, + onAlbumPress, +}: { + albums: ArtistAlbum[]; + onAlbumPress: (album: ArtistAlbum) => void; +}) { + return ( + + {albums.map((album) => ( + onAlbumPress(album)} + accessibilityRole="button" + > + + {album.artwork_hash ? ( + + ) : ( + + )} + + + {album.album} + + + {[album.year ? String(album.year) : null, formatCount(album.track_count, 'track')] + .filter(Boolean) + .join(' - ')} + + + ))} + + ); +} + +function StatChip({ icon, label }: { icon: IconName; label: string }) { + return ( + + + + {label} + + + ); +} + +function formatCount(count: number, noun: string): string { + return `${count} ${count === 1 ? noun : `${noun}s`}`; +} + +function formatRuntime(seconds: number): string { + const totalMinutes = seconds > 0 ? Math.max(1, Math.round(seconds / 60)) : 0; + if (totalMinutes < 60) return `${totalMinutes}m`; + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +} + +function trackSubtitle(track: DbTrack, section: 'appearances' | 'songs'): string { + if (section === 'appearances') return `${track.artist} - ${track.album}`; + return track.album; +} + const styles = StyleSheet.create({ back: { flexDirection: 'row', alignItems: 'center', gap: 2, marginTop: spacing.md, - marginBottom: spacing.md, + marginBottom: spacing.sm, alignSelf: 'flex-start', }, - header: { + listContent: { + paddingBottom: spacing.xxl, + }, + hero: { + alignItems: 'center', + gap: spacing.lg, + paddingTop: spacing.sm, + paddingBottom: spacing.lg, + }, + heroArt: { + width: 168, + height: 168, + borderRadius: radius.lg, + backgroundColor: colors.bgTertiary, + borderColor: colors.glassBorder, + borderWidth: StyleSheet.hairlineWidth, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + flexDirection: 'row', + flexWrap: 'wrap', + }, + heroArtCompact: { + width: 144, + height: 144, + }, + heroArtImage: { + width: '100%', + height: '100%', + }, + mosaicTile: { + width: '50%', + height: '50%', + }, + heroMeta: { + width: '100%', + alignItems: 'center', + gap: spacing.md, + }, + artistName: { + maxWidth: '100%', + textAlign: 'center', + lineHeight: fontSize.xxl * 1.08, + }, + stats: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + gap: spacing.xs, + }, + statChip: { + minHeight: 28, + maxWidth: '100%', flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', - marginBottom: spacing.lg, + gap: spacing.xs, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.pill, + backgroundColor: colors.glassHighlight, + borderColor: colors.glassBorder, + borderWidth: StyleSheet.hairlineWidth, + }, + statLabel: { + maxWidth: 180, + }, + actionRow: { + width: '100%', + flexDirection: 'row', gap: spacing.sm, }, - headerMeta: { + actionButton: { flex: 1, - gap: spacing.xs, - }, - playButton: { + minHeight: 44, flexDirection: 'row', alignItems: 'center', + justifyContent: 'center', gap: spacing.xs, - backgroundColor: colors.accent, borderRadius: radius.pill, paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, }, - shuffleButton: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, + primaryAction: { + backgroundColor: colors.accent, + }, + secondaryAction: { borderColor: colors.accent, borderWidth: StyleSheet.hairlineWidth, - borderRadius: radius.pill, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, + backgroundColor: colors.glassBg, }, - playLabel: { + disabledAction: { + opacity: 0.45, + }, + primaryActionText: { color: colors.bgPrimary, fontWeight: '600', }, + secondaryActionText: { + fontWeight: '600', + }, + sectionHeader: { + minHeight: 36, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.md, + marginTop: spacing.lg, + marginBottom: spacing.sm, + }, + sectionTitleGroup: { + flex: 1, + minWidth: 0, + }, + sectionTitle: { + fontSize: fontSize.md, + }, + seeAllButton: { + minHeight: 32, + flexDirection: 'row', + alignItems: 'center', + gap: 2, + paddingLeft: spacing.sm, + }, + albumRail: { + gap: spacing.md, + paddingRight: spacing.lg, + paddingBottom: spacing.sm, + }, + albumCard: { + width: 132, + }, + albumArt: { + width: 132, + height: 132, + borderRadius: radius.md, + backgroundColor: colors.bgTertiary, + borderColor: colors.glassBorder, + borderWidth: StyleSheet.hairlineWidth, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + marginBottom: spacing.sm, + }, + albumArtImage: { + width: '100%', + height: '100%', + }, + albumTitle: { + minHeight: 38, + fontSize: 14, + lineHeight: 19, + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.xxl, + }, + emptyText: { + textAlign: 'center', + }, }); diff --git a/src/app/(tabs)/library/artist/[name]/albums.tsx b/src/app/(tabs)/library/artist/[name]/albums.tsx new file mode 100644 index 0000000..3476041 --- /dev/null +++ b/src/app/(tabs)/library/artist/[name]/albums.tsx @@ -0,0 +1,111 @@ +import { useMemo } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { FlashList } from '@shopify/flash-list'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Screen } from '@/components/Screen'; +import { Text } from '@/components/Text'; +import { AlbumGridItem } from '@/components/library/AlbumGridItem'; +import { colors, spacing } from '@/theme'; +import { useLibraryStore } from '@/stores/libraryStore'; +import { useSettingsStore } from '@/stores/settingsStore'; +import { buildArtistDetail } from '@/library/artistDetail'; + +export default function ArtistAlbumsScreen() { + const router = useRouter(); + const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); + const allTracks = useLibraryStore((s) => s.tracks); + const groupingMode = useSettingsStore((s) => s.artistGroupingMode); + + const detail = useMemo( + () => buildArtistDetail(allTracks, name, groupingMode), + [allTracks, name, groupingMode] + ); + + return ( + + router.back()} hitSlop={8}> + + + {name} + + + + + + Albums + + {formatCount(detail.albums.length, 'album')} + + + album.identity_key} + showsVerticalScrollIndicator={false} + renderItem={({ item }) => ( + + + router.push({ + pathname: '/library/album/[key]', + params: { key: item.identity_key }, + }) + } + /> + + )} + ListEmptyComponent={} + contentContainerStyle={styles.listContent} + /> + + ); +} + +function EmptyList({ label }: { label: string }) { + return ( + + + + {label} + + + ); +} + +function formatCount(count: number, noun: string): string { + return `${count} ${count === 1 ? noun : `${noun}s`}`; +} + +const styles = StyleSheet.create({ + back: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + marginTop: spacing.md, + marginBottom: spacing.lg, + alignSelf: 'flex-start', + maxWidth: '100%', + }, + heading: { + gap: spacing.xs, + marginBottom: spacing.lg, + }, + listContent: { + paddingBottom: spacing.xxl, + }, + gridCell: { + flex: 1, + paddingHorizontal: spacing.xs, + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.xxl, + }, + emptyText: { + textAlign: 'center', + }, +}); diff --git a/src/app/(tabs)/library/artist/[name]/appearances.tsx b/src/app/(tabs)/library/artist/[name]/appearances.tsx new file mode 100644 index 0000000..2614a02 --- /dev/null +++ b/src/app/(tabs)/library/artist/[name]/appearances.tsx @@ -0,0 +1,117 @@ +import { useMemo, useState } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { FlashList } from '@shopify/flash-list'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Screen } from '@/components/Screen'; +import { Text } from '@/components/Text'; +import { TrackRow } from '@/components/library/TrackRow'; +import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; +import { colors, spacing } from '@/theme'; +import { useLibraryStore } from '@/stores/libraryStore'; +import { usePlayerStore } from '@/stores/playerStore'; +import { useSettingsStore } from '@/stores/settingsStore'; +import { playTracks } from '@/audio/playbackController'; +import { dbTrackToTrack } from '@/library/trackAdapter'; +import { buildArtistDetail } from '@/library/artistDetail'; +import type { DbTrack } from '@/types/library'; + +export default function ArtistAppearancesScreen() { + const router = useRouter(); + const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); + const allTracks = useLibraryStore((s) => s.tracks); + const groupingMode = useSettingsStore((s) => s.artistGroupingMode); + const currentPath = usePlayerStore((s) => s.currentTrack?.path); + const [actionTrack, setActionTrack] = useState(null); + + const detail = useMemo( + () => buildArtistDetail(allTracks, name, groupingMode), + [allTracks, name, groupingMode] + ); + const tracks = detail.appearanceTracks; + + const playFrom = (index: number) => { + if (tracks.length === 0) return; + void playTracks(tracks.map(dbTrackToTrack), index); + }; + + return ( + + router.back()} hitSlop={8}> + + + {name} + + + + + + Appears On + + {formatCount(tracks.length, 'track')} + + + String(track.id)} + showsVerticalScrollIndicator={false} + renderItem={({ item, index }) => ( + playFrom(index)} + onLongPress={() => setActionTrack(item)} + /> + )} + ListEmptyComponent={} + contentContainerStyle={styles.listContent} + /> + + setActionTrack(null)} /> + + ); +} + +function EmptyList({ label }: { label: string }) { + return ( + + + + {label} + + + ); +} + +function formatCount(count: number, noun: string): string { + return `${count} ${count === 1 ? noun : `${noun}s`}`; +} + +const styles = StyleSheet.create({ + back: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + marginTop: spacing.md, + marginBottom: spacing.lg, + alignSelf: 'flex-start', + maxWidth: '100%', + }, + heading: { + gap: spacing.xs, + marginBottom: spacing.lg, + }, + listContent: { + paddingBottom: spacing.xxl, + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.xxl, + }, + emptyText: { + textAlign: 'center', + }, +}); diff --git a/src/app/(tabs)/library/artist/[name]/songs.tsx b/src/app/(tabs)/library/artist/[name]/songs.tsx new file mode 100644 index 0000000..19ab604 --- /dev/null +++ b/src/app/(tabs)/library/artist/[name]/songs.tsx @@ -0,0 +1,117 @@ +import { useMemo, useState } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { FlashList } from '@shopify/flash-list'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Screen } from '@/components/Screen'; +import { Text } from '@/components/Text'; +import { TrackRow } from '@/components/library/TrackRow'; +import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; +import { colors, spacing } from '@/theme'; +import { useLibraryStore } from '@/stores/libraryStore'; +import { usePlayerStore } from '@/stores/playerStore'; +import { useSettingsStore } from '@/stores/settingsStore'; +import { playTracks } from '@/audio/playbackController'; +import { dbTrackToTrack } from '@/library/trackAdapter'; +import { buildArtistDetail } from '@/library/artistDetail'; +import type { DbTrack } from '@/types/library'; + +export default function ArtistSongsScreen() { + const router = useRouter(); + const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); + const allTracks = useLibraryStore((s) => s.tracks); + const groupingMode = useSettingsStore((s) => s.artistGroupingMode); + const currentPath = usePlayerStore((s) => s.currentTrack?.path); + const [actionTrack, setActionTrack] = useState(null); + + const detail = useMemo( + () => buildArtistDetail(allTracks, name, groupingMode), + [allTracks, name, groupingMode] + ); + const tracks = detail.songTracks; + + const playFrom = (index: number) => { + if (tracks.length === 0) return; + void playTracks(tracks.map(dbTrackToTrack), index); + }; + + return ( + + router.back()} hitSlop={8}> + + + {name} + + + + + + Songs + + {formatCount(tracks.length, 'track')} + + + String(track.id)} + showsVerticalScrollIndicator={false} + renderItem={({ item, index }) => ( + playFrom(index)} + onLongPress={() => setActionTrack(item)} + /> + )} + ListEmptyComponent={} + contentContainerStyle={styles.listContent} + /> + + setActionTrack(null)} /> + + ); +} + +function EmptyList({ label }: { label: string }) { + return ( + + + + {label} + + + ); +} + +function formatCount(count: number, noun: string): string { + return `${count} ${count === 1 ? noun : `${noun}s`}`; +} + +const styles = StyleSheet.create({ + back: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + marginTop: spacing.md, + marginBottom: spacing.lg, + alignSelf: 'flex-start', + maxWidth: '100%', + }, + heading: { + gap: spacing.xs, + marginBottom: spacing.lg, + }, + listContent: { + paddingBottom: spacing.xxl, + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.xxl, + }, + emptyText: { + textAlign: 'center', + }, +}); diff --git a/src/components/library/TrackRow.tsx b/src/components/library/TrackRow.tsx index 3c0fb87..f81c8fe 100644 --- a/src/components/library/TrackRow.tsx +++ b/src/components/library/TrackRow.tsx @@ -20,6 +20,7 @@ export function TrackRow({ onPress, onLongPress, showArtist = true, + subtitle, active = false, swipeToQueue = true, }: { @@ -29,6 +30,8 @@ export function TrackRow({ onLongPress?: () => void; /** Hide on album detail where every row shares the artist. */ showArtist?: boolean; + /** Overrides the secondary line; useful for artist pages that need album context. */ + subtitle?: string; active?: boolean; /** Swipe right → play next, swipe left → add to queue. Off in queue-like lists. */ swipeToQueue?: boolean; @@ -38,6 +41,7 @@ export function TrackRow({ const thumbUri = artworkHash && failedArtworkHash !== artworkHash ? artworkThumbUri(artworkHash) : null; + const secondaryText = subtitle ?? (showArtist ? track.artist : null); const row = ( {track.title} - {showArtist ? ( + {secondaryText ? ( - {track.artist} + {secondaryText} ) : null} diff --git a/src/library/artistDetail.ts b/src/library/artistDetail.ts new file mode 100644 index 0000000..bc93fc9 --- /dev/null +++ b/src/library/artistDetail.ts @@ -0,0 +1,116 @@ +import { + filterTracksByArtist, + normalizeKey, + resolveCanonicalBrowseArtist, + splitAlbumArtistCollaborators, + type ArtistGroupingMode, +} from '@/library/artistGrouping'; +import type { DbTrack } from '@/types/library'; + +export interface ArtistAlbum { + identity_key: string; + album: string; + artist: string; + year: number | null; + artwork_hash: string | null; + track_count: number; + duration: number; +} + +export interface ArtistDetail { + tracks: DbTrack[]; + mainTracks: DbTrack[]; + appearanceTracks: DbTrack[]; + songTracks: DbTrack[]; + albums: ArtistAlbum[]; + artworkHashes: string[]; + playbackTracks: DbTrack[]; + totalDuration: number; + showAppearances: boolean; +} + +export function buildArtistDetail( + allTracks: readonly DbTrack[], + artistName: string, + mode: ArtistGroupingMode +): ArtistDetail { + const tracks = filterTracksByArtist(allTracks, artistName, mode); + const artistKey = normalizeKey(artistName); + + const mainTracks = + mode === 'fileTags' + ? tracks + : tracks.filter((track) => isMainArtistTrack(track, artistKey)); + const appearanceTracks = + mode === 'fileTags' ? [] : tracks.filter((track) => !isMainArtistTrack(track, artistKey)); + const songTracks = mainTracks.length > 0 ? mainTracks : tracks; + const albums = buildArtistAlbums(mainTracks); + + return { + tracks, + mainTracks, + appearanceTracks, + songTracks, + albums, + artworkHashes: buildArtworkHashes(albums, tracks), + playbackTracks: songTracks, + totalDuration: tracks.reduce((sum, track) => sum + track.duration, 0), + showAppearances: mainTracks.length > 0 && appearanceTracks.length > 0, + }; +} + +function isMainArtistTrack(track: DbTrack, artistKey: string): boolean { + if (!artistKey) return false; + if (normalizeKey(resolveCanonicalBrowseArtist(track)) === artistKey) return true; + if (normalizeKey(track.artist) === artistKey) return true; + if (normalizeKey(track.album_artist ?? '') === artistKey) return true; + return splitAlbumArtistCollaborators(track.album_artist ?? '').some( + (name) => normalizeKey(name) === artistKey + ); +} + +function buildArtistAlbums(tracks: readonly DbTrack[]): ArtistAlbum[] { + const byKey = new Map(); + + for (const track of tracks) { + const existing = byKey.get(track.album_identity_key); + if (existing) { + existing.track_count += 1; + existing.duration += track.duration; + if (existing.year == null && track.year != null) existing.year = track.year; + if (!existing.artwork_hash && track.artwork_hash) existing.artwork_hash = track.artwork_hash; + continue; + } + + byKey.set(track.album_identity_key, { + identity_key: track.album_identity_key, + album: track.album, + artist: track.album_artist ?? track.artist, + year: track.year, + artwork_hash: track.artwork_hash, + track_count: 1, + duration: track.duration, + }); + } + + return Array.from(byKey.values()).sort((a, b) => { + if (a.year != null && b.year != null && a.year !== b.year) return b.year - a.year; + if (a.year != null && b.year == null) return -1; + if (a.year == null && b.year != null) return 1; + return a.album.localeCompare(b.album, undefined, { sensitivity: 'base' }); + }); +} + +function buildArtworkHashes(albums: readonly ArtistAlbum[], tracks: readonly DbTrack[]): string[] { + const hashes: string[] = []; + const seen = new Set(); + const add = (hash: string | null) => { + if (!hash || seen.has(hash)) return; + seen.add(hash); + hashes.push(hash); + }; + + for (const album of albums) add(album.artwork_hash); + for (const track of tracks) add(track.artwork_hash); + return hashes; +}