m1, add file scan, library, browse, play, seek

This commit is contained in:
Boof2015
2026-06-12 19:18:42 -04:00
parent 9761975af7
commit 23be9875e8
202 changed files with 6270 additions and 115 deletions
-46
View File
@@ -1,46 +0,0 @@
import { View, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
export default function LibraryScreen() {
return (
<Screen>
<Text variant="title" style={styles.heading}>
Library
</Text>
<View style={styles.empty}>
<Ionicons name="musical-notes-outline" size={48} color={colors.textTertiary} />
<Text variant="heading" style={styles.emptyTitle}>
No music yet
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.emptyBody}>
On-device file scanning, metadata, and the SQLite library arrive in M1.
</Text>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
heading: {
marginTop: spacing.xl,
marginBottom: spacing.lg,
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingBottom: spacing.xxl,
},
emptyTitle: {
marginTop: spacing.sm,
},
emptyBody: {
textAlign: 'center',
maxWidth: 280,
},
});
+17
View File
@@ -0,0 +1,17 @@
import { Stack } from 'expo-router';
import { colors } from '@/theme';
/**
* Nested stack inside the Library tab so album/artist detail screens keep the
* tab bar + mini-player visible.
*/
export default function LibraryLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.bgPrimary },
}}
/>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { useMemo } from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
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 { AstraLogo } from '@/components/AstraLogo';
import { TrackRow } from '@/components/library/TrackRow';
import { colors, radius, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { artworkUri } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
export default function AlbumScreen() {
const router = useRouter();
const { key } = useLocalSearchParams<{ key: string }>();
const albums = useLibraryStore((s) => s.albums);
const allTracks = useLibraryStore((s) => s.tracks);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const album = albums.find((entry) => entry.identity_key === key);
// Store tracks are ordered artist/album/disc/track, so the filtered slice
// keeps disc/track order within one album.
const tracks = useMemo(
() => allTracks.filter((track) => track.album_identity_key === key),
[allTracks, key]
);
const totalDuration = tracks.reduce((sum, track) => sum + track.duration, 0);
const playFrom = (index: number) => {
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}>
Library
</Text>
</Pressable>
<View style={styles.header}>
<View style={styles.art}>
{album?.artwork_hash ? (
<Image
source={{ uri: artworkUri(album.artwork_hash) }}
style={styles.artImage}
contentFit="cover"
transition={120}
/>
) : (
<AstraLogo size={42} />
)}
</View>
<View style={styles.headerMeta}>
<Text variant="heading" numberOfLines={2}>
{album?.album ?? 'Album'}
</Text>
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
{album?.artist ?? ''}
</Text>
<Text variant="label">
{[
album?.year ? String(album.year) : null,
`${tracks.length} ${tracks.length === 1 ? 'track' : 'tracks'}`,
formatDuration(totalDuration),
]
.filter(Boolean)
.join(' · ')}
</Text>
<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>
</View>
</View>
<FlashList
data={tracks}
keyExtractor={(track) => String(track.id)}
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => (
<TrackRow
track={item}
showArtist={false}
active={item.path === currentPath}
onPress={() => playFrom(index)}
/>
)}
/>
</Screen>
);
}
const styles = StyleSheet.create({
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
marginTop: spacing.md,
marginBottom: spacing.md,
alignSelf: 'flex-start',
},
header: {
flexDirection: 'row',
gap: spacing.lg,
marginBottom: spacing.lg,
},
art: {
width: 128,
height: 128,
borderRadius: radius.md,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
artImage: {
width: '100%',
height: '100%',
},
headerMeta: {
flex: 1,
justifyContent: 'center',
gap: spacing.xs,
},
playButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.accent,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
alignSelf: 'flex-start',
marginTop: spacing.xs,
},
playLabel: {
color: colors.bgPrimary,
fontWeight: '600',
},
});
+107
View File
@@ -0,0 +1,107 @@
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 { TrackRow } from '@/components/library/TrackRow';
import { colors, radius, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
export default function ArtistScreen() {
const router = useRouter();
const { name } = useLocalSearchParams<{ name: string }>();
const allTracks = useLibraryStore((s) => s.tracks);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
// Store tracks are ordered artist/album/disc/track, so the filtered slice
// keeps album grouping and track order.
const tracks = useMemo(
() => allTracks.filter((track) => track.artist === name),
[allTracks, name]
);
const playFrom = (index: number) => {
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}>
Library
</Text>
</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>
</View>
<FlashList
data={tracks}
keyExtractor={(track) => String(track.id)}
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => (
<TrackRow
track={item}
active={item.path === currentPath}
onPress={() => playFrom(index)}
/>
)}
/>
</Screen>
);
}
const styles = StyleSheet.create({
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
marginTop: spacing.md,
marginBottom: spacing.md,
alignSelf: 'flex-start',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.lg,
gap: spacing.lg,
},
headerMeta: {
flex: 1,
gap: spacing.xs,
},
playButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.accent,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
playLabel: {
color: colors.bgPrimary,
fontWeight: '600',
},
});
+135
View File
@@ -0,0 +1,135 @@
import { View, StyleSheet } from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher';
import { AlbumGridItem } from '@/components/library/AlbumGridItem';
import { TrackRow } from '@/components/library/TrackRow';
import { ArtistRow } from '@/components/library/ArtistRow';
import { FoldersView } from '@/components/library/FoldersView';
import { ScanProgress } from '@/components/library/ScanProgress';
import { EmptyLibrary } from '@/components/library/EmptyLibrary';
import { colors, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
export default function LibraryScreen() {
const router = useRouter();
const viewMode = useLibraryStore((s) => s.viewMode);
const setViewMode = useLibraryStore((s) => s.setViewMode);
const albums = useLibraryStore((s) => s.albums);
const artists = useLibraryStore((s) => s.artists);
const tracks = useLibraryStore((s) => s.tracks);
const folders = useLibraryStore((s) => s.folders);
const isScanning = useLibraryStore((s) => s.isScanning);
const scanError = useLibraryStore((s) => s.scanError);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const isEmpty = tracks.length === 0 && folders.length === 0 && !isScanning;
const playAllFrom = (index: number) => {
void playTracks(tracks.map(dbTrackToTrack), index);
};
return (
<Screen>
<Text variant="title" style={styles.heading}>
Library
</Text>
{isEmpty ? (
<EmptyLibrary />
) : (
<>
<View style={styles.switcher}>
<ViewModeSwitcher value={viewMode} onChange={setViewMode} />
</View>
<ScanProgress />
{scanError ? (
<Text variant="caption" color={colors.warning} style={styles.error} numberOfLines={2}>
Scan problem: {scanError}
</Text>
) : null}
{viewMode === 'albums' ? (
<FlashList
data={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>
)}
/>
) : null}
{viewMode === 'artists' ? (
<FlashList
data={artists}
keyExtractor={(artist) => artist.artist}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
<ArtistRow
artist={item}
onPress={() =>
router.push({
pathname: '/library/artist/[name]',
params: { name: item.artist },
})
}
/>
)}
/>
) : null}
{viewMode === 'tracks' ? (
<FlashList
data={tracks}
keyExtractor={(track) => String(track.id)}
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => (
<TrackRow
track={item}
active={item.path === currentPath}
onPress={() => playAllFrom(index)}
/>
)}
/>
) : null}
{viewMode === 'folders' ? <FoldersView /> : null}
</>
)}
</Screen>
);
}
const styles = StyleSheet.create({
heading: {
marginTop: spacing.xl,
marginBottom: spacing.lg,
},
switcher: {
marginBottom: spacing.md,
},
error: {
marginBottom: spacing.md,
},
gridCell: {
flex: 1,
paddingHorizontal: spacing.xs,
},
});
+10
View File
@@ -16,6 +16,7 @@ import {
JetBrainsMono_500Medium,
} from '@expo-google-fonts/jetbrains-mono';
import { usePlaybackSync } from '@/audio/usePlaybackSync';
import { useLibraryStore } from '@/stores/libraryStore';
import { colors } from '@/theme';
SplashScreen.preventAutoHideAsync();
@@ -42,6 +43,15 @@ export default function RootLayout() {
}
}, [fontsLoaded]);
// Eager library init: SQLite open + initial reads are tens of ms, and the
// Library tab + playback adapters get data immediately.
useEffect(() => {
useLibraryStore
.getState()
.initialize()
.catch((err) => console.error('[library] init failed', err));
}, []);
if (!fontsLoaded) return null;
return (
+10 -41
View File
@@ -6,16 +6,10 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge';
import { colors, fonts, radius, spacing } from '@/theme';
import { SeekBar } from '@/components/SeekBar';
import { colors, radius, spacing } from '@/theme';
import { usePlayerStore } from '@/stores/playerStore';
import { skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController';
function formatTime(seconds: number): string {
const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
const m = Math.floor(safe / 60);
const s = Math.floor(safe % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
import { seekTo, skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController';
export default function NowPlayingScreen() {
const router = useRouter();
@@ -27,7 +21,6 @@ export default function NowPlayingScreen() {
const isPlaying = playbackState === 'playing';
const isLoading = playbackState === 'loading';
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
return (
<View
@@ -71,17 +64,12 @@ export default function NowPlayingScreen() {
</View>
<View style={styles.progressBlock}>
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
</View>
<View style={styles.times}>
<Text variant="mono" style={styles.time}>
{formatTime(currentTime)}
</Text>
<Text variant="mono" style={styles.time}>
{formatTime(duration)}
</Text>
</View>
<SeekBar
currentTime={currentTime}
duration={duration}
trackKey={track.id}
onSeek={(seconds) => void seekTo(seconds)}
/>
</View>
<View style={styles.transport}>
@@ -150,26 +138,7 @@ const styles = StyleSheet.create({
marginTop: spacing.md,
},
progressBlock: {
marginTop: spacing.xl,
},
progressTrack: {
height: 4,
borderRadius: radius.pill,
backgroundColor: colors.glassBorder,
overflow: 'hidden',
},
progressFill: {
height: 4,
borderRadius: radius.pill,
backgroundColor: colors.accent,
},
times: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: spacing.xs,
},
time: {
color: colors.textTertiary,
marginTop: spacing.md,
},
transport: {
marginTop: spacing.xl,