mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
m2, library ux
This commit is contained in:
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -30,6 +30,16 @@ export async function playTracks(tracks: Track[], startIndex = 0): Promise<void>
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
|
||||
/** Fisher–Yates 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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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) => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user