Files
astra-mobile/src/components/search/QuickSearchOverlay.tsx
T
2026-07-25 11:12:02 -04:00

1315 lines
38 KiB
TypeScript

import {
useEffect,
useMemo,
useRef,
useState
} from 'react';
import {
Keyboard,
Modal,
Pressable,
StyleSheet,
TextInput,
View,
useWindowDimensions,
type GestureResponderEvent
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useReturnToTabs } from '@/navigation/returnToTabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import {
fonts,
fontSize,
radius,
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { rgbaFromHex } from '@/theme/colorUtils';
import { enqueueTop, playLibraryQuery } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import {
albumArtworkSource,
artworkUri,
trackArtworkThumbSource
} from '@/library/artwork';
import { multiFieldScore, MIN_SCORE_THRESHOLD } from '@/lib/fuzzySearch';
import { formatDuration } from '@/lib/format';
import { playHaptic } from '@/lib/haptics';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSearchStore } from '@/stores/searchStore';
import type {
Album,
Artist,
DbTrack
} 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 =
| '/'
| '/library'
| '/eq'
| '/settings'
| '/settings/appearance'
| '/settings/library'
| '/settings/audio'
| '/settings/playback'
| '/settings/services'
| '/settings/lyrics'
| '/settings/experimental'
| '/settings/troubleshooting'
| '/settings/info'
| '/sources'
| '/lastfm';
type LibraryViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders';
const NAV_RESULT_LIMIT = 3;
const SETTINGS_RESULT_LIMIT = 4;
const TRACK_RESULT_LIMIT = 5;
const ALBUM_RESULT_LIMIT = 4;
const ARTIST_RESULT_LIMIT = 4;
const PLAYLIST_RESULT_LIMIT = 4;
const EMPTY_RECENT_TRACKS_LIMIT = 3;
const ALL_TRACK_RESULT_LIMIT = 120;
const ALL_ENTITY_RESULT_LIMIT = 80;
const QUEUED_FEEDBACK_MS = 1000;
const NAV_ENTRIES: {
id: string;
label: string;
href: RouteHref;
icon: IconName;
keywords: string[];
libraryViewMode?: LibraryViewMode;
}[] = [
{
id: 'nav:home',
label: 'Home',
href: '/',
icon: 'home',
keywords: ['home', 'dashboard', 'main'],
},
{
id: 'nav:library',
label: 'Library',
href: '/library',
icon: 'musical-notes',
keywords: ['library', 'tracks', 'songs', 'browse', 'collection'],
},
{
id: 'nav:library-folders',
label: 'Browse folders',
href: '/library',
icon: 'folder-open-outline',
keywords: ['folders', 'folder explorer', 'browse folders', 'library folders'],
libraryViewMode: 'folders',
},
{
id: 'nav:eq',
label: 'Equalizer',
href: '/eq',
icon: 'options',
keywords: ['eq', 'equalizer', 'bands', 'frequency', 'bass', 'treble'],
},
{
id: 'nav:settings',
label: 'Settings',
href: '/settings',
icon: 'settings',
keywords: ['settings', 'preferences'],
},
];
const SETTING_ENTRIES: {
id: string;
label: string;
subtitle: string;
href: RouteHref;
icon: IconName;
keywords: string[];
libraryViewMode?: LibraryViewMode;
}[] = [
{
id: 'setting:appearance',
label: 'Appearance settings',
subtitle: 'Theme / dark style / accent',
href: '/settings/appearance',
icon: 'color-palette-outline',
keywords: ['appearance', 'theme', 'dark mode', 'amoled', 'material you', 'accent', 'color'],
},
{
id: 'setting:audio',
label: 'Audio settings',
subtitle: 'Normalization / ReplayGain',
href: '/settings/audio',
icon: 'volume-high',
keywords: ['audio', 'normalization', 'replaygain', 'loudness', 'gain', 'target lufs'],
},
{
id: 'setting:playback',
label: 'Playback settings',
subtitle: 'Sleep timer / end of track',
href: SETTINGS_SEARCH_ROUTES.playback,
icon: 'play-circle-outline',
keywords: ['playback', 'sleep timer', 'timer', 'end of track', 'pause after track'],
},
{
id: 'setting:library',
label: 'Library settings',
subtitle: 'Folders / artist grouping / albums',
href: '/settings/library',
icon: 'people',
keywords: ['library', 'artist grouping', 'collaborators', 'file tags', 'singles', 'albums'],
},
{
id: 'setting:folders',
label: 'Manage folders',
subtitle: 'Add / rescan / remove folders',
href: '/settings/library',
icon: 'folder-open-outline',
keywords: ['folders', 'scan', 'rescan', 'add folder', 'remove folder', 'local files', 'storage'],
},
{
id: 'setting:services',
label: 'Services settings',
subtitle: 'Remote sources / scrobbling',
href: '/settings/services',
icon: 'server-outline',
keywords: ['services', 'integrations', 'remote sources', 'scrobbling'],
},
{
id: 'setting:lyrics',
label: 'Lyrics settings',
subtitle: 'XLRC / furigana / translations',
href: SETTINGS_SEARCH_ROUTES.lyrics,
icon: 'musical-notes-outline',
keywords: ['lyrics', 'xlrc', 'word timing', 'furigana', 'translations', 'voice labels', 'lrclib', 'xlrcdb'],
},
{
id: 'setting:sources',
label: 'Remote sources',
subtitle: 'Subsonic / Jellyfin servers',
href: '/settings/services',
icon: 'server-outline',
keywords: ['subsonic', 'jellyfin', 'server', 'streaming', 'remote'],
},
{
id: 'setting:lastfm',
label: 'Scrobbling',
subtitle: 'Last.fm / ListenBrainz',
href: '/settings/services',
icon: 'radio-outline',
keywords: ['lastfm', 'last.fm', 'listenbrainz', 'scrobble', 'audioscrobbler'],
},
{
id: 'setting:experimental',
label: 'Experimental settings',
subtitle: 'Desktop Remote / Desktop Sync',
href: '/settings/experimental',
icon: 'flask-outline',
keywords: ['experimental', 'desktop remote', 'desktop sync', 'pairing', 'phone remote'],
},
{
id: 'setting:info',
label: 'Info',
subtitle: 'Version / attribution / license',
href: '/settings/info',
icon: 'information-circle-outline',
keywords: ['info', 'about', 'version', 'license', 'attribution', 'github', 'repo', 'repository', 'discord', 'kofi', 'ko-fi', 'support', 'gpl'],
},
{
id: 'setting:troubleshooting',
label: 'Troubleshooting',
subtitle: 'Rescan / rebuild / clear caches',
href: SETTINGS_SEARCH_ROUTES.troubleshooting,
icon: 'build-outline',
keywords: ['troubleshooting', 'support', 'rescan', 'rebuild index', 'clear lyrics cache', 'clear waveform cache', 'onboarding'],
},
];
const EMPTY_SHORTCUT_IDS = ['nav:library', 'nav:eq', 'setting:sources', 'setting:lastfm'];
interface ResultGroup {
id: string;
label: string;
results: SearchResult[];
}
interface SearchBaseResult {
id: string;
score: number;
}
interface TrackResult extends SearchBaseResult {
kind: 'track';
track: DbTrack;
}
interface AlbumResult extends SearchBaseResult {
kind: 'album';
album: Album;
}
interface ArtistResult extends SearchBaseResult {
kind: 'artist';
artist: Artist;
}
interface SearchPlaylist {
id: number | 'favorites';
name: string;
trackCount: number;
coverHash: string | null;
pinned?: boolean;
remote?: boolean;
}
interface PlaylistResult extends SearchBaseResult {
kind: 'playlist';
playlist: SearchPlaylist;
}
interface NavResult extends SearchBaseResult {
kind: 'nav';
label: string;
subtitle: string;
href: RouteHref;
icon: IconName;
libraryViewMode?: LibraryViewMode;
}
interface SettingResult extends SearchBaseResult {
kind: 'setting';
label: string;
subtitle: string;
href: RouteHref;
icon: IconName;
libraryViewMode?: LibraryViewMode;
}
interface ShowAllResult {
kind: 'show-all';
id: 'show-all-library';
query: string;
total: number;
}
interface ShowTopResult {
kind: 'show-top';
id: 'show-top-results';
}
type SearchResult =
| TrackResult
| AlbumResult
| ArtistResult
| PlaylistResult
| NavResult
| SettingResult
| ShowAllResult
| ShowTopResult;
type SearchListItem =
| { type: 'header'; key: string; label: string }
| { type: 'result'; key: string; result: SearchResult };
function compareScoredResults<T extends { score: number; id: string }>(a: T, b: T): number {
if (a.score !== b.score) return b.score - a.score;
return a.id.localeCompare(b.id);
}
function plural(count: number, noun: string): string {
return `${count} ${count === 1 ? noun : `${noun}s`}`;
}
function scoreOrNull(score: number | null): score is number {
return score !== null && score >= MIN_SCORE_THRESHOLD;
}
function playlistFromRow(playlist: Playlist): SearchPlaylist {
return {
id: playlist.id,
name: playlist.name,
trackCount: playlist.track_count,
coverHash: playlist.auto_cover_hash,
remote: playlist.remote_source_id != null,
};
}
function resultLabel(result: SearchResult): string {
switch (result.kind) {
case 'track':
return result.track.title;
case 'album':
return result.album.album;
case 'artist':
return result.artist.artist;
case 'playlist':
return result.playlist.name;
case 'nav':
case 'setting':
return result.label;
case 'show-all':
return 'Show all library matches';
case 'show-top':
return 'Back to top matches';
}
}
function resultSubtitle(result: SearchResult): string {
switch (result.kind) {
case 'track':
return [result.track.artist, result.track.album, formatDuration(result.track.duration)]
.filter(Boolean)
.join(' / ');
case 'album':
return [`by ${result.album.artist}`, plural(result.album.track_count, 'track')]
.filter(Boolean)
.join(' / ');
case 'artist':
return plural(result.artist.track_count, 'track');
case 'playlist':
return result.playlist.pinned
? plural(result.playlist.trackCount, 'favorite')
: plural(result.playlist.trackCount, 'track');
case 'nav':
case 'setting':
return result.subtitle;
case 'show-all':
return `${plural(result.total, 'match')} for "${result.query}"`;
case 'show-top':
return 'Navigation and settings shortcuts return';
}
}
function resultIcon(result: SearchResult): IconName {
switch (result.kind) {
case 'track':
return 'musical-note';
case 'album':
return 'disc-outline';
case 'artist':
return 'person';
case 'playlist':
return result.playlist.pinned ? 'heart' : 'musical-notes-outline';
case 'nav':
case 'setting':
return result.icon;
case 'show-all':
return 'list';
case 'show-top':
return 'arrow-up';
}
}
function HighlightedLabel({ text, query }: { text: string; query: string }) {
const styles = useStyles();
const normalizedText = text.toLocaleLowerCase();
const normalizedQuery = query.toLocaleLowerCase().trim();
if (!normalizedQuery) {
return <>{text}</>;
}
const substringIndex = normalizedText.indexOf(normalizedQuery);
if (substringIndex >= 0) {
return (
<>
{text.slice(0, substringIndex)}
<Text variant="body" style={styles.highlight}>
{text.slice(substringIndex, substringIndex + normalizedQuery.length)}
</Text>
{text.slice(substringIndex + normalizedQuery.length)}
</>
);
}
const parts: { text: string; highlighted: boolean; key: string }[] = [];
let queryIndex = 0;
let lastPushed = 0;
for (let i = 0; i < text.length && queryIndex < normalizedQuery.length; i += 1) {
if (text[i].toLocaleLowerCase() !== normalizedQuery[queryIndex]) continue;
if (i > lastPushed) {
parts.push({ text: text.slice(lastPushed, i), highlighted: false, key: `plain-${i}` });
}
parts.push({ text: text[i], highlighted: true, key: `mark-${i}` });
queryIndex += 1;
lastPushed = i + 1;
}
if (lastPushed < text.length) {
parts.push({ text: text.slice(lastPushed), highlighted: false, key: 'tail' });
}
return (
<>
{parts.map((part) =>
part.highlighted ? (
<Text key={part.key} variant="body" style={styles.highlight}>
{part.text}
</Text>
) : (
part.text
)
)}
</>
);
}
function ResultThumb({ result }: { result: SearchResult }) {
const styles = useStyles();
const colors = useColors();
const uri =
result.kind === 'track'
? trackArtworkThumbSource(result.track)
: result.kind === 'album'
? albumArtworkSource(result.album)
: result.kind === 'artist' && result.artist.artwork_hash
? artworkUri(result.artist.artwork_hash)
: result.kind === 'playlist' && result.playlist.coverHash
? artworkUri(result.playlist.coverHash)
: null;
const icon = resultIcon(result);
const round = result.kind === 'artist';
const accented = result.kind === 'playlist' && result.playlist.pinned;
return (
<View style={[styles.thumb, round && styles.thumbRound]}>
{uri ? (
<Image source={{ uri }} style={styles.thumbImage} contentFit="cover" cachePolicy="memory-disk" />
) : result.kind === 'track' ? (
<AstraLogo size={18} />
) : (
<Ionicons name={icon} size={20} color={accented ? colors.accent : colors.textTertiary} />
)}
</View>
);
}
function ResultRow({
result,
query,
active,
queued,
onPress,
onQueueTrack,
}: {
result: SearchResult;
query: string;
active: boolean;
queued: boolean;
onPress: () => void;
onQueueTrack: (track: DbTrack) => void;
}) {
const styles = useStyles();
const ripple = useRipple();
const colors = useColors();
const isTrack = result.kind === 'track';
const isShowMode = result.kind === 'show-all' || result.kind === 'show-top';
const queueTrack = (event: GestureResponderEvent) => {
event.stopPropagation();
if (result.kind !== 'track') return;
onQueueTrack(result.track);
};
return (
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[styles.resultRow, active && styles.resultRowActive, isShowMode && styles.showModeRow]}
onPress={onPress}
accessibilityRole="button"
>
<ResultThumb result={result} />
<View style={styles.resultText}>
<Text variant="body" numberOfLines={1} style={[styles.resultLabel, active && styles.activeLabel]}>
<HighlightedLabel text={resultLabel(result)} query={query} />
</Text>
<Text variant="label" numberOfLines={1} style={styles.resultSubtitle}>
{resultSubtitle(result)}
</Text>
</View>
{isTrack ? (
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.queueButton}
onPress={queueTrack}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Play ${result.track.title} next`}
>
<Ionicons name={queued ? 'checkmark' : 'add'} size={18} color={colors.accentTextStrong} />
</Pressable>
) : result.kind === 'playlist' && result.playlist.remote ? (
<Ionicons name="cloud" size={14} color={colors.accent} />
) : (
<Ionicons name="chevron-forward" size={16} color={colors.textTertiary} />
)}
</Pressable>
);
}
function QuickSearchPanel({
initialQuery,
onClose,
}: {
initialQuery: string;
onClose: () => void;
}) {
const styles = useStyles();
const ripple = useRipple();
const colors = useColors();
const returnToTabs = useReturnToTabs();
const insets = useSafeAreaInsets();
const { height } = useWindowDimensions();
const inputRef = useRef<TextInput | null>(null);
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);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
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);
}, []);
useEffect(
() => () => {
queuedFeedbackTimers.current.forEach(clearTimeout);
queuedFeedbackTimers.current.clear();
},
[]
);
const updateQuery = (value: string) => {
setQuery(value);
setShowAllLibrary(false);
};
const navResults = useMemo(() => {
if (!hasQuery || showAllLibrary) return [] as NavResult[];
const results: NavResult[] = [];
for (const entry of NAV_ENTRIES) {
const score = multiFieldScore(trimmedQuery, [
{ value: entry.label, weight: 1.5 },
{ value: entry.keywords.join(' '), weight: 1 },
]);
if (!scoreOrNull(score)) continue;
results.push({
kind: 'nav' as const,
id: entry.id,
score,
label: entry.label,
subtitle: 'Navigate',
href: entry.href,
icon: entry.icon,
libraryViewMode: entry.libraryViewMode,
});
}
return results.sort(compareScoredResults).slice(0, NAV_RESULT_LIMIT);
}, [hasQuery, showAllLibrary, trimmedQuery]);
const settingResults = useMemo(() => {
if (!hasQuery || showAllLibrary) return [] as SettingResult[];
const results: SettingResult[] = [];
for (const entry of SETTING_ENTRIES) {
const score = multiFieldScore(trimmedQuery, [
{ value: entry.label, weight: 1.4 },
{ value: entry.subtitle, weight: 1.1 },
{ value: entry.keywords.join(' '), weight: 1 },
]);
if (!scoreOrNull(score)) continue;
results.push({
kind: 'setting' as const,
id: entry.id,
score,
label: entry.label,
subtitle: entry.subtitle,
href: entry.href,
icon: entry.icon,
libraryViewMode: entry.libraryViewMode,
});
}
return results.sort(compareScoredResults).slice(0, SETTINGS_RESULT_LIMIT);
}, [hasQuery, showAllLibrary, trimmedQuery]);
const allTrackResults = useMemo(() => {
if (!hasQuery) return [] as TrackResult[];
return tracks
.map((track) => {
const score = multiFieldScore(trimmedQuery, [
{ value: track.title, weight: 1.5 },
{ value: track.artist, weight: 1.2 },
{ value: track.album_artist, weight: 1 },
{ value: track.album, weight: 1 },
{ value: track.file_name, weight: 0.8 },
{ value: track.genre, weight: 0.5 },
]);
if (!scoreOrNull(score)) return null;
return {
kind: 'track' as const,
id: `track:${track.id}`,
score,
track,
};
})
.filter((result): result is TrackResult => result !== null)
.sort(compareScoredResults);
}, [hasQuery, tracks, trimmedQuery]);
const allAlbumResults = useMemo(() => {
if (!hasQuery) return [] as AlbumResult[];
return albums
.map((album) => {
const score = multiFieldScore(trimmedQuery, [
{ value: album.album, weight: 1.4 },
{ value: album.artist, weight: 1.1 },
{ value: album.year == null ? null : String(album.year), weight: 0.4 },
]);
if (!scoreOrNull(score)) return null;
return {
kind: 'album' as const,
id: `album:${album.identity_key}`,
score,
album,
};
})
.filter((result): result is AlbumResult => result !== null)
.sort(compareScoredResults);
}, [albums, hasQuery, trimmedQuery]);
const allArtistResults = useMemo(() => {
if (!hasQuery) return [] as ArtistResult[];
return artists
.map((artist) => {
const score = multiFieldScore(trimmedQuery, [{ value: artist.artist, weight: 1.5 }]);
if (!scoreOrNull(score)) return null;
return {
kind: 'artist' as const,
id: `artist:${artist.artist}`,
score,
artist,
};
})
.filter((result): result is ArtistResult => result !== null)
.sort(compareScoredResults);
}, [artists, hasQuery, trimmedQuery]);
const allPlaylistResults = useMemo(() => {
if (!hasQuery) return [] as PlaylistResult[];
const candidates: SearchPlaylist[] = [
{
id: 'favorites',
name: 'Favorites',
trackCount: favoriteTracks.length,
coverHash: null,
pinned: true,
},
...playlists.map(playlistFromRow),
];
return candidates
.map((playlist) => {
const score = multiFieldScore(trimmedQuery, [
{ value: playlist.name, weight: 1.5 },
]);
if (!scoreOrNull(score)) return null;
return {
kind: 'playlist' as const,
id: `playlist:${playlist.id}`,
score,
playlist,
};
})
.filter((result): result is PlaylistResult => result !== null)
.sort(compareScoredResults);
}, [favoriteTracks.length, hasQuery, playlists, trimmedQuery]);
const trackResults = showAllLibrary
? allTrackResults.slice(0, ALL_TRACK_RESULT_LIMIT)
: allTrackResults.slice(0, TRACK_RESULT_LIMIT);
const albumResults = showAllLibrary
? allAlbumResults.slice(0, ALL_ENTITY_RESULT_LIMIT)
: allAlbumResults.slice(0, ALBUM_RESULT_LIMIT);
const artistResults = showAllLibrary
? allArtistResults.slice(0, ALL_ENTITY_RESULT_LIMIT)
: allArtistResults.slice(0, ARTIST_RESULT_LIMIT);
const playlistResults = showAllLibrary
? allPlaylistResults.slice(0, ALL_ENTITY_RESULT_LIMIT)
: allPlaylistResults.slice(0, PLAYLIST_RESULT_LIMIT);
const recentTrackResults = useMemo(() => {
if (hasQuery) return [] as TrackResult[];
return recentlyPlayedTracks.slice(0, EMPTY_RECENT_TRACKS_LIMIT).map((track) => ({
kind: 'track' as const,
id: `recent:${track.id}`,
score: 0,
track,
}));
}, [hasQuery, recentlyPlayedTracks]);
const quickShortcutResults = useMemo(() => {
if (hasQuery) return [] as (NavResult | SettingResult)[];
const results: (NavResult | SettingResult)[] = [];
for (const id of EMPTY_SHORTCUT_IDS) {
const nav = NAV_ENTRIES.find((entry) => entry.id === id);
if (nav) {
results.push({
kind: 'nav' as const,
id: nav.id,
score: 0,
label: nav.label,
subtitle: 'Navigate',
href: nav.href,
icon: nav.icon,
libraryViewMode: nav.libraryViewMode,
});
continue;
}
const setting = SETTING_ENTRIES.find((entry) => entry.id === id);
if (!setting) continue;
results.push({
kind: 'setting' as const,
id: setting.id,
score: 0,
label: setting.label,
subtitle: setting.subtitle,
href: setting.href,
icon: setting.icon,
libraryViewMode: setting.libraryViewMode,
});
}
return results;
}, [hasQuery]);
const libraryMatchTotal =
allTrackResults.length + allAlbumResults.length + allArtistResults.length + allPlaylistResults.length;
const visibleLibraryTotal =
trackResults.length + albumResults.length + artistResults.length + playlistResults.length;
const showAllResult = useMemo<ShowAllResult | null>(
() =>
hasQuery && !showAllLibrary && libraryMatchTotal > visibleLibraryTotal
? {
kind: 'show-all',
id: 'show-all-library',
query: trimmedQuery,
total: libraryMatchTotal,
}
: null,
[hasQuery, libraryMatchTotal, showAllLibrary, trimmedQuery, visibleLibraryTotal]
);
const resultGroups = useMemo<ResultGroup[]>(() => {
if (!hasQuery) {
const groups: ResultGroup[] = [];
if (recentTrackResults.length > 0) {
groups.push({ id: 'recent', label: 'Recently Played', results: recentTrackResults });
}
groups.push({ id: 'shortcuts', label: 'Shortcuts', results: quickShortcutResults });
return groups;
}
if (showAllLibrary) {
const groups: ResultGroup[] = [];
if (trackResults.length > 0) groups.push({ id: 'tracks', label: 'Tracks', results: trackResults });
if (albumResults.length > 0) groups.push({ id: 'albums', label: 'Albums', results: albumResults });
if (artistResults.length > 0) groups.push({ id: 'artists', label: 'Artists', results: artistResults });
if (playlistResults.length > 0) {
groups.push({ id: 'playlists', label: 'Playlists', results: playlistResults });
}
return groups;
}
const pinned: ResultGroup[] = [];
if (navResults.length > 0) {
pinned.push({ id: 'nav', label: 'Go To', results: navResults });
}
const scoredGroups: { group: ResultGroup; topScore: number }[] = [];
if (trackResults.length > 0) {
scoredGroups.push({
group: { id: 'tracks', label: 'Tracks', results: trackResults },
topScore: trackResults[0].score,
});
}
if (albumResults.length > 0) {
scoredGroups.push({
group: { id: 'albums', label: 'Albums', results: albumResults },
topScore: albumResults[0].score,
});
}
if (artistResults.length > 0) {
scoredGroups.push({
group: { id: 'artists', label: 'Artists', results: artistResults },
topScore: artistResults[0].score,
});
}
if (playlistResults.length > 0) {
scoredGroups.push({
group: { id: 'playlists', label: 'Playlists', results: playlistResults },
topScore: playlistResults[0].score,
});
}
if (settingResults.length > 0) {
scoredGroups.push({
group: { id: 'settings', label: 'Settings', results: settingResults },
topScore: settingResults[0].score,
});
}
scoredGroups.sort((a, b) => b.topScore - a.topScore);
return [...pinned, ...scoredGroups.map((entry) => entry.group)];
}, [
albumResults,
artistResults,
hasQuery,
navResults,
playlistResults,
quickShortcutResults,
recentTrackResults,
settingResults,
showAllLibrary,
trackResults,
]);
const listItems = useMemo<SearchListItem[]>(() => {
const items: SearchListItem[] = [];
for (const group of resultGroups) {
items.push({ type: 'header', key: `header:${group.id}`, label: group.label });
for (const result of group.results) {
items.push({ type: 'result', key: result.id, result });
}
}
if (showAllResult) {
items.push({ type: 'result', key: showAllResult.id, result: showAllResult });
} else if (showAllLibrary) {
items.push({ type: 'result', key: 'show-top-results', result: { kind: 'show-top', id: 'show-top-results' } });
}
return items;
}, [resultGroups, showAllLibrary, showAllResult]);
const firstActionableResult = listItems.find((item) => item.type === 'result')?.result ?? null;
const close = () => {
Keyboard.dismiss();
onClose();
};
// The quick-search overlay renders above the navigator, so it can fire while a
// root-stack sibling of `(tabs)` is focused — a bare push there mints a second
// copy of the whole tab tree and back-navigation dead-ends on the stale one.
const navigateTo = (href: RouteHref) => {
returnToTabs(href as never, 'push');
};
const executeResult = (result: SearchResult) => {
if (result.kind === 'show-all') {
setShowAllLibrary(true);
return;
}
if (result.kind === 'show-top') {
setShowAllLibrary(false);
return;
}
close();
if (result.kind === 'track') {
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;
}
if (result.kind === 'album') {
returnToTabs(
{ pathname: '/library/album/[key]', params: { key: result.album.identity_key } },
'push'
);
return;
}
if (result.kind === 'artist') {
returnToTabs(
{ pathname: '/library/artist/[name]', params: { name: result.artist.artist } },
'push'
);
return;
}
if (result.kind === 'playlist') {
returnToTabs(
{ pathname: '/library/playlist/[id]', params: { id: result.playlist.id } },
'push'
);
return;
}
if (result.libraryViewMode) {
setViewMode(result.libraryViewMode);
}
navigateTo(result.href);
};
const queueTrack = (track: DbTrack) => {
playHaptic('confirm');
const existingTimer = queuedFeedbackTimers.current.get(track.path);
if (existingTimer) clearTimeout(existingTimer);
setQueuedTrackPaths((current) => {
if (current.has(track.path)) return current;
const next = new Set(current);
next.add(track.path);
return next;
});
const feedbackTimer = setTimeout(() => {
queuedFeedbackTimers.current.delete(track.path);
setQueuedTrackPaths((current) => {
if (!current.has(track.path)) return current;
const next = new Set(current);
next.delete(track.path);
return next;
});
}, QUEUED_FEEDBACK_MS);
queuedFeedbackTimers.current.set(track.path, feedbackTimer);
void enqueueTop(dbTrackToTrack(track));
};
const submitFirstResult = () => {
if (firstActionableResult) executeResult(firstActionableResult);
};
const panelMaxHeight = Math.max(320, height - insets.top - insets.bottom - spacing.xxl * 2);
const emptyText = hasQuery
? showAllLibrary
? `No library results for "${trimmedQuery}"`
: `No results for "${trimmedQuery}"`
: 'Type to search tracks, albums, artists, playlists, and settings';
return (
<View
style={[
styles.panel,
{
marginTop: insets.top + spacing.lg,
marginBottom: insets.bottom + spacing.lg,
height: panelMaxHeight,
},
]}
>
<View style={styles.inputWrap}>
<Ionicons name="search" size={20} color={colors.textTertiary} />
<TextInput
ref={inputRef}
value={query}
onChangeText={updateQuery}
onSubmitEditing={submitFirstResult}
placeholder="Search tracks, albums, artists, playlists, settings"
placeholderTextColor={colors.textTertiary}
selectionColor={colors.accent}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
style={styles.input}
/>
{query.length > 0 ? (
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
onPress={() => updateQuery('')}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Clear search"
>
<Ionicons name="close-circle" size={18} color={colors.textTertiary} />
</Pressable>
) : null}
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={close} hitSlop={8} accessibilityRole="button" accessibilityLabel="Close search">
<Ionicons name="close" size={20} color={colors.textSecondary} />
</Pressable>
</View>
{showAllLibrary ? (
<View style={styles.modeBanner}>
<Text variant="caption" numberOfLines={1}>
{`All library matches for "${trimmedQuery}"`}
</Text>
</View>
) : null}
{listItems.length === 0 ? (
<View style={styles.empty}>
<Text variant="caption" style={styles.emptyText}>
{emptyText}
</Text>
</View>
) : (
<FlashList
style={styles.resultsList}
data={listItems}
keyExtractor={(item) => item.key}
getItemType={(item) => item.type}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.resultsContent}
renderItem={({ item }) =>
item.type === 'header' ? (
<Text variant="mono" style={styles.groupLabel}>
{item.label.toUpperCase()}
</Text>
) : (
<ResultRow
result={item.result}
query={trimmedQuery}
active={item.result.kind === 'track' && item.result.track.path === currentPath}
queued={item.result.kind === 'track' && queuedTrackPaths.has(item.result.track.path)}
onPress={() => executeResult(item.result)}
onQueueTrack={queueTrack}
/>
)
}
/>
)}
</View>
);
}
export function QuickSearchOverlay() {
const styles = useStyles();
const ripple = useRipple();
const isOpen = useSearchStore((s) => s.isQuickSearchOpen);
const initialQuery = useSearchStore((s) => s.initialQuery);
const openVersion = useSearchStore((s) => s.openVersion);
const closeQuickSearch = useSearchStore((s) => s.closeQuickSearch);
const close = () => {
Keyboard.dismiss();
closeQuickSearch();
};
return (
<Modal
visible={isOpen}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={close}
>
<View style={styles.modalRoot}>
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={StyleSheet.absoluteFill} onPress={close} accessibilityRole="button" />
{isOpen ? (
<QuickSearchPanel key={openVersion} initialQuery={initialQuery} onClose={close} />
) : null}
</View>
</Modal>
);
}
const useStyles = createThemedStyles((colors) => ({
modalRoot: {
flex: 1,
alignItems: 'center',
backgroundColor: colors.backdrop,
paddingHorizontal: spacing.md,
},
panel: {
width: '100%',
maxWidth: 720,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgSecondary,
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.45,
shadowRadius: 28,
shadowOffset: { width: 0, height: 18 },
elevation: 20,
},
resultsList: {
flex: 1,
},
inputWrap: {
minHeight: 58,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
},
input: {
flex: 1,
minWidth: 0,
fontFamily: fonts.sans.regular,
fontSize: fontSize.base,
color: colors.textPrimary,
paddingVertical: spacing.md,
},
modeBanner: {
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
backgroundColor: colors.glassBg,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
resultsContent: {
paddingHorizontal: spacing.sm,
paddingTop: spacing.sm,
paddingBottom: spacing.md,
},
groupLabel: {
color: colors.textTertiary,
fontSize: 10,
letterSpacing: 0,
paddingHorizontal: spacing.sm,
paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
resultRow: {
minHeight: 58,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
borderRadius: radius.md,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
},
resultRowActive: {
backgroundColor: rgbaFromHex(colors.accent, 0.12),
},
showModeRow: {
marginTop: spacing.sm,
borderTopColor: colors.glassBorder,
borderTopWidth: StyleSheet.hairlineWidth,
},
thumb: {
width: 42,
height: 42,
flexShrink: 0,
borderRadius: radius.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgTertiary,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
thumbRound: {
borderRadius: radius.pill,
},
thumbImage: {
width: '100%',
height: '100%',
},
resultText: {
flex: 1,
minWidth: 0,
gap: 2,
},
resultLabel: {
fontSize: 15,
},
activeLabel: {
color: colors.accentTextStrong,
},
resultSubtitle: {
color: colors.textSecondary,
},
highlight: {
color: colors.accentTextStrong,
backgroundColor: rgbaFromHex(colors.accent, 0.22),
borderRadius: 2,
},
queueButton: {
width: 32,
height: 32,
flexShrink: 0,
borderRadius: radius.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassHighlight,
alignItems: 'center',
justifyContent: 'center',
},
empty: {
flex: 1,
minHeight: 160,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
},
emptyText: {
textAlign: 'center',
},
}));