m2, library ux

This commit is contained in:
Boof2015
2026-06-13 13:13:53 -04:00
parent 23be9875e8
commit 3d30a09713
24 changed files with 2397 additions and 39 deletions
+41 -10
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
@@ -8,13 +8,15 @@ import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, radius, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { artworkUri } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
import type { DbTrack } from '@/types/library';
export default function AlbumScreen() {
const router = useRouter();
@@ -32,6 +34,7 @@ export default function AlbumScreen() {
);
const totalDuration = tracks.reduce((sum, track) => sum + track.duration, 0);
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const playFrom = (index: number) => {
void playTracks(tracks.map(dbTrackToTrack), index);
@@ -75,12 +78,24 @@ export default function AlbumScreen() {
.filter(Boolean)
.join(' · ')}
</Text>
<Pressable style={styles.playButton} onPress={() => playFrom(0)} accessibilityRole="button">
<Ionicons name="play" size={16} color={colors.bgPrimary} />
<Text variant="body" style={styles.playLabel}>
Play
</Text>
</Pressable>
<View style={styles.buttons}>
<Pressable style={styles.playButton} onPress={() => playFrom(0)} accessibilityRole="button">
<Ionicons name="play" size={16} color={colors.bgPrimary} />
<Text variant="body" style={styles.playLabel}>
Play
</Text>
</Pressable>
<Pressable
style={styles.shuffleButton}
onPress={() => void shuffleTracks(tracks.map(dbTrackToTrack))}
accessibilityRole="button"
>
<Ionicons name="shuffle" size={16} color={colors.accent} />
<Text variant="body" color={colors.accent}>
Shuffle
</Text>
</Pressable>
</View>
</View>
</View>
@@ -94,9 +109,12 @@ export default function AlbumScreen() {
showArtist={false}
active={item.path === currentPath}
onPress={() => playFrom(index)}
onLongPress={() => setActionTrack(item)}
/>
)}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
@@ -135,6 +153,11 @@ const styles = StyleSheet.create({
justifyContent: 'center',
gap: spacing.xs,
},
buttons: {
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.xs,
},
playButton: {
flexDirection: 'row',
alignItems: 'center',
@@ -143,8 +166,16 @@ const styles = StyleSheet.create({
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
alignSelf: 'flex-start',
marginTop: spacing.xs,
},
shuffleButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
playLabel: {
color: colors.bgPrimary,
+29 -3
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
@@ -6,17 +6,20 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, radius, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import type { DbTrack } from '@/types/library';
export default function ArtistScreen() {
const router = useRouter();
const { name } = useLocalSearchParams<{ name: string }>();
const allTracks = useLibraryStore((s) => s.tracks);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
// Store tracks are ordered artist/album/disc/track, so the filtered slice
// keeps album grouping and track order.
@@ -53,6 +56,16 @@ export default function ArtistScreen() {
Play
</Text>
</Pressable>
<Pressable
style={styles.shuffleButton}
onPress={() => void shuffleTracks(tracks.map(dbTrackToTrack))}
accessibilityRole="button"
>
<Ionicons name="shuffle" size={16} color={colors.accent} />
<Text variant="body" color={colors.accent}>
Shuffle
</Text>
</Pressable>
</View>
<FlashList
@@ -64,9 +77,12 @@ export default function ArtistScreen() {
track={item}
active={item.path === currentPath}
onPress={() => playFrom(index)}
onLongPress={() => setActionTrack(item)}
/>
)}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
@@ -85,7 +101,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.lg,
gap: spacing.lg,
gap: spacing.sm,
},
headerMeta: {
flex: 1,
@@ -100,6 +116,16 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
shuffleButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
playLabel: {
color: colors.bgPrimary,
fontWeight: '600',
+90 -18
View File
@@ -1,4 +1,6 @@
import { View, StyleSheet } from 'react-native';
import { useMemo, useState } from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
@@ -8,13 +10,20 @@ import { AlbumGridItem } from '@/components/library/AlbumGridItem';
import { TrackRow } from '@/components/library/TrackRow';
import { ArtistRow } from '@/components/library/ArtistRow';
import { FoldersView } from '@/components/library/FoldersView';
import { PlaylistsView } from '@/components/library/PlaylistsView';
import { ScanProgress } from '@/components/library/ScanProgress';
import { EmptyLibrary } from '@/components/library/EmptyLibrary';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { ActionSheet } from '@/components/sheets/ActionSheet';
import { colors, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { sortTracks, TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort';
import type { DbTrack } from '@/types/library';
const SORT_OPTIONS: TrackSort[] = ['artist', 'title', 'recently_added', 'duration'];
export default function LibraryScreen() {
const router = useRouter();
@@ -24,21 +33,41 @@ export default function LibraryScreen() {
const artists = useLibraryStore((s) => s.artists);
const tracks = useLibraryStore((s) => s.tracks);
const folders = useLibraryStore((s) => s.folders);
const trackSort = useLibraryStore((s) => s.trackSort);
const setTrackSort = useLibraryStore((s) => s.setTrackSort);
const isScanning = useLibraryStore((s) => s.isScanning);
const scanError = useLibraryStore((s) => s.scanError);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const [sortSheetOpen, setSortSheetOpen] = useState(false);
const isEmpty = tracks.length === 0 && folders.length === 0 && !isScanning;
const sortedTracks = useMemo(() => sortTracks(tracks, trackSort), [tracks, trackSort]);
// Tap index is within sortedTracks so the tapped row is the track that plays.
const playAllFrom = (index: number) => {
void playTracks(tracks.map(dbTrackToTrack), index);
void playTracks(sortedTracks.map(dbTrackToTrack), index);
};
return (
<Screen>
<Text variant="title" style={styles.heading}>
Library
</Text>
<View style={styles.headingRow}>
<Text variant="title" style={styles.heading}>
Library
</Text>
{!isEmpty ? (
<Pressable
hitSlop={8}
onPress={() => router.push('/library/search')}
accessibilityRole="button"
accessibilityLabel="Search library"
>
<Ionicons name="search" size={22} color={colors.textSecondary} />
</Pressable>
) : null}
</View>
{isEmpty ? (
<EmptyLibrary />
@@ -96,38 +125,81 @@ export default function LibraryScreen() {
) : null}
{viewMode === 'tracks' ? (
<FlashList
data={tracks}
keyExtractor={(track) => String(track.id)}
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => (
<TrackRow
track={item}
active={item.path === currentPath}
onPress={() => playAllFrom(index)}
/>
)}
/>
<>
<Pressable
style={styles.sortTrigger}
onPress={() => setSortSheetOpen(true)}
accessibilityRole="button"
>
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} />
<Text variant="label">{TRACK_SORT_LABELS[trackSort]}</Text>
</Pressable>
<FlashList
data={sortedTracks}
keyExtractor={(track) => String(track.id)}
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => (
<TrackRow
track={item}
active={item.path === currentPath}
onPress={() => playAllFrom(index)}
onLongPress={() => setActionTrack(item)}
/>
)}
/>
</>
) : null}
{viewMode === 'playlists' ? <PlaylistsView /> : null}
{viewMode === 'folders' ? <FoldersView /> : null}
</>
)}
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
<ActionSheet
visible={sortSheetOpen}
title="Sort tracks by"
items={SORT_OPTIONS.map((option) => ({
key: option,
label: TRACK_SORT_LABELS[option],
selected: option === trackSort,
onPress: () => {
setTrackSort(option);
setSortSheetOpen(false);
},
}))}
onClose={() => setSortSheetOpen(false)}
/>
</Screen>
);
}
const styles = StyleSheet.create({
heading: {
headingRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: spacing.xl,
marginBottom: spacing.lg,
},
heading: {
flex: 1,
},
switcher: {
marginBottom: spacing.md,
},
error: {
marginBottom: spacing.md,
},
sortTrigger: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
alignSelf: 'flex-end',
paddingVertical: spacing.xs,
marginBottom: spacing.xs,
},
gridCell: {
flex: 1,
paddingHorizontal: spacing.xs,
+348
View File
@@ -0,0 +1,348 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
import { colors, radius, spacing } from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { artworkUri } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
import type { DbTrack } from '@/types/library';
import type { PlaylistTrackEntry } from '@/types/playlist';
function basename(path: string): string {
const decoded = decodeURIComponent(path.split('/').pop() ?? path);
return decoded.split(/[/:]/).pop() || path;
}
function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongPress: () => void }) {
return (
<Pressable style={styles.missingRow} onLongPress={onLongPress} accessibilityRole="button">
<View style={styles.missingMeta}>
<Text variant="body" numberOfLines={1} color={colors.textTertiary}>
{entry.fallback_title ?? basename(entry.track_path)}
</Text>
<Text variant="label" numberOfLines={1} color={colors.textTertiary}>
{entry.fallback_artist ?? 'Track not in library'}
</Text>
</View>
<Ionicons name="alert-circle-outline" size={18} color={colors.warning} />
</Pressable>
);
}
export default function PlaylistScreen() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const isFavorites = id === 'favorites';
const playlistId = isFavorites ? null : Number(id);
const playlists = usePlaylistStore((s) => s.playlists);
const favoriteTracks = usePlaylistStore((s) => s.favoriteTracks);
const activeEntries = usePlaylistStore((s) => s.activeEntries);
const openPlaylist = usePlaylistStore((s) => s.openPlaylist);
const closePlaylist = usePlaylistStore((s) => s.closePlaylist);
const moveTrack = usePlaylistStore((s) => s.moveTrack);
const removeFromPlaylist = usePlaylistStore((s) => s.removeFromPlaylist);
const markPlayed = usePlaylistStore((s) => s.markPlayed);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const [actionEntry, setActionEntry] = useState<PlaylistTrackEntry | null>(null);
const [missingEntry, setMissingEntry] = useState<PlaylistTrackEntry | null>(null);
useEffect(() => {
if (playlistId == null || Number.isNaN(playlistId)) return;
void openPlaylist(playlistId);
return () => closePlaylist();
}, [playlistId, openPlaylist, closePlaylist]);
const playlist = isFavorites ? null : playlists.find((entry) => entry.id === playlistId);
const name = isFavorites ? 'Favorites' : (playlist?.name ?? 'Playlist');
const coverHash = playlist?.auto_cover_hash ?? null;
const entries: PlaylistTrackEntry[] = useMemo(
() =>
isFavorites
? favoriteTracks.map((track, index) => ({
id: track.id,
track_path: track.path,
position: index,
added_at: track.added_at,
missing: false,
fallback_title: null,
fallback_artist: null,
fallback_album: null,
track,
}))
: activeEntries,
[isFavorites, favoriteTracks, activeEntries]
);
const playable = useMemo(
() => entries.filter((entry) => entry.track !== null).map((entry) => entry.track as DbTrack),
[entries]
);
const playableIndexByEntryId = useMemo(() => {
const map = new Map<number, number>();
let index = 0;
for (const entry of entries) {
if (entry.track) {
map.set(entry.id, index);
index += 1;
}
}
return map;
}, [entries]);
const totalDuration = playable.reduce((sum, track) => sum + track.duration, 0);
const startPlayback = (index: number) => {
if (playable.length === 0) return;
void playTracks(playable.map(dbTrackToTrack), index);
if (playlistId != null && !Number.isNaN(playlistId)) void markPlayed(playlistId);
};
const startShuffle = () => {
if (playable.length === 0) return;
void shuffleTracks(playable.map(dbTrackToTrack));
if (playlistId != null && !Number.isNaN(playlistId)) void markPlayed(playlistId);
};
// Move/remove only exist on real playlists; favorites rows use the standard
// sheet (its favorite toggle is the "remove" affordance there).
const extraItems: ActionSheetItem[] =
playlistId != null && actionEntry
? [
{
key: 'move-up',
label: 'Move up',
icon: 'arrow-up',
onPress: () => {
void moveTrack(playlistId, actionEntry.track_path, -1);
setActionEntry(null);
},
},
{
key: 'move-down',
label: 'Move down',
icon: 'arrow-down',
onPress: () => {
void moveTrack(playlistId, actionEntry.track_path, 1);
setActionEntry(null);
},
},
{
key: 'remove',
label: 'Remove from playlist',
icon: 'remove-circle-outline',
destructive: true,
onPress: () => {
void removeFromPlaylist(playlistId, actionEntry.track_path);
setActionEntry(null);
},
},
]
: [];
return (
<Screen>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Library
</Text>
</Pressable>
<View style={styles.header}>
<View style={styles.art}>
{coverHash ? (
<Image
source={{ uri: artworkUri(coverHash) }}
style={styles.artImage}
contentFit="cover"
transition={120}
/>
) : (
<Ionicons
name={isFavorites ? 'heart' : 'musical-notes-outline'}
size={36}
color={isFavorites ? colors.accent : colors.textTertiary}
/>
)}
</View>
<View style={styles.headerMeta}>
<Text variant="heading" numberOfLines={2}>
{name}
</Text>
<Text variant="label">
{[
`${playable.length} ${playable.length === 1 ? 'track' : 'tracks'}`,
entries.length > playable.length ? `${entries.length - playable.length} missing` : null,
formatDuration(totalDuration),
]
.filter(Boolean)
.join(' · ')}
</Text>
<View style={styles.buttons}>
<Pressable
style={[styles.playButton, playable.length === 0 && styles.buttonDisabled]}
disabled={playable.length === 0}
onPress={() => startPlayback(0)}
accessibilityRole="button"
>
<Ionicons name="play" size={16} color={colors.bgPrimary} />
<Text variant="body" style={styles.playLabel}>
Play
</Text>
</Pressable>
<Pressable
style={[styles.shuffleButton, playable.length === 0 && styles.buttonDisabled]}
disabled={playable.length === 0}
onPress={startShuffle}
accessibilityRole="button"
>
<Ionicons name="shuffle" size={16} color={colors.accent} />
<Text variant="body" color={colors.accent}>
Shuffle
</Text>
</Pressable>
</View>
</View>
</View>
<FlashList
data={entries}
keyExtractor={(entry) => String(entry.id)}
showsVerticalScrollIndicator={false}
renderItem={({ item }) =>
item.track ? (
<TrackRow
track={item.track}
active={item.track.path === currentPath}
onPress={() => startPlayback(playableIndexByEntryId.get(item.id) ?? 0)}
onLongPress={() => setActionEntry(item)}
/>
) : (
<MissingRow entry={item} onLongPress={() => setMissingEntry(item)} />
)
}
/>
<TrackActionsSheet
track={actionEntry?.track ?? null}
onClose={() => setActionEntry(null)}
extraItems={extraItems}
/>
<ActionSheet
visible={missingEntry !== null}
title={missingEntry?.fallback_title ?? 'Missing track'}
items={
playlistId != null && missingEntry
? [
{
key: 'remove',
label: 'Remove from playlist',
icon: 'remove-circle-outline',
destructive: true,
onPress: () => {
void removeFromPlaylist(playlistId, missingEntry.track_path);
setMissingEntry(null);
},
},
]
: []
}
onClose={() => setMissingEntry(null)}
/>
</Screen>
);
}
const styles = StyleSheet.create({
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
marginTop: spacing.md,
marginBottom: spacing.md,
alignSelf: 'flex-start',
},
header: {
flexDirection: 'row',
gap: spacing.lg,
marginBottom: spacing.lg,
},
art: {
width: 128,
height: 128,
borderRadius: radius.md,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
artImage: {
width: '100%',
height: '100%',
},
headerMeta: {
flex: 1,
justifyContent: 'center',
gap: spacing.xs,
},
buttons: {
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.xs,
},
playButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.accent,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
shuffleButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
buttonDisabled: {
opacity: 0.4,
},
playLabel: {
color: colors.bgPrimary,
fontWeight: '600',
},
missingRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm + 2,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
opacity: 0.7,
},
missingMeta: {
flex: 1,
gap: 2,
},
});
+220
View File
@@ -0,0 +1,220 @@
import { useDeferredValue, useMemo, useState } from 'react';
import { View, Pressable, StyleSheet, TextInput } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { AlbumRow } from '@/components/library/AlbumRow';
import { ArtistRow } from '@/components/library/ArtistRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, fonts, fontSize, radius, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import type { Album, Artist, DbTrack } from '@/types/library';
const TRACK_CAP = 50;
const ALBUM_CAP = 12;
const ARTIST_CAP = 12;
type SearchItem =
| { type: 'header'; key: string; label: string }
| { type: 'album'; key: string; album: Album }
| { type: 'artist'; key: string; artist: Artist }
| { type: 'track'; key: string; track: DbTrack; index: number };
function filterCap<T>(items: T[], predicate: (item: T) => boolean, cap: number): T[] {
const out: T[] = [];
for (const item of items) {
if (predicate(item)) {
out.push(item);
if (out.length >= cap) break;
}
}
return out;
}
export default function SearchScreen() {
const router = useRouter();
const tracks = useLibraryStore((s) => s.tracks);
const albums = useLibraryStore((s) => s.albums);
const artists = useLibraryStore((s) => s.artists);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const [query, setQuery] = useState('');
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const needle = useDeferredValue(query.trim().toLocaleLowerCase());
const { items, trackResults } = useMemo(() => {
if (!needle) return { items: [] as SearchItem[], trackResults: [] as DbTrack[] };
// In-memory ≈ desktop searchTracks (LIKE %q% over title/artist/album).
const albumResults = filterCap(
albums,
(album) =>
album.album.toLocaleLowerCase().includes(needle) ||
album.artist.toLocaleLowerCase().includes(needle),
ALBUM_CAP
);
const artistResults = filterCap(
artists,
(artist) => artist.artist.toLocaleLowerCase().includes(needle),
ARTIST_CAP
);
const trackResults = filterCap(
tracks,
(track) =>
track.title.toLocaleLowerCase().includes(needle) ||
track.artist.toLocaleLowerCase().includes(needle) ||
track.album.toLocaleLowerCase().includes(needle),
TRACK_CAP
);
const items: SearchItem[] = [];
if (albumResults.length > 0) {
items.push({ type: 'header', key: 'header-albums', label: 'Albums' });
for (const album of albumResults) {
items.push({ type: 'album', key: `album-${album.identity_key}`, album });
}
}
if (artistResults.length > 0) {
items.push({ type: 'header', key: 'header-artists', label: 'Artists' });
for (const artist of artistResults) {
items.push({ type: 'artist', key: `artist-${artist.artist}`, artist });
}
}
if (trackResults.length > 0) {
items.push({ type: 'header', key: 'header-tracks', label: 'Tracks' });
trackResults.forEach((track, index) => {
items.push({ type: 'track', key: `track-${track.id}`, track, index });
});
}
return { items, trackResults };
}, [needle, tracks, albums, artists]);
const playFrom = (index: number) => {
void playTracks(trackResults.map(dbTrackToTrack), index);
};
return (
<Screen>
<View style={styles.searchBar}>
<Pressable onPress={() => router.back()} hitSlop={8} accessibilityRole="button">
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
</Pressable>
<TextInput
style={styles.input}
value={query}
onChangeText={setQuery}
placeholder="Search tracks, albums, artists"
placeholderTextColor={colors.textTertiary}
autoFocus
returnKeyType="search"
selectionColor={colors.accent}
/>
{query.length > 0 ? (
<Pressable onPress={() => setQuery('')} hitSlop={8} accessibilityRole="button">
<Ionicons name="close-circle" size={18} color={colors.textTertiary} />
</Pressable>
) : null}
</View>
{!needle ? (
<View style={styles.empty}>
<Text variant="caption">Search your library</Text>
</View>
) : items.length === 0 ? (
<View style={styles.empty}>
<Text variant="caption">No results for {query.trim()}</Text>
</View>
) : (
<FlashList
data={items}
keyExtractor={(item) => item.key}
getItemType={(item) => item.type}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => {
switch (item.type) {
case 'header':
return (
<Text variant="label" style={styles.sectionHeader}>
{item.label.toUpperCase()}
</Text>
);
case 'album':
return (
<AlbumRow
album={item.album}
onPress={() =>
router.push({
pathname: '/library/album/[key]',
params: { key: item.album.identity_key },
})
}
/>
);
case 'artist':
return (
<ArtistRow
artist={item.artist}
onPress={() =>
router.push({
pathname: '/library/artist/[name]',
params: { name: item.artist.artist },
})
}
/>
);
case 'track':
return (
<TrackRow
track={item.track}
active={item.track.path === currentPath}
onPress={() => playFrom(item.index)}
onLongPress={() => setActionTrack(item.track)}
/>
);
}
}}
/>
)}
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
const styles = StyleSheet.create({
searchBar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.md,
marginBottom: spacing.md,
},
input: {
flex: 1,
fontFamily: fonts.sans.regular,
fontSize: fontSize.base,
color: colors.textPrimary,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
},
empty: {
alignItems: 'center',
marginTop: spacing.xxl,
},
sectionHeader: {
marginTop: spacing.lg,
marginBottom: spacing.xs,
letterSpacing: 1,
},
});