Files
astra-mobile/src/app/(tabs)/library/album/[key].tsx
T
2026-07-25 15:46:38 -04:00

195 lines
7.0 KiB
TypeScript

import { useMemo, useState } from 'react';
import { Image } from 'expo-image';
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams } 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 { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { usePlayerStore } from '@/stores/playerStore';
import { playLibraryQuery } from '@/audio/playbackController';
import { useNativeAlbumDetail } from '@/library/nativePages';
import { albumArtworkSource, artworkThumbUri, artworkUri } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack';
import type { DbTrack } from '@/types/library';
type AlbumRow =
| { kind: 'track'; track: DbTrack; index: number }
| { kind: 'disc'; disc: number };
function DiscHeader({ disc }: { disc: number }) {
const colors = useColors();
return (
<View style={styles.discHeader}>
<Ionicons name="disc-outline" size={16} color={colors.textSecondary} />
<Text variant="heading">Disc {disc}</Text>
</View>
);
}
export default function AlbumScreen() {
const colors = useColors();
const { key } = useLocalSearchParams<{ key: string }>();
const { items: tracks, summary: album, totalCount, loadMore } = useNativeAlbumDetail(key);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const { goBack, backLabel } = useLibraryDetailBack();
const insets = useSafeAreaInsets();
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
useDetailCollapse();
const totalDuration =
album?.total_duration ?? tracks.reduce((sum, track) => sum + track.duration, 0);
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
// Interleave "Disc N" headers only when the album spans multiple discs;
// untagged tracks fall back to disc 1. Track rows keep their index into the
// flat `tracks` array so tap-to-play stays correct.
const albumItems = useMemo<AlbumRow[]>(() => {
const maxDisc = tracks.reduce((m, track) => Math.max(m, track.disc_number ?? 1), 1);
if (maxDisc <= 1) {
return tracks.map((track, index) => ({ kind: 'track', track, index }));
}
const rows: AlbumRow[] = [];
let lastDisc: number | null = null;
tracks.forEach((track, index) => {
const disc = track.disc_number ?? 1;
if (disc !== lastDisc) {
rows.push({ kind: 'disc', disc });
lastDisc = disc;
}
rows.push({ kind: 'track', track, index });
});
return rows;
}, [tracks]);
const playFrom = (index: number) => {
void playLibraryQuery({ kind: 'album', albumKey: key }, {
anchorPath: tracks[index]?.path,
source: { kind: 'album', label: album?.album ?? tracks[0]?.album ?? 'Album' },
});
};
// Eligibility can filter an album out of the store list (a single reached via
// "go to album") — fall back to the filtered tracks' own metadata.
const fallbackTrack = tracks[0];
const headerArtist =
album?.artist ??
fallbackTrack?.album_display_artist ??
fallbackTrack?.album_artist ??
fallbackTrack?.artist;
const headerArtworkHash = album?.artwork_hash ?? fallbackTrack?.artwork_hash ?? null;
const artSource = album
? albumArtworkSource(album)
: headerArtworkHash
? artworkUri(headerArtworkHash)
: null;
// Blur the cached thumbnail, not the full-res cover (remote albums have no
// local thumb — their server URL is already sized reasonably).
const backdropUri = headerArtworkHash ? artworkThumbUri(headerArtworkHash) : artSource;
const meta = [
(album?.year ?? fallbackTrack?.year) ? String(album?.year ?? fallbackTrack?.year) : null,
`${totalCount} ${totalCount === 1 ? 'track' : 'tracks'}`,
formatDuration(totalDuration),
]
.filter(Boolean)
.join(' · ');
return (
<Screen padded={false} style={styles.screen}>
<FlashList
data={albumItems}
keyExtractor={(item) => (item.kind === 'disc' ? `disc-${item.disc}` : String(item.track.id))}
getItemType={(item) => item.kind}
showsVerticalScrollIndicator={false}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
onEndReached={() => void loadMore()}
onEndReachedThreshold={2}
contentContainerStyle={{
paddingTop: insets.top + expandedHeight,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xxl,
}}
renderItem={({ item }) =>
item.kind === 'disc' ? (
<DiscHeader disc={item.disc} />
) : (
<TrackRow
track={item.track}
showArtist={false}
active={item.track.path === currentPath}
onPress={() => playFrom(item.index)}
onLongPress={() => setActionTrack(item.track)}
onOpenActions={() => setActionTrack(item.track)}
/>
)
}
/>
<CollapsingHeader
artwork={
artSource ? (
<Image source={{ uri: artSource }} style={styles.artFill} contentFit="cover" transition={150} />
) : (
<AstraLogo size={56} />
)
}
backdropUri={backdropUri}
title={album?.album ?? tracks[0]?.album ?? 'Album'}
heroMeta={
<>
{headerArtist ? (
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
{headerArtist}
</Text>
) : null}
<Text variant="label">{meta}</Text>
</>
}
disabled={tracks.length === 0}
onBack={goBack}
backLabel={backLabel}
onPlay={() => playFrom(0)}
onShuffle={() => void playLibraryQuery({ kind: 'album', albumKey: key }, {
shuffle: true,
source: {
kind: 'album',
label: album?.album ?? tracks[0]?.album ?? 'Album',
},
})}
scrollY={scrollY}
heroFaded={heroFaded}
collapsed={collapsed}
expandedHeight={expandedHeight}
onHeroBlockLayout={onHeroBlockLayout}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
const styles = StyleSheet.create({
// The backdrop runs behind the status bar; content pads itself instead.
screen: {
paddingTop: 0,
},
artFill: {
width: '100%',
height: '100%',
},
discHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingTop: spacing.xl,
paddingBottom: spacing.md,
},
});