mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 20:20:18 +02:00
m2, library ux
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user