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,
+21 -9
View File
@@ -1,4 +1,5 @@
import TrackPlayer, { isPlaying } from 'react-native-track-player';
import type { Track } from '@/types/audio';
import { setupPlayer } from './trackPlayer';
import { SAMPLE_TRACKS, toRntpTrack } from './sampleTracks';
@@ -9,22 +10,33 @@ import { SAMPLE_TRACKS, toRntpTrack } from './sampleTracks';
*/
/**
* Set up the player and load the demo queue. Setup is deferred to here (a
* user-initiated play) rather than app launch: RNTP starts a foreground
* MediaSession service on setup, and Android only permits starting a foreground
* service while the app is in the foreground.
* Set up the player. Setup is deferred to here (a user-initiated play) rather
* than app launch: RNTP starts a foreground MediaSession service on setup, and
* Android only permits starting a foreground service while the app is in the
* foreground.
*/
async function ensurePlayerReady(): Promise<void> {
await setupPlayer();
}
/** Replace the queue with the given tracks and start playing at startIndex. */
export async function playTracks(tracks: Track[], startIndex = 0): Promise<void> {
if (tracks.length === 0) return;
await ensurePlayerReady();
await TrackPlayer.setQueue(tracks.map(toRntpTrack));
if (startIndex > 0) {
await TrackPlayer.skip(startIndex);
}
await TrackPlayer.play();
}
/** M0 demo entry point: load the streamed sample queue if nothing is queued. */
export async function playSample(): Promise<void> {
await ensurePlayerReady();
const queue = await TrackPlayer.getQueue();
if (queue.length === 0) {
await TrackPlayer.add(SAMPLE_TRACKS.map(toRntpTrack));
}
}
/** M0 entry point: ensure the player is ready, then start playback. */
export async function playSample(): Promise<void> {
await ensurePlayerReady();
await TrackPlayer.play();
}
+162
View File
@@ -0,0 +1,162 @@
import { useRef, useState } from 'react';
import { View, StyleSheet, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native';
import { Text } from './Text';
import { colors, radius, spacing } from '@/theme';
import { formatDuration } from '@/lib/format';
const THUMB_SIZE = 12;
interface SeekBarProps {
currentTime: number;
duration: number;
onSeek: (seconds: number) => void;
/** Identity of the playing track; a pending seek only applies to its own track. */
trackKey?: string | number;
}
const clamp = (fraction: number) => Math.min(1, Math.max(0, fraction));
/**
* Tap/drag seek bar with time labels. Plain progress bar for M1 — the
* waveform seek bar port (desktop WaveformSeekBar) replaces the visuals at M3+.
*/
export function SeekBar({ currentTime, duration, onSeek, trackKey }: SeekBarProps) {
const [scrubFraction, setScrubFraction] = useState<number | null>(null);
const [barWidth, setBarWidth] = useState(0);
// Last released seek. Displayed instead of live progress until playback
// catches up, so the bar doesn't snap back to a stale pre-seek progress
// event; never cleared, just superseded or ignored (pure derivation below).
const [pendingSeek, setPendingSeek] = useState<{ target: number; key?: string | number } | null>(
null
);
// Gesture-internal values (only touched inside event handlers).
const widthRef = useRef(0);
const scrubRef = useRef<number | null>(null);
const grantRef = useRef({ fraction: 0, pageX: 0 });
const setScrub = (fraction: number | null) => {
scrubRef.current = fraction;
setScrubFraction(fraction);
};
const onLayout = (event: LayoutChangeEvent) => {
widthRef.current = event.nativeEvent.layout.width;
setBarWidth(event.nativeEvent.layout.width);
};
const handleGrant = (event: GestureResponderEvent) => {
const fraction = clamp(event.nativeEvent.locationX / Math.max(1, widthRef.current));
grantRef.current = { fraction, pageX: event.nativeEvent.pageX };
setScrub(fraction);
};
const handleMove = (event: GestureResponderEvent) => {
const delta = (event.nativeEvent.pageX - grantRef.current.pageX) / Math.max(1, widthRef.current);
setScrub(clamp(grantRef.current.fraction + delta));
};
const handleRelease = () => {
const fraction = scrubRef.current ?? grantRef.current.fraction;
const target = fraction * duration;
setPendingSeek({ target, key: trackKey });
onSeek(target);
setScrub(null);
};
// Displayed position: scrub > held seek target > live progress. The held
// target applies only while playback hasn't caught up on the same track.
const holdSeek =
pendingSeek != null &&
pendingSeek.key === trackKey &&
duration > 0 &&
Math.abs(currentTime - pendingSeek.target) >= 1.5;
const liveFraction = duration > 0 ? Math.min(1, currentTime / duration) : 0;
const heldFraction = holdSeek ? clamp(pendingSeek.target / duration) : null;
const fraction = scrubFraction ?? heldFraction ?? liveFraction;
const shownTime = fraction * duration;
return (
<View>
<View
style={styles.touchArea}
onLayout={onLayout}
onStartShouldSetResponder={() => duration > 0}
onMoveShouldSetResponder={() => duration > 0}
onResponderTerminationRequest={() => false}
onResponderGrant={handleGrant}
onResponderMove={handleMove}
onResponderRelease={handleRelease}
onResponderTerminate={() => setScrub(null)}
accessibilityRole="adjustable"
accessibilityLabel="Seek"
accessibilityValue={{
min: 0,
max: Math.round(duration),
now: Math.round(shownTime),
}}
>
<View style={styles.track}>
<View style={[styles.fill, { width: `${fraction * 100}%` }]} />
</View>
<View
pointerEvents="none"
style={[
styles.thumb,
scrubFraction != null && styles.thumbActive,
{ left: Math.max(0, fraction * barWidth - THUMB_SIZE / 2) },
]}
/>
</View>
<View style={styles.times}>
<Text variant="mono" style={[styles.time, scrubFraction != null && styles.timeActive]}>
{formatDuration(shownTime)}
</Text>
<Text variant="mono" style={styles.time}>
{formatDuration(duration)}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
touchArea: {
justifyContent: 'center',
paddingVertical: spacing.md, // generous touch target around the 4px track
},
track: {
height: 4,
borderRadius: radius.pill,
backgroundColor: colors.glassBorder,
overflow: 'hidden',
},
fill: {
height: 4,
borderRadius: radius.pill,
backgroundColor: colors.accent,
},
thumb: {
position: 'absolute',
width: THUMB_SIZE,
height: THUMB_SIZE,
borderRadius: THUMB_SIZE / 2,
backgroundColor: colors.accent,
},
thumbActive: {
transform: [{ scale: 1.35 }],
backgroundColor: colors.accentHover,
},
times: {
flexDirection: 'row',
justifyContent: 'space-between',
},
time: {
color: colors.textTertiary,
},
timeActive: {
color: colors.accentText,
},
});
export default SeekBar;
+57
View File
@@ -0,0 +1,57 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { colors, radius, spacing } from '@/theme';
import { artworkUri } from '@/library/artwork';
import type { Album } from '@/types/library';
export function AlbumGridItem({ album, onPress }: { album: Album; onPress: () => void }) {
return (
<Pressable style={styles.item} onPress={onPress} accessibilityRole="button">
<View style={styles.art}>
{album.artwork_hash ? (
<Image
source={{ uri: artworkUri(album.artwork_hash) }}
style={styles.artImage}
contentFit="cover"
transition={120}
/>
) : (
<AstraLogo size={36} />
)}
</View>
<Text variant="body" numberOfLines={1} style={styles.title}>
{album.album}
</Text>
<Text variant="label" numberOfLines={1}>
{album.artist}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
item: {
flex: 1,
marginBottom: spacing.lg,
},
art: {
aspectRatio: 1,
borderRadius: radius.md,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginBottom: spacing.sm,
},
artImage: {
width: '100%',
height: '100%',
},
title: {
fontSize: 14,
},
});
+61
View File
@@ -0,0 +1,61 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { artworkUri } from '@/library/artwork';
import type { Artist } from '@/types/library';
export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () => void }) {
return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button">
<View style={styles.art}>
{artist.artwork_hash ? (
<Image
source={{ uri: artworkUri(artist.artwork_hash) }}
style={styles.artImage}
contentFit="cover"
/>
) : (
<Ionicons name="person" size={20} color={colors.textTertiary} />
)}
</View>
<View style={styles.meta}>
<Text variant="body" numberOfLines={1}>
{artist.artist}
</Text>
<Text variant="label">
{artist.track_count} {artist.track_count === 1 ? 'track' : 'tracks'}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textTertiary} />
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.sm + 2,
gap: spacing.md,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
},
art: {
width: 44,
height: 44,
borderRadius: radius.pill,
backgroundColor: colors.bgTertiary,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
artImage: {
width: '100%',
height: '100%',
},
meta: {
flex: 1,
},
});
+58
View File
@@ -0,0 +1,58 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
export function EmptyLibrary() {
const addFolder = useLibraryStore((s) => s.addFolder);
return (
<View style={styles.empty}>
<Ionicons name="musical-notes-outline" size={48} color={colors.textTertiary} />
<Text variant="heading" style={styles.title}>
No music yet
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.body}>
Pick a folder on this device and Astra will scan it into your library.
</Text>
<Pressable style={styles.cta} onPress={() => void addFolder()} accessibilityRole="button">
<Ionicons name="folder-open-outline" size={18} color={colors.bgPrimary} />
<Text variant="body" style={styles.ctaLabel}>
Add music folder
</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingBottom: spacing.xxl,
},
title: {
marginTop: spacing.sm,
},
body: {
textAlign: 'center',
maxWidth: 280,
},
cta: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.accent,
borderRadius: radius.pill,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
marginTop: spacing.lg,
},
ctaLabel: {
color: colors.bgPrimary,
fontWeight: '600',
},
});
+121
View File
@@ -0,0 +1,121 @@
import { View, Pressable, StyleSheet, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import type { FolderWithCount } from '@/stores/libraryStore';
function FolderRow({ folder }: { folder: FolderWithCount }) {
const removeFolder = useLibraryStore((s) => s.removeFolder);
const confirmRemove = () => {
Alert.alert(
'Remove folder?',
`"${folder.display_name}" and its ${folder.track_count} tracks will be removed from the library. Files on disk are not touched.`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Remove', style: 'destructive', onPress: () => void removeFolder(folder.id) },
]
);
};
return (
<View style={styles.row}>
<Ionicons
name={folder.available ? 'folder-outline' : 'alert-circle-outline'}
size={22}
color={folder.available ? colors.textSecondary : colors.warning}
/>
<View style={styles.meta}>
<Text variant="body" numberOfLines={1}>
{folder.display_name}
</Text>
<Text variant="label" numberOfLines={1}>
{folder.available
? `${folder.track_count} ${folder.track_count === 1 ? 'track' : 'tracks'}`
: 'Access lost — remove and add the folder again'}
</Text>
</View>
<Pressable hitSlop={8} onPress={confirmRemove} accessibilityRole="button">
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
</Pressable>
</View>
);
}
export function FoldersView() {
const folders = useLibraryStore((s) => s.folders);
const isScanning = useLibraryStore((s) => s.isScanning);
const addFolder = useLibraryStore((s) => s.addFolder);
const rescan = useLibraryStore((s) => s.rescan);
return (
<View style={styles.container}>
{folders.map((folder) => (
<FolderRow key={folder.id} folder={folder} />
))}
<View style={styles.actions}>
<Pressable
style={[styles.action, isScanning && styles.actionDisabled]}
disabled={isScanning}
onPress={() => void addFolder()}
accessibilityRole="button"
>
<Ionicons name="add" size={18} color={colors.accent} />
<Text variant="body" color={colors.accent}>
Add folder
</Text>
</Pressable>
{folders.length > 0 ? (
<Pressable
style={[styles.action, isScanning && styles.actionDisabled]}
disabled={isScanning}
onPress={() => void rescan()}
accessibilityRole="button"
>
<Ionicons name="refresh" size={16} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Rescan all
</Text>
</Pressable>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: spacing.xs,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
},
meta: {
flex: 1,
},
actions: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.lg,
},
action: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
actionDisabled: {
opacity: 0.4,
},
});
+62
View File
@@ -0,0 +1,62 @@
import { View, StyleSheet } from 'react-native';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
/** Thin accent bar + caption shown under the library header while scanning. */
export function ScanProgress() {
const isScanning = useLibraryStore((s) => s.isScanning);
const progress = useLibraryStore((s) => s.scanProgress);
if (!isScanning) return null;
const label =
progress.phase === 'extracting'
? `Scanning ${progress.folderName ?? ''}${progress.processed}/${progress.total}`
: progress.total > 0
? `Found ${progress.total} files in ${progress.folderName ?? ''}`
: `Looking for music${progress.folderName ? ` in ${progress.folderName}` : ''}`;
const fraction =
progress.phase === 'extracting' && progress.total > 0
? progress.processed / progress.total
: 0;
return (
<View style={styles.container}>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{label}
</Text>
<View style={styles.track}>
<View
style={[
styles.fill,
// Indeterminate discovery phase shows a faint full-width bar.
fraction > 0 ? { width: `${fraction * 100}%` } : styles.fillIndeterminate,
]}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: spacing.xs,
marginBottom: spacing.md,
},
track: {
height: 2,
backgroundColor: colors.glassBorder,
borderRadius: 1,
overflow: 'hidden',
},
fill: {
height: 2,
backgroundColor: colors.accent,
},
fillIndeterminate: {
width: '100%',
opacity: 0.35,
},
});
+91
View File
@@ -0,0 +1,91 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { Text } from '@/components/Text';
import { FormatBadges } from '@/components/FormatBadge';
import { colors, spacing } from '@/theme';
import { formatDuration } from '@/lib/format';
import type { DbTrack } from '@/types/library';
export function TrackRow({
track,
onPress,
showArtist = true,
active = false,
}: {
track: DbTrack;
onPress: () => void;
/** Hide on album detail where every row shares the artist. */
showArtist?: boolean;
active?: boolean;
}) {
return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button">
{track.track_number != null && !showArtist ? (
<Text variant="mono" style={styles.trackNumber}>
{track.track_number}
</Text>
) : null}
<View style={styles.meta}>
<Text
variant="body"
numberOfLines={1}
style={[styles.title, active && styles.titleActive]}
>
{track.title}
</Text>
{showArtist ? (
<Text variant="label" numberOfLines={1}>
{track.artist}
</Text>
) : null}
<View style={styles.badges}>
<FormatBadges
track={{
format: track.format,
bitDepth: track.bit_depth ?? undefined,
sampleRate: track.sample_rate ?? undefined,
}}
/>
</View>
</View>
<Text variant="mono" style={styles.duration}>
{formatDuration(track.duration)}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.sm + 2,
gap: spacing.md,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
},
trackNumber: {
width: 24,
fontSize: 12,
color: colors.textTertiary,
textAlign: 'right',
},
meta: {
flex: 1,
gap: 2,
},
title: {
fontSize: 15,
},
titleActive: {
color: colors.accent,
},
badges: {
marginTop: 2,
},
duration: {
fontSize: 12,
color: colors.textTertiary,
},
});
@@ -0,0 +1,69 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
export type LibraryViewMode = 'albums' | 'artists' | 'tracks' | 'folders';
const MODES: { key: LibraryViewMode; label: string }[] = [
{ key: 'albums', label: 'Albums' },
{ key: 'artists', label: 'Artists' },
{ key: 'tracks', label: 'Tracks' },
{ key: 'folders', label: 'Folders' },
];
export function ViewModeSwitcher({
value,
onChange,
}: {
value: LibraryViewMode;
onChange: (mode: LibraryViewMode) => void;
}) {
return (
<View style={styles.row}>
{MODES.map((mode) => {
const active = mode.key === value;
return (
<Pressable
key={mode.key}
onPress={() => onChange(mode.key)}
style={[styles.pill, active && styles.pillActive]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Text
variant="label"
style={[styles.label, active && styles.labelActive]}
>
{mode.label}
</Text>
</Pressable>
);
})}
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: spacing.sm,
},
pill: {
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
},
pillActive: {
borderColor: colors.accent,
backgroundColor: 'rgba(56, 189, 248, 0.08)',
},
label: {
color: colors.textSecondary,
},
labelActive: {
color: colors.accent,
},
});
+78
View File
@@ -0,0 +1,78 @@
// SQLite access layer — ports the desktop `LibrarySqliteDatabase` wrapper
// (astra/src/main/services/library.ts) onto op-sqlite. Same method surface so
// desktop SQL ports verbatim; methods are async because op-sqlite is async.
import { open, type DB, type QueryResult, type Scalar, type Transaction } from '@op-engineering/op-sqlite';
import { migrate } from './schema';
export type SqlParams = Scalar[];
interface Executor {
execute: (query: string, params?: Scalar[]) => Promise<QueryResult>;
}
export class LibraryDatabase {
constructor(
private readonly executor: Executor,
private readonly db: DB | null = null
) {}
async run(sql: string, params: SqlParams = []): Promise<{ changes: number; lastInsertRowid: number }> {
const result = await this.executor.execute(sql, params);
return { changes: result.rowsAffected, lastInsertRowid: result.insertId ?? 0 };
}
async exec(sql: string): Promise<void> {
await this.executor.execute(sql);
}
async get<T>(sql: string, params: SqlParams = []): Promise<T | undefined> {
const result = await this.executor.execute(sql, params);
return result.rows[0] as T | undefined;
}
async all<T>(sql: string, params: SqlParams = []): Promise<T[]> {
const result = await this.executor.execute(sql, params);
return result.rows as T[];
}
/**
* Runs `fn` inside a single transaction; op-sqlite commits on resolve and
* rolls back on throw. The callback receives a LibraryDatabase scoped to the
* transaction — nesting is not supported.
*/
async transaction(fn: (tx: LibraryDatabase) => Promise<void>): Promise<void> {
if (!this.db) {
throw new Error('Nested transactions are not supported');
}
await this.db.transaction(async (tx: Transaction) => {
await fn(new LibraryDatabase(tx));
});
}
close(): void {
this.db?.close();
}
}
let dbPromise: Promise<LibraryDatabase> | null = null;
async function doOpen(): Promise<LibraryDatabase> {
const raw = open({ name: 'astra-library.db' });
const db = new LibraryDatabase(raw, raw);
await db.exec('PRAGMA journal_mode = WAL');
await db.exec('PRAGMA foreign_keys = ON');
await migrate(db);
return db;
}
/** Opens (once) and migrates the library database. */
export function openLibraryDb(): Promise<LibraryDatabase> {
if (!dbPromise) {
dbPromise = doOpen().catch((err) => {
dbPromise = null; // allow retry on genuine failure
throw err;
});
}
return dbPromise;
}
+212
View File
@@ -0,0 +1,212 @@
// Library queries — SQL ported/adapted from the desktop library service.
import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library';
import type { LibraryDatabase, SqlParams } from './database';
/** Row shape the scanner produces for insert/update (id and timestamps are db-managed). */
export interface TrackUpsert {
path: string;
folder_id: number;
title: string;
artist: string;
album: string;
album_artist: string | null;
album_identity_key: string;
duration: number;
track_number: number | null;
disc_number: number | null;
year: number | null;
genre: string | null;
artwork_hash: string | null;
format: string;
sample_rate: number | null;
bit_depth: number | null;
bitrate: number | null;
channels: number | null;
codec: string | null;
file_name: string;
size: number | null;
mtime: number;
}
const UPSERT_TRACK_SQL = `
INSERT INTO tracks (
path, folder_id, title, artist, album, album_artist, album_identity_key,
duration, track_number, disc_number, year, genre, artwork_hash, format,
sample_rate, bit_depth, bitrate, channels, codec, source_type,
file_name, size, mtime, added_at, modified_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local', ?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
folder_id = excluded.folder_id,
title = excluded.title,
artist = excluded.artist,
album = excluded.album,
album_artist = excluded.album_artist,
album_identity_key = excluded.album_identity_key,
duration = excluded.duration,
track_number = excluded.track_number,
disc_number = excluded.disc_number,
year = excluded.year,
genre = excluded.genre,
artwork_hash = excluded.artwork_hash,
format = excluded.format,
sample_rate = excluded.sample_rate,
bit_depth = excluded.bit_depth,
bitrate = excluded.bitrate,
channels = excluded.channels,
codec = excluded.codec,
file_name = excluded.file_name,
size = excluded.size,
mtime = excluded.mtime,
modified_at = excluded.modified_at
`;
export async function upsertTracks(db: LibraryDatabase, rows: TrackUpsert[]): Promise<void> {
if (rows.length === 0) return;
const now = Date.now();
await db.transaction(async (tx) => {
for (const row of rows) {
await tx.run(UPSERT_TRACK_SQL, [
row.path,
row.folder_id,
row.title,
row.artist,
row.album,
row.album_artist,
row.album_identity_key,
row.duration,
row.track_number,
row.disc_number,
row.year,
row.genre,
row.artwork_hash,
row.format,
row.sample_rate,
row.bit_depth,
row.bitrate,
row.channels,
row.codec,
row.file_name,
row.size,
row.mtime,
now,
now,
]);
}
});
}
const TRACK_ORDER = 'COALESCE(disc_number, 9999), COALESCE(track_number, 9999), title COLLATE NOCASE';
export function getAlbums(db: LibraryDatabase): Promise<Album[]> {
return db.all<Album>(`
SELECT album_identity_key AS identity_key,
MAX(album) AS album,
MAX(COALESCE(album_artist, artist)) AS artist,
MAX(year) AS year,
MAX(artwork_hash) AS artwork_hash,
COUNT(*) AS track_count
FROM tracks
GROUP BY album_identity_key
ORDER BY 3 COLLATE NOCASE, 2 COLLATE NOCASE
`);
}
export function getArtists(db: LibraryDatabase): Promise<Artist[]> {
return db.all<Artist>(`
SELECT artist,
COUNT(*) AS track_count,
MAX(artwork_hash) AS artwork_hash
FROM tracks
GROUP BY artist
ORDER BY artist COLLATE NOCASE
`);
}
export function getAllTracks(db: LibraryDatabase): Promise<DbTrack[]> {
return db.all<DbTrack>(`
SELECT * FROM tracks
ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, ${TRACK_ORDER}
`);
}
export function getTracksByAlbumKey(db: LibraryDatabase, identityKey: string): Promise<DbTrack[]> {
return db.all<DbTrack>(
`SELECT * FROM tracks WHERE album_identity_key = ? ORDER BY ${TRACK_ORDER}`,
[identityKey]
);
}
export function getTracksByArtist(db: LibraryDatabase, artist: string): Promise<DbTrack[]> {
return db.all<DbTrack>(
`SELECT * FROM tracks WHERE artist = ? ORDER BY album COLLATE NOCASE, ${TRACK_ORDER}`,
[artist]
);
}
export async function getTrackCount(db: LibraryDatabase): Promise<number> {
const row = await db.get<{ count: number }>('SELECT COUNT(*) AS count FROM tracks');
return row?.count ?? 0;
}
// --- Folders -----------------------------------------------------------------
type FolderRow = Omit<LibraryFolder, 'available'>;
export function getFolders(db: LibraryDatabase): Promise<FolderRow[]> {
return db.all<FolderRow>('SELECT * FROM folders ORDER BY added_at');
}
export async function getFolderTrackCounts(db: LibraryDatabase): Promise<Map<number, number>> {
const rows = await db.all<{ folder_id: number; count: number }>(
'SELECT folder_id, COUNT(*) AS count FROM tracks GROUP BY folder_id'
);
return new Map(rows.map((row) => [row.folder_id, row.count]));
}
export async function insertFolder(
db: LibraryDatabase,
treeUri: string,
displayName: string
): Promise<FolderRow> {
await db.run(
`INSERT INTO folders (tree_uri, display_name, added_at) VALUES (?, ?, ?)
ON CONFLICT(tree_uri) DO UPDATE SET display_name = excluded.display_name`,
[treeUri, displayName, Date.now()]
);
const row = await db.get<FolderRow>('SELECT * FROM folders WHERE tree_uri = ?', [treeUri]);
if (!row) throw new Error('Folder insert failed');
return row;
}
export async function deleteFolder(db: LibraryDatabase, folderId: number): Promise<void> {
// ON DELETE CASCADE removes the folder's tracks (foreign_keys is ON per connection).
await db.run('DELETE FROM folders WHERE id = ?', [folderId]);
}
export async function markFolderScanned(db: LibraryDatabase, folderId: number): Promise<void> {
await db.run('UPDATE folders SET last_scanned_at = ? WHERE id = ?', [Date.now(), folderId]);
}
// --- Scan support ------------------------------------------------------------
export function getFolderSyncRows(
db: LibraryDatabase,
folderId: number
): Promise<{ path: string; size: number | null; mtime: number }[]> {
return db.all('SELECT path, size, mtime FROM tracks WHERE folder_id = ?', [folderId]);
}
export async function deleteTracksByPaths(db: LibraryDatabase, paths: string[]): Promise<number> {
let deleted = 0;
for (let i = 0; i < paths.length; i += 500) {
const chunk = paths.slice(i, i + 500);
const placeholders = chunk.map(() => '?').join(', ');
const result = await db.run(
`DELETE FROM tracks WHERE path IN (${placeholders})`,
chunk as SqlParams
);
deleted += result.changes;
}
return deleted;
}
+66
View File
@@ -0,0 +1,66 @@
// Library schema — a trimmed port of the desktop schema (astra
// src/main/services/library.ts). v1 covers M1 (local scan + browse);
// playlists arrive as v2 at M2, metadata overrides / lyrics later.
import type { LibraryDatabase } from './database';
export const SCHEMA_VERSION = 1;
// One statement per entry — op-sqlite executes single statements.
const MIGRATIONS: readonly (readonly string[])[] = [
// v0 -> v1
[
`CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tree_uri TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
added_at INTEGER NOT NULL,
last_scanned_at INTEGER
)`,
`CREATE TABLE IF NOT EXISTS tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT UNIQUE NOT NULL,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT NOT NULL,
album_artist TEXT,
album_identity_key TEXT NOT NULL,
duration REAL NOT NULL DEFAULT 0,
track_number INTEGER,
disc_number INTEGER,
year INTEGER,
genre TEXT,
artwork_hash TEXT,
format TEXT NOT NULL,
sample_rate INTEGER,
bit_depth INTEGER,
bitrate INTEGER,
channels INTEGER,
codec TEXT,
source_type TEXT NOT NULL DEFAULT 'local',
file_name TEXT NOT NULL,
size INTEGER,
mtime INTEGER NOT NULL DEFAULT 0,
added_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL
)`,
'CREATE INDEX IF NOT EXISTS idx_tracks_album_identity ON tracks(album_identity_key)',
'CREATE INDEX IF NOT EXISTS idx_tracks_artist ON tracks(artist)',
'CREATE INDEX IF NOT EXISTS idx_tracks_folder ON tracks(folder_id)',
],
];
export async function migrate(db: LibraryDatabase): Promise<void> {
const row = await db.get<{ user_version: number }>('PRAGMA user_version');
const current = row?.user_version ?? 0;
for (let version = current; version < SCHEMA_VERSION; version++) {
await db.transaction(async (tx) => {
for (const statement of MIGRATIONS[version]) {
await tx.exec(statement);
}
await tx.exec(`PRAGMA user_version = ${version + 1}`);
});
}
}
+8
View File
@@ -0,0 +1,8 @@
/** 271.3 -> "4:31"; hours roll into minutes ("73:09") like desktop track lists. */
export function formatDuration(seconds: number): string {
const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
const total = Math.floor(safe);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
+13
View File
@@ -0,0 +1,13 @@
// Artwork cache lives in the app's files dir; the native scanner writes
// md5-named files (desktop convention) and tracks store the file name.
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
let artworkDir: string | null = null;
export function artworkUri(hash: string): string {
if (!artworkDir) {
artworkDir = AstraLibraryScanner.getArtworkDirPath();
}
return `file://${artworkDir}/${hash}`;
}
+17
View File
@@ -0,0 +1,17 @@
// Ported from desktop AUDIO_EXTENSIONS (astra src/main/services/library.ts).
// Extensions are passed to the native walk without leading dots.
export const AUDIO_EXTENSIONS = [
'mp3',
'flac',
'wav',
'ogg',
'aac',
'm4a',
'opus',
'wma',
'aiff',
'alac',
'ape',
'wv',
];
+185
View File
@@ -0,0 +1,185 @@
// Scan orchestration: SAF folder pick -> native walk -> diff against DB ->
// native metadata extraction in batches -> batched upserts. Concepts (mtime
// skip, batching, never-abort-on-file-errors) ported from desktop scanFolder.
import { StorageAccessFramework } from 'expo-file-system/legacy';
import { AstraLibraryScanner, type ScannedFile } from '../../modules/astra-library-scanner';
import { openLibraryDb } from '@/db/database';
import {
deleteFolder,
deleteTracksByPaths,
getFolders,
getFolderSyncRows,
getFolderTrackCounts,
insertFolder,
markFolderScanned,
upsertTracks,
type TrackUpsert,
} from '@/db/queries';
import type { LibraryFolder } from '@/types/library';
import { AUDIO_EXTENSIONS } from './audioExtensions';
import { metadataToUpsertRow } from './trackAdapter';
const EXTRACT_BATCH_SIZE = 24;
export interface ScanProgress {
phase: 'discovering' | 'extracting';
processed: number;
total: number;
folderName: string;
}
export interface ScanCallbacks {
onProgress?: (progress: ScanProgress) => void;
}
export interface ScanResult {
added: number;
updated: number;
removed: number;
errors: number;
}
function emptyResult(): ScanResult {
return { added: 0, updated: 0, removed: 0, errors: 0 };
}
/** "content://…/tree/primary%3AMusic%2FAstraTest" -> "AstraTest" */
function displayNameFromTreeUri(treeUri: string): string {
const lastSegment = treeUri.split('/').pop() ?? treeUri;
const decoded = decodeURIComponent(lastSegment);
const name = decoded.split(/[/:]/).pop()?.trim();
return name || 'Music folder';
}
/** Folder rows joined with current permission state and track counts. */
export async function loadFolders(): Promise<(LibraryFolder & { track_count: number })[]> {
const db = await openLibraryDb();
const [rows, counts] = await Promise.all([getFolders(db), getFolderTrackCounts(db)]);
const persisted = new Set(AstraLibraryScanner.getPersistedTreeUris());
return rows.map((row) => ({
...row,
available: persisted.has(row.tree_uri),
track_count: counts.get(row.id) ?? 0,
}));
}
/**
* System folder picker -> persist grant -> folder row -> scan.
* Returns null if the user cancelled the picker.
*/
export async function addFolderViaPicker(callbacks?: ScanCallbacks): Promise<ScanResult | null> {
const permission = await StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!permission.granted) return null;
const treeUri = permission.directoryUri;
await AstraLibraryScanner.takePersistableUriPermission(treeUri);
const db = await openLibraryDb();
const row = await insertFolder(db, treeUri, displayNameFromTreeUri(treeUri));
return scanFolder({ ...row, available: true }, { callbacks });
}
export async function scanFolder(
folder: Omit<LibraryFolder, 'available'> & { available?: boolean },
opts: { mode?: 'incremental' | 'full'; callbacks?: ScanCallbacks } = {}
): Promise<ScanResult> {
const { mode = 'incremental', callbacks } = opts;
const db = await openLibraryDb();
const result = emptyResult();
// Native discovery runs as one promise; forward its progress events.
const subscription = AstraLibraryScanner.addListener('onScanProgress', (event) => {
callbacks?.onProgress?.({
phase: 'discovering',
processed: 0,
total: event.found,
folderName: folder.display_name,
});
});
callbacks?.onProgress?.({ phase: 'discovering', processed: 0, total: 0, folderName: folder.display_name });
let files: ScannedFile[];
let covers: Record<string, string>;
try {
const listing = await AstraLibraryScanner.listAudioFiles(folder.tree_uri, AUDIO_EXTENSIONS);
files = listing.files;
covers = listing.covers;
} finally {
subscription.remove();
}
// Diff against what the DB knows about this folder.
const existingRows = await getFolderSyncRows(db, folder.id);
const existingByPath = new Map(existingRows.map((row) => [row.path, row]));
const seenPaths = new Set(files.map((file) => file.uri));
const toDelete = existingRows.filter((row) => !seenPaths.has(row.path)).map((row) => row.path);
const toExtract = files.filter((file) => {
const existing = existingByPath.get(file.uri);
if (!existing || mode === 'full') return true;
return existing.mtime !== file.lastModified || existing.size !== file.size;
});
result.removed = await deleteTracksByPaths(db, toDelete);
let processed = 0;
for (let i = 0; i < toExtract.length; i += EXTRACT_BATCH_SIZE) {
const batch = toExtract.slice(i, i + EXTRACT_BATCH_SIZE);
const extracted = await AstraLibraryScanner.extractMetadata(
batch.map((file) => ({ uri: file.uri, coverUri: covers[file.parentUri] ?? null }))
);
const metaByUri = new Map(extracted.map((meta) => [meta.uri, meta]));
const rows: TrackUpsert[] = [];
for (const file of batch) {
const meta = metaByUri.get(file.uri);
if (!meta?.ok) {
result.errors += 1;
continue;
}
rows.push(metadataToUpsertRow(meta, file, folder.id));
if (existingByPath.has(file.uri)) {
result.updated += 1;
} else {
result.added += 1;
}
}
await upsertTracks(db, rows);
processed += batch.length;
callbacks?.onProgress?.({
phase: 'extracting',
processed,
total: toExtract.length,
folderName: folder.display_name,
});
}
await markFolderScanned(db, folder.id);
return result;
}
/** Rescans every folder whose permission grant is still alive. */
export async function rescanAll(
opts: { mode?: 'incremental' | 'full'; callbacks?: ScanCallbacks } = {}
): Promise<ScanResult> {
const folders = await loadFolders();
const total = emptyResult();
for (const folder of folders) {
if (!folder.available) continue;
const result = await scanFolder(folder, opts);
total.added += result.added;
total.updated += result.updated;
total.removed += result.removed;
total.errors += result.errors;
}
return total;
}
/** Explicit user removal: drop the folder (tracks CASCADE) and release the grant. */
export async function removeFolder(folder: Pick<LibraryFolder, 'id' | 'tree_uri'>): Promise<void> {
const db = await openLibraryDb();
await deleteFolder(db, folder.id);
await AstraLibraryScanner.releasePersistedUriPermission(folder.tree_uri);
}
+128
View File
@@ -0,0 +1,128 @@
// Adapters between the native scanner output, the SQLite row shape, and the
// app-level Track model the player consumes.
import type { Track } from '@/types/audio';
import type { DbTrack } from '@/types/library';
import type { TrackUpsert } from '@/db/queries';
import type { ExtractedMetadata, ScannedFile } from '../../modules/astra-library-scanner';
import { artworkUri } from './artwork';
const UNKNOWN_ARTIST = 'Unknown Artist';
const UNKNOWN_ALBUM = 'Unknown Album';
// Ports desktop normalizeDisplay/normalizeKey (library.ts:1023).
function normalizeKey(value: string): string {
return value.replace(/\s+/g, ' ').trim().toLocaleLowerCase();
}
// M1-simple album identity: normalized "<album artist or artist>|<album>".
// Stored per track so getAlbums is a plain GROUP BY; the desktop compilation
// heuristic can land later by recomputing this column.
export function buildAlbumIdentityKey(
albumArtist: string | null,
artist: string,
album: string
): string {
const artistKey = normalizeKey(albumArtist || artist) || 'unknown artist';
const albumKey = normalizeKey(album) || 'unknown album';
return `${artistKey}|${albumKey}`;
}
const CODEC_BY_MIME: Record<string, string> = {
'audio/flac': 'flac',
'audio/mpeg': 'mp3',
'audio/mpeg-l2': 'mp2',
'audio/mp4a-latm': 'aac',
'audio/aac': 'aac',
'audio/alac': 'alac',
'audio/opus': 'opus',
'audio/vorbis': 'vorbis',
'audio/raw': 'pcm',
'audio/ac3': 'ac3',
'audio/eac3': 'eac3',
};
function codecFromMime(
trackMime: string | null | undefined,
containerMime: string | null | undefined
): string | null {
// Some framework extractors (e.g. FLAC) expose the decoded track as
// audio/raw; the container mime identifies the real codec there.
const mime = trackMime === 'audio/raw' && containerMime ? containerMime : trackMime;
if (!mime) return null;
return CODEC_BY_MIME[mime] ?? mime.replace(/^audio\//, '');
}
function fileExtension(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot + 1) : '';
}
function cleanTag(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
export function metadataToUpsertRow(
meta: ExtractedMetadata,
file: ScannedFile,
folderId: number
): TrackUpsert {
const extension = fileExtension(file.name);
const title = cleanTag(meta.title) ?? file.name.slice(0, file.name.length - (extension ? extension.length + 1 : 0));
const artist = cleanTag(meta.artist) ?? UNKNOWN_ARTIST;
const album = cleanTag(meta.album) ?? UNKNOWN_ALBUM;
const albumArtist = cleanTag(meta.albumArtist);
return {
path: file.uri,
folder_id: folderId,
title,
artist,
album,
album_artist: albumArtist,
album_identity_key: buildAlbumIdentityKey(albumArtist, artist, album),
duration: meta.durationMs != null ? meta.durationMs / 1000 : 0,
track_number: meta.trackNumber ?? null,
disc_number: meta.discNumber ?? null,
year: meta.year ?? null,
genre: cleanTag(meta.genre),
artwork_hash: meta.artworkHash ?? null,
format: extension ? extension.toUpperCase() : 'UNKNOWN',
sample_rate: meta.sampleRate ?? null,
bit_depth: meta.bitsPerSample ?? null,
bitrate: meta.bitrate ?? null,
channels: meta.channels ?? null,
codec: codecFromMime(meta.codecMime, meta.mimeType),
file_name: file.name,
size: file.size,
mtime: file.lastModified,
};
}
export function dbTrackToTrack(track: DbTrack): Track {
return {
id: String(track.id),
path: track.path,
origin: 'library',
title: track.title,
artist: track.artist,
album: track.album,
albumArtist: track.album_artist ?? undefined,
albumIdentityKey: track.album_identity_key,
duration: track.duration,
trackNumber: track.track_number ?? undefined,
discNumber: track.disc_number ?? undefined,
year: track.year ?? undefined,
genre: track.genre ?? undefined,
artworkData: track.artwork_hash ? artworkUri(track.artwork_hash) : undefined,
artworkHash: track.artwork_hash ?? undefined,
format: track.format,
sampleRate: track.sample_rate ?? undefined,
bitDepth: track.bit_depth ?? undefined,
bitrate: track.bitrate ?? undefined,
channels: track.channels ?? undefined,
codec: track.codec ?? undefined,
sourceType: track.source_type,
};
}
+103 -12
View File
@@ -1,30 +1,121 @@
import { create } from 'zustand';
import type { Album, Artist, DbTrack } from '@/types/library';
import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library';
import { openLibraryDb } from '@/db/database';
import { getAlbums, getAllTracks, getArtists, getTrackCount } from '@/db/queries';
import {
addFolderViaPicker,
loadFolders,
removeFolder as scannerRemoveFolder,
rescanAll,
type ScanProgress,
type ScanResult,
} from '@/library/scanner';
/**
* Library state — minimal M0 stub. Real on-device scanning + SQLite (op-sqlite)
* land at M1; the shapes here mirror desktop `libraryStore` so that work slots in.
* Library state — SQLite is the source of truth (no persist middleware);
* this store mirrors it in memory for the UI plus scan/UI state.
*/
type ViewMode = 'tracks' | 'albums' | 'artists' | 'folders';
export type FolderWithCount = LibraryFolder & { track_count: number };
interface ScanProgressState {
phase: 'idle' | 'discovering' | 'extracting';
processed: number;
total: number;
folderName?: string;
}
const IDLE_PROGRESS: ScanProgressState = { phase: 'idle', processed: 0, total: 0 };
interface LibraryStore {
initialized: boolean;
tracks: DbTrack[];
albums: Album[];
artists: Artist[];
folders: FolderWithCount[];
totalTrackCount: number;
viewMode: ViewMode;
isScanning: boolean;
scanProgress: ScanProgressState;
scanError: string | null;
initialize: () => Promise<void>;
refresh: () => Promise<void>;
setViewMode: (mode: ViewMode) => void;
addFolder: () => Promise<void>;
removeFolder: (folderId: number) => Promise<void>;
rescan: () => Promise<void>;
}
export const useLibraryStore = create<LibraryStore>((set) => ({
tracks: [],
albums: [],
artists: [],
totalTrackCount: 0,
viewMode: 'albums',
isScanning: false,
let initPromise: Promise<void> | null = null;
setViewMode: (viewMode) => set({ viewMode }),
}));
export const useLibraryStore = create<LibraryStore>((set, get) => {
const onProgress = (progress: ScanProgress) => set({ scanProgress: progress });
/** Shared scan wrapper: progress/error state + refresh, scans never overlap. */
const runScan = async (scan: () => Promise<ScanResult | null>) => {
if (get().isScanning) return;
set({ isScanning: true, scanError: null, scanProgress: { ...IDLE_PROGRESS } });
try {
await scan();
} catch (err) {
set({ scanError: err instanceof Error ? err.message : String(err) });
} finally {
await get().refresh();
set({ isScanning: false, scanProgress: { ...IDLE_PROGRESS } });
}
};
return {
initialized: false,
tracks: [],
albums: [],
artists: [],
folders: [],
totalTrackCount: 0,
viewMode: 'albums',
isScanning: false,
scanProgress: { ...IDLE_PROGRESS },
scanError: null,
initialize: () => {
if (!initPromise) {
initPromise = (async () => {
await openLibraryDb();
await get().refresh();
set({ initialized: true });
})().catch((err) => {
initPromise = null; // allow retry on genuine failure
throw err;
});
}
return initPromise;
},
refresh: async () => {
const db = await openLibraryDb();
const [tracks, albums, artists, folders, totalTrackCount] = await Promise.all([
getAllTracks(db),
getAlbums(db),
getArtists(db),
loadFolders(),
getTrackCount(db),
]);
set({ tracks, albums, artists, folders, totalTrackCount });
},
setViewMode: (viewMode) => set({ viewMode }),
addFolder: () => runScan(() => addFolderViaPicker({ onProgress })),
removeFolder: async (folderId) => {
const folder = get().folders.find((entry) => entry.id === folderId);
if (!folder) return;
await scannerRemoveFolder(folder);
await get().refresh();
},
rescan: () => runScan(() => rescanAll({ callbacks: { onProgress } })),
};
});
+3
View File
@@ -18,6 +18,9 @@ export const colors = {
textSecondary: 'rgba(255, 255, 255, 0.6)',
textTertiary: 'rgba(255, 255, 255, 0.4)',
// Warning amber (desktop .graph-meta-chip-warning)
warning: '#f3d27d',
// Cyan accent
accent: '#38bdf8',
accentHover: '#7dd3fc',
+17 -1
View File
@@ -5,11 +5,13 @@ export type TrackSourceType = 'local' | 'subsonic' | 'jellyfin';
export interface DbTrack {
id: number;
path: string;
path: string; // SAF document content:// URI for local tracks
folder_id: number;
title: string;
artist: string;
album: string;
album_artist: string | null;
album_identity_key: string;
duration: number;
track_number: number | null;
disc_number: number | null;
@@ -23,7 +25,21 @@ export interface DbTrack {
channels: number | null;
codec: string | null;
source_type: TrackSourceType;
file_name: string;
size: number | null;
mtime: number;
added_at: number;
modified_at: number;
}
export interface LibraryFolder {
id: number;
tree_uri: string;
display_name: string;
added_at: number;
last_scanned_at: number | null;
/** Computed against persisted URI permissions at load time — not stored. */
available: boolean;
}
export interface Album {