mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-18 03:36:09 +02:00
1315 lines
38 KiB
TypeScript
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',
|
|
},
|
|
}));
|