mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 04:59:53 +02:00
568 lines
16 KiB
TypeScript
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',
|
|
},
|
|
}));
|