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(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.slice(substringIndex, substringIndex + normalizedQuery.length)} {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 ? ( {part.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 ( {uri ? ( ) : result.kind === 'track' ? ( ) : ( )} ); } 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 ( {resultSubtitle(result)} {isTrack ? ( ) : result.kind === 'playlist' && result.playlist.remote ? ( ) : ( )} ); } 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(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([]); const [albums, setAlbums] = useState([]); const [artists, setArtists] = useState([]); const [queuedTrackPaths, setQueuedTrackPaths] = useState>(() => new Set()); const queuedFeedbackTimers = useRef(new Map>()); const trimmedQuery = query.trim(); const hasQuery = trimmedQuery.length > 0; useEffect(() => { if (!hasQuery) return; let cancelled = false; const timer = setTimeout(() => { void AstraLibraryData.searchLibrary( 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( () => hasQuery && !showAllLibrary && libraryMatchTotal > visibleLibraryTotal ? { kind: 'show-all', id: 'show-all-library', query: trimmedQuery, total: libraryMatchTotal, } : null, [hasQuery, libraryMatchTotal, showAllLibrary, trimmedQuery, visibleLibraryTotal] ); const resultGroups = useMemo(() => { 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(() => { 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 ( {query.length > 0 ? ( updateQuery('')} hitSlop={8} accessibilityRole="button" accessibilityLabel="Clear search" > ) : null} {showAllLibrary ? ( {`All library matches for "${trimmedQuery}"`} ) : null} {listItems.length === 0 ? ( {emptyText} ) : ( item.key} getItemType={(item) => item.type} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} contentContainerStyle={styles.resultsContent} renderItem={({ item }) => item.type === 'header' ? ( {item.label.toUpperCase()} ) : ( executeResult(item.result)} onQueueTrack={queueTrack} /> ) } /> )} ); } 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 ( {isOpen ? ( ) : null} ); } 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', }, }));