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
+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,
},
});