mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
m1, add file scan, library, browse, play, seek
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user