mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 20:49:47 +02:00
improve artist page
This commit is contained in:
@@ -1,40 +1,137 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState, type ComponentProps } from 'react';
|
||||||
import { Pressable, StyleSheet, View } from 'react-native';
|
import { Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { FlashList } from '@shopify/flash-list';
|
import { FlashList } from '@shopify/flash-list';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
|
import { AstraLogo } from '@/components/AstraLogo';
|
||||||
import { TrackRow } from '@/components/library/TrackRow';
|
import { TrackRow } from '@/components/library/TrackRow';
|
||||||
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
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 { useLibraryStore } from '@/stores/libraryStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { useSettingsStore } from '@/stores/settingsStore';
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
import { playTracks, shuffleTracks } from '@/audio/playbackController';
|
import { playTracks, shuffleTracks } from '@/audio/playbackController';
|
||||||
import { dbTrackToTrack } from '@/library/trackAdapter';
|
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';
|
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: '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() {
|
export default function ArtistScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { name } = useLocalSearchParams<{ name: string }>();
|
const { name = 'Artist' } = useLocalSearchParams<{ name: string }>();
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
const allTracks = useLibraryStore((s) => s.tracks);
|
const allTracks = useLibraryStore((s) => s.tracks);
|
||||||
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
||||||
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
|
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
|
||||||
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
|
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
|
||||||
|
|
||||||
// Match the artist list's grouping mode; store tracks are ordered
|
const detail = useMemo(
|
||||||
// artist/album/disc/track, so the filtered slice keeps album/track order.
|
() => buildArtistDetail(allTracks, name, groupingMode),
|
||||||
const tracks = useMemo(
|
|
||||||
() => filterTracksByArtist(allTracks, name, groupingMode),
|
|
||||||
[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);
|
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 (
|
||||||
|
<ArtistHero
|
||||||
|
artistName={name}
|
||||||
|
detail={detail}
|
||||||
|
compact={width < 380}
|
||||||
|
onPlay={playArtist}
|
||||||
|
onShuffle={shuffleArtist}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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)}
|
||||||
|
onLongPress={() => 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
|
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
|
||||||
@@ -44,45 +141,12 @@ export default function ArtistScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<View style={styles.header}>
|
|
||||||
<View style={styles.headerMeta}>
|
|
||||||
<Text variant="heading" numberOfLines={2}>
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
<Text variant="label">
|
|
||||||
{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Pressable style={styles.playButton} onPress={() => playFrom(0)} accessibilityRole="button">
|
|
||||||
<Ionicons name="play" size={16} color={colors.bgPrimary} />
|
|
||||||
<Text variant="body" style={styles.playLabel}>
|
|
||||||
Play
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
style={styles.shuffleButton}
|
|
||||||
onPress={() => void shuffleTracks(tracks.map(dbTrackToTrack))}
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<Ionicons name="shuffle" size={16} color={colors.accent} />
|
|
||||||
<Text variant="body" color={colors.accent}>
|
|
||||||
Shuffle
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FlashList
|
<FlashList
|
||||||
data={tracks}
|
data={listItems}
|
||||||
keyExtractor={(track) => String(track.id)}
|
keyExtractor={(item) => item.key}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={renderItem}
|
||||||
<TrackRow
|
contentContainerStyle={styles.listContent}
|
||||||
track={item}
|
|
||||||
active={item.path === currentPath}
|
|
||||||
onPress={() => playFrom(index)}
|
|
||||||
onLongPress={() => setActionTrack(item)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
|
<TrackActionsSheet track={actionTrack} onClose={() => 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 (
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<ArtistArtwork hashes={detail.artworkHashes} compact={compact} />
|
||||||
|
<View style={styles.heroMeta}>
|
||||||
|
<Text
|
||||||
|
variant="title"
|
||||||
|
numberOfLines={3}
|
||||||
|
adjustsFontSizeToFit
|
||||||
|
minimumFontScale={0.78}
|
||||||
|
style={styles.artistName}
|
||||||
|
>
|
||||||
|
{artistName}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.stats}>
|
||||||
|
{detail.albums.length > 0 ? (
|
||||||
|
<StatChip icon="albums-outline" label={formatCount(detail.albums.length, 'album')} />
|
||||||
|
) : null}
|
||||||
|
<StatChip icon="musical-notes-outline" label={formatCount(detail.tracks.length, 'track')} />
|
||||||
|
{detail.totalDuration > 0 ? (
|
||||||
|
<StatChip icon="time-outline" label={formatRuntime(detail.totalDuration)} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.actionButton, styles.primaryAction, disabled && styles.disabledAction]}
|
||||||
|
onPress={onPlay}
|
||||||
|
disabled={disabled}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
<Ionicons name="play" size={17} color={colors.bgPrimary} />
|
||||||
|
<Text variant="body" style={styles.primaryActionText}>
|
||||||
|
Play
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.actionButton, styles.secondaryAction, disabled && styles.disabledAction]}
|
||||||
|
onPress={onShuffle}
|
||||||
|
disabled={disabled}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
<Ionicons name="shuffle" size={17} color={colors.accent} />
|
||||||
|
<Text variant="body" color={colors.accent} style={styles.secondaryActionText}>
|
||||||
|
Shuffle
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={[styles.heroArt, compact && styles.heroArtCompact]}>
|
||||||
|
{displayHashes.length === 0 ? (
|
||||||
|
<Ionicons name="person" size={compact ? 42 : 52} color={colors.textTertiary} />
|
||||||
|
) : useMosaic ? (
|
||||||
|
displayHashes.map((hash) => (
|
||||||
|
<Image
|
||||||
|
key={hash}
|
||||||
|
source={{ uri: artworkUri(hash) }}
|
||||||
|
style={styles.mosaicTile}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={120}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
source={{ uri: artworkUri(displayHashes[0]) }}
|
||||||
|
style={styles.heroArtImage}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={120}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({
|
||||||
|
title,
|
||||||
|
trailing,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
trailing: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
}) {
|
||||||
|
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 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;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.albumRail}
|
||||||
|
>
|
||||||
|
{albums.map((album) => (
|
||||||
|
<Pressable
|
||||||
|
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 }) {
|
||||||
|
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 styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
back: {
|
back: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 2,
|
gap: 2,
|
||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
marginBottom: spacing.md,
|
marginBottom: spacing.sm,
|
||||||
alignSelf: 'flex-start',
|
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',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
gap: spacing.xs,
|
||||||
marginBottom: spacing.lg,
|
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,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
headerMeta: {
|
actionButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
gap: spacing.xs,
|
minHeight: 44,
|
||||||
},
|
|
||||||
playButton: {
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
backgroundColor: colors.accent,
|
|
||||||
borderRadius: radius.pill,
|
borderRadius: radius.pill,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
},
|
},
|
||||||
shuffleButton: {
|
primaryAction: {
|
||||||
flexDirection: 'row',
|
backgroundColor: colors.accent,
|
||||||
alignItems: 'center',
|
},
|
||||||
gap: spacing.xs,
|
secondaryAction: {
|
||||||
borderColor: colors.accent,
|
borderColor: colors.accent,
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
borderRadius: radius.pill,
|
backgroundColor: colors.glassBg,
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
},
|
},
|
||||||
playLabel: {
|
disabledAction: {
|
||||||
|
opacity: 0.45,
|
||||||
|
},
|
||||||
|
primaryActionText: {
|
||||||
color: colors.bgPrimary,
|
color: colors.bgPrimary,
|
||||||
fontWeight: '600',
|
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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Screen>
|
||||||
|
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
|
||||||
|
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
|
||||||
|
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={styles.heading}>
|
||||||
|
<Text variant="title" numberOfLines={1}>
|
||||||
|
Albums
|
||||||
|
</Text>
|
||||||
|
<Text variant="label">{formatCount(detail.albums.length, 'album')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlashList
|
||||||
|
data={detail.albums}
|
||||||
|
numColumns={2}
|
||||||
|
keyExtractor={(album) => album.identity_key}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={styles.gridCell}>
|
||||||
|
<AlbumGridItem
|
||||||
|
album={item}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '/library/album/[key]',
|
||||||
|
params: { key: item.identity_key },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={<EmptyList label="No albums found for this artist." />}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyList({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Ionicons name="albums-outline" size={24} color={colors.textTertiary} />
|
||||||
|
<Text variant="body" color={colors.textSecondary} style={styles.emptyText}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<DbTrack | null>(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 (
|
||||||
|
<Screen>
|
||||||
|
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
|
||||||
|
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
|
||||||
|
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={styles.heading}>
|
||||||
|
<Text variant="title" numberOfLines={1}>
|
||||||
|
Appears On
|
||||||
|
</Text>
|
||||||
|
<Text variant="label">{formatCount(tracks.length, 'track')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlashList
|
||||||
|
data={tracks}
|
||||||
|
keyExtractor={(track) => String(track.id)}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TrackRow
|
||||||
|
track={item}
|
||||||
|
subtitle={`${item.artist} - ${item.album}`}
|
||||||
|
active={item.path === currentPath}
|
||||||
|
onPress={() => playFrom(index)}
|
||||||
|
onLongPress={() => setActionTrack(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={<EmptyList label="No appearances found for this artist." />}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyList({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Ionicons name="people-outline" size={24} color={colors.textTertiary} />
|
||||||
|
<Text variant="body" color={colors.textSecondary} style={styles.emptyText}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<DbTrack | null>(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 (
|
||||||
|
<Screen>
|
||||||
|
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
|
||||||
|
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
|
||||||
|
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={styles.heading}>
|
||||||
|
<Text variant="title" numberOfLines={1}>
|
||||||
|
Songs
|
||||||
|
</Text>
|
||||||
|
<Text variant="label">{formatCount(tracks.length, 'track')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlashList
|
||||||
|
data={tracks}
|
||||||
|
keyExtractor={(track) => String(track.id)}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TrackRow
|
||||||
|
track={item}
|
||||||
|
subtitle={item.album}
|
||||||
|
active={item.path === currentPath}
|
||||||
|
onPress={() => playFrom(index)}
|
||||||
|
onLongPress={() => setActionTrack(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={<EmptyList label="No songs found for this artist." />}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyList({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Ionicons name="musical-notes" size={24} color={colors.textTertiary} />
|
||||||
|
<Text variant="body" color={colors.textSecondary} style={styles.emptyText}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ export function TrackRow({
|
|||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
showArtist = true,
|
showArtist = true,
|
||||||
|
subtitle,
|
||||||
active = false,
|
active = false,
|
||||||
swipeToQueue = true,
|
swipeToQueue = true,
|
||||||
}: {
|
}: {
|
||||||
@@ -29,6 +30,8 @@ export function TrackRow({
|
|||||||
onLongPress?: () => void;
|
onLongPress?: () => void;
|
||||||
/** Hide on album detail where every row shares the artist. */
|
/** Hide on album detail where every row shares the artist. */
|
||||||
showArtist?: boolean;
|
showArtist?: boolean;
|
||||||
|
/** Overrides the secondary line; useful for artist pages that need album context. */
|
||||||
|
subtitle?: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
/** Swipe right → play next, swipe left → add to queue. Off in queue-like lists. */
|
/** Swipe right → play next, swipe left → add to queue. Off in queue-like lists. */
|
||||||
swipeToQueue?: boolean;
|
swipeToQueue?: boolean;
|
||||||
@@ -38,6 +41,7 @@ export function TrackRow({
|
|||||||
|
|
||||||
const thumbUri =
|
const thumbUri =
|
||||||
artworkHash && failedArtworkHash !== artworkHash ? artworkThumbUri(artworkHash) : null;
|
artworkHash && failedArtworkHash !== artworkHash ? artworkThumbUri(artworkHash) : null;
|
||||||
|
const secondaryText = subtitle ?? (showArtist ? track.artist : null);
|
||||||
|
|
||||||
const row = (
|
const row = (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -77,9 +81,9 @@ export function TrackRow({
|
|||||||
>
|
>
|
||||||
{track.title}
|
{track.title}
|
||||||
</Text>
|
</Text>
|
||||||
{showArtist ? (
|
{secondaryText ? (
|
||||||
<Text variant="label" numberOfLines={1}>
|
<Text variant="label" numberOfLines={1}>
|
||||||
{track.artist}
|
{secondaryText}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={styles.badges}>
|
<View style={styles.badges}>
|
||||||
|
|||||||
@@ -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<string, ArtistAlbum>();
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user