mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
import {
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState
|
|
} from 'react';
|
|
import {
|
|
BackHandler,
|
|
View,
|
|
Pressable,
|
|
StyleSheet
|
|
} from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { FlashList, type FlashListRef } from '@shopify/flash-list';
|
|
import { useRouter } from 'expo-router';
|
|
import { Screen } from '@/components/Screen';
|
|
import { Text } from '@/components/Text';
|
|
import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher';
|
|
import { AlbumGridItem } from '@/components/library/AlbumGridItem';
|
|
import { ArtistGridItem } from '@/components/library/ArtistGridItem';
|
|
import { TrackRow } from '@/components/library/TrackRow';
|
|
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 { AlphabetRail } from '@/components/library/AlphabetRail';
|
|
import { SelectionActionBar } from '@/components/library/SelectionActionBar';
|
|
import {
|
|
AppSheet,
|
|
AppSheetItem,
|
|
AppSheetSection
|
|
} from '@/components/sheets/AppSheet';
|
|
import { PlaylistPickerSheet } from '@/components/sheets/PlaylistPickerSheet';
|
|
import {
|
|
PullSearchGesture,
|
|
PullSearchScrollView,
|
|
useScrollTopGate
|
|
} from '@/components/search/PullSearchGesture';
|
|
import { spacing } from '@/theme';
|
|
import { useColors } from '@/theme/themed';
|
|
import { useRipple } from '@/theme/ripple';
|
|
import { useLibraryStore } from '@/stores/libraryStore';
|
|
import { usePlayerStore } from '@/stores/playerStore';
|
|
import { useSearchStore } from '@/stores/searchStore';
|
|
import {
|
|
enqueueEndMany,
|
|
enqueueTopMany,
|
|
playTracks
|
|
} from '@/audio/playbackController';
|
|
import { dbTrackToTrack } from '@/library/trackAdapter';
|
|
import { playHaptic } from '@/lib/haptics';
|
|
import {
|
|
sortTracks,
|
|
TRACK_SORT_LABELS,
|
|
type TrackSort
|
|
} from '@/lib/trackSort';
|
|
import {
|
|
sortAlbums,
|
|
ALBUM_SORT_LABELS,
|
|
type AlbumSort
|
|
} from '@/lib/albumSort';
|
|
import {
|
|
sortArtists,
|
|
ARTIST_SORT_LABELS,
|
|
type ArtistSort
|
|
} from '@/lib/artistSort';
|
|
import { buildLetterIndex, resolveJumpIndex } from '@/lib/letterIndex';
|
|
import type {
|
|
Album,
|
|
Artist,
|
|
DbTrack
|
|
} from '@/types/library';
|
|
|
|
const TRACK_SORT_OPTIONS: TrackSort[] = ['artist', 'title', 'recently_added', 'duration'];
|
|
const ALBUM_SORT_OPTIONS: AlbumSort[] = ['artist', 'name', 'recently_added', 'year'];
|
|
const ARTIST_SORT_OPTIONS: ArtistSort[] = ['name', 'track_count'];
|
|
|
|
export default function LibraryScreen() {
|
|
const colors = useColors();
|
|
const ripple = useRipple();
|
|
const router = useRouter();
|
|
const viewMode = useLibraryStore((s) => s.viewMode);
|
|
const setViewMode = useLibraryStore((s) => s.setViewMode);
|
|
const albums = useLibraryStore((s) => s.albums);
|
|
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 albumSort = useLibraryStore((s) => s.albumSort);
|
|
const setAlbumSort = useLibraryStore((s) => s.setAlbumSort);
|
|
const artistSort = useLibraryStore((s) => s.artistSort);
|
|
const setArtistSort = useLibraryStore((s) => s.setArtistSort);
|
|
const isScanning = useLibraryStore((s) => s.isScanning);
|
|
const scanError = useLibraryStore((s) => s.scanError);
|
|
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
|
|
const openQuickSearch = useSearchStore((s) => s.openQuickSearch);
|
|
|
|
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
|
|
const [sortSheetOpen, setSortSheetOpen] = useState(false);
|
|
const [selectMode, setSelectMode] = useState(false);
|
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
|
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
|
const scrollTop = useScrollTopGate();
|
|
|
|
const tracksListRef = useRef<FlashListRef<DbTrack>>(null);
|
|
const albumsListRef = useRef<FlashListRef<Album>>(null);
|
|
const artistsListRef = useRef<FlashListRef<Artist>>(null);
|
|
|
|
const isEmpty = tracks.length === 0 && folders.length === 0 && !isScanning;
|
|
|
|
const sortedTracks = useMemo(
|
|
() => (viewMode === 'tracks' ? sortTracks(tracks, trackSort) : []),
|
|
[trackSort, tracks, viewMode]
|
|
);
|
|
const sortedAlbums = useMemo(
|
|
() => (viewMode === 'albums' ? sortAlbums(albums, albumSort) : []),
|
|
[albumSort, albums, viewMode]
|
|
);
|
|
const sortedArtists = useMemo(
|
|
() => (viewMode === 'artists' ? sortArtists(artists, artistSort) : []),
|
|
[artistSort, artists, viewMode]
|
|
);
|
|
|
|
// Tap index is within sortedTracks so the tapped row is the track that plays.
|
|
const playAllFrom = (index: number) => {
|
|
void playTracks(sortedTracks.map(dbTrackToTrack), index);
|
|
};
|
|
const openSearch = () => openQuickSearch();
|
|
|
|
// A-Z rail: only for sorts where a letter jump is meaningful.
|
|
const letterIndex = useMemo(() => {
|
|
if (viewMode === 'tracks' && (trackSort === 'artist' || trackSort === 'title')) {
|
|
return buildLetterIndex(sortedTracks, (t) => (trackSort === 'title' ? t.title : t.artist));
|
|
}
|
|
if (viewMode === 'albums' && (albumSort === 'artist' || albumSort === 'name')) {
|
|
return buildLetterIndex(sortedAlbums, (a) => (albumSort === 'name' ? a.album : a.artist));
|
|
}
|
|
if (viewMode === 'artists' && artistSort === 'name') {
|
|
return buildLetterIndex(sortedArtists, (a) => a.artist);
|
|
}
|
|
return [];
|
|
}, [albumSort, artistSort, sortedAlbums, sortedArtists, sortedTracks, trackSort, viewMode]);
|
|
|
|
const railVisible = letterIndex.length > 1;
|
|
const railLetters = useMemo(
|
|
() => new Set(letterIndex.map((entry) => entry.letter)),
|
|
[letterIndex]
|
|
);
|
|
|
|
const jumpToLetter = (letter: string) => {
|
|
const index = resolveJumpIndex(letterIndex, letter);
|
|
if (index == null) return;
|
|
// Fire-and-forget: letter-change granularity already throttles the calls.
|
|
if (viewMode === 'tracks') void tracksListRef.current?.scrollToIndex({ index, animated: false });
|
|
else if (viewMode === 'albums') void albumsListRef.current?.scrollToIndex({ index, animated: false });
|
|
else if (viewMode === 'artists') void artistsListRef.current?.scrollToIndex({ index, animated: false });
|
|
};
|
|
|
|
// Multi-select (tracks view): long-press arms it, batch actions live in the
|
|
// bottom bar, selection order follows the current display order.
|
|
const enterSelection = (track: DbTrack) => {
|
|
playHaptic('threshold');
|
|
setSelectMode(true);
|
|
setSelectedIds(new Set([track.id]));
|
|
};
|
|
|
|
const toggleSelected = (id: number) => {
|
|
playHaptic('selection');
|
|
setSelectedIds((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const exitSelection = () => {
|
|
setSelectMode(false);
|
|
setSelectedIds(new Set());
|
|
setPlaylistPickerOpen(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!selectMode) return;
|
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
exitSelection();
|
|
return true;
|
|
});
|
|
return () => sub.remove();
|
|
}, [selectMode]);
|
|
|
|
const selectedDbTracks = () => sortedTracks.filter((track) => selectedIds.has(track.id));
|
|
|
|
const batchPlayNext = () => {
|
|
const tracks = selectedDbTracks().map(dbTrackToTrack);
|
|
playHaptic('confirm');
|
|
exitSelection();
|
|
void enqueueTopMany(tracks);
|
|
};
|
|
|
|
const batchAddToQueue = () => {
|
|
const tracks = selectedDbTracks().map(dbTrackToTrack);
|
|
playHaptic('confirm');
|
|
exitSelection();
|
|
void enqueueEndMany(tracks);
|
|
};
|
|
|
|
// One sort trigger + sheet across the three sortable views.
|
|
const sortable = viewMode === 'tracks' || viewMode === 'albums' || viewMode === 'artists';
|
|
const sortLabel =
|
|
viewMode === 'tracks'
|
|
? TRACK_SORT_LABELS[trackSort]
|
|
: viewMode === 'albums'
|
|
? ALBUM_SORT_LABELS[albumSort]
|
|
: ARTIST_SORT_LABELS[artistSort];
|
|
const sortSheetLabel =
|
|
viewMode === 'tracks' ? 'SORT TRACKS BY' : viewMode === 'albums' ? 'SORT ALBUMS BY' : 'SORT ARTISTS BY';
|
|
const sortItems =
|
|
viewMode === 'tracks'
|
|
? TRACK_SORT_OPTIONS.map((option) => ({
|
|
key: option,
|
|
label: TRACK_SORT_LABELS[option],
|
|
selected: option === trackSort,
|
|
onSelect: () => setTrackSort(option),
|
|
}))
|
|
: viewMode === 'albums'
|
|
? ALBUM_SORT_OPTIONS.map((option) => ({
|
|
key: option,
|
|
label: ALBUM_SORT_LABELS[option],
|
|
selected: option === albumSort,
|
|
onSelect: () => setAlbumSort(option),
|
|
}))
|
|
: ARTIST_SORT_OPTIONS.map((option) => ({
|
|
key: option,
|
|
label: ARTIST_SORT_LABELS[option],
|
|
selected: option === artistSort,
|
|
onSelect: () => setArtistSort(option),
|
|
}));
|
|
|
|
return (
|
|
<Screen>
|
|
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
|
|
<View style={styles.headingRow}>
|
|
<Text variant="title" style={styles.heading}>
|
|
Library
|
|
</Text>
|
|
{!isEmpty ? (
|
|
<Pressable android_ripple={ripple.bounded}
|
|
hitSlop={8}
|
|
onPress={() => openQuickSearch()}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Search library"
|
|
>
|
|
<Ionicons name="search" size={22} color={colors.textSecondary} />
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
|
|
{isEmpty ? (
|
|
<EmptyLibrary />
|
|
) : (
|
|
<>
|
|
<View style={styles.switcher}>
|
|
<ViewModeSwitcher
|
|
value={viewMode}
|
|
onChange={(mode) => {
|
|
if (selectMode) exitSelection();
|
|
scrollTop.setScrollAtTop(true);
|
|
setViewMode(mode);
|
|
}}
|
|
/>
|
|
</View>
|
|
<ScanProgress />
|
|
{scanError ? (
|
|
<Text variant="caption" color={colors.warning} style={styles.error} numberOfLines={2}>
|
|
Scan problem: {scanError}
|
|
</Text>
|
|
) : null}
|
|
|
|
{selectMode ? (
|
|
<View style={styles.selectionHeader}>
|
|
<Text variant="label">
|
|
{selectedIds.size} selected
|
|
</Text>
|
|
<Pressable android_ripple={ripple.bounded} onPress={exitSelection} hitSlop={8} accessibilityRole="button">
|
|
<Text variant="label" color={colors.accentText}>
|
|
Cancel
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : sortable ? (
|
|
<Pressable android_ripple={ripple.bounded}
|
|
style={styles.sortTrigger}
|
|
onPress={() => setSortSheetOpen(true)}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`Sort by ${sortLabel}`}
|
|
>
|
|
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} />
|
|
<Text variant="label">{sortLabel}</Text>
|
|
</Pressable>
|
|
) : null}
|
|
|
|
<View style={styles.listArea}>
|
|
{viewMode === 'albums' ? (
|
|
<FlashList
|
|
ref={albumsListRef}
|
|
data={sortedAlbums}
|
|
numColumns={3}
|
|
keyExtractor={(album) => album.identity_key}
|
|
showsVerticalScrollIndicator={false}
|
|
overScrollMode="never"
|
|
renderScrollComponent={PullSearchScrollView}
|
|
onScroll={scrollTop.onScroll}
|
|
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
|
renderItem={({ item }) => (
|
|
<View style={styles.gridCell}>
|
|
<AlbumGridItem
|
|
album={item}
|
|
onPress={() =>
|
|
router.push({
|
|
pathname: '/library/album/[key]',
|
|
params: { key: item.identity_key },
|
|
})
|
|
}
|
|
/>
|
|
</View>
|
|
)}
|
|
/>
|
|
) : null}
|
|
|
|
{viewMode === 'artists' ? (
|
|
<FlashList
|
|
ref={artistsListRef}
|
|
data={sortedArtists}
|
|
numColumns={3}
|
|
keyExtractor={(artist) => artist.artist}
|
|
showsVerticalScrollIndicator={false}
|
|
overScrollMode="never"
|
|
renderScrollComponent={PullSearchScrollView}
|
|
onScroll={scrollTop.onScroll}
|
|
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
|
renderItem={({ item }) => (
|
|
<View style={styles.gridCell}>
|
|
<ArtistGridItem
|
|
artist={item}
|
|
onPress={() =>
|
|
router.push({
|
|
pathname: '/library/artist/[name]',
|
|
params: { name: item.artist },
|
|
})
|
|
}
|
|
/>
|
|
</View>
|
|
)}
|
|
/>
|
|
) : null}
|
|
|
|
{viewMode === 'tracks' ? (
|
|
<FlashList
|
|
ref={tracksListRef}
|
|
data={sortedTracks}
|
|
keyExtractor={(track) => String(track.id)}
|
|
showsVerticalScrollIndicator={false}
|
|
overScrollMode="never"
|
|
renderScrollComponent={PullSearchScrollView}
|
|
onScroll={scrollTop.onScroll}
|
|
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
|
extraData={selectMode ? selectedIds : undefined}
|
|
renderItem={({ item, index }) => (
|
|
<TrackRow
|
|
track={item}
|
|
active={item.path === currentPath}
|
|
onPress={() => playAllFrom(index)}
|
|
onLongPress={() => enterSelection(item)}
|
|
onOpenActions={() => setActionTrack(item)}
|
|
selectionMode={selectMode}
|
|
selected={selectedIds.has(item.id)}
|
|
onToggleSelect={() => toggleSelected(item.id)}
|
|
/>
|
|
)}
|
|
/>
|
|
) : null}
|
|
|
|
{viewMode === 'playlists' ? (
|
|
<PlaylistsView
|
|
onScroll={scrollTop.onScroll}
|
|
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
|
/>
|
|
) : null}
|
|
|
|
{viewMode === 'folders' ? (
|
|
<FoldersView
|
|
onScroll={scrollTop.onScroll}
|
|
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
|
/>
|
|
) : null}
|
|
|
|
{railVisible ? (
|
|
<AlphabetRail activeLetters={railLetters} onJumpToLetter={jumpToLetter} />
|
|
) : null}
|
|
</View>
|
|
</>
|
|
)}
|
|
</PullSearchGesture>
|
|
|
|
{selectMode && viewMode === 'tracks' ? (
|
|
<SelectionActionBar
|
|
count={selectedIds.size}
|
|
onPlayNext={batchPlayNext}
|
|
onAddToQueue={batchAddToQueue}
|
|
onAddToPlaylist={() => setPlaylistPickerOpen(true)}
|
|
/>
|
|
) : null}
|
|
|
|
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
|
|
{playlistPickerOpen ? (
|
|
<PlaylistPickerSheet
|
|
tracks={selectedDbTracks()}
|
|
subtitle={`${selectedIds.size} ${selectedIds.size === 1 ? 'track' : 'tracks'}`}
|
|
onClose={() => setPlaylistPickerOpen(false)}
|
|
onAdded={() => {
|
|
playHaptic('confirm');
|
|
exitSelection();
|
|
}}
|
|
/>
|
|
) : null}
|
|
{sortSheetOpen ? (
|
|
<AppSheet onClose={() => setSortSheetOpen(false)}>
|
|
<AppSheetSection label={sortSheetLabel} />
|
|
{sortItems.map(({ key, label, selected, onSelect }) => (
|
|
<AppSheetItem
|
|
key={key}
|
|
label={label}
|
|
selected={selected}
|
|
onPress={() => {
|
|
onSelect();
|
|
setSortSheetOpen(false);
|
|
}}
|
|
/>
|
|
))}
|
|
</AppSheet>
|
|
) : null}
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
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,
|
|
},
|
|
// Same vertical rhythm as sortTrigger so entering selection doesn't shift the list.
|
|
selectionHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingVertical: spacing.xs,
|
|
marginBottom: spacing.xs,
|
|
},
|
|
gridCell: {
|
|
flex: 1,
|
|
paddingHorizontal: spacing.xs,
|
|
},
|
|
listArea: {
|
|
flex: 1,
|
|
},
|
|
});
|