import { useMemo, useState, type ComponentProps } from 'react'; import { Pressable, ScrollView, StyleSheet, View } 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 { useSafeAreaInsets } from 'react-native-safe-area-context'; 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 { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail'; import { fontSize, radius, spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import type { Palette } from '@/theme/palettes'; import { usePlayerStore } from '@/stores/playerStore'; import { useSettingsStore } from '@/stores/settingsStore'; import { playLibraryQuery } from '@/audio/playbackController'; import { artworkThumbUri, artworkUri } from '@/library/artwork'; import { buildArtistDetail, type ArtistAlbum, type ArtistDetail } from '@/library/artistDetail'; import { useNativeArtistAlbums, useNativeArtistDetail, } from '@/library/nativePages'; import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack'; 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: 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 styles = useStyles(); const colors = useColors(); const router = useRouter(); const { name = 'Artist', credit } = useLocalSearchParams<{ name: string; credit?: string; }>(); const { goBack, backLabel } = useLibraryDetailBack(); const insets = useSafeAreaInsets(); const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } = useDetailCollapse(); const groupingMode = useSettingsStore((s) => s.artistGroupingMode); const detailGroupingMode = credit === '1' ? 'astra' : groupingMode; const currentPath = usePlayerStore((s) => s.currentTrack?.path); const [actionTrack, setActionTrack] = useState(null); const allPage = useNativeArtistDetail(name, detailGroupingMode, 'all'); const songsPage = useNativeArtistDetail(name, detailGroupingMode, 'songs'); const appearancesPage = useNativeArtistDetail(name, detailGroupingMode, 'appearances'); const albumsPage = useNativeArtistAlbums(name, detailGroupingMode); const detail = useMemo(() => { const base = buildArtistDetail(allPage.items, name, detailGroupingMode); return { ...base, albums: albumsPage.items.map((album) => ({ ...album, duration: album.total_duration ?? 0, })), tracks: allPage.items, playbackTracks: allPage.items, songTracks: songsPage.items, appearanceTracks: appearancesPage.items, showAppearances: appearancesPage.totalCount > 0, artworkHashes: allPage.summary?.artwork_hashes ?? base.artworkHashes, }; }, [ allPage.items, allPage.summary, albumsPage.items, appearancesPage.items, appearancesPage.totalCount, detailGroupingMode, name, songsPage.items, ]); const listItems = useMemo( () => buildListItems(detail, songsPage.totalCount, appearancesPage.totalCount), [appearancesPage.totalCount, detail, songsPage.totalCount] ); const playTrackListFrom = ( tracks: readonly DbTrack[], index: number, section: 'songs' | 'appearances' | 'all', ) => { if (tracks.length === 0) return; void playLibraryQuery({ kind: 'artist', artistKey: name, groupingMode: detailGroupingMode, section, }, { anchorPath: tracks[index]?.path, source: { kind: 'artist', label: name }, }); }; const playArtist = () => playTrackListFrom(detail.playbackTracks, 0, 'all'); const shuffleArtist = () => { if (detail.playbackTracks.length === 0) return; void playLibraryQuery({ kind: 'artist', artistKey: name, groupingMode: detailGroupingMode, section: 'all', }, { shuffle: true, source: { kind: 'artist', label: name }, }); }; const openSection = (target: ArtistSectionTarget) => { router.push({ pathname: `/library/artist/[name]/${target}`, params: { name, ...(credit === '1' ? { credit: '1' } : {}) }, }); }; const renderItem = ({ item }: { item: ArtistPageItem }) => { switch (item.type) { 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, item.section === 'appearances' ? 'appearances' : 'songs', ) } onLongPress={() => setActionTrack(item.track)} onOpenActions={() => setActionTrack(item.track)} /> ); } case 'empty': return ( No tracks found for this artist. ); } }; const backdropHash = detail.artworkHashes[0] ?? null; const disabled = detail.playbackTracks.length === 0; return ( item.key} showsVerticalScrollIndicator={false} renderItem={renderItem} onScroll={onScroll} scrollEventThrottle={scrollEventThrottle} contentContainerStyle={{ paddingTop: insets.top + expandedHeight, paddingHorizontal: spacing.lg, paddingBottom: spacing.xxl, }} /> {(allPage.summary?.album_count ?? detail.albums.length) > 0 ? ( ) : null} {detail.totalDuration > 0 ? ( ) : null} } disabled={disabled} onBack={goBack} backLabel={backLabel} onPlay={playArtist} onShuffle={shuffleArtist} scrollY={scrollY} heroFaded={heroFaded} collapsed={collapsed} expandedHeight={expandedHeight} onHeroBlockLayout={onHeroBlockLayout} /> setActionTrack(null)} /> ); } function buildListItems( detail: ArtistDetail, songCount: number, appearanceCount: number ): ArtistPageItem[] { const items: ArtistPageItem[] = []; if (detail.tracks.length === 0) { items.push({ key: 'empty', type: 'empty' }); return items; } 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' }); } items.push({ key: 'section-songs', type: 'section', title: 'Songs', trailing: formatCount(songCount, '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.showAppearances) { items.push({ key: 'section-appearances', type: 'section', title: 'Appears On', trailing: formatCount(appearanceCount, '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; } /** Inner artwork for the collapsing header: 2x2 album mosaic, single cover, or fallback. */ function artistArtwork( hashes: string[], colors: Palette, styles: ReturnType, ) { const useMosaic = hashes.length >= 4; const display = useMosaic ? hashes.slice(0, 4) : hashes.slice(0, 1); if (display.length === 0) { return ; } if (useMosaic) { return ( {display.map((hash) => ( ))} ); } return ( ); } function SectionHeader({ title, trailing, onPress, }: { title: string; trailing: string; onPress?: () => void; }) { const styles = useStyles(); const ripple = useRipple(); const colors = useColors(); return ( {title} {trailing} {onPress ? ( See all ) : null} ); } function AlbumRail({ albums, onAlbumPress, }: { albums: ArtistAlbum[]; onAlbumPress: (album: ArtistAlbum) => void; }) { const styles = useStyles(); const ripple = useRipple(); 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 }) { const styles = useStyles(); const colors = useColors(); 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 useStyles = createThemedStyles((colors) => ({ // The backdrop runs behind the status bar; content pads itself instead. screen: { paddingTop: 0, }, mosaic: { width: '100%', height: '100%', flexDirection: 'row', flexWrap: 'wrap', }, mosaicTile: { width: '50%', height: '50%', }, artFill: { width: '100%', height: '100%', }, stats: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: spacing.xs, }, statChip: { minHeight: 28, maxWidth: '100%', flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, borderRadius: radius.pill, backgroundColor: colors.glassHighlight, borderColor: colors.glassBorder, borderWidth: StyleSheet.hairlineWidth, }, statLabel: { maxWidth: 180, }, 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', }, }));