Files
astra-mobile/src/app/(tabs)/library/artist/[name].tsx
T
2026-07-25 11:12:02 -04:00

568 lines
16 KiB
TypeScript

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<typeof Ionicons>['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<DbTrack | null>(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 (
<SectionHeader
title={item.title}
trailing={item.trailing}
onPress={target ? () => openSection(target) : undefined}
/>
);
}
case 'albums':
return (
<AlbumRail
albums={detail.albums.slice(0, ALBUM_PREVIEW_LIMIT)}
onAlbumPress={(album) =>
router.push({
pathname: '/library/album/[key]',
params: { key: album.identity_key },
})
}
/>
);
case 'track': {
const sourceTracks =
item.section === 'appearances' ? detail.appearanceTracks : detail.songTracks;
return (
<TrackRow
track={item.track}
subtitle={trackSubtitle(item.track, item.section)}
active={item.track.path === currentPath}
onPress={() =>
playTrackListFrom(
sourceTracks,
item.index,
item.section === 'appearances' ? 'appearances' : 'songs',
)
}
onLongPress={() => setActionTrack(item.track)}
onOpenActions={() => setActionTrack(item.track)}
/>
);
}
case 'empty':
return (
<View style={styles.emptyState}>
<Ionicons name="musical-notes" size={24} color={colors.textTertiary} />
<Text variant="body" color={colors.textSecondary} style={styles.emptyText}>
No tracks found for this artist.
</Text>
</View>
);
}
};
const backdropHash = detail.artworkHashes[0] ?? null;
const disabled = detail.playbackTracks.length === 0;
return (
<Screen padded={false} style={styles.screen}>
<FlashList
data={listItems}
keyExtractor={(item) => item.key}
showsVerticalScrollIndicator={false}
renderItem={renderItem}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
contentContainerStyle={{
paddingTop: insets.top + expandedHeight,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xxl,
}}
/>
<CollapsingHeader
artwork={artistArtwork(detail.artworkHashes, colors, styles)}
backdropUri={backdropHash ? artworkThumbUri(backdropHash) : null}
title={name}
heroMeta={
<View style={styles.stats}>
{(allPage.summary?.album_count ?? detail.albums.length) > 0 ? (
<StatChip
icon="albums-outline"
label={formatCount(allPage.summary?.album_count ?? detail.albums.length, 'album')}
/>
) : null}
<StatChip icon="musical-notes-outline" label={formatCount(allPage.totalCount, 'track')} />
{detail.totalDuration > 0 ? (
<StatChip icon="time-outline" label={formatRuntime(detail.totalDuration)} />
) : null}
</View>
}
disabled={disabled}
onBack={goBack}
backLabel={backLabel}
onPlay={playArtist}
onShuffle={shuffleArtist}
scrollY={scrollY}
heroFaded={heroFaded}
collapsed={collapsed}
expandedHeight={expandedHeight}
onHeroBlockLayout={onHeroBlockLayout}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
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<typeof useStyles>,
) {
const useMosaic = hashes.length >= 4;
const display = useMosaic ? hashes.slice(0, 4) : hashes.slice(0, 1);
if (display.length === 0) {
return <Ionicons name="person" size={60} color={colors.textTertiary} />;
}
if (useMosaic) {
return (
<View style={styles.mosaic}>
{display.map((hash) => (
<Image
key={hash}
source={{ uri: artworkUri(hash) }}
style={styles.mosaicTile}
contentFit="cover"
transition={120}
/>
))}
</View>
);
}
return (
<Image source={{ uri: artworkUri(display[0]) }} style={styles.artFill} contentFit="cover" transition={120} />
);
}
function SectionHeader({
title,
trailing,
onPress,
}: {
title: string;
trailing: string;
onPress?: () => void;
}) {
const styles = useStyles();
const ripple = useRipple();
const colors = useColors();
return (
<View style={styles.sectionHeader}>
<View style={styles.sectionTitleGroup}>
<Text variant="heading" style={styles.sectionTitle}>
{title}
</Text>
<Text variant="label" numberOfLines={1}>
{trailing}
</Text>
</View>
{onPress ? (
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.seeAllButton} onPress={onPress} accessibilityRole="button">
<Text variant="label" color={colors.accentText}>
See all
</Text>
<Ionicons name="chevron-forward" size={14} color={colors.accentText} />
</Pressable>
) : null}
</View>
);
}
function AlbumRail({
albums,
onAlbumPress,
}: {
albums: ArtistAlbum[];
onAlbumPress: (album: ArtistAlbum) => void;
}) {
const styles = useStyles();
const ripple = useRipple();
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.albumRail}
>
{albums.map((album) => (
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
key={album.identity_key}
style={styles.albumCard}
onPress={() => onAlbumPress(album)}
accessibilityRole="button"
>
<View style={styles.albumArt}>
{album.artwork_hash ? (
<Image
source={{ uri: artworkUri(album.artwork_hash) }}
style={styles.albumArtImage}
contentFit="cover"
transition={120}
/>
) : (
<AstraLogo size={34} />
)}
</View>
<Text variant="body" numberOfLines={2} style={styles.albumTitle}>
{album.album}
</Text>
<Text variant="label" numberOfLines={1}>
{[album.year ? String(album.year) : null, formatCount(album.track_count, 'track')]
.filter(Boolean)
.join(' - ')}
</Text>
</Pressable>
))}
</ScrollView>
);
}
function StatChip({ icon, label }: { icon: IconName; label: string }) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.statChip}>
<Ionicons name={icon} size={13} color={colors.accentText} />
<Text variant="caption" color={colors.accentText} numberOfLines={1} style={styles.statLabel}>
{label}
</Text>
</View>
);
}
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',
},
}));