mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
initial db rewrite
This commit is contained in:
@@ -11,26 +11,54 @@ import {
|
||||
} from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
|
||||
export function EmptyLibrary() {
|
||||
const styles = useStyles();
|
||||
const ripple = useRipple();
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
const recoveryNotice = useLibraryStore((state) => state.recoveryNotice);
|
||||
const status = useLibraryStore((state) => state.status);
|
||||
const fatal = status === 'fatalUserData';
|
||||
const rebuilding = status === 'rebuilding';
|
||||
const degraded = status === 'degraded';
|
||||
|
||||
return (
|
||||
<View style={styles.empty}>
|
||||
<Ionicons name="musical-notes-outline" size={48} color={colors.textTertiary} />
|
||||
<Ionicons
|
||||
name={fatal || degraded ? 'warning-outline' : rebuilding ? 'construct-outline' : 'musical-notes-outline'}
|
||||
size={48}
|
||||
color={fatal || degraded ? colors.warning : colors.textTertiary}
|
||||
/>
|
||||
<Text variant="heading" style={styles.title}>
|
||||
No music yet
|
||||
{fatal
|
||||
? 'Library data unavailable'
|
||||
: rebuilding
|
||||
? 'Rebuilding your library'
|
||||
: degraded
|
||||
? 'Library temporarily unavailable'
|
||||
: 'No music yet'}
|
||||
</Text>
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.body}>
|
||||
Pick a folder on this device and Astra will scan it into your library.
|
||||
{fatal
|
||||
? 'Astra could not restore your playlists, favorites, and settings from either safety snapshot. Your music files were not changed.'
|
||||
: rebuilding
|
||||
? 'The catalog was quarantined and Astra is rebuilding it from your available folders.'
|
||||
: degraded
|
||||
? 'The last valid catalog could not be opened. Astra will keep trying to recover without treating it as an empty library.'
|
||||
: recoveryNotice ??
|
||||
'Pick a folder on this device and Astra will scan it into your library.'}
|
||||
</Text>
|
||||
<Pressable android_ripple={ripple.bounded} style={styles.cta} onPress={() => router.push('/settings')} accessibilityRole="button">
|
||||
<Ionicons name="folder-open-outline" size={18} color={colors.bgPrimary} />
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={styles.cta}
|
||||
onPress={() => router.push(fatal ? '/settings/troubleshooting' : '/settings')}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Ionicons name={fatal ? 'build-outline' : 'folder-open-outline'} size={18} color={colors.bgPrimary} />
|
||||
<Text variant="body" style={styles.ctaLabel}>
|
||||
Folder settings
|
||||
{fatal ? 'Troubleshooting' : 'Folder settings'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,94 +1,98 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent
|
||||
type NativeSyntheticEvent,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import {
|
||||
AstraLibraryData,
|
||||
type NativeFolderNode,
|
||||
} from '../../../modules/astra-library-scanner';
|
||||
import { Text } from '@/components/Text';
|
||||
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
||||
import {
|
||||
AppSheet,
|
||||
AppSheetItem,
|
||||
AppSheetTitle
|
||||
AppSheetTitle,
|
||||
} from '@/components/sheets/AppSheet';
|
||||
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
|
||||
import {
|
||||
playTracks,
|
||||
shuffleTracks,
|
||||
enqueueTopMany,
|
||||
enqueueEndMany
|
||||
enqueueLibraryQuery,
|
||||
playLibraryQuery,
|
||||
} from '@/audio/playbackController';
|
||||
import { dbTrackToTrack } from '@/library/trackAdapter';
|
||||
import {
|
||||
buildFolderTree,
|
||||
flattenFolderTree,
|
||||
type FlattenedFolderTreeRow,
|
||||
type FolderTreeNode
|
||||
} from '@/library/folderTree';
|
||||
import { formatDuration } from '@/lib/format';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import {
|
||||
radius,
|
||||
spacing,
|
||||
} from '@/theme';
|
||||
import { spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import type { DbTrack } from '@/types/library';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface FoldersViewProps {
|
||||
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
scrollEventThrottle?: number;
|
||||
}
|
||||
|
||||
function FolderRow({
|
||||
row,
|
||||
interface LoadedNode {
|
||||
node: NativeFolderNode;
|
||||
childIds: string[];
|
||||
tracks: DbTrack[];
|
||||
nextOffset: number | null;
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
type FolderRow =
|
||||
| { type: 'folder'; id: string; state: LoadedNode; expanded: boolean }
|
||||
| { type: 'track'; id: string; track: DbTrack; node: NativeFolderNode }
|
||||
| { type: 'more'; id: string; nodeId: string; depth: number };
|
||||
|
||||
function FolderNodeRow({
|
||||
state,
|
||||
expanded,
|
||||
onToggle,
|
||||
onPlay,
|
||||
onShuffle,
|
||||
onOpenActions,
|
||||
}: {
|
||||
row: Extract<FlattenedFolderTreeRow, { type: 'folder' }>;
|
||||
onToggle: (nodeId: string) => void;
|
||||
onPlay: (node: FolderTreeNode) => void;
|
||||
onShuffle: (node: FolderTreeNode) => void;
|
||||
onOpenActions: (node: FolderTreeNode) => void;
|
||||
state: LoadedNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onPlay: () => void;
|
||||
onShuffle: () => void;
|
||||
onOpenActions: () => void;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const { node, depth, isExpanded } = row;
|
||||
|
||||
const play = (event: GestureResponderEvent) => {
|
||||
const { node } = state;
|
||||
const stop = (callback: () => void) => (event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onPlay(node);
|
||||
callback();
|
||||
};
|
||||
const shuffle = (event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onShuffle(node);
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
android_ripple={ripple.bounded}
|
||||
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
style={styles.folderRow}
|
||||
onPress={() => onToggle(node.id)}
|
||||
onPress={onToggle}
|
||||
onLongPress={() => {
|
||||
playHaptic('holdAccepted');
|
||||
onOpenActions(node);
|
||||
onOpenActions();
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: isExpanded }}
|
||||
accessibilityState={{ expanded }}
|
||||
>
|
||||
<View style={[styles.indent, { width: depth * 18 }]} />
|
||||
<View style={[styles.indent, { width: node.depth * 18 }]} />
|
||||
<Ionicons
|
||||
name={isExpanded ? 'chevron-down' : 'chevron-forward'}
|
||||
name={state.loading ? 'ellipsis-horizontal' : expanded ? 'chevron-down' : 'chevron-forward'}
|
||||
size={16}
|
||||
color={colors.textTertiary}
|
||||
/>
|
||||
@@ -98,22 +102,17 @@ function FolderRow({
|
||||
color={node.available ? colors.textSecondary : colors.warning}
|
||||
/>
|
||||
<View style={styles.folderMeta}>
|
||||
<Text variant="body" numberOfLines={1}>
|
||||
{node.name}
|
||||
</Text>
|
||||
<Text variant="body" numberOfLines={1}>{node.name}</Text>
|
||||
{!node.available ? (
|
||||
<Text variant="caption" color={colors.warning} numberOfLines={1}>
|
||||
Access lost
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.warning}>Access lost</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text variant="mono" style={styles.count}>
|
||||
{node.totalTrackCount}
|
||||
</Text>
|
||||
<Text variant="mono" style={styles.count}>{node.totalTrackCount}</Text>
|
||||
<Pressable
|
||||
android_ripple={ripple.icon(20)} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
android_ripple={ripple.icon(20)}
|
||||
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
style={styles.folderButton}
|
||||
onPress={play}
|
||||
onPress={stop(onPlay)}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Play ${node.name}`}
|
||||
@@ -121,9 +120,10 @@ function FolderRow({
|
||||
<Ionicons name="play" size={16} color={colors.accent} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
android_ripple={ripple.icon(20)} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
android_ripple={ripple.icon(20)}
|
||||
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
style={styles.folderButton}
|
||||
onPress={shuffle}
|
||||
onPress={stop(onShuffle)}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Shuffle ${node.name}`}
|
||||
@@ -135,61 +135,67 @@ function FolderRow({
|
||||
}
|
||||
|
||||
function FolderTrackRow({
|
||||
row,
|
||||
track,
|
||||
node,
|
||||
active,
|
||||
onOpenActions,
|
||||
}: {
|
||||
row: Extract<FlattenedFolderTreeRow, { type: 'track' }>;
|
||||
track: DbTrack;
|
||||
node: NativeFolderNode;
|
||||
active: boolean;
|
||||
onOpenActions: () => void;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const index = row.folderTracks.findIndex((track) => track.path === row.track.path);
|
||||
|
||||
const playFolderTrack = () => {
|
||||
void playTracks(row.folderTracks.map(dbTrackToTrack), {
|
||||
startIndex: Math.max(0, index),
|
||||
source: { kind: 'folder', label: row.folderName },
|
||||
});
|
||||
};
|
||||
const openActions = (event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onOpenActions();
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
android_ripple={ripple.bounded}
|
||||
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
style={[styles.trackRow, active && styles.trackRowActive]}
|
||||
onPress={playFolderTrack}
|
||||
onPress={() => {
|
||||
void playLibraryQuery(
|
||||
{ kind: 'folder', folderNodeId: node.id },
|
||||
{
|
||||
anchorPath: track.path,
|
||||
source: { kind: 'folder', label: node.name },
|
||||
}
|
||||
);
|
||||
}}
|
||||
onLongPress={() => {
|
||||
playHaptic('holdAccepted');
|
||||
onOpenActions();
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<View style={[styles.indent, { width: row.depth * 18 + 16 }]} />
|
||||
<Ionicons name={active ? 'volume-high' : 'musical-note'} size={15} color={active ? colors.accent : colors.textTertiary} />
|
||||
<View style={[styles.indent, { width: (node.depth + 1) * 18 + 16 }]} />
|
||||
<Ionicons
|
||||
name={active ? 'volume-high' : 'musical-note'}
|
||||
size={15}
|
||||
color={active ? colors.accent : colors.textTertiary}
|
||||
/>
|
||||
<View style={styles.trackMeta}>
|
||||
<Text variant="body" style={[styles.trackTitle, active && styles.trackTitleActive]} numberOfLines={1}>
|
||||
{row.track.title}
|
||||
</Text>
|
||||
<Text variant="label" numberOfLines={1}>
|
||||
{row.track.artist}
|
||||
<Text
|
||||
variant="body"
|
||||
style={[styles.trackTitle, active && styles.trackTitleActive]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{track.title}
|
||||
</Text>
|
||||
<Text variant="label" numberOfLines={1}>{track.artist}</Text>
|
||||
</View>
|
||||
<Text variant="mono" style={styles.duration}>
|
||||
{formatDuration(row.track.duration)}
|
||||
</Text>
|
||||
<Text variant="mono" style={styles.duration}>{formatDuration(track.duration)}</Text>
|
||||
<Pressable
|
||||
android_ripple={ripple.icon(21)} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
android_ripple={ripple.icon(21)}
|
||||
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
style={styles.actionsButton}
|
||||
onPress={openActions}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenActions();
|
||||
}}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`More actions for ${row.track.title}`}
|
||||
accessibilityLabel={`More actions for ${track.title}`}
|
||||
>
|
||||
<Ionicons name="ellipsis-horizontal" size={18} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
@@ -200,52 +206,113 @@ function FolderTrackRow({
|
||||
export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const folders = useLibraryStore((s) => s.folders);
|
||||
const tracks = useLibraryStore((s) => s.tracks);
|
||||
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(() => new Set());
|
||||
const currentPath = usePlayerStore((state) => state.currentTrack?.path);
|
||||
const [nodes, setNodes] = useState<Map<string, LoadedNode>>(() => new Map());
|
||||
const [rootIds, setRootIds] = useState<string[]>([]);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
|
||||
const [actionFolder, setActionFolder] = useState<FolderTreeNode | null>(null);
|
||||
const [actionFolder, setActionFolder] = useState<NativeFolderNode | null>(null);
|
||||
|
||||
const tree = useMemo(() => buildFolderTree(folders, tracks), [folders, tracks]);
|
||||
const rows = useMemo(() => flattenFolderTree(tree, expandedNodeIds), [expandedNodeIds, tree]);
|
||||
const replaceRoots = async () => {
|
||||
const roots = await AstraLibraryData.getFolderNodes(null);
|
||||
setNodes(new Map(roots.map((node) => [
|
||||
node.id,
|
||||
{ node, childIds: [], tracks: [], nextOffset: 0, loaded: false, loading: false },
|
||||
])));
|
||||
setRootIds(roots.map((node) => node.id));
|
||||
setExpanded(new Set());
|
||||
};
|
||||
|
||||
// Folder-level playback runs the whole subtree (subfolders included), in tree order.
|
||||
const playFolder = (node: FolderTreeNode) => {
|
||||
if (node.subtreeTracks.length === 0) return;
|
||||
void playTracks(node.subtreeTracks.map(dbTrackToTrack), {
|
||||
source: { kind: 'folder', label: node.name },
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => void replaceRoots());
|
||||
const subscription = AstraLibraryData.addListener('onCatalogChanged', () => {
|
||||
void replaceRoots();
|
||||
});
|
||||
};
|
||||
const shuffleFolder = (node: FolderTreeNode) => {
|
||||
if (node.subtreeTracks.length === 0) return;
|
||||
void shuffleTracks(node.subtreeTracks.map(dbTrackToTrack), {
|
||||
kind: 'folder',
|
||||
label: node.name,
|
||||
});
|
||||
};
|
||||
const playFolderNext = (node: FolderTreeNode) => {
|
||||
if (node.subtreeTracks.length === 0) return;
|
||||
void enqueueTopMany(node.subtreeTracks.map(dbTrackToTrack));
|
||||
};
|
||||
const queueFolder = (node: FolderTreeNode) => {
|
||||
if (node.subtreeTracks.length === 0) return;
|
||||
void enqueueEndMany(node.subtreeTracks.map(dbTrackToTrack));
|
||||
};
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
const toggleFolder = (nodeId: string) => {
|
||||
setExpandedNodeIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId);
|
||||
} else {
|
||||
next.add(nodeId);
|
||||
const loadNode = async (nodeId: string, append = false) => {
|
||||
const current = nodes.get(nodeId);
|
||||
if (!current || current.loading || (append && current.nextOffset == null)) return;
|
||||
setNodes((existing) => {
|
||||
const next = new Map(existing);
|
||||
next.set(nodeId, { ...current, loading: true });
|
||||
return next;
|
||||
});
|
||||
const offset = append ? current.nextOffset ?? 0 : 0;
|
||||
const [children, page] = await Promise.all([
|
||||
append ? Promise.resolve([]) : AstraLibraryData.getFolderNodes(nodeId),
|
||||
AstraLibraryData.getFolderTracks<DbTrack>(nodeId, offset, PAGE_SIZE),
|
||||
]);
|
||||
setNodes((existing) => {
|
||||
const next = new Map(existing);
|
||||
for (const child of children) {
|
||||
const old = next.get(child.id);
|
||||
next.set(child.id, old ?? {
|
||||
node: child,
|
||||
childIds: [],
|
||||
tracks: [],
|
||||
nextOffset: 0,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
const latest = next.get(nodeId) ?? current;
|
||||
next.set(nodeId, {
|
||||
...latest,
|
||||
childIds: append ? latest.childIds : children.map((child) => child.id),
|
||||
tracks: append ? [...latest.tracks, ...page.items] : page.items,
|
||||
nextOffset: page.nextOffset,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (tree.length === 0) {
|
||||
const toggleNode = (nodeId: string) => {
|
||||
const opening = !expanded.has(nodeId);
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
if (opening) next.add(nodeId);
|
||||
else next.delete(nodeId);
|
||||
return next;
|
||||
});
|
||||
if (opening && !nodes.get(nodeId)?.loaded) void loadNode(nodeId);
|
||||
};
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const result: FolderRow[] = [];
|
||||
const visit = (id: string) => {
|
||||
const state = nodes.get(id);
|
||||
if (!state) return;
|
||||
const isExpanded = expanded.has(id);
|
||||
result.push({ type: 'folder', id, state, expanded: isExpanded });
|
||||
if (!isExpanded) return;
|
||||
for (const childId of state.childIds) visit(childId);
|
||||
for (const track of state.tracks) {
|
||||
result.push({ type: 'track', id: `track:${id}:${track.path}`, track, node: state.node });
|
||||
}
|
||||
if (state.nextOffset != null) {
|
||||
result.push({ type: 'more', id: `more:${id}:${state.nextOffset}`, nodeId: id, depth: state.node.depth + 1 });
|
||||
}
|
||||
};
|
||||
rootIds.forEach(visit);
|
||||
return result;
|
||||
}, [expanded, nodes, rootIds]);
|
||||
|
||||
const playFolder = (node: NativeFolderNode, shuffle = false) => {
|
||||
if (node.totalTrackCount === 0) return;
|
||||
void playLibraryQuery(
|
||||
{ kind: 'folder', folderNodeId: node.id },
|
||||
{
|
||||
shuffle,
|
||||
source: { kind: 'folder', label: node.name },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (rootIds.length === 0) {
|
||||
return (
|
||||
<View style={styles.empty}>
|
||||
<Ionicons name="folder-open-outline" size={36} color={colors.textTertiary} />
|
||||
@@ -268,23 +335,38 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
|
||||
onScroll={onScroll}
|
||||
scrollEventThrottle={scrollEventThrottle}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) =>
|
||||
item.type === 'folder' ? (
|
||||
<FolderRow
|
||||
row={item}
|
||||
onToggle={toggleFolder}
|
||||
onPlay={playFolder}
|
||||
onShuffle={shuffleFolder}
|
||||
onOpenActions={setActionFolder}
|
||||
/>
|
||||
) : (
|
||||
renderItem={({ item }) => {
|
||||
if (item.type === 'folder') {
|
||||
return (
|
||||
<FolderNodeRow
|
||||
state={item.state}
|
||||
expanded={item.expanded}
|
||||
onToggle={() => toggleNode(item.id)}
|
||||
onPlay={() => playFolder(item.state.node)}
|
||||
onShuffle={() => playFolder(item.state.node, true)}
|
||||
onOpenActions={() => setActionFolder(item.state.node)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.type === 'more') {
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.moreRow, { paddingLeft: item.depth * 18 + 16 }]}
|
||||
onPress={() => void loadNode(item.nodeId, true)}
|
||||
>
|
||||
<Text variant="label" color={colors.accent}>Load more tracks</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FolderTrackRow
|
||||
row={item}
|
||||
track={item.track}
|
||||
node={item.node}
|
||||
active={item.track.path === currentPath}
|
||||
onOpenActions={() => setActionTrack(item.track)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
|
||||
{actionFolder ? (
|
||||
@@ -305,7 +387,7 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
|
||||
label="Shuffle"
|
||||
icon="shuffle"
|
||||
onPress={() => {
|
||||
shuffleFolder(actionFolder);
|
||||
playFolder(actionFolder, true);
|
||||
setActionFolder(null);
|
||||
}}
|
||||
/>
|
||||
@@ -313,7 +395,10 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
|
||||
label="Play next"
|
||||
icon="play-skip-forward"
|
||||
onPress={() => {
|
||||
playFolderNext(actionFolder);
|
||||
void enqueueLibraryQuery(
|
||||
{ kind: 'folder', folderNodeId: actionFolder.id },
|
||||
'next',
|
||||
);
|
||||
setActionFolder(null);
|
||||
}}
|
||||
/>
|
||||
@@ -321,7 +406,10 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
|
||||
label="Add to queue"
|
||||
icon="list-outline"
|
||||
onPress={() => {
|
||||
queueFolder(actionFolder);
|
||||
void enqueueLibraryQuery(
|
||||
{ kind: 'folder', folderNodeId: actionFolder.id },
|
||||
'end',
|
||||
);
|
||||
setActionFolder(null);
|
||||
}}
|
||||
/>
|
||||
@@ -350,68 +438,62 @@ const useStyles = createThemedStyles((colors) => ({
|
||||
folderMeta: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
count: {
|
||||
minWidth: 34,
|
||||
textAlign: 'right',
|
||||
color: colors.textTertiary,
|
||||
fontSize: 12,
|
||||
},
|
||||
folderButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
flexShrink: 0,
|
||||
borderRadius: radius.pill,
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 17,
|
||||
},
|
||||
trackRow: {
|
||||
minHeight: 56,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minHeight: 46,
|
||||
gap: spacing.sm,
|
||||
borderBottomColor: colors.glassBorder,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
trackRowActive: {
|
||||
backgroundColor: colors.accentGlow,
|
||||
backgroundColor: colors.bgSecondary,
|
||||
},
|
||||
trackMeta: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
trackTitle: {
|
||||
fontSize: 15,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
trackTitleActive: {
|
||||
color: colors.accent,
|
||||
},
|
||||
duration: {
|
||||
minWidth: 42,
|
||||
textAlign: 'right',
|
||||
color: colors.textTertiary,
|
||||
fontSize: 12,
|
||||
},
|
||||
actionsButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
flexShrink: 0,
|
||||
borderRadius: radius.pill,
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 18,
|
||||
},
|
||||
moreRow: {
|
||||
minHeight: 44,
|
||||
justifyContent: 'center',
|
||||
borderBottomColor: colors.glassBorder,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
empty: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingBottom: spacing.xxl,
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
maxWidth: 260,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -49,6 +49,8 @@ import { artworkThumbFromSource } from '@/library/artwork';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import { useQueueStore } from '@/stores/queueStore';
|
||||
import {
|
||||
getVirtualQueuePage,
|
||||
getVirtualQueueState,
|
||||
jumpToQueueIndex,
|
||||
moveQueueItem,
|
||||
removeFromQueue,
|
||||
@@ -74,6 +76,7 @@ interface QueueEntry {
|
||||
key: string;
|
||||
identity: string;
|
||||
track: RntpTrack;
|
||||
absoluteIndex: number;
|
||||
}
|
||||
|
||||
function rntpKey(track: RntpTrack): string {
|
||||
@@ -108,7 +111,8 @@ function clampLocal(value: number, len: number): number {
|
||||
function reconcileQueueEntries(
|
||||
tracks: readonly RntpTrack[],
|
||||
previous: readonly QueueEntry[],
|
||||
nextSerial: { current: number }
|
||||
nextSerial: { current: number },
|
||||
baseOffset: number,
|
||||
): QueueEntry[] {
|
||||
const available = new Map<string, QueueEntry[]>();
|
||||
previous.forEach((entry) => {
|
||||
@@ -117,19 +121,23 @@ function reconcileQueueEntries(
|
||||
else available.set(entry.identity, [entry]);
|
||||
});
|
||||
|
||||
return tracks.map((track) => {
|
||||
return tracks.map((track, index) => {
|
||||
const identity = rntpKey(track);
|
||||
const nativePosition = track.astraQueuePosition;
|
||||
const absoluteIndex = typeof nativePosition === 'number'
|
||||
? nativePosition
|
||||
: baseOffset + index;
|
||||
const reused = available.get(identity)?.shift();
|
||||
if (reused) {
|
||||
// Same track object → same entry object, so memo'd rows bail out when
|
||||
// only other parts of the queue changed (e.g. a track advance).
|
||||
if (reused.track === track) return reused;
|
||||
return { ...reused, track, identity };
|
||||
if (reused.track === track && reused.absoluteIndex === absoluteIndex) return reused;
|
||||
return { ...reused, track, identity, absoluteIndex };
|
||||
}
|
||||
|
||||
const key = `${identity}:${nextSerial.current}`;
|
||||
nextSerial.current += 1;
|
||||
return { key, identity, track };
|
||||
return { key, identity, track, absoluteIndex };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,13 +197,74 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
|
||||
const { tracks, activeIndex, hasSnapshot, refresh } = useQueue(true);
|
||||
const currentTrack = activeIndex >= 0 ? tracks[activeIndex] : undefined;
|
||||
const upcomingTracks = useMemo(
|
||||
const rollingUpcomingTracks = useMemo(
|
||||
() => (activeIndex >= 0 ? tracks.slice(activeIndex + 1) : tracks),
|
||||
[tracks, activeIndex]
|
||||
);
|
||||
const upcomingTotal =
|
||||
activeIndex >= 0 ? Math.max(0, tracks.length - activeIndex - 1) : tracks.length;
|
||||
const baseOffset = activeIndex >= 0 ? activeIndex + 1 : 0;
|
||||
const virtualState = getVirtualQueueState();
|
||||
const virtualMode = virtualState !== null;
|
||||
const virtualActivePosition = virtualState?.activePosition ?? -1;
|
||||
const [virtualTracks, setVirtualTracks] = useState<RntpTrack[]>([]);
|
||||
const virtualTracksRef = useRef<RntpTrack[]>([]);
|
||||
const virtualLoadGeneration = useRef(0);
|
||||
const virtualLoading = useRef(false);
|
||||
|
||||
const loadVirtualPage = useCallback(async (reset: boolean) => {
|
||||
const state = getVirtualQueueState();
|
||||
if (!state || (!reset && virtualLoading.current)) return;
|
||||
virtualLoading.current = true;
|
||||
const generation = reset ? ++virtualLoadGeneration.current : virtualLoadGeneration.current;
|
||||
const existing = reset ? [] : virtualTracksRef.current;
|
||||
const lastPosition = existing.length > 0
|
||||
? existing[existing.length - 1].astraQueuePosition
|
||||
: state.activePosition;
|
||||
const start = typeof lastPosition === 'number'
|
||||
? lastPosition + 1
|
||||
: state.activePosition + 1;
|
||||
try {
|
||||
const page = await getVirtualQueuePage(start, 100);
|
||||
if (
|
||||
!page ||
|
||||
generation !== virtualLoadGeneration.current ||
|
||||
getVirtualQueueState()?.sessionId !== state.sessionId
|
||||
) return;
|
||||
const next = reset ? page.items.map((item) => item.track) : [
|
||||
...existing,
|
||||
...page.items.map((item) => item.track),
|
||||
];
|
||||
// Keep no more than five tray pages in JS.
|
||||
const bounded = next.slice(-500);
|
||||
virtualTracksRef.current = bounded;
|
||||
setVirtualTracks(bounded);
|
||||
} finally {
|
||||
if (generation === virtualLoadGeneration.current) virtualLoading.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!virtualMode) {
|
||||
virtualLoadGeneration.current += 1;
|
||||
virtualTracksRef.current = [];
|
||||
setVirtualTracks([]);
|
||||
return;
|
||||
}
|
||||
void loadVirtualPage(true);
|
||||
}, [loadVirtualPage, virtualActivePosition, virtualMode, virtualState?.sessionId]);
|
||||
|
||||
const upcomingTracks = virtualMode ? virtualTracks : rollingUpcomingTracks;
|
||||
const upcomingTotal = virtualState
|
||||
? Math.max(0, virtualState.totalCount - virtualState.activePosition - 1)
|
||||
: activeIndex >= 0
|
||||
? Math.max(0, tracks.length - activeIndex - 1)
|
||||
: tracks.length;
|
||||
const firstVirtualPosition = virtualTracks[0]?.astraQueuePosition;
|
||||
const baseOffset = virtualState
|
||||
? typeof firstVirtualPosition === 'number'
|
||||
? firstVirtualPosition
|
||||
: virtualState.activePosition + 1
|
||||
: activeIndex >= 0
|
||||
? activeIndex + 1
|
||||
: 0;
|
||||
// Row callbacks resolve indices at call time from refs so their identities
|
||||
// survive track advances — an index captured at render time would go stale.
|
||||
const baseOffsetRef = useRef(baseOffset);
|
||||
@@ -206,7 +275,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
// Built synchronously so a warm mirror paints on the list's first frame; the
|
||||
// update effect below takes over from there.
|
||||
const [entries, setEntries] = useState<QueueEntry[]>(() =>
|
||||
hasSnapshot ? reconcileQueueEntries(upcomingTracks, [], { current: 0 }) : []
|
||||
hasSnapshot ? reconcileQueueEntries(upcomingTracks, [], { current: 0 }, baseOffset) : []
|
||||
);
|
||||
const entrySerial = useRef(entries.length);
|
||||
const entriesRef = useRef<QueueEntry[]>(entries);
|
||||
@@ -289,9 +358,11 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
const setOptimisticEntries = useCallback(
|
||||
(nextEntries: QueueEntry[]) => {
|
||||
setVisibleEntries(nextEntries);
|
||||
useQueueStore.getState().replaceUpcoming(nextEntries.map((entry) => entry.track));
|
||||
if (!virtualMode) {
|
||||
useQueueStore.getState().replaceUpcoming(nextEntries.map((entry) => entry.track));
|
||||
}
|
||||
},
|
||||
[setVisibleEntries]
|
||||
[setVisibleEntries, virtualMode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -300,7 +371,8 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
? reconcileQueueEntries(
|
||||
upcomingTracks,
|
||||
entriesRef.current.length > 0 ? entriesRef.current : previous,
|
||||
entrySerial
|
||||
entrySerial,
|
||||
baseOffset,
|
||||
)
|
||||
: [];
|
||||
// The mount-time reconcile of the synchronous initial state is a no-op;
|
||||
@@ -315,7 +387,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
}, [hasSnapshot, upcomingTracks, updateDragIndexMap]);
|
||||
}, [baseOffset, hasSnapshot, upcomingTracks, updateDragIndexMap]);
|
||||
|
||||
const visibleSelectedKeys = useMemo(() => {
|
||||
if (selectedKeys.size === 0) return EMPTY_KEY_SET;
|
||||
@@ -325,19 +397,27 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
|
||||
const retrySetUpcoming = useCallback(
|
||||
(nextTracks: RntpTrack[]) => {
|
||||
if (virtualMode) {
|
||||
void loadVirtualPage(true);
|
||||
return;
|
||||
}
|
||||
useQueueStore.getState().replaceUpcoming(nextTracks);
|
||||
void setUpcoming(nextTracks).catch(() => refresh());
|
||||
},
|
||||
[refresh]
|
||||
[loadVirtualPage, refresh, virtualMode]
|
||||
);
|
||||
|
||||
const commitNativeMove = useCallback(
|
||||
(fromAbsolute: number, toAbsolute: number, nextTracks: RntpTrack[]) => {
|
||||
void moveQueueItem(fromAbsolute, toAbsolute).catch(() => {
|
||||
retrySetUpcoming(nextTracks);
|
||||
});
|
||||
void moveQueueItem(fromAbsolute, toAbsolute, { virtualPosition: virtualMode })
|
||||
.then(() => {
|
||||
if (virtualMode) void loadVirtualPage(true);
|
||||
})
|
||||
.catch(() => {
|
||||
retrySetUpcoming(nextTracks);
|
||||
});
|
||||
},
|
||||
[retrySetUpcoming]
|
||||
[loadVirtualPage, retrySetUpcoming, virtualMode]
|
||||
);
|
||||
|
||||
const finishDrag = useCallback(
|
||||
@@ -348,13 +428,18 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEntries = moveQueueEntry(snapshot, from, to);
|
||||
const positions = snapshot.map((entry) => entry.absoluteIndex);
|
||||
const nextEntries = moveQueueEntry(snapshot, from, to).map((entry, index) => (
|
||||
entry.absoluteIndex === positions[index]
|
||||
? entry
|
||||
: { ...entry, absoluteIndex: positions[index] }
|
||||
));
|
||||
playHaptic('queueDrop');
|
||||
setVisibleEntries(nextEntries);
|
||||
clearDragAfterReorderCommit();
|
||||
commitNativeMove(
|
||||
baseOffsetRef.current + from,
|
||||
baseOffsetRef.current + to,
|
||||
snapshot[from].absoluteIndex,
|
||||
snapshot[to].absoluteIndex,
|
||||
nextEntries.map((entry) => entry.track)
|
||||
);
|
||||
},
|
||||
@@ -492,18 +577,27 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
|
||||
const runAndRefresh = useCallback(
|
||||
(task: Promise<void>) => {
|
||||
void task.catch(() => refresh());
|
||||
void task.then(
|
||||
() => {
|
||||
if (virtualMode) void loadVirtualPage(true);
|
||||
},
|
||||
() => {
|
||||
if (virtualMode) void loadVirtualPage(true);
|
||||
else void refresh();
|
||||
},
|
||||
);
|
||||
},
|
||||
[refresh]
|
||||
[loadVirtualPage, refresh, virtualMode]
|
||||
);
|
||||
|
||||
const jump = useCallback(
|
||||
(key: string) => {
|
||||
const localIndex = entriesRef.current.findIndex((entry) => entry.key === key);
|
||||
if (localIndex < 0) return;
|
||||
runAndRefresh(jumpToQueueIndex(baseOffsetRef.current + localIndex));
|
||||
const entry = entriesRef.current[localIndex];
|
||||
runAndRefresh(jumpToQueueIndex(entry.absoluteIndex, { virtualPosition: virtualMode }));
|
||||
},
|
||||
[runAndRefresh]
|
||||
[runAndRefresh, virtualMode]
|
||||
);
|
||||
|
||||
const playNext = useCallback(
|
||||
@@ -512,9 +606,11 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
if (localIndex < 0) return;
|
||||
const nextEntries = moveQueueEntry(entriesRef.current, localIndex, 0);
|
||||
setOptimisticEntries(nextEntries);
|
||||
runAndRefresh(requeueToTop(baseOffsetRef.current + localIndex));
|
||||
runAndRefresh(requeueToTop(entriesRef.current[localIndex].absoluteIndex, {
|
||||
virtualPosition: virtualMode,
|
||||
}));
|
||||
},
|
||||
[runAndRefresh, setOptimisticEntries]
|
||||
[runAndRefresh, setOptimisticEntries, virtualMode]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
@@ -525,9 +621,12 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
if (!action) return;
|
||||
|
||||
setOptimisticEntries(action.nextEntries);
|
||||
runAndRefresh(removeFromQueue(action.absoluteIndex, { updateMirror: false }));
|
||||
runAndRefresh(removeFromQueue(
|
||||
entriesRef.current[localIndex].absoluteIndex,
|
||||
{ updateMirror: false, virtualPosition: virtualMode },
|
||||
));
|
||||
},
|
||||
[runAndRefresh, setOptimisticEntries]
|
||||
[runAndRefresh, setOptimisticEntries, virtualMode]
|
||||
);
|
||||
|
||||
const toggleSelect = useCallback((key: string) => {
|
||||
@@ -554,27 +653,50 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
|
||||
const groupPlayNext = useCallback(() => {
|
||||
const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset);
|
||||
if (action.absoluteIndices.length === 0) {
|
||||
const absoluteIndices = entriesRef.current
|
||||
.filter((entry) => visibleSelectedKeys.has(entry.key))
|
||||
.map((entry) => entry.absoluteIndex);
|
||||
if (absoluteIndices.length === 0) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOptimisticEntries(action.entriesWithSelectedFirst);
|
||||
runAndRefresh(requeueManyToTop(action.absoluteIndices));
|
||||
runAndRefresh(requeueManyToTop(absoluteIndices, { virtualPosition: virtualMode }));
|
||||
clearSelection();
|
||||
}, [baseOffset, clearSelection, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]);
|
||||
}, [
|
||||
baseOffset,
|
||||
clearSelection,
|
||||
runAndRefresh,
|
||||
setOptimisticEntries,
|
||||
virtualMode,
|
||||
visibleSelectedKeys,
|
||||
]);
|
||||
|
||||
const groupRemove = useCallback(() => {
|
||||
const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset);
|
||||
if (action.absoluteIndices.length === 0) {
|
||||
const absoluteIndices = entriesRef.current
|
||||
.filter((entry) => visibleSelectedKeys.has(entry.key))
|
||||
.map((entry) => entry.absoluteIndex);
|
||||
if (absoluteIndices.length === 0) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOptimisticEntries(action.entriesWithoutSelected);
|
||||
runAndRefresh(removeManyFromQueue(action.absoluteIndices, { updateMirror: false }));
|
||||
runAndRefresh(removeManyFromQueue(absoluteIndices, {
|
||||
updateMirror: false,
|
||||
virtualPosition: virtualMode,
|
||||
}));
|
||||
clearSelection();
|
||||
}, [baseOffset, clearSelection, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]);
|
||||
}, [
|
||||
baseOffset,
|
||||
clearSelection,
|
||||
runAndRefresh,
|
||||
setOptimisticEntries,
|
||||
virtualMode,
|
||||
visibleSelectedKeys,
|
||||
]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
@@ -727,6 +849,8 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
renderItem={renderItem}
|
||||
extraData={listExtraData}
|
||||
onLoad={onListLoad}
|
||||
onEndReached={virtualMode ? () => void loadVirtualPage(false) : undefined}
|
||||
onEndReachedThreshold={0.6}
|
||||
contentContainerStyle={embedded
|
||||
? styles.embeddedListContent
|
||||
: editMode && selectedCount > 0
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||
import { rgbaFromHex } from '@/theme/colorUtils';
|
||||
import { enqueueTop, playTracks } from '@/audio/playbackController';
|
||||
import { enqueueTop, playLibraryQuery } from '@/audio/playbackController';
|
||||
import { dbTrackToTrack } from '@/library/trackAdapter';
|
||||
import {
|
||||
albumArtworkSource,
|
||||
@@ -51,6 +51,8 @@ import type {
|
||||
} from '@/types/library';
|
||||
import type { Playlist } from '@/types/playlist';
|
||||
import { SETTINGS_SEARCH_ROUTES } from '@/components/search/settingsSearchRoutes';
|
||||
import { AstraLibraryData } from '../../../modules/astra-library-scanner';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
|
||||
type IconName = keyof typeof Ionicons.glyphMap;
|
||||
type RouteHref =
|
||||
@@ -572,11 +574,11 @@ function QuickSearchPanel({
|
||||
const { height } = useWindowDimensions();
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
|
||||
const tracks = useLibraryStore((s) => s.tracks);
|
||||
const albums = useLibraryStore((s) => s.albums);
|
||||
const artists = useLibraryStore((s) => s.artists);
|
||||
const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks);
|
||||
const setViewMode = useLibraryStore((s) => s.setViewMode);
|
||||
const includeCollabArtists = useLibraryStore((s) => s.includeCollabArtists);
|
||||
const includeSingles = useSettingsStore((s) => s.includeSingles);
|
||||
const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
||||
|
||||
const playlists = usePlaylistStore((s) => s.playlists);
|
||||
const favoriteTracks = usePlaylistStore((s) => s.favoriteTracks);
|
||||
@@ -584,11 +586,49 @@ function QuickSearchPanel({
|
||||
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [showAllLibrary, setShowAllLibrary] = useState(false);
|
||||
const [tracks, setTracks] = useState<DbTrack[]>([]);
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [artists, setArtists] = useState<Artist[]>([]);
|
||||
const [queuedTrackPaths, setQueuedTrackPaths] = useState<Set<string>>(() => new Set());
|
||||
const queuedFeedbackTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
||||
const trimmedQuery = query.trim();
|
||||
const hasQuery = trimmedQuery.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasQuery) return;
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
void AstraLibraryData.searchLibrary<DbTrack, Album, Artist>(
|
||||
trimmedQuery,
|
||||
showAllLibrary ? 100 : 20,
|
||||
includeSingles,
|
||||
artistGroupingMode,
|
||||
includeCollabArtists
|
||||
).then((result) => {
|
||||
if (cancelled) return;
|
||||
setTracks(result.tracks);
|
||||
setAlbums(result.albums);
|
||||
setArtists(result.artists);
|
||||
}).catch(() => {
|
||||
if (cancelled) return;
|
||||
setTracks([]);
|
||||
setAlbums([]);
|
||||
setArtists([]);
|
||||
});
|
||||
}, 120);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [
|
||||
artistGroupingMode,
|
||||
hasQuery,
|
||||
includeCollabArtists,
|
||||
includeSingles,
|
||||
showAllLibrary,
|
||||
trimmedQuery,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 80);
|
||||
return () => clearTimeout(timer);
|
||||
@@ -933,17 +973,17 @@ function QuickSearchPanel({
|
||||
close();
|
||||
|
||||
if (result.kind === 'track') {
|
||||
const context = hasQuery ? allTrackResults.map((entry) => entry.track) : recentTrackResults.map((entry) => entry.track);
|
||||
const index = Math.max(
|
||||
0,
|
||||
context.findIndex((track) => track.path === result.track.path)
|
||||
);
|
||||
void playTracks(context.map(dbTrackToTrack), {
|
||||
startIndex: index,
|
||||
void playLibraryQuery(
|
||||
hasQuery
|
||||
? { kind: 'search', query: trimmedQuery }
|
||||
: { kind: 'recent' },
|
||||
{
|
||||
anchorPath: result.track.path,
|
||||
source: hasQuery
|
||||
? { kind: 'search', label: `Search: ${trimmedQuery}` }
|
||||
: { kind: 'recently-played', label: 'Recently Played' },
|
||||
});
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user