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