import { useMemo, useState } from 'react'; import { Pressable, ScrollView, StyleSheet, View, type LayoutChangeEvent } from 'react-native'; import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; import { AstraLogo } from '@/components/AstraLogo'; import { SpectrumCurve } from '@/components/SpectrumCurve'; import { TrackRow } from '@/components/library/TrackRow'; import { PlaylistRow } from '@/components/library/PlaylistRow'; import { ScanProgress } from '@/components/library/ScanProgress'; import { colors, fonts, radius, spacing } from '@/theme'; import { useLibraryStore } from '@/stores/libraryStore'; import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlayerStore } from '@/stores/playerStore'; import { playTracks, shuffleTracks, skipToNext, skipToPrevious, togglePlay, } from '@/audio/playbackController'; import { dbTrackToTrack } from '@/library/trackAdapter'; import { albumArtworkSource } from '@/library/artwork'; import { formatDuration } from '@/lib/format'; import { useScopeActive } from '@/scope/scopeStore'; import type { PlaybackState, Track } from '@/types/audio'; import type { Album, DbTrack } from '@/types/library'; const RECENT_ALBUM_LIMIT = 8; const RECENT_TRACK_LIMIT = 3; const PLAYLIST_LIMIT = 4; const PLAYER_CARD_MIN_HEIGHT = 174; const CURVE_POINTS = 64; function chooseRandomAlbum(albums: Album[], currentKey?: string | null): string | null { if (albums.length === 0) return null; if (albums.length === 1) return albums[0].identity_key; let next: string | null = currentKey ?? null; while (next === currentKey) { next = albums[Math.floor(Math.random() * albums.length)].identity_key; } return next; } function albumMeta(album: Album, tracks: DbTrack[]): string { const duration = tracks.reduce((sum, track) => sum + track.duration, 0); return [ album.year ? String(album.year) : null, `${album.track_count} ${album.track_count === 1 ? 'track' : 'tracks'}`, formatDuration(duration), ] .filter(Boolean) .join(' / '); } function formatCount(count: number, noun: string): string { return `${count} ${count === 1 ? noun : `${noun}s`}`; } function SectionHeader({ title, trailing, actionLabel, onActionPress, }: { title: string; trailing?: string; actionLabel?: string; onActionPress?: () => void; }) { return ( {title} {trailing ? ( {trailing} ) : null} {onActionPress && actionLabel ? ( {actionLabel} ) : null} ); } function AlbumCover({ album, size }: { album: Album; size: number }) { const artUri = albumArtworkSource(album); return ( {artUri ? ( ) : ( )} ); } function NowPlayingCard({ track, playbackState, currentTime, duration, onOpen, }: { track: Track; playbackState: PlaybackState; currentTime: number; duration: number; onOpen: () => void; }) { const isPlaying = playbackState === 'playing'; const isLoading = playbackState === 'loading'; const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0; const scopeActive = useScopeActive(); const [cardSize, setCardSize] = useState({ width: 0, height: PLAYER_CARD_MIN_HEIGHT }); const onCardLayout = (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setCardSize((prev) => prev.width === width && prev.height === height ? prev : { width, height } ); }; return ( {scopeActive && cardSize.width > 0 ? ( ) : null} {scopeActive && cardSize.width > 0 ? ( ) : null} {track.artworkData ? ( ) : ( )} NOW PLAYING {track.title} {track.album ? `${track.artist} / ${track.album}` : track.artist} void skipToPrevious()}> void togglePlay()} style={styles.playCircle}> void skipToNext()}> ); } function RecentlyAddedAlbum({ album, onPress, }: { album: Album; onPress: () => void; }) { return ( {album.album} {album.artist} ); } function RandomAlbumCard({ album, tracks, onPlay, onShuffle, onReroll, onOpen, }: { album: Album; tracks: DbTrack[]; onPlay: () => void; onShuffle: () => void; onReroll: () => void; onOpen: () => void; }) { const disabled = tracks.length === 0; return ( RANDOM ALBUM {album.album} {album.artist} {albumMeta(album, tracks)} Play Shuffle ); } function EmptyHomeCard({ isScanning, scanError, onAddFolder, }: { isScanning: boolean; scanError: string | null; onAddFolder: () => void; }) { return ( No music yet Add a local folder to fill Home with albums, history, favorites, and playlists. {scanError ? ( Scan problem: {scanError} ) : null} Add folder ); } export default function HomeScreen() { const router = useRouter(); const tracks = useLibraryStore((s) => s.tracks); const albums = useLibraryStore((s) => s.albums); const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks); const isScanning = useLibraryStore((s) => s.isScanning); const scanError = useLibraryStore((s) => s.scanError); const addFolder = useLibraryStore((s) => s.addFolder); const playlists = usePlaylistStore((s) => s.playlists); const favoriteTracks = usePlaylistStore((s) => s.favoriteTracks); const currentTrack = usePlayerStore((s) => s.currentTrack); const currentPath = currentTrack?.path; const playbackState = usePlayerStore((s) => s.playbackState); const currentTime = usePlayerStore((s) => s.currentTime); const duration = usePlayerStore((s) => s.duration); const [randomAlbumKey, setRandomAlbumKey] = useState(null); const [randomSeed] = useState(() => Math.random()); const tracksByAlbum = useMemo(() => { const map = new Map(); for (const track of tracks) { const list = map.get(track.album_identity_key) ?? []; list.push(track); map.set(track.album_identity_key, list); } return map; }, [tracks]); const recentlyAddedAlbums = useMemo( () => [...albums].sort((a, b) => b.latest_added_at - a.latest_added_at).slice(0, RECENT_ALBUM_LIMIT), [albums] ); const homePlaylists = useMemo( () => [...playlists] .sort( (a, b) => (b.last_played_at ?? b.updated_at ?? b.created_at) - (a.last_played_at ?? a.updated_at ?? a.created_at) ) .slice(0, PLAYLIST_LIMIT), [playlists] ); const randomAlbum = useMemo(() => { if (!albums.length) return null; const selected = randomAlbumKey ? albums.find((album) => album.identity_key === randomAlbumKey) : null; if (selected) return selected; return albums[Math.floor(randomSeed * albums.length) % albums.length]; }, [albums, randomAlbumKey, randomSeed]); const randomTracks = randomAlbum ? (tracksByAlbum.get(randomAlbum.identity_key) ?? []) : []; const recentTracks = recentlyPlayedTracks.slice(0, RECENT_TRACK_LIMIT); const canExpandRecentTracks = recentlyPlayedTracks.length > RECENT_TRACK_LIMIT; const hasLibrary = tracks.length > 0; const openAlbum = (album: Album) => { router.push({ pathname: '/library/album/[key]', params: { key: album.identity_key }, }); }; const playTrackList = (list: DbTrack[], index = 0) => { if (list.length === 0) return; void playTracks(list.map(dbTrackToTrack), index); }; const playAlbum = (album: Album, shuffled = false) => { const albumTracks = tracksByAlbum.get(album.identity_key) ?? []; if (albumTracks.length === 0) return; if (shuffled) { void shuffleTracks(albumTracks.map(dbTrackToTrack)); } else { void playTracks(albumTracks.map(dbTrackToTrack), 0); } }; return ( ASTRA Audiophile player {!hasLibrary ? ( <> {currentTrack ? ( router.push('/now-playing')} /> ) : null} void addFolder()} /> ) : ( <> {currentTrack ? ( router.push('/now-playing')} /> ) : randomAlbum ? ( openAlbum(randomAlbum)} onPlay={() => playAlbum(randomAlbum)} onShuffle={() => playAlbum(randomAlbum, true)} onReroll={() => setRandomAlbumKey(chooseRandomAlbum(albums, randomAlbum.identity_key))} /> ) : null} 0 ? formatCount(recentlyPlayedTracks.length, 'track') : undefined } actionLabel={ canExpandRecentTracks ? 'See all' : undefined } onActionPress={ canExpandRecentTracks ? () => router.push('/recently-played') : undefined } /> {recentTracks.length > 0 ? ( {recentTracks.map((track, index) => ( playTrackList(recentTracks, index)} /> ))} ) : ( No recent plays yet. )} {recentlyAddedAlbums.map((album) => ( openAlbum(album)} /> ))} router.push('/library/playlist/favorites')} /> {homePlaylists.map((playlist) => ( router.push(`/library/playlist/${playlist.id}`)} /> ))} )} ); } const styles = StyleSheet.create({ content: { paddingBottom: spacing.xxl, }, header: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginTop: spacing.xl, }, wordmark: { fontFamily: fonts.sans.bold, fontSize: 30, letterSpacing: 6, color: colors.textPrimary, }, tagline: { marginTop: spacing.xs, letterSpacing: 1, }, topFeature: { marginTop: spacing.xxl, }, playerCard: { minHeight: PLAYER_CARD_MIN_HEIGHT, flexDirection: 'row', alignItems: 'stretch', gap: spacing.lg, padding: spacing.lg, borderRadius: radius.md, backgroundColor: colors.glassBg, borderColor: colors.glassBorder, borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden', }, playerSpectrum: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, playerSpectrumVeil: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(8, 10, 15, 0.28)', }, playerArt: { width: 112, aspectRatio: 1, alignSelf: 'center', borderRadius: radius.md, backgroundColor: colors.bgTertiary, borderColor: colors.glassBorder, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', }, playerMeta: { flex: 1, minWidth: 0, justifyContent: 'center', gap: spacing.xs, }, seekTrack: { height: 3, borderRadius: 2, backgroundColor: colors.glassBorder, overflow: 'hidden', marginTop: spacing.sm, }, seekFill: { width: '38%', height: 3, backgroundColor: colors.accent, }, placeholderControls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: spacing.sm, maxWidth: 220, }, playCircle: { width: 38, height: 38, borderRadius: 19, backgroundColor: colors.accent, alignItems: 'center', justifyContent: 'center', }, section: { marginTop: spacing.xl, }, sectionHeader: { minHeight: 32, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing.md, marginBottom: spacing.md, }, sectionTitleGroup: { flex: 1, minWidth: 0, gap: 2, }, sectionTitle: { flex: 1, }, seeAllButton: { flexDirection: 'row', alignItems: 'center', gap: 2, paddingVertical: spacing.xs, paddingLeft: spacing.sm, }, albumRail: { gap: spacing.md, paddingRight: spacing.lg, }, recentAlbum: { width: 112, }, recentAlbumTitle: { marginTop: spacing.sm, fontSize: 14, }, albumArt: { borderRadius: radius.md, backgroundColor: colors.bgTertiary, borderColor: colors.glassBorder, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', }, image: { width: '100%', height: '100%', }, randomCard: { borderRadius: radius.md, backgroundColor: colors.glassBg, borderColor: colors.glassBorder, borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden', }, randomMain: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, padding: spacing.lg, }, randomMeta: { flex: 1, minWidth: 0, gap: 3, }, reroll: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.glassHighlight, }, randomActions: { flexDirection: 'row', gap: spacing.sm, paddingHorizontal: spacing.lg, paddingBottom: spacing.lg, }, primaryButton: { minHeight: 40, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.xs, backgroundColor: colors.accent, borderRadius: radius.pill, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, }, primaryButtonText: { color: colors.bgPrimary, fontFamily: fonts.sans.semibold, }, secondaryButton: { minHeight: 40, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.xs, borderColor: colors.accent, borderWidth: StyleSheet.hairlineWidth, borderRadius: radius.pill, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, }, buttonDisabled: { opacity: 0.45, }, listBlock: { backgroundColor: colors.bgPrimary, }, emptyLine: { paddingVertical: spacing.md, }, emptyCard: { marginTop: spacing.xl, padding: spacing.lg, borderRadius: radius.md, backgroundColor: colors.glassBg, borderColor: colors.glassBorder, borderWidth: StyleSheet.hairlineWidth, gap: spacing.md, }, emptyCopy: { gap: spacing.xs, }, });