Files
astra-mobile/src/app/(tabs)/index.tsx
T
2026-08-01 00:27:27 -04:00

1080 lines
33 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import {
AppState,
Pressable,
ScrollView,
StyleSheet,
View,
type GestureResponderEvent,
type LayoutChangeEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect, 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 { getHomeLayout, HOME_COLUMN_GAP } from '@/home/homeLayout';
import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation';
import type { Album, Artist, DbTrack } from '@/types/library';
import {
hasListeningPreview,
ListeningPreviewCard,
} from '@/components/listening/ListeningPreviewCard';
import { useListeningStatsStore } from '@/stores/listeningStatsStore';
import { subscribeToListeningHistory } from '@/listeningStats/events';
import { useSceneBottomInset } from '@/navigation/useShellLayout';
const RECENT_ALBUM_LIMIT = 8;
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<typeof setInterval> | 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 = (
<Pressable
style={styles.mastheadSearch}
android_ripple={ripple.icon(22)}
unstable_pressDelay={SCROLL_PRESS_DELAY}
onPress={onSearch}
accessibilityRole="button"
accessibilityLabel="Search music"
hitSlop={4}
>
<Ionicons name="search" size={22} color={colors.textPrimary} />
</Pressable>
);
const scanButton = (
<Pressable
style={styles.mastheadSearch}
android_ripple={ripple.icon(22)}
unstable_pressDelay={SCROLL_PRESS_DELAY}
onPress={onScan}
accessibilityRole="button"
accessibilityLabel="Scan an Astra Signal"
hitSlop={4}
>
<Ionicons name="scan-outline" size={22} color={colors.textPrimary} />
</Pressable>
);
const utilityButtons = (
<View style={styles.mastheadActions}>
{scanButton}
{searchButton}
</View>
);
if (mode === 'off') {
return <View style={styles.mastheadUtility}>{utilityButtons}</View>;
}
const primary = mode === 'clock' ? formatHomeClockTime(clockNow) : greeting.primary;
const subline = mode === 'clock' ? formatHomeClockDate(clockNow) : greeting.subline;
return (
<View style={styles.masthead}>
<View style={styles.mastheadCopy}>
<Text variant="heading" style={styles.mastheadPrimary} numberOfLines={2}>
{primary}
</Text>
{subline ? (
<Text variant="body" color={colors.textSecondary} numberOfLines={2}>
{subline}
</Text>
) : null}
</View>
{utilityButtons}
</View>
);
}
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 (
<View style={styles.sectionHeader}>
<View style={styles.sectionTitleGroup}>
<Text variant="heading" style={styles.sectionTitle}>
{title}
</Text>
{trailing ? (
<Text variant="label" numberOfLines={1}>
{trailing}
</Text>
) : null}
</View>
{onActionPress && actionLabel ? (
<Pressable style={styles.seeAllButton} android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onActionPress} accessibilityRole="button">
<Text variant="label" color={colors.accentText}>
{actionLabel}
</Text>
<Ionicons name="chevron-forward" size={14} color={colors.accentText} />
</Pressable>
) : null}
</View>
);
}
function AlbumCover({ album, size }: { album: Album; size: number }) {
const styles = useStyles();
const artUri = albumArtworkSource(album);
return (
<View style={[styles.albumArt, { width: size, height: size }]}>
{artUri ? (
<Image
source={{ uri: artUri }}
style={styles.image}
contentFit="cover"
transition={120}
/>
) : (
<AstraLogo size={Math.round(size * 0.36)} />
)}
</View>
);
}
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 (
<View style={[styles.albumArt, styles.artistArt, { width: size, height: size }]}>
{hashes.length === 0 ? (
<Ionicons name="person" size={Math.round(size * 0.4)} color={colors.textTertiary} />
) : useMosaic ? (
hashes.map((hash) => (
<Image
key={hash}
source={{ uri: artworkUri(hash) }}
style={styles.artistMosaicTile}
contentFit="cover"
recyclingKey={hash}
transition={null}
/>
))
) : (
<Image
source={{ uri: artworkUri(hashes[0]) }}
style={styles.image}
contentFit="cover"
recyclingKey={hashes[0]}
transition={null}
/>
)}
</View>
);
}
function RecentlyAddedAlbum({
album,
size,
onPress,
}: {
album: Album;
size: number;
onPress: () => void;
}) {
const styles = useStyles();
const ripple = useRipple();
return (
<Pressable style={[styles.recentAlbum, { width: size }]} android_ripple={ripple.tile} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onPress} accessibilityRole="button">
<AlbumCover album={album} size={size} />
<Text variant="body" numberOfLines={1} style={styles.recentAlbumTitle}>
{album.album}
</Text>
<Text variant="label" numberOfLines={1}>
{album.artist}
</Text>
</Pressable>
);
}
function RandomSpotlightCard({
spotlight,
hasTracks,
coverSize,
style,
onPlay,
onShuffle,
onReroll,
onOpen,
}: {
spotlight: { kind: 'album'; album: Album } | { kind: 'artist'; artist: Artist };
hasTracks: boolean;
coverSize: number;
style?: StyleProp<ViewStyle>;
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 (
<Pressable
style={[styles.randomCard, style]}
android_ripple={ripple.tile}
unstable_pressDelay={SCROLL_PRESS_DELAY}
onPress={onOpen}
accessibilityRole="button"
accessibilityLabel={`Open ${title}`}
>
<View style={styles.randomMain}>
{spotlight.kind === 'album' ? (
<AlbumCover album={spotlight.album} size={coverSize} />
) : (
<ArtistCover artist={spotlight.artist} size={coverSize} />
)}
<View style={styles.randomMeta}>
<Text variant="label" color={colors.textTertiary}>
{label}
</Text>
<Text variant="heading" numberOfLines={1}>
{title}
</Text>
<Text variant="label" color={colors.textSecondary} numberOfLines={1}>
{meta}
</Text>
<View style={styles.randomActions}>
<Pressable
android_ripple={ripple.onAccent()}
unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[styles.randomPrimaryAction, disabled && styles.buttonDisabled]}
disabled={disabled}
onPress={(event) => runAction(event, onPlay)}
hitSlop={{ top: 7, right: 4, bottom: 7, left: 4 }}
accessibilityRole="button"
accessibilityLabel={`Play ${title}`}
>
<Ionicons name="play" size={16} color={colors.bgPrimary} />
</Pressable>
<Pressable
android_ripple={ripple.icon(18)}
unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[styles.randomAction, disabled && styles.buttonDisabled]}
disabled={disabled}
onPress={(event) => runAction(event, onShuffle)}
hitSlop={{ top: 7, right: 4, bottom: 7, left: 4 }}
accessibilityRole="button"
accessibilityLabel={`Shuffle ${title}`}
>
<Ionicons name="shuffle" size={17} color={colors.accent} />
</Pressable>
<Pressable
android_ripple={ripple.icon(18)}
unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.randomAction}
onPress={(event) => runAction(event, onReroll)}
hitSlop={{ top: 7, right: 4, bottom: 7, left: 4 }}
accessibilityRole="button"
accessibilityLabel="Pick another random album or artist"
>
<Ionicons name="refresh" size={17} color={colors.textSecondary} />
</Pressable>
</View>
</View>
</View>
</Pressable>
);
}
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 (
<View style={styles.emptyCard}>
<Ionicons
name={fatal || degraded ? 'warning-outline' : rebuilding ? 'construct-outline' : 'folder-open-outline'}
size={34}
color={fatal || degraded ? colors.warning : colors.textTertiary}
/>
<View style={styles.emptyCopy}>
<Text variant="heading">
{fatal
? 'Library data unavailable'
: rebuilding
? 'Rebuilding your library'
: degraded
? 'Library temporarily unavailable'
: 'No music yet'}
</Text>
<Text variant="body" color={colors.textSecondary}>
{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.'}
</Text>
{scanError ? (
<Text variant="caption" color={colors.warning} numberOfLines={2}>
Scan problem: {scanError}
</Text>
) : null}
</View>
<Pressable
android_ripple={ripple.onAccent()} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.primaryButton}
onPress={onManageFolders}
accessibilityRole="button"
>
<Ionicons name={fatal ? 'build-outline' : 'folder-open-outline'} size={18} color={colors.bgPrimary} />
<Text variant="body" style={styles.primaryButtonText}>
{fatal ? 'Troubleshooting' : 'Folder settings'}
</Text>
</Pressable>
</View>
);
}
/**
* One Home section and the space above it. Sections carry no margin of their
* own so the same section can be stacked or seated in a band without either
* shape double-paying for it.
*/
function HomeSection({ children }: { children: ReactNode }) {
const styles = useStyles();
if (!children) return null;
return <View style={styles.section}>{children}</View>;
}
/**
* Two Home sections of similar weight, seated side by side when the scene is
* wide enough and stacked when it isn't.
*
* Collapses to a single full-width section whenever one half has nothing to
* show — otherwise the survivor sits in half a row beside an empty column.
*/
function HomeBand({
paired,
stretch = false,
primary,
secondary,
}: {
paired: boolean;
/** Match the two columns to the taller one. For cards; wrong for row lists. */
stretch?: boolean;
primary: ReactNode;
secondary: ReactNode;
}) {
const styles = useStyles();
if (!paired || !primary || !secondary) {
return (
<>
<HomeSection>{primary}</HomeSection>
<HomeSection>{secondary}</HomeSection>
</>
);
}
return (
<View style={[styles.section, styles.band, stretch && styles.bandStretch]}>
<View style={styles.bandColumn}>{primary}</View>
<View style={styles.bandColumn}>{secondary}</View>
</View>
);
}
export default function HomeScreen() {
const sceneBottomInset = useSceneBottomInset();
const styles = useStyles();
const router = useRouter();
const openLibrary = useHomeLibraryNavigation();
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 listeningPreview = useListeningStatsStore((s) => s.homePreview);
const loadListeningPreview = useListeningStatsStore((s) => s.loadHomePreview);
const [spotlightOverride, setSpotlightOverride] = useState<RandomSpotlight | null>(null);
const [randomSeeds] = useState(() => [Math.random(), Math.random()] as const);
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const scrollTop = useScrollTopGate();
const hasLibrary = totalTrackCount > 0;
// Measured, not derived from the window: the player dock takes a column out
// of the scene, so a tablet with the dock open has a phone's worth of content
// width and has to lay out like one.
const [contentWidth, setContentWidth] = useState(0);
const measureContent = useCallback((event: LayoutChangeEvent) => {
setContentWidth(event.nativeEvent.layout.width);
}, []);
const home = getHomeLayout(contentWidth);
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, home.recentTrackCount);
const canExpandRecentTracks = recentlyPlayedTracks.length > home.recentTrackCount;
const openAlbum = (album: Album) => {
openLibrary({ kind: 'album', key: album.identity_key });
};
const openArtist = (artist: Artist) => {
openLibrary({ kind: 'artist', 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);
useFocusEffect(
useCallback(() => {
void loadListeningPreview();
const unsubscribe = subscribeToListeningHistory(() => void loadListeningPreview());
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') void loadListeningPreview();
});
return () => {
unsubscribe();
subscription.remove();
};
}, [loadListeningPreview]),
);
const spotlightCard = spotlightContent ? (
<RandomSpotlightCard
spotlight={spotlightContent}
hasTracks={
spotlightContent.kind === 'album'
? spotlightContent.album.track_count > 0
: spotlightContent.artist.track_count > 0
}
coverSize={home.spotlightCoverSize}
style={home.paired ? styles.bandCard : undefined}
onOpen={() => spotlightContent.kind === 'album'
? openAlbum(spotlightContent.album)
: openArtist(spotlightContent.artist)}
onPlay={() => playSpotlight()}
onShuffle={() => playSpotlight(true)}
onReroll={rerollSpotlight}
/>
) : null;
// Asked before rendering rather than after: the card returns null on its own
// when there is no history, which inside a band would leave an empty column.
const listeningCard = hasListeningPreview(listeningPreview) ? (
<ListeningPreviewCard
dashboard={listeningPreview}
style={home.paired ? styles.bandCard : undefined}
onPress={() => router.push('/stats' as never)}
/>
) : null;
const recentlyPlayedSection = recentTracks.length > 0 ? (
<>
<SectionHeader
title="Recently Played"
trailing={formatCount(recentlyPlayedTracks.length, 'track')}
actionLabel={canExpandRecentTracks ? 'See all' : undefined}
onActionPress={
canExpandRecentTracks ? () => router.push('/recently-played') : undefined
}
/>
<View style={styles.listBlock}>
{recentTracks.map((track, index) => (
<TrackRow
key={track.path}
track={track}
active={track.path === currentPath}
swipeToQueue={false}
onPress={() => playRecentlyPlayed(recentTracks, index)}
onLongPress={() => setActionTrack(track)}
onOpenActions={() => setActionTrack(track)}
/>
))}
</View>
</>
) : null;
const recentlyAddedSection = recentlyAddedAlbums.length > 0 ? (
<>
<SectionHeader title="Recently Added" />
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.albumRail}
>
{recentlyAddedAlbums.map((album) => (
<RecentlyAddedAlbum
key={album.identity_key}
album={album}
size={home.railCoverSize}
onPress={() => openAlbum(album)}
/>
))}
</ScrollView>
</>
) : null;
const playlistsSection = favoriteTracks.length > 0 || homePlaylists.length > 0 ? (
<>
<SectionHeader title="Favorites & Playlists" />
<View style={styles.listBlock}>
{favoriteTracks.length > 0 ? (
<PlaylistRow
name="Favorites"
trackCount={favoriteTracks.length}
coverHash={favoriteTracks[0]?.artwork_hash ?? null}
pinned
onPress={() => openLibrary({ kind: 'playlist', id: 'favorites' })}
/>
) : null}
{homePlaylists.map((playlist) => (
<PlaylistRow
key={playlist.id}
name={playlist.name}
trackCount={playlist.track_count}
missingCount={playlist.missing_track_count}
coverHash={playlist.auto_cover_hash}
onPress={() => openLibrary({ kind: 'playlist', id: playlist.id })}
/>
))}
</View>
</>
) : null;
return (
<Screen>
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
<PullSearchScrollView
showsVerticalScrollIndicator={false}
overScrollMode="never"
contentContainerStyle={{ paddingBottom: sceneBottomInset }}
onLayout={measureContent}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
>
<HomeMasthead
mode={homeGreetingTextMode}
onSearch={openSearch}
onScan={openSignalScanner}
/>
<ScanProgress />
{!hasLibrary ? (
<HomeBand
paired={home.paired}
primary={
<EmptyHomeCard
scanError={scanError}
status={libraryStatus}
onManageFolders={() => router.push(
libraryStatus === 'fatalUserData' ? '/settings/troubleshooting' : '/settings'
)}
/>
}
secondary={listeningCard}
/>
) : home.paired ? (
// Wide: two bands of paired sections around the one section that
// genuinely wants the full width. Recently Added moves above
// Recently Played here and only here — a rail is the natural break
// between two bands, and it has nothing to pair with.
<>
<HomeBand paired stretch primary={spotlightCard} secondary={listeningCard} />
<HomeSection>{recentlyAddedSection}</HomeSection>
<HomeBand
paired
primary={recentlyPlayedSection}
secondary={playlistsSection}
/>
</>
) : (
<>
<HomeSection>{spotlightCard}</HomeSection>
<HomeSection>{listeningCard}</HomeSection>
<HomeSection>{recentlyPlayedSection}</HomeSection>
<HomeSection>{recentlyAddedSection}</HomeSection>
<HomeSection>{playlistsSection}</HomeSection>
</>
)}
</PullSearchScrollView>
</PullSearchGesture>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
const useStyles = createThemedStyles((colors) => ({
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,
},
section: {
marginTop: spacing.xl,
},
band: {
flexDirection: 'row',
// Row lists end where their content ends; only cards are matched.
alignItems: 'flex-start',
gap: HOME_COLUMN_GAP,
},
bandStretch: {
alignItems: 'stretch',
},
bandColumn: {
flex: 1,
minWidth: 0,
},
/** Fills the height a stretched column hands down. */
bandCard: {
flex: 1,
},
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 comes from the layout — the rail keeps the full scene width, so its
// tiles are what grows when the scene does.
},
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,
// Centres the cover and meta in whatever height a stretched band hands
// down; a no-op when the card is sizing itself.
justifyContent: 'center',
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: {
padding: spacing.lg,
borderRadius: radius.md,
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
gap: spacing.md,
},
emptyCopy: {
gap: spacing.xs,
},
}));