m2, library ux

This commit is contained in:
Boof2015
2026-06-13 13:13:53 -04:00
parent 23be9875e8
commit 3d30a09713
24 changed files with 2397 additions and 39 deletions
+10
View File
@@ -19,6 +19,7 @@
"expo-asset": "~56.0.14",
"expo-constants": "~56.0.15",
"expo-device": "~56.0.4",
"expo-document-picker": "~56.0.4",
"expo-file-system": "~56.0.8",
"expo-font": "~56.0.5",
"expo-glass-effect": "~56.0.4",
@@ -6126,6 +6127,15 @@
"expo": "*"
}
},
"node_modules/expo-document-picker": {
"version": "56.0.4",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-56.0.4.tgz",
"integrity": "sha512-75Apf74XNkYYohObIH19VZw42xpe0gmEnPccuzGXKVAzlvTYCfibSgW17F+6vt4paOfZEnAoZ1QFZM6dmaujRA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "56.0.8",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.8.tgz",
+1
View File
@@ -13,6 +13,7 @@
"expo-asset": "~56.0.14",
"expo-constants": "~56.0.15",
"expo-device": "~56.0.4",
"expo-document-picker": "~56.0.4",
"expo-file-system": "~56.0.8",
"expo-font": "~56.0.5",
"expo-glass-effect": "~56.0.4",
+41 -10
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
@@ -8,13 +8,15 @@ import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, radius, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { artworkUri } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
import type { DbTrack } from '@/types/library';
export default function AlbumScreen() {
const router = useRouter();
@@ -32,6 +34,7 @@ export default function AlbumScreen() {
);
const totalDuration = tracks.reduce((sum, track) => sum + track.duration, 0);
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const playFrom = (index: number) => {
void playTracks(tracks.map(dbTrackToTrack), index);
@@ -75,12 +78,24 @@ export default function AlbumScreen() {
.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 style={styles.buttons}>
<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>
<Pressable
style={styles.shuffleButton}
onPress={() => void shuffleTracks(tracks.map(dbTrackToTrack))}
accessibilityRole="button"
>
<Ionicons name="shuffle" size={16} color={colors.accent} />
<Text variant="body" color={colors.accent}>
Shuffle
</Text>
</Pressable>
</View>
</View>
</View>
@@ -94,9 +109,12 @@ export default function AlbumScreen() {
showArtist={false}
active={item.path === currentPath}
onPress={() => playFrom(index)}
onLongPress={() => setActionTrack(item)}
/>
)}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
@@ -135,6 +153,11 @@ const styles = StyleSheet.create({
justifyContent: 'center',
gap: spacing.xs,
},
buttons: {
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.xs,
},
playButton: {
flexDirection: 'row',
alignItems: 'center',
@@ -143,8 +166,16 @@ const styles = StyleSheet.create({
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
alignSelf: 'flex-start',
marginTop: spacing.xs,
},
shuffleButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
playLabel: {
color: colors.bgPrimary,
+29 -3
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
@@ -6,17 +6,20 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, radius, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import type { DbTrack } from '@/types/library';
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);
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
// Store tracks are ordered artist/album/disc/track, so the filtered slice
// keeps album grouping and track order.
@@ -53,6 +56,16 @@ export default function ArtistScreen() {
Play
</Text>
</Pressable>
<Pressable
style={styles.shuffleButton}
onPress={() => void shuffleTracks(tracks.map(dbTrackToTrack))}
accessibilityRole="button"
>
<Ionicons name="shuffle" size={16} color={colors.accent} />
<Text variant="body" color={colors.accent}>
Shuffle
</Text>
</Pressable>
</View>
<FlashList
@@ -64,9 +77,12 @@ export default function ArtistScreen() {
track={item}
active={item.path === currentPath}
onPress={() => playFrom(index)}
onLongPress={() => setActionTrack(item)}
/>
)}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
@@ -85,7 +101,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.lg,
gap: spacing.lg,
gap: spacing.sm,
},
headerMeta: {
flex: 1,
@@ -100,6 +116,16 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
shuffleButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
playLabel: {
color: colors.bgPrimary,
fontWeight: '600',
+90 -18
View File
@@ -1,4 +1,6 @@
import { View, StyleSheet } from 'react-native';
import { useMemo, useState } from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
@@ -8,13 +10,20 @@ 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 { PlaylistsView } from '@/components/library/PlaylistsView';
import { ScanProgress } from '@/components/library/ScanProgress';
import { EmptyLibrary } from '@/components/library/EmptyLibrary';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { ActionSheet } from '@/components/sheets/ActionSheet';
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';
import { sortTracks, TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort';
import type { DbTrack } from '@/types/library';
const SORT_OPTIONS: TrackSort[] = ['artist', 'title', 'recently_added', 'duration'];
export default function LibraryScreen() {
const router = useRouter();
@@ -24,21 +33,41 @@ export default function LibraryScreen() {
const artists = useLibraryStore((s) => s.artists);
const tracks = useLibraryStore((s) => s.tracks);
const folders = useLibraryStore((s) => s.folders);
const trackSort = useLibraryStore((s) => s.trackSort);
const setTrackSort = useLibraryStore((s) => s.setTrackSort);
const isScanning = useLibraryStore((s) => s.isScanning);
const scanError = useLibraryStore((s) => s.scanError);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const [sortSheetOpen, setSortSheetOpen] = useState(false);
const isEmpty = tracks.length === 0 && folders.length === 0 && !isScanning;
const sortedTracks = useMemo(() => sortTracks(tracks, trackSort), [tracks, trackSort]);
// Tap index is within sortedTracks so the tapped row is the track that plays.
const playAllFrom = (index: number) => {
void playTracks(tracks.map(dbTrackToTrack), index);
void playTracks(sortedTracks.map(dbTrackToTrack), index);
};
return (
<Screen>
<Text variant="title" style={styles.heading}>
Library
</Text>
<View style={styles.headingRow}>
<Text variant="title" style={styles.heading}>
Library
</Text>
{!isEmpty ? (
<Pressable
hitSlop={8}
onPress={() => router.push('/library/search')}
accessibilityRole="button"
accessibilityLabel="Search library"
>
<Ionicons name="search" size={22} color={colors.textSecondary} />
</Pressable>
) : null}
</View>
{isEmpty ? (
<EmptyLibrary />
@@ -96,38 +125,81 @@ export default function LibraryScreen() {
) : 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)}
/>
)}
/>
<>
<Pressable
style={styles.sortTrigger}
onPress={() => setSortSheetOpen(true)}
accessibilityRole="button"
>
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} />
<Text variant="label">{TRACK_SORT_LABELS[trackSort]}</Text>
</Pressable>
<FlashList
data={sortedTracks}
keyExtractor={(track) => String(track.id)}
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => (
<TrackRow
track={item}
active={item.path === currentPath}
onPress={() => playAllFrom(index)}
onLongPress={() => setActionTrack(item)}
/>
)}
/>
</>
) : null}
{viewMode === 'playlists' ? <PlaylistsView /> : null}
{viewMode === 'folders' ? <FoldersView /> : null}
</>
)}
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
<ActionSheet
visible={sortSheetOpen}
title="Sort tracks by"
items={SORT_OPTIONS.map((option) => ({
key: option,
label: TRACK_SORT_LABELS[option],
selected: option === trackSort,
onPress: () => {
setTrackSort(option);
setSortSheetOpen(false);
},
}))}
onClose={() => setSortSheetOpen(false)}
/>
</Screen>
);
}
const styles = StyleSheet.create({
heading: {
headingRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: spacing.xl,
marginBottom: spacing.lg,
},
heading: {
flex: 1,
},
switcher: {
marginBottom: spacing.md,
},
error: {
marginBottom: spacing.md,
},
sortTrigger: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
alignSelf: 'flex-end',
paddingVertical: spacing.xs,
marginBottom: spacing.xs,
},
gridCell: {
flex: 1,
paddingHorizontal: spacing.xs,
+348
View File
@@ -0,0 +1,348 @@
import { useEffect, useMemo, useState } 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 { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
import { colors, radius, spacing } from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { artworkUri } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
import type { DbTrack } from '@/types/library';
import type { PlaylistTrackEntry } from '@/types/playlist';
function basename(path: string): string {
const decoded = decodeURIComponent(path.split('/').pop() ?? path);
return decoded.split(/[/:]/).pop() || path;
}
function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongPress: () => void }) {
return (
<Pressable style={styles.missingRow} onLongPress={onLongPress} accessibilityRole="button">
<View style={styles.missingMeta}>
<Text variant="body" numberOfLines={1} color={colors.textTertiary}>
{entry.fallback_title ?? basename(entry.track_path)}
</Text>
<Text variant="label" numberOfLines={1} color={colors.textTertiary}>
{entry.fallback_artist ?? 'Track not in library'}
</Text>
</View>
<Ionicons name="alert-circle-outline" size={18} color={colors.warning} />
</Pressable>
);
}
export default function PlaylistScreen() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const isFavorites = id === 'favorites';
const playlistId = isFavorites ? null : Number(id);
const playlists = usePlaylistStore((s) => s.playlists);
const favoriteTracks = usePlaylistStore((s) => s.favoriteTracks);
const activeEntries = usePlaylistStore((s) => s.activeEntries);
const openPlaylist = usePlaylistStore((s) => s.openPlaylist);
const closePlaylist = usePlaylistStore((s) => s.closePlaylist);
const moveTrack = usePlaylistStore((s) => s.moveTrack);
const removeFromPlaylist = usePlaylistStore((s) => s.removeFromPlaylist);
const markPlayed = usePlaylistStore((s) => s.markPlayed);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const [actionEntry, setActionEntry] = useState<PlaylistTrackEntry | null>(null);
const [missingEntry, setMissingEntry] = useState<PlaylistTrackEntry | null>(null);
useEffect(() => {
if (playlistId == null || Number.isNaN(playlistId)) return;
void openPlaylist(playlistId);
return () => closePlaylist();
}, [playlistId, openPlaylist, closePlaylist]);
const playlist = isFavorites ? null : playlists.find((entry) => entry.id === playlistId);
const name = isFavorites ? 'Favorites' : (playlist?.name ?? 'Playlist');
const coverHash = playlist?.auto_cover_hash ?? null;
const entries: PlaylistTrackEntry[] = useMemo(
() =>
isFavorites
? favoriteTracks.map((track, index) => ({
id: track.id,
track_path: track.path,
position: index,
added_at: track.added_at,
missing: false,
fallback_title: null,
fallback_artist: null,
fallback_album: null,
track,
}))
: activeEntries,
[isFavorites, favoriteTracks, activeEntries]
);
const playable = useMemo(
() => entries.filter((entry) => entry.track !== null).map((entry) => entry.track as DbTrack),
[entries]
);
const playableIndexByEntryId = useMemo(() => {
const map = new Map<number, number>();
let index = 0;
for (const entry of entries) {
if (entry.track) {
map.set(entry.id, index);
index += 1;
}
}
return map;
}, [entries]);
const totalDuration = playable.reduce((sum, track) => sum + track.duration, 0);
const startPlayback = (index: number) => {
if (playable.length === 0) return;
void playTracks(playable.map(dbTrackToTrack), index);
if (playlistId != null && !Number.isNaN(playlistId)) void markPlayed(playlistId);
};
const startShuffle = () => {
if (playable.length === 0) return;
void shuffleTracks(playable.map(dbTrackToTrack));
if (playlistId != null && !Number.isNaN(playlistId)) void markPlayed(playlistId);
};
// Move/remove only exist on real playlists; favorites rows use the standard
// sheet (its favorite toggle is the "remove" affordance there).
const extraItems: ActionSheetItem[] =
playlistId != null && actionEntry
? [
{
key: 'move-up',
label: 'Move up',
icon: 'arrow-up',
onPress: () => {
void moveTrack(playlistId, actionEntry.track_path, -1);
setActionEntry(null);
},
},
{
key: 'move-down',
label: 'Move down',
icon: 'arrow-down',
onPress: () => {
void moveTrack(playlistId, actionEntry.track_path, 1);
setActionEntry(null);
},
},
{
key: 'remove',
label: 'Remove from playlist',
icon: 'remove-circle-outline',
destructive: true,
onPress: () => {
void removeFromPlaylist(playlistId, actionEntry.track_path);
setActionEntry(null);
},
},
]
: [];
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}>
{coverHash ? (
<Image
source={{ uri: artworkUri(coverHash) }}
style={styles.artImage}
contentFit="cover"
transition={120}
/>
) : (
<Ionicons
name={isFavorites ? 'heart' : 'musical-notes-outline'}
size={36}
color={isFavorites ? colors.accent : colors.textTertiary}
/>
)}
</View>
<View style={styles.headerMeta}>
<Text variant="heading" numberOfLines={2}>
{name}
</Text>
<Text variant="label">
{[
`${playable.length} ${playable.length === 1 ? 'track' : 'tracks'}`,
entries.length > playable.length ? `${entries.length - playable.length} missing` : null,
formatDuration(totalDuration),
]
.filter(Boolean)
.join(' · ')}
</Text>
<View style={styles.buttons}>
<Pressable
style={[styles.playButton, playable.length === 0 && styles.buttonDisabled]}
disabled={playable.length === 0}
onPress={() => startPlayback(0)}
accessibilityRole="button"
>
<Ionicons name="play" size={16} color={colors.bgPrimary} />
<Text variant="body" style={styles.playLabel}>
Play
</Text>
</Pressable>
<Pressable
style={[styles.shuffleButton, playable.length === 0 && styles.buttonDisabled]}
disabled={playable.length === 0}
onPress={startShuffle}
accessibilityRole="button"
>
<Ionicons name="shuffle" size={16} color={colors.accent} />
<Text variant="body" color={colors.accent}>
Shuffle
</Text>
</Pressable>
</View>
</View>
</View>
<FlashList
data={entries}
keyExtractor={(entry) => String(entry.id)}
showsVerticalScrollIndicator={false}
renderItem={({ item }) =>
item.track ? (
<TrackRow
track={item.track}
active={item.track.path === currentPath}
onPress={() => startPlayback(playableIndexByEntryId.get(item.id) ?? 0)}
onLongPress={() => setActionEntry(item)}
/>
) : (
<MissingRow entry={item} onLongPress={() => setMissingEntry(item)} />
)
}
/>
<TrackActionsSheet
track={actionEntry?.track ?? null}
onClose={() => setActionEntry(null)}
extraItems={extraItems}
/>
<ActionSheet
visible={missingEntry !== null}
title={missingEntry?.fallback_title ?? 'Missing track'}
items={
playlistId != null && missingEntry
? [
{
key: 'remove',
label: 'Remove from playlist',
icon: 'remove-circle-outline',
destructive: true,
onPress: () => {
void removeFromPlaylist(playlistId, missingEntry.track_path);
setMissingEntry(null);
},
},
]
: []
}
onClose={() => setMissingEntry(null)}
/>
</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,
},
buttons: {
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.xs,
},
playButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.accent,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
shuffleButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
buttonDisabled: {
opacity: 0.4,
},
playLabel: {
color: colors.bgPrimary,
fontWeight: '600',
},
missingRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm + 2,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
opacity: 0.7,
},
missingMeta: {
flex: 1,
gap: 2,
},
});
+220
View File
@@ -0,0 +1,220 @@
import { useDeferredValue, useMemo, useState } from 'react';
import { View, Pressable, StyleSheet, TextInput } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { AlbumRow } from '@/components/library/AlbumRow';
import { ArtistRow } from '@/components/library/ArtistRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, fonts, fontSize, 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 type { Album, Artist, DbTrack } from '@/types/library';
const TRACK_CAP = 50;
const ALBUM_CAP = 12;
const ARTIST_CAP = 12;
type SearchItem =
| { type: 'header'; key: string; label: string }
| { type: 'album'; key: string; album: Album }
| { type: 'artist'; key: string; artist: Artist }
| { type: 'track'; key: string; track: DbTrack; index: number };
function filterCap<T>(items: T[], predicate: (item: T) => boolean, cap: number): T[] {
const out: T[] = [];
for (const item of items) {
if (predicate(item)) {
out.push(item);
if (out.length >= cap) break;
}
}
return out;
}
export default function SearchScreen() {
const router = useRouter();
const tracks = useLibraryStore((s) => s.tracks);
const albums = useLibraryStore((s) => s.albums);
const artists = useLibraryStore((s) => s.artists);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const [query, setQuery] = useState('');
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const needle = useDeferredValue(query.trim().toLocaleLowerCase());
const { items, trackResults } = useMemo(() => {
if (!needle) return { items: [] as SearchItem[], trackResults: [] as DbTrack[] };
// In-memory ≈ desktop searchTracks (LIKE %q% over title/artist/album).
const albumResults = filterCap(
albums,
(album) =>
album.album.toLocaleLowerCase().includes(needle) ||
album.artist.toLocaleLowerCase().includes(needle),
ALBUM_CAP
);
const artistResults = filterCap(
artists,
(artist) => artist.artist.toLocaleLowerCase().includes(needle),
ARTIST_CAP
);
const trackResults = filterCap(
tracks,
(track) =>
track.title.toLocaleLowerCase().includes(needle) ||
track.artist.toLocaleLowerCase().includes(needle) ||
track.album.toLocaleLowerCase().includes(needle),
TRACK_CAP
);
const items: SearchItem[] = [];
if (albumResults.length > 0) {
items.push({ type: 'header', key: 'header-albums', label: 'Albums' });
for (const album of albumResults) {
items.push({ type: 'album', key: `album-${album.identity_key}`, album });
}
}
if (artistResults.length > 0) {
items.push({ type: 'header', key: 'header-artists', label: 'Artists' });
for (const artist of artistResults) {
items.push({ type: 'artist', key: `artist-${artist.artist}`, artist });
}
}
if (trackResults.length > 0) {
items.push({ type: 'header', key: 'header-tracks', label: 'Tracks' });
trackResults.forEach((track, index) => {
items.push({ type: 'track', key: `track-${track.id}`, track, index });
});
}
return { items, trackResults };
}, [needle, tracks, albums, artists]);
const playFrom = (index: number) => {
void playTracks(trackResults.map(dbTrackToTrack), index);
};
return (
<Screen>
<View style={styles.searchBar}>
<Pressable onPress={() => router.back()} hitSlop={8} accessibilityRole="button">
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
</Pressable>
<TextInput
style={styles.input}
value={query}
onChangeText={setQuery}
placeholder="Search tracks, albums, artists"
placeholderTextColor={colors.textTertiary}
autoFocus
returnKeyType="search"
selectionColor={colors.accent}
/>
{query.length > 0 ? (
<Pressable onPress={() => setQuery('')} hitSlop={8} accessibilityRole="button">
<Ionicons name="close-circle" size={18} color={colors.textTertiary} />
</Pressable>
) : null}
</View>
{!needle ? (
<View style={styles.empty}>
<Text variant="caption">Search your library</Text>
</View>
) : items.length === 0 ? (
<View style={styles.empty}>
<Text variant="caption">No results for {query.trim()}</Text>
</View>
) : (
<FlashList
data={items}
keyExtractor={(item) => item.key}
getItemType={(item) => item.type}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => {
switch (item.type) {
case 'header':
return (
<Text variant="label" style={styles.sectionHeader}>
{item.label.toUpperCase()}
</Text>
);
case 'album':
return (
<AlbumRow
album={item.album}
onPress={() =>
router.push({
pathname: '/library/album/[key]',
params: { key: item.album.identity_key },
})
}
/>
);
case 'artist':
return (
<ArtistRow
artist={item.artist}
onPress={() =>
router.push({
pathname: '/library/artist/[name]',
params: { name: item.artist.artist },
})
}
/>
);
case 'track':
return (
<TrackRow
track={item.track}
active={item.track.path === currentPath}
onPress={() => playFrom(item.index)}
onLongPress={() => setActionTrack(item.track)}
/>
);
}
}}
/>
)}
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
const styles = StyleSheet.create({
searchBar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.md,
marginBottom: spacing.md,
},
input: {
flex: 1,
fontFamily: fonts.sans.regular,
fontSize: fontSize.base,
color: colors.textPrimary,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
},
empty: {
alignItems: 'center',
marginTop: spacing.xxl,
},
sectionHeader: {
marginTop: spacing.lg,
marginBottom: spacing.xs,
letterSpacing: 1,
},
});
+10
View File
@@ -30,6 +30,16 @@ export async function playTracks(tracks: Track[], startIndex = 0): Promise<void>
await TrackPlayer.play();
}
/** FisherYates shuffle a copy of the tracks and play from the top. */
export async function shuffleTracks(tracks: Track[]): Promise<void> {
const shuffled = [...tracks];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
await playTracks(shuffled);
}
/** M0 demo entry point: load the streamed sample queue if nothing is queued. */
export async function playSample(): Promise<void> {
await ensurePlayerReady();
+65
View File
@@ -0,0 +1,65 @@
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 { Album } from '@/types/library';
/** Compact album list row (search results) — the grid uses AlbumGridItem. */
export function AlbumRow({ album, onPress }: { album: Album; onPress: () => void }) {
return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button">
<View style={styles.art}>
{album.artwork_hash ? (
<Image
source={{ uri: artworkUri(album.artwork_hash) }}
style={styles.artImage}
contentFit="cover"
/>
) : (
<Ionicons name="disc-outline" size={20} color={colors.textTertiary} />
)}
</View>
<View style={styles.meta}>
<Text variant="body" numberOfLines={1}>
{album.album}
</Text>
<Text variant="label" numberOfLines={1}>
{album.artist}
</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.sm,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
artImage: {
width: '100%',
height: '100%',
},
meta: {
flex: 1,
gap: 2,
},
});
+95
View File
@@ -0,0 +1,95 @@
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';
export function PlaylistRow({
name,
trackCount,
missingCount = 0,
coverHash,
pinned = false,
onPress,
onLongPress,
}: {
name: string;
trackCount: number;
missingCount?: number;
coverHash: string | null;
/** Favorites pseudo-playlist: heart cover instead of artwork/logo. */
pinned?: boolean;
onPress: () => void;
onLongPress?: () => void;
}) {
return (
<Pressable
style={styles.row}
onPress={onPress}
onLongPress={onLongPress}
accessibilityRole="button"
>
<View style={styles.cover}>
{coverHash ? (
<Image
source={{ uri: artworkUri(coverHash) }}
style={styles.coverImage}
contentFit="cover"
transition={120}
/>
) : (
<Ionicons
name={pinned ? 'heart' : 'musical-notes-outline'}
size={20}
color={pinned ? colors.accent : colors.textTertiary}
/>
)}
</View>
<View style={styles.meta}>
<Text variant="body" numberOfLines={1}>
{name}
</Text>
<Text variant="label" numberOfLines={1}>
{`${trackCount} ${trackCount === 1 ? 'track' : 'tracks'}`}
{missingCount > 0 ? (
<Text variant="label" color={colors.warning}>
{` · ${missingCount} missing`}
</Text>
) : null}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm + 2,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
},
cover: {
width: 48,
height: 48,
borderRadius: radius.sm,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
coverImage: {
width: '100%',
height: '100%',
},
meta: {
flex: 1,
gap: 2,
},
});
+219
View File
@@ -0,0 +1,219 @@
import { useState } from 'react';
import { View, Pressable, StyleSheet, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
import { Text } from '@/components/Text';
import { ActionSheet } from '@/components/sheets/ActionSheet';
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
import { PlaylistRow } from '@/components/library/PlaylistRow';
import { colors, radius, spacing } from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import type { Playlist } from '@/types/playlist';
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** "content://…/Test.m3u8" -> "Test.m3u8" for the export confirmation. */
function fileDisplayName(fileUri: string): string {
const decoded = decodeURIComponent(fileUri.split('/').pop() ?? fileUri);
return decoded.split(/[/:]/).pop() || fileUri;
}
type Prompt = { kind: 'create' } | { kind: 'rename'; playlist: Playlist } | null;
export function PlaylistsView() {
const router = useRouter();
const playlists = usePlaylistStore((s) => s.playlists);
const favoriteCount = usePlaylistStore((s) => s.favoriteTracks.length);
const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
const renamePlaylist = usePlaylistStore((s) => s.renamePlaylist);
const deletePlaylist = usePlaylistStore((s) => s.deletePlaylist);
const importM3u = usePlaylistStore((s) => s.importM3u);
const exportM3u = usePlaylistStore((s) => s.exportM3u);
const [prompt, setPrompt] = useState<Prompt>(null);
const [menuFor, setMenuFor] = useState<Playlist | 'favorites' | null>(null);
const handleExport = async (target: number | 'favorites') => {
try {
const result = await exportM3u(target);
if (result) {
Alert.alert(
'Playlist exported',
`Wrote ${result.entryCount} ${result.entryCount === 1 ? 'entry' : 'entries'} to "${fileDisplayName(result.fileUri)}".`
);
}
} catch (err) {
Alert.alert('Export failed', errorMessage(err));
}
};
const handleImport = async () => {
try {
const summary = await importM3u();
if (!summary) return;
const matched = summary.matchedByPath + summary.matchedByMetadata;
const parts = [`${matched} of ${summary.total} entries matched the library`];
if (summary.missing > 0) parts.push(`${summary.missing} kept as missing`);
if (summary.ambiguous > 0) parts.push(`${summary.ambiguous} ambiguous`);
Alert.alert(`Imported "${summary.name}"`, `${parts.join(', ')}.`);
} catch (err) {
Alert.alert('Import failed', errorMessage(err));
}
};
const confirmDelete = (playlist: Playlist) => {
Alert.alert('Delete playlist?', `"${playlist.name}" will be deleted. Tracks are not touched.`, [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: () => void deletePlaylist(playlist.id) },
]);
};
const menuItems =
menuFor === 'favorites'
? [
{
key: 'export',
label: 'Export M3U',
icon: 'download-outline' as const,
onPress: () => {
setMenuFor(null);
void handleExport('favorites');
},
},
]
: menuFor
? [
{
key: 'rename',
label: 'Rename…',
icon: 'pencil-outline' as const,
onPress: () => {
setPrompt({ kind: 'rename', playlist: menuFor });
setMenuFor(null);
},
},
{
key: 'export',
label: 'Export M3U',
icon: 'download-outline' as const,
onPress: () => {
const id = menuFor.id;
setMenuFor(null);
void handleExport(id);
},
},
{
key: 'delete',
label: 'Delete…',
icon: 'trash-outline' as const,
destructive: true,
onPress: () => {
const playlist = menuFor;
setMenuFor(null);
confirmDelete(playlist);
},
},
]
: [];
return (
<View style={styles.container}>
<FlashList
data={playlists}
keyExtractor={(playlist) => String(playlist.id)}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<PlaylistRow
name="Favorites"
trackCount={favoriteCount}
coverHash={null}
pinned
onPress={() => router.push('/library/playlist/favorites')}
onLongPress={() => setMenuFor('favorites')}
/>
}
renderItem={({ item }) => (
<PlaylistRow
name={item.name}
trackCount={item.track_count}
missingCount={item.missing_track_count}
coverHash={item.auto_cover_hash}
onPress={() => router.push(`/library/playlist/${item.id}`)}
onLongPress={() => setMenuFor(item)}
/>
)}
ListFooterComponent={
<View style={styles.actions}>
<Pressable
style={styles.action}
onPress={() => setPrompt({ kind: 'create' })}
accessibilityRole="button"
>
<Ionicons name="add" size={18} color={colors.accent} />
<Text variant="body" color={colors.accent}>
New playlist
</Text>
</Pressable>
<Pressable
style={styles.action}
onPress={() => void handleImport()}
accessibilityRole="button"
>
<Ionicons name="document-text-outline" size={16} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Import M3U
</Text>
</Pressable>
</View>
}
/>
<ActionSheet
visible={menuFor !== null}
title={menuFor === 'favorites' ? 'Favorites' : (menuFor?.name ?? '')}
items={menuItems}
onClose={() => setMenuFor(null)}
/>
<TextPromptModal
visible={prompt !== null}
title={prompt?.kind === 'rename' ? 'Rename playlist' : 'New playlist'}
placeholder="Playlist name"
initialValue={prompt?.kind === 'rename' ? prompt.playlist.name : ''}
submitLabel={prompt?.kind === 'rename' ? 'Rename' : 'Create'}
onSubmit={(name) => {
if (prompt?.kind === 'rename') {
void renamePlaylist(prompt.playlist.id, name);
} else {
void createPlaylist(name);
}
setPrompt(null);
}}
onClose={() => setPrompt(null)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
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,
},
});
@@ -0,0 +1,101 @@
import { useState } from 'react';
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
import { usePlaylistStore } from '@/stores/playlistStore';
import type { DbTrack } from '@/types/library';
interface TrackActionsSheetProps {
/** null = hidden. */
track: DbTrack | null;
onClose: () => void;
/** Screen-specific extras (e.g. playlist detail: remove / move). Handlers should close. */
extraItems?: ActionSheetItem[];
}
/** Long-press track menu: favorite toggle, add-to-playlist (pick or create), extras. */
export function TrackActionsSheet(props: TrackActionsSheetProps) {
// Mount fresh per track so the step state resets.
if (!props.track) return null;
return <TrackActionsSheetInner {...props} track={props.track} />;
}
function TrackActionsSheetInner({
track,
onClose,
extraItems = [],
}: TrackActionsSheetProps & { track: DbTrack }) {
const [step, setStep] = useState<'menu' | 'pickPlaylist' | 'newPlaylist'>('menu');
const playlists = usePlaylistStore((s) => s.playlists);
const isFavorite = usePlaylistStore((s) => s.favoritePaths.has(track.path));
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
const addTracksToPlaylist = usePlaylistStore((s) => s.addTracksToPlaylist);
const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
const menuItems: ActionSheetItem[] = [
{
key: 'favorite',
label: isFavorite ? 'Remove from favorites' : 'Add to favorites',
icon: isFavorite ? 'heart-dislike-outline' : 'heart-outline',
onPress: () => {
void toggleFavorite(track);
onClose();
},
},
{
key: 'add-to-playlist',
label: 'Add to playlist…',
icon: 'add-circle-outline',
onPress: () => setStep('pickPlaylist'),
},
...extraItems,
];
const pickItems: ActionSheetItem[] = [
...playlists.map((playlist) => ({
key: `playlist-${playlist.id}`,
label: playlist.name,
icon: 'musical-notes-outline' as const,
onPress: () => {
void addTracksToPlaylist(playlist.id, [track]);
onClose();
},
})),
{
key: 'new-playlist',
label: 'New playlist…',
icon: 'add',
onPress: () => setStep('newPlaylist'),
},
];
return (
<>
<ActionSheet
visible={step === 'menu'}
title={track.title}
items={menuItems}
onClose={onClose}
/>
<ActionSheet
visible={step === 'pickPlaylist'}
title="Add to playlist"
items={pickItems}
onClose={onClose}
/>
<TextPromptModal
visible={step === 'newPlaylist'}
title="New playlist"
placeholder="Playlist name"
submitLabel="Create"
onSubmit={(name) => {
void (async () => {
const playlist = await createPlaylist(name);
await addTracksToPlaylist(playlist.id, [track]);
})();
onClose();
}}
onClose={onClose}
/>
</>
);
}
+9 -1
View File
@@ -8,17 +8,25 @@ import type { DbTrack } from '@/types/library';
export function TrackRow({
track,
onPress,
onLongPress,
showArtist = true,
active = false,
}: {
track: DbTrack;
onPress: () => void;
/** Opens the track actions sheet where wired. */
onLongPress?: () => void;
/** Hide on album detail where every row shares the artist. */
showArtist?: boolean;
active?: boolean;
}) {
return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button">
<Pressable
style={styles.row}
onPress={onPress}
onLongPress={onLongPress}
accessibilityRole="button"
>
{track.track_number != null && !showArtist ? (
<Text variant="mono" style={styles.trackNumber}>
{track.track_number}
+5 -4
View File
@@ -1,13 +1,14 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { Pressable, ScrollView, StyleSheet } from 'react-native';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
export type LibraryViewMode = 'albums' | 'artists' | 'tracks' | 'folders';
export type LibraryViewMode = 'albums' | 'artists' | 'tracks' | 'playlists' | 'folders';
const MODES: { key: LibraryViewMode; label: string }[] = [
{ key: 'albums', label: 'Albums' },
{ key: 'artists', label: 'Artists' },
{ key: 'tracks', label: 'Tracks' },
{ key: 'playlists', label: 'Playlists' },
{ key: 'folders', label: 'Folders' },
];
@@ -19,7 +20,7 @@ export function ViewModeSwitcher({
onChange: (mode: LibraryViewMode) => void;
}) {
return (
<View style={styles.row}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
{MODES.map((mode) => {
const active = mode.key === value;
return (
@@ -39,7 +40,7 @@ export function ViewModeSwitcher({
</Pressable>
);
})}
</View>
</ScrollView>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { Modal, Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
export interface ActionSheetItem {
key: string;
label: string;
icon?: keyof typeof Ionicons.glyphMap;
destructive?: boolean;
selected?: boolean;
onPress: () => void;
}
/**
* Bottom action sheet on a plain RN Modal. Item presses do NOT auto-close —
* the consumer decides (allows multi-step flows like add-to-playlist).
*/
export function ActionSheet({
visible,
title,
items,
onClose,
}: {
visible: boolean;
title?: string;
items: ActionSheetItem[];
onClose: () => void;
}) {
const insets = useSafeAreaInsets();
return (
<Modal
visible={visible}
transparent
animationType="slide"
statusBarTranslucent
onRequestClose={onClose}
>
<Pressable style={styles.backdrop} onPress={onClose} accessibilityRole="button">
<Pressable style={[styles.card, { paddingBottom: insets.bottom + spacing.md }]}>
<View style={styles.grabber} />
{title ? (
<Text variant="label" numberOfLines={1} style={styles.title}>
{title}
</Text>
) : null}
{items.map((item) => (
<Pressable
key={item.key}
style={({ pressed }) => [styles.item, pressed && styles.itemPressed]}
onPress={item.onPress}
accessibilityRole="button"
>
{item.icon ? (
<Ionicons
name={item.icon}
size={20}
color={item.destructive ? colors.warning : colors.textSecondary}
/>
) : null}
<Text
variant="body"
numberOfLines={1}
style={styles.itemLabel}
color={item.destructive ? colors.warning : undefined}
>
{item.label}
</Text>
{item.selected ? (
<Ionicons name="checkmark" size={18} color={colors.accent} />
) : null}
</Pressable>
))}
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
justifyContent: 'flex-end',
},
card: {
backgroundColor: colors.bgSecondary,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm,
},
grabber: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: radius.pill,
backgroundColor: colors.glassBorder,
marginBottom: spacing.sm,
},
title: {
marginBottom: spacing.xs,
},
item: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
},
itemPressed: {
opacity: 0.6,
},
itemLabel: {
flex: 1,
},
});
+121
View File
@@ -0,0 +1,121 @@
import { useState } from 'react';
import { Modal, Pressable, StyleSheet, TextInput, View } from 'react-native';
import { Text } from '@/components/Text';
import { colors, fonts, fontSize, radius, spacing } from '@/theme';
interface TextPromptModalProps {
visible: boolean;
title: string;
placeholder?: string;
initialValue?: string;
submitLabel?: string;
onSubmit: (value: string) => void;
onClose: () => void;
}
/** Text input prompt (RN's Alert.prompt is iOS-only). */
export function TextPromptModal(props: TextPromptModalProps) {
// Mount the inner component fresh per open so the input state resets.
if (!props.visible) return null;
return <TextPromptModalInner {...props} />;
}
function TextPromptModalInner({
title,
placeholder,
initialValue = '',
submitLabel = 'Save',
onSubmit,
onClose,
}: TextPromptModalProps) {
const [value, setValue] = useState(initialValue);
const trimmed = value.trim();
const submit = () => {
if (!trimmed) return;
onSubmit(trimmed);
};
return (
<Modal visible transparent animationType="fade" statusBarTranslucent onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose} accessibilityRole="button">
<Pressable style={styles.card}>
<Text variant="heading" style={styles.title}>
{title}
</Text>
<TextInput
style={styles.input}
value={value}
onChangeText={setValue}
placeholder={placeholder}
placeholderTextColor={colors.textTertiary}
autoFocus
returnKeyType="done"
onSubmitEditing={submit}
selectionColor={colors.accent}
/>
<View style={styles.actions}>
<Pressable style={styles.action} onPress={onClose} accessibilityRole="button">
<Text variant="body" color={colors.textSecondary}>
Cancel
</Text>
</Pressable>
<Pressable
style={[styles.action, !trimmed && styles.actionDisabled]}
disabled={!trimmed}
onPress={submit}
accessibilityRole="button"
>
<Text variant="body" color={colors.accent}>
{submitLabel}
</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
justifyContent: 'center',
padding: spacing.xl,
},
card: {
backgroundColor: colors.bgSecondary,
borderRadius: radius.lg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
padding: spacing.lg,
gap: spacing.md,
},
title: {
fontSize: fontSize.base,
},
input: {
fontFamily: fonts.sans.regular,
fontSize: fontSize.base,
color: colors.textPrimary,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
},
action: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
actionDisabled: {
opacity: 0.4,
},
});
+247
View File
@@ -0,0 +1,247 @@
// Playlist + favorites queries — SQL ported from the desktop library service
// (getPlaylists / addPlaylistEntries / removeFromPlaylist / favorites CRUD).
import type { DbTrack } from '@/types/library';
import type { Playlist, PlaylistTrackEntry } from '@/types/playlist';
import type { LibraryDatabase } from './database';
const PLAYLIST_SELECT = `
SELECT p.id, p.name, p.created_at, p.updated_at, p.last_played_at,
(SELECT t.artwork_hash
FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path
WHERE pt.playlist_id = p.id AND t.artwork_hash IS NOT NULL
ORDER BY pt.position, pt.id LIMIT 1) AS auto_cover_hash,
(SELECT COUNT(*)
FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path
WHERE pt.playlist_id = p.id) AS track_count,
(SELECT COUNT(*)
FROM playlist_tracks pt LEFT JOIN tracks t ON t.path = pt.track_path
WHERE pt.playlist_id = p.id AND t.path IS NULL) AS missing_track_count
FROM playlists p
`;
export function getPlaylists(db: LibraryDatabase): Promise<Playlist[]> {
return db.all<Playlist>(`
${PLAYLIST_SELECT}
ORDER BY (p.last_played_at IS NULL), p.last_played_at DESC, p.updated_at DESC
`);
}
export async function getPlaylist(db: LibraryDatabase, id: number): Promise<Playlist | undefined> {
return db.get<Playlist>(`${PLAYLIST_SELECT} WHERE p.id = ?`, [id]);
}
export async function createPlaylist(db: LibraryDatabase, name: string): Promise<Playlist> {
const now = Date.now();
const result = await db.run(
'INSERT INTO playlists (name, created_at, updated_at) VALUES (?, ?, ?)',
[name, now, now]
);
const row = await getPlaylist(db, result.lastInsertRowid);
if (!row) throw new Error('Playlist insert failed');
return row;
}
export async function renamePlaylist(db: LibraryDatabase, id: number, name: string): Promise<void> {
await db.run('UPDATE playlists SET name = ?, updated_at = ? WHERE id = ?', [
name,
Date.now(),
id,
]);
}
export async function deletePlaylist(db: LibraryDatabase, id: number): Promise<void> {
// ON DELETE CASCADE removes the entries (foreign_keys is ON per connection).
await db.run('DELETE FROM playlists WHERE id = ?', [id]);
}
export async function markPlaylistPlayed(db: LibraryDatabase, id: number): Promise<void> {
await db.run('UPDATE playlists SET last_played_at = ? WHERE id = ?', [Date.now(), id]);
}
// --- Entries -----------------------------------------------------------------
interface EntryRow extends Omit<DbTrack, 'id' | 'path' | 'added_at'> {
entry_id: number;
entry_track_path: string;
entry_position: number;
entry_added_at: number;
fallback_title: string | null;
fallback_artist: string | null;
fallback_album: string | null;
id: number | null;
path: string | null;
added_at: number | null;
}
export async function getPlaylistEntries(
db: LibraryDatabase,
playlistId: number
): Promise<PlaylistTrackEntry[]> {
const rows = await db.all<EntryRow>(
`SELECT pt.id AS entry_id, pt.track_path AS entry_track_path,
pt.position AS entry_position, pt.added_at AS entry_added_at,
pt.fallback_title, pt.fallback_artist, pt.fallback_album,
t.*
FROM playlist_tracks pt
LEFT JOIN tracks t ON t.path = pt.track_path
WHERE pt.playlist_id = ?
ORDER BY pt.position, pt.id`,
[playlistId]
);
return rows.map((row) => {
const {
entry_id,
entry_track_path,
entry_position,
entry_added_at,
fallback_title,
fallback_artist,
fallback_album,
...trackColumns
} = row;
const missing = trackColumns.path == null;
return {
id: entry_id,
track_path: entry_track_path,
position: entry_position,
added_at: entry_added_at,
missing,
fallback_title,
fallback_artist,
fallback_album,
track: missing ? null : (trackColumns as DbTrack),
};
});
}
export interface PlaylistEntryInsert {
trackPath: string;
fallbackTitle?: string | null;
fallbackArtist?: string | null;
fallbackAlbum?: string | null;
}
/** Appends entries (deduped against input and existing membership). Returns inserted count. */
export async function addPlaylistEntries(
db: LibraryDatabase,
playlistId: number,
entries: PlaylistEntryInsert[]
): Promise<number> {
if (entries.length === 0) return 0;
let inserted = 0;
await db.transaction(async (tx) => {
const existing = await tx.all<{ track_path: string }>(
'SELECT track_path FROM playlist_tracks WHERE playlist_id = ?',
[playlistId]
);
const seen = new Set(existing.map((row) => row.track_path));
const maxRow = await tx.get<{ max_position: number }>(
'SELECT COALESCE(MAX(position), -1) AS max_position FROM playlist_tracks WHERE playlist_id = ?',
[playlistId]
);
let position = maxRow?.max_position ?? -1;
const now = Date.now();
for (const entry of entries) {
if (seen.has(entry.trackPath)) continue;
seen.add(entry.trackPath);
position += 1;
await tx.run(
`INSERT OR IGNORE INTO playlist_tracks
(playlist_id, track_path, position, added_at, fallback_title, fallback_artist, fallback_album)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
playlistId,
entry.trackPath,
position,
now,
entry.fallbackTitle ?? null,
entry.fallbackArtist ?? null,
entry.fallbackAlbum ?? null,
]
);
inserted += 1;
}
if (inserted > 0) {
await tx.run('UPDATE playlists SET updated_at = ? WHERE id = ?', [now, playlistId]);
}
});
return inserted;
}
async function renormalizePositions(tx: LibraryDatabase, playlistId: number): Promise<void> {
const rows = await tx.all<{ id: number }>(
'SELECT id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position, id',
[playlistId]
);
for (let i = 0; i < rows.length; i++) {
await tx.run('UPDATE playlist_tracks SET position = ? WHERE id = ?', [i, rows[i].id]);
}
}
export async function removeFromPlaylist(
db: LibraryDatabase,
playlistId: number,
trackPath: string
): Promise<void> {
await db.transaction(async (tx) => {
await tx.run('DELETE FROM playlist_tracks WHERE playlist_id = ? AND track_path = ?', [
playlistId,
trackPath,
]);
await renormalizePositions(tx, playlistId);
await tx.run('UPDATE playlists SET updated_at = ? WHERE id = ?', [Date.now(), playlistId]);
});
}
/** Swaps the entry with its neighbor above (-1) or below (+1); no-op at list edges. */
export async function movePlaylistTrack(
db: LibraryDatabase,
playlistId: number,
trackPath: string,
direction: -1 | 1
): Promise<void> {
await db.transaction(async (tx) => {
const row = await tx.get<{ id: number; position: number }>(
'SELECT id, position FROM playlist_tracks WHERE playlist_id = ? AND track_path = ?',
[playlistId, trackPath]
);
if (!row) return;
const neighbor = await tx.get<{ id: number; position: number }>(
direction === -1
? 'SELECT id, position FROM playlist_tracks WHERE playlist_id = ? AND position < ? ORDER BY position DESC LIMIT 1'
: 'SELECT id, position FROM playlist_tracks WHERE playlist_id = ? AND position > ? ORDER BY position ASC LIMIT 1',
[playlistId, row.position]
);
if (!neighbor) return;
await tx.run('UPDATE playlist_tracks SET position = ? WHERE id = ?', [neighbor.position, row.id]);
await tx.run('UPDATE playlist_tracks SET position = ? WHERE id = ?', [row.position, neighbor.id]);
await tx.run('UPDATE playlists SET updated_at = ? WHERE id = ?', [Date.now(), playlistId]);
});
}
// --- Favorites ---------------------------------------------------------------
export function getFavoriteTracks(db: LibraryDatabase): Promise<DbTrack[]> {
return db.all<DbTrack>(`
SELECT t.* FROM favorites f
JOIN tracks t ON t.path = f.track_path
ORDER BY f.added_at DESC
`);
}
export async function getFavoritePaths(db: LibraryDatabase): Promise<string[]> {
const rows = await db.all<{ track_path: string }>('SELECT track_path FROM favorites');
return rows.map((row) => row.track_path);
}
export async function addFavorite(db: LibraryDatabase, trackPath: string): Promise<void> {
await db.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [
trackPath,
Date.now(),
]);
}
export async function removeFavorite(db: LibraryDatabase, trackPath: string): Promise<void> {
await db.run('DELETE FROM favorites WHERE track_path = ?', [trackPath]);
}
+30 -2
View File
@@ -1,10 +1,10 @@
// 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.
// v2 adds playlists + favorites (M2); metadata overrides / lyrics later.
import type { LibraryDatabase } from './database';
export const SCHEMA_VERSION = 1;
export const SCHEMA_VERSION = 2;
// One statement per entry — op-sqlite executes single statements.
const MIGRATIONS: readonly (readonly string[])[] = [
@@ -49,6 +49,34 @@ const MIGRATIONS: readonly (readonly string[])[] = [
'CREATE INDEX IF NOT EXISTS idx_tracks_artist ON tracks(artist)',
'CREATE INDEX IF NOT EXISTS idx_tracks_folder ON tracks(folder_id)',
],
// v1 -> v2 — playlists + favorites (desktop library.ts tables, trimmed).
// track_path deliberately has NO FK to tracks: entries survive folder removal
// and resolve again when the same folder is re-granted (identical SAF URIs).
[
`CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_played_at INTEGER
)`,
`CREATE TABLE IF NOT EXISTS playlist_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
track_path TEXT NOT NULL,
position INTEGER NOT NULL,
added_at INTEGER NOT NULL,
fallback_title TEXT,
fallback_artist TEXT,
fallback_album TEXT,
UNIQUE(playlist_id, track_path)
)`,
'CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id, position)',
`CREATE TABLE IF NOT EXISTS favorites (
track_path TEXT PRIMARY KEY NOT NULL,
added_at INTEGER NOT NULL
)`,
],
];
export async function migrate(db: LibraryDatabase): Promise<void> {
+99
View File
@@ -0,0 +1,99 @@
// M3U/M3U8 parse + serialize — ported from the desktop playlist import/export
// (playlistImport.ts parseM3uDocument/parseExtInfLine, library.ts formatM3u*).
// Pure string handling; file IO and library matching live in playlistFiles.ts.
export interface M3uEntry {
path: string;
title?: string;
artist?: string;
}
export interface M3uExportEntry {
path: string;
title: string | null;
artist: string | null;
duration: number | null;
}
function stripUtf8Bom(content: string): string {
return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
}
function toOptionalValue(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function parseExtInfLine(line: string): { title?: string; artist?: string } | null {
const commaIndex = line.indexOf(',');
if (commaIndex < 0) return null;
const display = toOptionalValue(line.slice(commaIndex + 1));
if (!display) return null;
const dashIndex = display.indexOf(' - ');
if (dashIndex <= 0 || dashIndex >= display.length - 3) {
return { title: display };
}
return {
artist: toOptionalValue(display.slice(0, dashIndex)),
title: toOptionalValue(display.slice(dashIndex + 3)),
};
}
export function parseM3u(content: string): M3uEntry[] {
const entries: M3uEntry[] = [];
const lines = stripUtf8Bom(content).split(/\r?\n/);
let pendingInfo: { title?: string; artist?: string } | null = null;
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
if (/^#EXTINF:/i.test(line)) {
pendingInfo = parseExtInfLine(line);
continue;
}
if (line.startsWith('#')) continue;
entries.push({ path: line, title: pendingInfo?.title, artist: pendingInfo?.artist });
pendingInfo = null;
}
return entries;
}
function normalizeM3uLineValue(value: string): string {
return value.replace(/[\r\n]+/g, ' ').trim();
}
function basenameFromPath(value: string): string {
const normalized = normalizeM3uLineValue(value);
const parts = normalized.split(/[\\/]/);
return parts[parts.length - 1] || normalized;
}
function formatM3uDuration(duration: number | null): number {
if (typeof duration !== 'number' || !Number.isFinite(duration) || duration < 0) {
return -1;
}
return Math.max(0, Math.round(duration));
}
function formatM3uDisplayTitle(entry: M3uExportEntry): string {
const title = normalizeM3uLineValue(entry.title ?? '');
const artist = normalizeM3uLineValue(entry.artist ?? '');
if (title && artist) return `${artist} - ${title}`;
if (title) return title;
if (artist) return artist;
return basenameFromPath(entry.path);
}
export function serializeM3u(entries: M3uExportEntry[]): string {
const lines = ['#EXTM3U'];
for (const entry of entries) {
lines.push(`#EXTINF:${formatM3uDuration(entry.duration)},${formatM3uDisplayTitle(entry)}`);
lines.push(normalizeM3uLineValue(entry.path).replace(/\\/g, '/'));
}
return `${lines.join('\n')}\n`;
}
+24
View File
@@ -0,0 +1,24 @@
import type { DbTrack } from '@/types/library';
export type TrackSort = 'artist' | 'title' | 'recently_added' | 'duration';
export const TRACK_SORT_LABELS: Record<TrackSort, string> = {
artist: 'Artist',
title: 'Title',
recently_added: 'Recently added',
duration: 'Duration',
};
/** 'artist' is the DB's native order (getAllTracks); others sort a copy. */
export function sortTracks(tracks: DbTrack[], sort: TrackSort): DbTrack[] {
switch (sort) {
case 'artist':
return tracks;
case 'title':
return [...tracks].sort((a, b) => a.title.localeCompare(b.title));
case 'recently_added':
return [...tracks].sort((a, b) => b.added_at - a.added_at);
case 'duration':
return [...tracks].sort((a, b) => b.duration - a.duration);
}
}
+217
View File
@@ -0,0 +1,217 @@
// M3U file IO (SAF export, document-picker import) + the import matching
// ladder: exact content URI -> decoded SAF path -> file name (+ path-suffix
// overlap) -> metadata (title+artist -> title, unique-or-null), ported in
// spirit from the desktop playlist importer.
import * as DocumentPicker from 'expo-document-picker';
import {
StorageAccessFramework,
readAsStringAsync,
writeAsStringAsync,
} from 'expo-file-system/legacy';
import type { DbTrack } from '@/types/library';
import { parseM3u, serializeM3u, type M3uEntry, type M3uExportEntry } from '@/lib/m3u';
/** "content://…/document/primary%3AMusic%2FA%2Ff.flac" -> "Music/A/f.flac" */
export function decodedDocPath(contentUri: string): string | null {
const marker = '/document/';
const idx = contentUri.indexOf(marker);
if (idx < 0) return null;
let docId: string;
try {
docId = decodeURIComponent(contentUri.slice(idx + marker.length));
} catch {
return null;
}
const colon = docId.indexOf(':');
return colon >= 0 ? docId.slice(colon + 1) : docId;
}
// --- Import matching ---------------------------------------------------------
interface IndexedTrack {
track: DbTrack;
/** Lowercased decoded SAF path, for suffix-overlap scoring. */
decodedPath: string | null;
}
export interface ImportMatchIndex {
byContentUri: Map<string, DbTrack>;
byDecodedPath: Map<string, DbTrack>;
byFileName: Map<string, IndexedTrack[]>;
/** Metadata maps are unique-or-null: null marks a collision (ambiguous). */
byTitleArtist: Map<string, DbTrack | null>;
byTitle: Map<string, DbTrack | null>;
}
function upsertUnique(map: Map<string, DbTrack | null>, key: string, track: DbTrack): void {
if (!key) return;
map.set(key, map.has(key) ? null : track);
}
export function buildImportIndex(tracks: DbTrack[]): ImportMatchIndex {
const index: ImportMatchIndex = {
byContentUri: new Map(),
byDecodedPath: new Map(),
byFileName: new Map(),
byTitleArtist: new Map(),
byTitle: new Map(),
};
for (const track of tracks) {
index.byContentUri.set(track.path, track);
const decoded = decodedDocPath(track.path)?.toLocaleLowerCase() ?? null;
if (decoded && !index.byDecodedPath.has(decoded)) {
index.byDecodedPath.set(decoded, track);
}
const fileName = track.file_name.toLocaleLowerCase();
const bucket = index.byFileName.get(fileName);
if (bucket) {
bucket.push({ track, decodedPath: decoded });
} else {
index.byFileName.set(fileName, [{ track, decodedPath: decoded }]);
}
const title = track.title.trim().toLocaleLowerCase();
const artist = track.artist.trim().toLocaleLowerCase();
upsertUnique(index.byTitleArtist, `${title}\n${artist}`, track);
upsertUnique(index.byTitle, title, track);
}
return index;
}
/** Foreign playlist paths: strip file://, unify slashes, percent-decode. */
export function normalizeEntryPath(path: string): string {
let value = path.trim().replace(/\\/g, '/');
if (/^file:\/\//i.test(value)) value = value.slice('file://'.length);
if (value.includes('%')) {
try {
value = decodeURIComponent(value);
} catch {
// keep the raw value
}
}
return value;
}
export type ImportMatch =
| { kind: 'matched'; track: DbTrack; via: 'path' | 'metadata' }
| { kind: 'ambiguous' }
| { kind: 'none' };
/** Trailing path-segment overlap between an entry path and a track's decoded path. */
function suffixOverlap(entrySegments: string[], decodedPath: string | null): number {
if (!decodedPath) return 1; // file name matched, nothing more to compare
const trackSegments = decodedPath.split('/');
let overlap = 0;
while (
overlap < entrySegments.length &&
overlap < trackSegments.length &&
entrySegments[entrySegments.length - 1 - overlap] ===
trackSegments[trackSegments.length - 1 - overlap]
) {
overlap += 1;
}
return overlap;
}
export function matchImportEntry(entry: M3uEntry, index: ImportMatchIndex): ImportMatch {
const raw = entry.path.trim();
// 1. Exact SAF content URI (our own exports never write these, but be safe).
const exact = index.byContentUri.get(raw);
if (exact) return { kind: 'matched', track: exact, via: 'path' };
const normalized = normalizeEntryPath(raw).toLocaleLowerCase();
// 2. Decoded SAF path ("Music/Artist/Album/file.flac" — our export format).
const byPath = index.byDecodedPath.get(normalized);
if (byPath) return { kind: 'matched', track: byPath, via: 'path' };
// 3. File name bucket, disambiguated by longest trailing-segment overlap.
const entrySegments = normalized.split('/');
const fileName = entrySegments[entrySegments.length - 1];
const candidates = index.byFileName.get(fileName) ?? [];
if (candidates.length === 1) {
return { kind: 'matched', track: candidates[0].track, via: 'path' };
}
if (candidates.length > 1) {
let best: IndexedTrack | null = null;
let bestScore = 0;
let tied = false;
for (const candidate of candidates) {
const score = suffixOverlap(entrySegments, candidate.decodedPath);
if (score > bestScore) {
best = candidate;
bestScore = score;
tied = false;
} else if (score === bestScore) {
tied = true;
}
}
if (best && !tied) return { kind: 'matched', track: best.track, via: 'path' };
return { kind: 'ambiguous' };
}
// 4. Metadata from EXTINF: title+artist, then title (unique-or-null).
const title = entry.title?.trim().toLocaleLowerCase();
if (title) {
const artist = entry.artist?.trim().toLocaleLowerCase();
if (artist) {
const hit = index.byTitleArtist.get(`${title}\n${artist}`);
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
if (hit === null) return { kind: 'ambiguous' };
}
const hit = index.byTitle.get(title);
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
if (hit === null) return { kind: 'ambiguous' };
}
return { kind: 'none' };
}
// --- File IO -------------------------------------------------------------------
function sanitizeFileName(name: string): string {
const cleaned = name.replace(/[\\/:*?"<>|]/g, '_').trim();
return cleaned || 'Playlist';
}
export interface M3uExportResult {
fileUri: string;
entryCount: number;
}
/** Folder picker -> create .m3u8 -> write. Returns null if the picker is cancelled. */
export async function exportPlaylistM3u(
name: string,
entries: M3uExportEntry[]
): Promise<M3uExportResult | null> {
const permission = await StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!permission.granted) return null;
const fileUri = await StorageAccessFramework.createFileAsync(
permission.directoryUri,
`${sanitizeFileName(name)}.m3u8`,
// Matches the .m3u8 extension so SAF doesn't append another one.
'application/vnd.apple.mpegurl'
);
await writeAsStringAsync(fileUri, serializeM3u(entries));
return { fileUri, entryCount: entries.length };
}
/** Document picker -> parse. Returns null on cancel; throws on a non-M3U pick. */
export async function pickAndParseM3u(): Promise<{ name: string; entries: M3uEntry[] } | null> {
const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true });
if (result.canceled || result.assets.length === 0) return null;
const asset = result.assets[0];
// M3U mime types are unreliable across file managers — validate by name.
if (!/\.m3u8?$/i.test(asset.name)) {
throw new Error(`"${asset.name}" is not an .m3u/.m3u8 playlist`);
}
const content = await readAsStringAsync(asset.uri);
const name = asset.name.replace(/\.m3u8?$/i, '').trim() || 'Imported playlist';
return { name, entries: parseM3u(content) };
}
+10 -1
View File
@@ -10,12 +10,14 @@ import {
type ScanProgress,
type ScanResult,
} from '@/library/scanner';
import type { TrackSort } from '@/lib/trackSort';
import { usePlaylistStore } from './playlistStore';
/**
* 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';
type ViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders';
export type FolderWithCount = LibraryFolder & { track_count: number };
@@ -36,6 +38,7 @@ interface LibraryStore {
folders: FolderWithCount[];
totalTrackCount: number;
viewMode: ViewMode;
trackSort: TrackSort;
isScanning: boolean;
scanProgress: ScanProgressState;
scanError: string | null;
@@ -43,6 +46,7 @@ interface LibraryStore {
initialize: () => Promise<void>;
refresh: () => Promise<void>;
setViewMode: (mode: ViewMode) => void;
setTrackSort: (sort: TrackSort) => void;
addFolder: () => Promise<void>;
removeFolder: (folderId: number) => Promise<void>;
rescan: () => Promise<void>;
@@ -75,6 +79,7 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
folders: [],
totalTrackCount: 0,
viewMode: 'albums',
trackSort: 'artist',
isScanning: false,
scanProgress: { ...IDLE_PROGRESS },
scanError: null,
@@ -103,10 +108,14 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
getTrackCount(db),
]);
set({ tracks, albums, artists, folders, totalTrackCount });
// Playlist counts/missing states depend on tracks — keep them in step.
await usePlaylistStore.getState().refresh();
},
setViewMode: (viewMode) => set({ viewMode }),
setTrackSort: (trackSort) => set({ trackSort }),
addFolder: () => runScan(() => addFolderViaPicker({ onProgress })),
removeFolder: async (folderId) => {
+256
View File
@@ -0,0 +1,256 @@
import { create } from 'zustand';
import type { DbTrack } from '@/types/library';
import type { Playlist, PlaylistTrackEntry } from '@/types/playlist';
import { openLibraryDb, type LibraryDatabase } from '@/db/database';
import { getAllTracks } from '@/db/queries';
import * as playlistDb from '@/db/playlistQueries';
import {
buildImportIndex,
decodedDocPath,
exportPlaylistM3u,
matchImportEntry,
normalizeEntryPath,
pickAndParseM3u,
type M3uExportResult,
} from '@/library/playlistFiles';
import type { M3uExportEntry } from '@/lib/m3u';
export interface M3uImportSummary {
playlistId: number;
name: string;
total: number;
matchedByPath: number;
matchedByMetadata: number;
missing: number;
ambiguous: number;
}
/**
* Playlists + favorites state — SQLite is the source of truth (no persist);
* every mutation re-queries. libraryStore.refresh() chains into refresh() so
* scans and folder removals update counts/missing states.
*/
interface PlaylistStore {
playlists: Playlist[];
favoritePaths: Set<string>;
favoriteTracks: DbTrack[];
activePlaylistId: number | null;
activeEntries: PlaylistTrackEntry[];
refresh: () => Promise<void>;
openPlaylist: (id: number) => Promise<void>;
closePlaylist: () => void;
createPlaylist: (name: string) => Promise<Playlist>;
renamePlaylist: (id: number, name: string) => Promise<void>;
deletePlaylist: (id: number) => Promise<void>;
addTracksToPlaylist: (id: number, tracks: DbTrack[]) => Promise<number>;
removeFromPlaylist: (id: number, trackPath: string) => Promise<void>;
moveTrack: (id: number, trackPath: string, direction: -1 | 1) => Promise<void>;
toggleFavorite: (track: DbTrack) => Promise<void>;
markPlayed: (id: number) => Promise<void>;
importM3u: () => Promise<M3uImportSummary | null>;
exportM3u: (target: number | 'favorites') => Promise<M3uExportResult | null>;
}
function trackToExportEntry(track: DbTrack): M3uExportEntry {
return {
path: decodedDocPath(track.path) ?? track.file_name,
title: track.title,
artist: track.artist,
duration: track.duration,
};
}
function entryToExportEntry(entry: PlaylistTrackEntry): M3uExportEntry {
if (entry.track) return trackToExportEntry(entry.track);
return {
path: decodedDocPath(entry.track_path) ?? entry.track_path,
title: entry.fallback_title,
artist: entry.fallback_artist,
duration: null,
};
}
export const usePlaylistStore = create<PlaylistStore>((set, get) => {
const reloadActive = async (db: LibraryDatabase) => {
const id = get().activePlaylistId;
if (id == null) return;
const activeEntries = await playlistDb.getPlaylistEntries(db, id);
set({ activeEntries });
};
const refreshWith = async (db: LibraryDatabase) => {
const [playlists, favoritePathList, favoriteTracks] = await Promise.all([
playlistDb.getPlaylists(db),
playlistDb.getFavoritePaths(db),
playlistDb.getFavoriteTracks(db),
]);
set({ playlists, favoritePaths: new Set(favoritePathList), favoriteTracks });
await reloadActive(db);
};
return {
playlists: [],
favoritePaths: new Set<string>(),
favoriteTracks: [],
activePlaylistId: null,
activeEntries: [],
refresh: async () => {
const db = await openLibraryDb();
await refreshWith(db);
},
openPlaylist: async (id) => {
const db = await openLibraryDb();
const activeEntries = await playlistDb.getPlaylistEntries(db, id);
set({ activePlaylistId: id, activeEntries });
},
closePlaylist: () => set({ activePlaylistId: null, activeEntries: [] }),
createPlaylist: async (name) => {
const db = await openLibraryDb();
const playlist = await playlistDb.createPlaylist(db, name);
await refreshWith(db);
return playlist;
},
renamePlaylist: async (id, name) => {
const db = await openLibraryDb();
await playlistDb.renamePlaylist(db, id, name);
await refreshWith(db);
},
deletePlaylist: async (id) => {
const db = await openLibraryDb();
await playlistDb.deletePlaylist(db, id);
if (get().activePlaylistId === id) {
set({ activePlaylistId: null, activeEntries: [] });
}
await refreshWith(db);
},
addTracksToPlaylist: async (id, tracks) => {
const db = await openLibraryDb();
const inserted = await playlistDb.addPlaylistEntries(
db,
id,
tracks.map((track) => ({
trackPath: track.path,
fallbackTitle: track.title,
fallbackArtist: track.artist,
fallbackAlbum: track.album,
}))
);
await refreshWith(db);
return inserted;
},
removeFromPlaylist: async (id, trackPath) => {
const db = await openLibraryDb();
await playlistDb.removeFromPlaylist(db, id, trackPath);
await refreshWith(db);
},
moveTrack: async (id, trackPath, direction) => {
const db = await openLibraryDb();
await playlistDb.movePlaylistTrack(db, id, trackPath, direction);
await refreshWith(db);
},
toggleFavorite: async (track) => {
const wasFavorite = get().favoritePaths.has(track.path);
// Optimistic Set swap (always a fresh Set — never mutate in place).
const optimistic = new Set(get().favoritePaths);
if (wasFavorite) {
optimistic.delete(track.path);
} else {
optimistic.add(track.path);
}
set({ favoritePaths: optimistic });
const db = await openLibraryDb();
if (wasFavorite) {
await playlistDb.removeFavorite(db, track.path);
} else {
await playlistDb.addFavorite(db, track.path);
}
const [favoritePathList, favoriteTracks] = await Promise.all([
playlistDb.getFavoritePaths(db),
playlistDb.getFavoriteTracks(db),
]);
set({ favoritePaths: new Set(favoritePathList), favoriteTracks });
},
markPlayed: async (id) => {
const db = await openLibraryDb();
await playlistDb.markPlaylistPlayed(db, id);
const playlists = await playlistDb.getPlaylists(db);
set({ playlists });
},
importM3u: async () => {
const picked = await pickAndParseM3u();
if (!picked) return null;
const db = await openLibraryDb();
const index = buildImportIndex(await getAllTracks(db));
const summary: Omit<M3uImportSummary, 'playlistId'> = {
name: picked.name,
total: picked.entries.length,
matchedByPath: 0,
matchedByMetadata: 0,
missing: 0,
ambiguous: 0,
};
const inserts: playlistDb.PlaylistEntryInsert[] = [];
for (const entry of picked.entries) {
const match = matchImportEntry(entry, index);
if (match.kind === 'matched') {
if (match.via === 'path') {
summary.matchedByPath += 1;
} else {
summary.matchedByMetadata += 1;
}
inserts.push({
trackPath: match.track.path,
fallbackTitle: match.track.title,
fallbackArtist: match.track.artist,
fallbackAlbum: match.track.album,
});
} else {
// Preserve unmatched entries as "missing" rows (desktop model).
summary.missing += 1;
if (match.kind === 'ambiguous') summary.ambiguous += 1;
inserts.push({
trackPath: normalizeEntryPath(entry.path),
fallbackTitle: entry.title ?? null,
fallbackArtist: entry.artist ?? null,
});
}
}
const playlist = await playlistDb.createPlaylist(db, picked.name);
await playlistDb.addPlaylistEntries(db, playlist.id, inserts);
await refreshWith(db);
return { ...summary, playlistId: playlist.id };
},
exportM3u: async (target) => {
const db = await openLibraryDb();
let name: string;
let entries: M3uExportEntry[];
if (target === 'favorites') {
name = 'Favorites';
entries = get().favoriteTracks.map(trackToExportEntry);
} else {
name = get().playlists.find((playlist) => playlist.id === target)?.name ?? 'Playlist';
const playlistEntries = await playlistDb.getPlaylistEntries(db, target);
entries = playlistEntries.map(entryToExportEntry);
}
return exportPlaylistM3u(name, entries);
},
};
});
+30
View File
@@ -0,0 +1,30 @@
// Playlist row types — mobile port of the desktop playlist model
// (astra src/main/services/library.ts, renderer playlistStore.ts).
import type { DbTrack } from './library';
export interface Playlist {
id: number;
name: string;
created_at: number;
updated_at: number;
last_played_at: number | null;
/** First entry's artwork — computed, not stored. */
auto_cover_hash: string | null;
/** Entries whose track exists in the library. */
track_count: number;
/** Entries whose track is gone (folder removed / file deleted). */
missing_track_count: number;
}
export interface PlaylistTrackEntry {
id: number;
track_path: string;
position: number;
added_at: number;
missing: boolean;
fallback_title: string | null;
fallback_artist: string | null;
fallback_album: string | null;
track: DbTrack | null;
}