import { useEffect, useMemo, useState } from 'react'; import { AppState, Pressable, ScrollView, StyleSheet, View, type GestureResponderEvent, } 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 { TrackRow } from '@/components/library/TrackRow'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { PlaylistRow } from '@/components/library/PlaylistRow'; import { ScanProgress } from '@/components/library/ScanProgress'; import { PullSearchGesture, PullSearchScrollView, useScrollTopGate } from '@/components/search/PullSearchGesture'; import { fonts, radius, spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { useLibraryStore } from '@/stores/libraryStore'; import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlayerStore } from '@/stores/playerStore'; import { useSearchStore } from '@/stores/searchStore'; import { useSettingsStore } from '@/stores/settingsStore'; import { playLibraryQuery } from '@/audio/playbackController'; import { filterArtistBrowseList } from '@/library/artistGrouping'; import { albumArtworkSource, artworkUri } from '@/library/artwork'; import { chooseHomeGreeting, HOME_GREETING_ROTATION_MS, type HomeGreetingTextMode, } from '@/home/homeGreeting'; import type { Album, Artist, DbTrack } from '@/types/library'; const RECENT_ALBUM_LIMIT = 8; const RECENT_TRACK_LIMIT = 3; const PLAYLIST_LIMIT = 4; type RandomSpotlight = | { kind: 'album'; key: string } | { kind: 'artist'; name: string }; function chooseRandomSpotlight( albums: Album[], artists: Artist[], current: RandomSpotlight | null = null, random: () => number = Math.random ): RandomSpotlight | null { const kinds: RandomSpotlight['kind'][] = []; if (albums.length > 0) kinds.push('album'); if (artists.length > 0) kinds.push('artist'); if (kinds.length === 0) return null; let kind = kinds[Math.floor(random() * kinds.length)]; const currentPoolSize = kind === 'album' ? albums.length : artists.length; if (current?.kind === kind && currentPoolSize === 1 && kinds.length > 1) { kind = kind === 'album' ? 'artist' : 'album'; } if (kind === 'album') { const candidates = current?.kind === 'album' && albums.length > 1 ? albums.filter((album) => album.identity_key !== current.key) : albums; const album = candidates[Math.floor(random() * candidates.length)]; return album ? { kind: 'album', key: album.identity_key } : null; } const candidates = current?.kind === 'artist' && artists.length > 1 ? artists.filter((artist) => artist.artist !== current.name) : artists; const artist = candidates[Math.floor(random() * candidates.length)]; return artist ? { kind: 'artist', name: artist.artist } : null; } function compactAlbumMeta(album: Album): string { return [ album.artist, album.year ? String(album.year) : null, `${album.track_count} ${album.track_count === 1 ? 'track' : 'tracks'}`, ] .filter(Boolean) .join(' / '); } function compactArtistMeta(artist: Artist): string { return [ `${artist.album_count} ${artist.album_count === 1 ? 'album' : 'albums'}`, `${artist.track_count} ${artist.track_count === 1 ? 'track' : 'tracks'}`, ].join(' / '); } function formatHomeClockTime(date: Date): string { return new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit', }).format(date); } function formatHomeClockDate(date: Date): string { return new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric', }).format(date); } function HomeMasthead({ mode, onSearch, onScan, }: { mode: HomeGreetingTextMode; onSearch: () => void; onScan: () => void; }) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); const [clockNow, setClockNow] = useState(() => new Date()); const [greeting, setGreeting] = useState(() => chooseHomeGreeting(null, new Date())); useEffect(() => { if (mode !== 'messages') return; const rotateGreeting = () => { setGreeting((current) => chooseHomeGreeting(current.id, new Date())); }; const interval = setInterval(rotateGreeting, HOME_GREETING_ROTATION_MS); return () => clearInterval(interval); }, [mode]); useEffect(() => { if (mode !== 'clock') return; let interval: ReturnType | null = null; const updateClock = () => setClockNow(new Date()); updateClock(); const now = new Date(); const delay = 60_000 - (now.getSeconds() * 1_000 + now.getMilliseconds()); const timeout = setTimeout(() => { updateClock(); interval = setInterval(updateClock, 60_000); }, Math.max(100, delay)); return () => { clearTimeout(timeout); if (interval) clearInterval(interval); }; }, [mode]); useEffect(() => { const subscription = AppState.addEventListener('change', (state) => { if (state !== 'active') return; const now = new Date(); setClockNow(now); if (mode === 'messages') { setGreeting((current) => chooseHomeGreeting(current.id, now)); } }); return () => subscription.remove(); }, [mode]); const searchButton = ( ); const scanButton = ( ); const utilityButtons = ( {scanButton} {searchButton} ); if (mode === 'off') { return {utilityButtons}; } const primary = mode === 'clock' ? formatHomeClockTime(clockNow) : greeting.primary; const subline = mode === 'clock' ? formatHomeClockDate(clockNow) : greeting.subline; return ( {primary} {subline ? ( {subline} ) : null} {utilityButtons} ); } 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; }) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); return ( {title} {trailing ? ( {trailing} ) : null} {onActionPress && actionLabel ? ( {actionLabel} ) : null} ); } function AlbumCover({ album, size }: { album: Album; size: number }) { const styles = useStyles(); const artUri = albumArtworkSource(album); return ( {artUri ? ( ) : ( )} ); } function ArtistCover({ artist, size }: { artist: Artist; size: number }) { const styles = useStyles(); const colors = useColors(); const useMosaic = artist.artwork_hashes.length >= 4; const hashes = useMosaic ? artist.artwork_hashes.slice(0, 4) : artist.artwork_hashes.slice(0, 1); return ( {hashes.length === 0 ? ( ) : useMosaic ? ( hashes.map((hash) => ( )) ) : ( )} ); } function RecentlyAddedAlbum({ album, onPress, }: { album: Album; onPress: () => void; }) { const styles = useStyles(); const ripple = useRipple(); return ( {album.album} {album.artist} ); } function RandomSpotlightCard({ spotlight, hasTracks, onPlay, onShuffle, onReroll, onOpen, }: { spotlight: { kind: 'album'; album: Album } | { kind: 'artist'; artist: Artist }; hasTracks: boolean; onPlay: () => void; onShuffle: () => void; onReroll: () => void; onOpen: () => void; }) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); const disabled = !hasTracks; const title = spotlight.kind === 'album' ? spotlight.album.album : spotlight.artist.artist; const label = spotlight.kind === 'album' ? 'RANDOM ALBUM' : 'RANDOM ARTIST'; const meta = spotlight.kind === 'album' ? compactAlbumMeta(spotlight.album) : compactArtistMeta(spotlight.artist); const runAction = (event: GestureResponderEvent, action: () => void) => { event.stopPropagation(); action(); }; return ( {spotlight.kind === 'album' ? ( ) : ( )} {label} {title} {meta} runAction(event, onPlay)} hitSlop={{ top: 7, right: 4, bottom: 7, left: 4 }} accessibilityRole="button" accessibilityLabel={`Play ${title}`} > runAction(event, onShuffle)} hitSlop={{ top: 7, right: 4, bottom: 7, left: 4 }} accessibilityRole="button" accessibilityLabel={`Shuffle ${title}`} > runAction(event, onReroll)} hitSlop={{ top: 7, right: 4, bottom: 7, left: 4 }} accessibilityRole="button" accessibilityLabel="Pick another random album or artist" > ); } function EmptyHomeCard({ scanError, status, onManageFolders, }: { scanError: string | null; status: 'initializing' | 'empty' | 'ready' | 'scanning' | 'rebuilding' | 'degraded' | 'fatalUserData'; onManageFolders: () => void; }) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); const fatal = status === 'fatalUserData'; const rebuilding = status === 'rebuilding'; const degraded = status === 'degraded'; return ( {fatal ? 'Library data unavailable' : rebuilding ? 'Rebuilding your library' : degraded ? 'Library temporarily unavailable' : 'No music yet'} {fatal ? 'Astra could not restore your playlists, favorites, and settings from either safety snapshot. Your music files were not changed.' : rebuilding ? 'The damaged catalog was quarantined. Astra is rebuilding from available folders and remote sources.' : degraded ? 'Astra cannot currently read the catalog, so it will not treat your library as empty.' : 'Add a local folder to fill Home with albums, history, favorites, and playlists.'} {scanError ? ( Scan problem: {scanError} ) : null} {fatal ? 'Troubleshooting' : 'Folder settings'} ); } export default function HomeScreen() { const styles = useStyles(); const router = useRouter(); const totalTrackCount = useLibraryStore((s) => s.totalTrackCount); const albums = useLibraryStore((s) => s.homeAlbums); const artists = useLibraryStore((s) => s.homeArtists); const includeCollabArtists = useLibraryStore((s) => s.includeCollabArtists); const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks); const scanError = useLibraryStore((s) => s.scanError); const libraryStatus = useLibraryStore((s) => s.status); const playlists = usePlaylistStore((s) => s.playlists); const favoriteTracks = usePlaylistStore((s) => s.favoriteTracks); const currentPath = usePlayerStore((s) => s.currentTrack?.path); const openQuickSearch = useSearchStore((s) => s.openQuickSearch); const homeGreetingTextMode = useSettingsStore((s) => s.homeGreetingTextMode); const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode); const [spotlightOverride, setSpotlightOverride] = useState(null); const [randomSeeds] = useState(() => [Math.random(), Math.random()] as const); const [actionTrack, setActionTrack] = useState(null); const scrollTop = useScrollTopGate(); const hasLibrary = totalTrackCount > 0; const recentlyAddedAlbums = useMemo( () => [...albums].sort((a, b) => b.latest_added_at - a.latest_added_at).slice(0, RECENT_ALBUM_LIMIT), [albums] ); const visibleArtists = useMemo( () => filterArtistBrowseList(artists, artistGroupingMode, includeCollabArtists), [artistGroupingMode, artists, includeCollabArtists] ); 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 randomSpotlight = useMemo(() => { const overrideValid = spotlightOverride?.kind === 'album' ? albums.some((album) => album.identity_key === spotlightOverride.key) : spotlightOverride?.kind === 'artist' ? visibleArtists.some((artist) => artist.artist === spotlightOverride.name) : false; if (spotlightOverride && overrideValid) return spotlightOverride; let seedIndex = 0; return chooseRandomSpotlight( albums, visibleArtists, null, () => randomSeeds[seedIndex++] ?? randomSeeds[0] ); }, [albums, randomSeeds, spotlightOverride, visibleArtists]); const randomAlbum = randomSpotlight?.kind === 'album' ? albums.find((album) => album.identity_key === randomSpotlight.key) ?? null : null; const randomArtist = randomSpotlight?.kind === 'artist' ? visibleArtists.find((artist) => artist.artist === randomSpotlight.name) ?? null : null; const spotlightContent = randomAlbum ? ({ kind: 'album', album: randomAlbum } as const) : randomArtist ? ({ kind: 'artist', artist: randomArtist } as const) : null; const recentTracks = recentlyPlayedTracks.slice(0, RECENT_TRACK_LIMIT); const canExpandRecentTracks = recentlyPlayedTracks.length > RECENT_TRACK_LIMIT; const openAlbum = (album: Album) => { router.push({ pathname: '/library/album/[key]', params: { key: album.identity_key }, }); }; const openArtist = (artist: Artist) => { router.push({ pathname: '/library/artist/[name]', params: { name: artist.artist }, }); }; const playRecentlyPlayed = (list: DbTrack[], index = 0) => { if (list.length === 0) return; void playLibraryQuery({ kind: 'recent' }, { anchorPath: list[index]?.path, source: { kind: 'recently-played', label: 'Recently Played' }, }); }; const playSpotlight = (shuffled = false) => { if (!spotlightContent) return; const source = spotlightContent?.kind === 'album' ? { kind: 'album' as const, label: spotlightContent.album.album } : { kind: 'artist' as const, label: spotlightContent?.artist.artist ?? 'Artist' }; const query = spotlightContent.kind === 'album' ? { kind: 'album' as const, albumKey: spotlightContent.album.identity_key } : { kind: 'artist' as const, artistKey: spotlightContent.artist.artist, groupingMode: artistGroupingMode, section: 'all' as const, }; void playLibraryQuery(query, { shuffle: shuffled, source }); }; const rerollSpotlight = () => { setSpotlightOverride(chooseRandomSpotlight(albums, visibleArtists, randomSpotlight)); }; const openSearch = () => openQuickSearch(); const openSignalScanner = () => router.push('/signal/scan' as never); return ( {!hasLibrary ? ( router.push( libraryStatus === 'fatalUserData' ? '/settings/troubleshooting' : '/settings' )} /> ) : ( <> {spotlightContent ? ( 0 : spotlightContent.artist.track_count > 0 } onOpen={() => spotlightContent.kind === 'album' ? openAlbum(spotlightContent.album) : openArtist(spotlightContent.artist)} onPlay={() => playSpotlight()} onShuffle={() => playSpotlight(true)} onReroll={rerollSpotlight} /> ) : null} {recentTracks.length > 0 ? ( router.push('/recently-played') : undefined } /> {recentTracks.map((track, index) => ( playRecentlyPlayed(recentTracks, index)} onLongPress={() => setActionTrack(track)} onOpenActions={() => setActionTrack(track)} /> ))} ) : null} {recentlyAddedAlbums.length > 0 ? ( {recentlyAddedAlbums.map((album) => ( openAlbum(album)} /> ))} ) : null} {favoriteTracks.length > 0 || homePlaylists.length > 0 ? ( {favoriteTracks.length > 0 ? ( router.push('/library/playlist/favorites')} /> ) : null} {homePlaylists.map((playlist) => ( router.push(`/library/playlist/${playlist.id}`)} /> ))} ) : null} )} setActionTrack(null)} /> ); } const useStyles = createThemedStyles((colors) => ({ content: { paddingBottom: spacing.xxl, }, masthead: { minHeight: 72, flexDirection: 'row', alignItems: 'center', gap: spacing.md, marginTop: spacing.xl, paddingVertical: spacing.sm, }, mastheadUtility: { height: 44, marginTop: spacing.xl, alignItems: 'flex-end', justifyContent: 'center', }, mastheadCopy: { flex: 1, minWidth: 0, justifyContent: 'center', gap: spacing.xs, }, mastheadActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, }, mastheadPrimary: { fontSize: 28, lineHeight: 32, }, mastheadSearch: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.glassHighlight, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, }, topFeature: { marginTop: spacing.xl, }, 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', }, artistArt: { flexDirection: 'row', flexWrap: 'wrap', }, artistMosaicTile: { width: '50%', height: '50%', }, image: { width: '100%', height: '100%', }, randomCard: { minHeight: 112, borderRadius: radius.md, backgroundColor: colors.glassBg, borderColor: colors.glassBorder, borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden', }, randomMain: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, padding: spacing.md, }, randomMeta: { flex: 1, minWidth: 0, alignSelf: 'stretch', justifyContent: 'space-between', gap: 2, }, randomActions: { flexDirection: 'row', gap: spacing.sm, alignItems: 'center', }, randomPrimaryAction: { width: 36, height: 30, borderRadius: 15, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.accent, }, randomAction: { width: 36, height: 30, borderRadius: 15, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.glassHighlight, }, 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, }, buttonDisabled: { opacity: 0.45, }, listBlock: { backgroundColor: colors.bgPrimary, }, 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, }, }));