Files
Psychotoxical-psysonic/src/components/LiveSearch.tsx
T
Psychotoxical 7c724a642f chore(eslint): add ESLint toolchain and clean src to strict 0/0 (#1165)
* chore(eslint): add eslint toolchain and configs

* fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …)

* chore(eslint): clear unused vars in config, contexts, app and test

* chore(eslint): clear unused vars in api and music-network

* chore(eslint): clear unused vars in utils

* chore(eslint): clear unused vars in store

* chore(eslint): clear unused vars in cover and hooks

* chore(eslint): clear unused vars in components

* chore(eslint): clear unused vars in pages

* chore(eslint): remove explicit any in src

* chore(eslint): align react-hooks exhaustive-deps

* chore(eslint): zero gradual config on src

* chore(eslint): strict hook rules in store and utils

* chore(eslint): strict hook rules in hooks and cover

* chore(eslint): strict hook rules in components

* chore(eslint): strict hook rules in pages and contexts

* chore(eslint): document scripts ignore in eslint config

* chore(eslint): add lint script and zero strict findings

* chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons)

* chore(eslint): tighten two set-state disable comments (review round 2 LOW)

* chore(nix): sync npmDepsHash with package-lock.json

* docs(changelog): add Under the Hood entry for ESLint setup (PR #1165)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-24 01:33:34 +02:00

831 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { subscribeLibrarySyncIdle, subscribeLibrarySyncProgress } from '../api/library';
import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import {
LIVE_SEARCH_DEBOUNCE_NETWORK_MS,
LIVE_SEARCH_DEBOUNCE_RACE_MS,
EMPTY_SEARCH_RESULTS,
liveSearchQueryRejected,
mergeLiveSearchResults,
runLocalLiveSearch,
runNetworkLiveSearch,
} from '../utils/library/liveSearchLocal';
import { raceLiveSearch } from '../utils/library/searchRace';
import { libraryIsReady } from '../utils/library/libraryReady';
import {
emitLiveSearchDebug,
searchHitCounts,
searchResultSamples,
} from '../utils/library/liveSearchDebug';
import {
logLibrarySearch,
} from '../utils/library/libraryDevLog';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useTranslation } from 'react-i18next';
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
import type { SubsonicSong } from '../api/subsonicTypes';
import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
import { CoverArtImage } from '../cover/CoverArtImage';
import { COVER_DENSE_SEARCH_CSS_PX } from '../cover/layoutSizes';
import { albumCoverRef } from '../cover/ref';
import { showToast } from '../utils/ui/toast';
import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults';
import {
LiveSearchScopeBadge,
LiveSearchScopeGhostBadge,
} from './search/liveSearchScopeUi';
import {
createLiveSearchScopeBackspaceState,
handleLiveSearchScopeBackspace,
handleLiveSearchScopeUndo,
isLiveSearchDropdownBlocked,
liveSearchScopePlaceholderKey,
noteLiveSearchScopeQueryInput,
resetLiveSearchScopeBackspaceState,
resolveLiveSearchScopeGhost,
} from './search/liveSearchScope';
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
import { resolveIndexKey } from '../utils/server/serverIndexKey';
type LiveSearchSource = 'local' | 'network';
function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
return (
<AlbumCoverArtImage
albumId={albumId}
coverArt={coverArt}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
className="search-result-thumb"
alt=""
ensurePriority="high"
/>
);
}
function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> }) {
// Search results carry the per-track `mf-…` coverArt id, which the cover
// pipeline fails to resolve and the thumbnail goes blank. The album-scoped
// `al-<albumId>_0` id is what actually loads (verified in the RC1 blank-thumb
// investigation), and a song's search thumbnail is its album cover anyway —
// so fetch the album cover from the albumId. Falls back to a music glyph when
// there is no album to key on.
const albumId = song.albumId?.trim();
const coverRef = React.useMemo(
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`) : undefined),
[albumId],
);
if (!coverRef) return <div className="search-result-icon"><Music size={14} /></div>;
return (
<CoverArtImage
coverRef={coverRef}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
className="search-result-thumb"
alt=""
ensurePriority="high"
/>
);
}
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
const [failed, setFailed] = useState(false);
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
return (
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
className="search-result-thumb"
alt=""
loading="eager"
ensurePriority="high"
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
onError={() => setFailed(true)}
/>
);
}
export default function LiveSearch() {
const { t } = useTranslation();
const query = useLiveSearchScopeStore(s => s.query);
const setQuery = useLiveSearchScopeStore(s => s.setQuery);
const scope = useLiveSearchScopeStore(s => s.scope);
const setScope = useLiveSearchScopeStore(s => s.setScope);
const clearScope = useLiveSearchScopeStore(s => s.clearScope);
const undoLiveSearch = useLiveSearchScopeStore(s => s.undo);
const scopeBackspaceRef = useRef(createLiveSearchScopeBackspaceState());
const location = useLocation();
const ghostScope = resolveLiveSearchScopeGhost(location.pathname, scope);
const [results, setResults] = useState<SearchResults | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [isFocused, setIsFocused] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchSource, setSearchSource] = useState<LiveSearchSource | null>(null);
const localReadyRef = useRef(false);
const liveSearchGenRef = useRef(0);
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const enqueue = usePlayerStore(state => state.enqueue);
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
const ctxItemId = usePlayerStore(state => (state.contextMenu.item as { id?: string } | null)?.id);
const ctxType = usePlayerStore(state => state.contextMenu.type);
const ref = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const collapsedRef = useRef(false);
const compactHeaderControlsRef = useRef(false);
const serverId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const refreshLocalReady = useCallback(async () => {
if (!serverId || !indexEnabled) {
localReadyRef.current = false;
return;
}
localReadyRef.current = await libraryIsReady(serverId);
}, [serverId, indexEnabled]);
useEffect(() => {
void refreshLocalReady();
}, [refreshLocalReady, musicLibraryFilterVersion]);
useEffect(() => {
resetLiveSearchScopeBackspaceState(scopeBackspaceRef.current);
}, [scope]);
useEffect(() => {
noteLiveSearchScopeQueryInput(scopeBackspaceRef.current, query);
}, [query]);
useEffect(() => {
if (!indexEnabled || !serverId) return;
let unlistenProgress: (() => void) | undefined;
let unlistenIdle: (() => void) | undefined;
const indexKey = resolveIndexKey(serverId);
void subscribeLibrarySyncIdle(payload => {
if (payload.serverId === indexKey) void refreshLocalReady();
}).then(fn => {
unlistenIdle = fn;
});
void subscribeLibrarySyncProgress(p => {
if (p.serverId === indexKey && p.kind === 'phase_changed') void refreshLocalReady();
}).then(fn => {
unlistenProgress = fn;
});
return () => {
unlistenIdle?.();
unlistenProgress?.();
};
}, [indexEnabled, serverId, refreshLocalReady]);
const closeSearch = useCallback(() => {
setOpen(false);
setQuery('');
setSearchSource(null);
}, [setQuery]);
const handleQueryChange = useCallback((value: string) => {
setQuery(value, { recordUndo: true });
if (!value) {
setResults(null);
setOpen(false);
setSearchSource(null);
}
}, [setQuery]);
/** Leave live search for a full-page route — cancel in-flight queries and reset overlay state. */
const leaveLiveSearchFor = useCallback((path: string) => {
liveSearchGenRef.current += 1;
setOpen(false);
setQuery('');
setResults(null);
setSearchSource(null);
setActiveIndex(-1);
setLoading(false);
setIsFocused(false);
inputRef.current?.blur();
navigate(path);
}, [navigate, setQuery]);
const share = useShareSearch(query, closeSearch);
useEffect(() => {
if (isLiveSearchDropdownBlocked(scope)) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setResults(null);
setOpen(false);
setSearchSource(null);
setLoading(false);
return;
}
if (share.shareMatch) {
setResults(null);
setLoading(false);
setSearchSource(null);
setOpen(true);
setActiveIndex(-1);
return;
}
const q = query.trim();
if (!q) {
setResults(null);
setOpen(false);
setSearchSource(null);
setLoading(false);
return;
}
setSearchSource(null);
setActiveIndex(-1);
const abort = new AbortController();
const debounceMs = indexEnabled ? LIVE_SEARCH_DEBOUNCE_RACE_MS : LIVE_SEARCH_DEBOUNCE_NETWORK_MS;
const timer = window.setTimeout(() => {
void (async () => {
const gen = liveSearchGenRef.current;
const isStale = () =>
gen !== liveSearchGenRef.current || abort.signal.aborted;
if (isStale()) return;
setLoading(true);
const searchT0 = performance.now();
try {
if (liveSearchQueryRejected(q)) {
if (!isStale()) {
setResults(EMPTY_SEARCH_RESULTS);
setSearchSource(null);
setOpen(true);
}
return;
}
const raceCtx = { epoch: gen, isStale, suppressLog: indexEnabled && !!serverId };
if (indexEnabled && serverId) {
const winner = await raceLiveSearch(
() => runLocalLiveSearch(serverId, q, raceCtx),
() => runNetworkLiveSearch(q, abort.signal),
isStale,
meta => {
emitLiveSearchDebug('race_settled', {
query: q,
winner: meta.winner,
localMs: meta.localMs,
networkMs: meta.networkMs,
localHits: meta.localHits,
networkHits: meta.networkHits,
});
if (isStale()) return;
if (meta.localResult && meta.networkResult) {
const primary =
meta.winner === 'local' ? meta.localResult : meta.networkResult;
const supplement =
meta.winner === 'local' ? meta.networkResult : meta.localResult;
const merged = mergeLiveSearchResults(primary, supplement);
const primaryHits = searchHitCounts(primary);
const mergedHits = searchHitCounts(merged);
if (mergedHits !== primaryHits) {
setResults(merged);
setSearchSource(meta.winner);
emitLiveSearchDebug('race_merged', {
query: q,
winner: meta.winner,
before: primaryHits,
after: mergedHits,
samples: searchResultSamples(merged),
});
}
}
},
);
if (isStale()) return;
if (winner) {
setResults(winner.result);
setSearchSource(winner.source);
setOpen(true);
const samples = searchResultSamples(winner.result);
emitLiveSearchDebug('race_winner', {
query: q,
winner: winner.source,
raceMs: winner.durationMs,
hits: searchHitCounts(winner.result),
samples,
path: 'search_race',
localReady: localReadyRef.current,
});
logLibrarySearch({
at: new Date().toISOString(),
query: q,
path: 'search_race',
surface: 'live_search',
durationMs: Math.round(performance.now() - searchT0),
debounceMs,
indexEnabled,
localReadyCached: localReadyRef.current,
raceWinner: winner.source,
raceWinnerMs: winner.durationMs,
counts: {
artists: winner.result.artists.length,
albums: winner.result.albums.length,
songs: winner.result.songs.length,
},
});
return;
}
showToast(t('search.liveSearchFailed'), 3200, 'error');
} else if (serverId) {
const network = await runNetworkLiveSearch(q, abort.signal);
if (isStale()) return;
if (network) {
setResults(network);
setSearchSource('network');
setOpen(true);
logLibrarySearch({
at: new Date().toISOString(),
query: q,
path: 'search3',
surface: 'live_search',
source: 'network',
durationMs: Math.round(performance.now() - searchT0),
debounceMs,
indexEnabled,
counts: {
artists: network.artists.length,
albums: network.albums.length,
songs: network.songs.length,
},
});
}
}
} catch (err) {
if (isStale()) return;
const name = err instanceof Error ? err.name : '';
if (name === 'CanceledError' || name === 'AbortError') return;
showToast(t('search.liveSearchFailed'), 3200, 'error');
} finally {
if (!isStale()) setLoading(false);
}
})();
}, debounceMs);
return () => {
window.clearTimeout(timer);
abort.abort();
liveSearchGenRef.current += 1;
};
}, [query, scope, share.shareMatch, serverId, indexEnabled, musicLibraryFilterVersion, t]);
const isSearchActive = isFocused || open || query.trim().length > 0 || scope != null;
useEffect(() => {
const root = ref.current;
if (!root) return;
const header = root.closest('.content-header') as HTMLElement | null;
if (!header) return;
const overlayActive = isCollapsed && isSearchActive;
if (overlayActive) {
header.dataset.liveSearchOverlay = 'true';
} else {
delete header.dataset.liveSearchOverlay;
}
return () => {
delete header.dataset.liveSearchOverlay;
};
}, [isCollapsed, isSearchActive]);
useEffect(() => {
const root = ref.current;
if (!root) return;
const header = root.closest('.content-header') as HTMLElement | null;
if (!header) return;
const spacer = header.querySelector('.spacer') as HTMLElement | null;
if (!spacer) return;
const MIN_EXPANDED_WIDTH = 260;
const SPACER_RESERVE = 24;
const HYSTERESIS_PX = 20;
// Live/Orbit compact-mode is intentionally stickier than search collapse,
// otherwise both systems can feed each other and oscillate.
const HEADER_CONTROLS_COMPACT_ON_SPACER = 36;
const HEADER_CONTROLS_COMPACT_OFF_SPACER = 108;
const SWITCH_COOLDOWN_MS = 180;
const collapseThreshold = MIN_EXPANDED_WIDTH + SPACER_RESERVE;
const expandThreshold = collapseThreshold + HYSTERESIS_PX;
let lastSwitchAt = 0;
let cooldownTimer: number | null = null;
const updateCollapsed = () => {
const searchWidth = root.getBoundingClientRect().width;
const spacerWidth = spacer.getBoundingClientRect().width;
const budget = searchWidth + spacerWidth;
const headerOverflowing = header.scrollWidth - header.clientWidth > 1;
let nextCollapsed = collapsedRef.current
? budget < expandThreshold
: budget < collapseThreshold;
// Priority rule: if we are already compacting Live/Orbit labels, search
// must stay collapsed until compact mode can be released.
if (compactHeaderControlsRef.current) {
nextCollapsed = true;
}
if (nextCollapsed !== collapsedRef.current) {
const now = performance.now();
const remaining = SWITCH_COOLDOWN_MS - (now - lastSwitchAt);
if (remaining > 0) {
if (cooldownTimer == null) {
cooldownTimer = window.setTimeout(() => {
cooldownTimer = null;
updateCollapsed();
}, remaining);
}
return;
}
lastSwitchAt = now;
collapsedRef.current = nextCollapsed;
setIsCollapsed(nextCollapsed);
}
const nextCompactControls = nextCollapsed
? (
compactHeaderControlsRef.current
// Stay compact until we clearly have room and no overflow.
? (headerOverflowing || spacerWidth < HEADER_CONTROLS_COMPACT_OFF_SPACER)
// Enter compact only when both tight spacer and real overflow exist.
: (headerOverflowing && spacerWidth < HEADER_CONTROLS_COMPACT_ON_SPACER)
)
: false;
if (nextCompactControls !== compactHeaderControlsRef.current) {
compactHeaderControlsRef.current = nextCompactControls;
if (nextCompactControls) {
header.dataset.liveHeaderCompact = 'true';
} else {
delete header.dataset.liveHeaderCompact;
}
}
};
updateCollapsed();
const ro = new ResizeObserver(updateCollapsed);
ro.observe(header);
ro.observe(spacer);
ro.observe(root);
window.addEventListener('resize', updateCollapsed);
return () => {
ro.disconnect();
window.removeEventListener('resize', updateCollapsed);
delete header.dataset.liveHeaderCompact;
if (cooldownTimer != null) {
window.clearTimeout(cooldownTimer);
}
};
}, []);
// Close on click outside — but stay open while a song context menu is up.
// The CM renders a fullscreen transparent backdrop (z-index 998) above the
// dropdown, so any mousedown — including a second right-click on another
// row — would otherwise hit the backdrop and trip this handler, yanking the
// dropdown closed mid-interaction.
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ctxIsOpen) return;
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [ctxIsOpen]);
const hasResults =
!!share.shareMatch ||
(results && (results.artists.length || results.albums.length || results.songs.length));
// Flat list of all navigable items for keyboard nav
const flatItems = share.shareMatch && share.hasShareKeyboardTarget ? [
{
id: 'share-link',
action: () => {
if (share.canQueueShareMatch) void share.enqueueShareMatch();
else if (share.canOpenShareAlbum) share.openShareAlbum();
else if (share.canOpenShareArtist) share.openShareArtist();
else if (share.canOpenShareComposer) share.openShareComposer();
},
},
] : results ? [
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id); setOpen(false); setQuery(''); } }))),
...(results.songs.map(s => ({ id: s.id, action: () => {
const track = songToTrack(s);
enqueue([track]);
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
setOpen(false); setQuery('');
}}))),
] : [];
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (handleLiveSearchScopeUndo(e, undoLiveSearch)) return;
if (handleLiveSearchScopeBackspace(e, query, scope, clearScope, scopeBackspaceRef.current)) return;
if (isLiveSearchDropdownBlocked(scope)) return;
if (share.shareMatch) {
if (e.key === 'Enter') {
e.preventDefault();
if (share.canQueueShareMatch) void share.enqueueShareMatch();
else if (share.canOpenShareAlbum) share.openShareAlbum();
else if (share.canOpenShareArtist) share.openShareArtist();
else if (share.canOpenShareComposer) share.openShareComposer();
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(share.hasShareKeyboardTarget ? 0 : -1);
} else if (e.key === 'Escape') {
setOpen(false);
setActiveIndex(-1);
}
return;
}
if (!open || !flatItems.length) {
if (e.key === 'Enter' && query.trim()) {
e.preventDefault();
leaveLiveSearchFor(`/search?q=${encodeURIComponent(query.trim())}`);
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = Math.min(activeIndex + 1, flatItems.length - 1);
setActiveIndex(next);
dropdownRef.current?.querySelectorAll<HTMLElement>('.search-result-item')[next]?.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const next = Math.max(activeIndex - 1, -1);
setActiveIndex(next);
if (next >= 0) dropdownRef.current?.querySelectorAll<HTMLElement>('.search-result-item')[next]?.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0) { flatItems[activeIndex].action(); setActiveIndex(-1); }
else if (query.trim()) {
leaveLiveSearchFor(`/search?q=${encodeURIComponent(query.trim())}`);
}
} else if (e.key === 'Escape') {
setOpen(false); setActiveIndex(-1);
}
};
return (
<div
className="live-search"
ref={ref}
role="search"
data-collapsed={isCollapsed || undefined}
data-active={isSearchActive || undefined}
>
<div
className="live-search-input-wrap"
onMouseDown={(e) => {
if (isSearchActive) return;
if (!isCollapsed) return;
e.preventDefault();
setIsFocused(true);
requestAnimationFrame(() => inputRef.current?.focus());
}}
>
<div className="live-search-field-cluster">
{loading ? (
<span className="live-search-leading-icon animate-spin" style={{ opacity: 0.6 }}>
<div style={{ width: 16, height: 16, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%' }} />
</span>
) : (
<Search size={16} className="live-search-leading-icon" aria-hidden />
)}
{scope && (
<LiveSearchScopeBadge
scope={scope}
className="live-search-scope-badge"
clearScope={clearScope}
/>
)}
{ghostScope && (
<LiveSearchScopeGhostBadge
scope={ghostScope}
className="live-search-scope-badge live-search-scope-badge--ghost"
setScope={setScope}
/>
)}
<input
ref={inputRef}
id="live-search-input"
className="input live-search-field"
type="search"
placeholder={t(liveSearchScopePlaceholderKey(scope))}
data-tooltip={scope ? t(liveSearchScopePlaceholderKey(scope)) : undefined}
data-tooltip-pos="bottom"
value={query}
onChange={e => handleQueryChange(e.target.value)}
onFocus={() => {
setIsFocused(true);
if (!isLiveSearchDropdownBlocked(scope) && results) setOpen(true);
}}
onBlur={() => setIsFocused(false)}
onKeyDown={handleKeyDown}
aria-autocomplete="list"
aria-controls="search-results"
aria-expanded={open && !isLiveSearchDropdownBlocked(scope)}
autoComplete="off"
/>
</div>
<button
className="live-search-adv-btn"
type="button"
onMouseDown={(e) => {
// Keep focus on the search input so collapsed-overlay controls
// remain active long enough for this button click to fire.
e.preventDefault();
}}
onClick={() => {
const q = query.trim();
leaveLiveSearchFor(q ? `/search/advanced?q=${encodeURIComponent(q)}` : '/search/advanced');
}}
data-tooltip={t('search.advanced')}
data-tooltip-pos="bottom"
aria-label={t('search.advanced')}
>
<TextSearch size={14} />
</button>
</div>
{open && !isLiveSearchDropdownBlocked(scope) && (
<div className="live-search-dropdown" id="search-results" role="listbox" ref={dropdownRef}>
{searchSource && !share.shareMatch && (
<div
className={`live-search-source live-search-source--${searchSource}`}
data-tooltip={t(
searchSource === 'local'
? 'search.localIndexBadgeTooltip'
: 'search.networkSearchBadgeTooltip',
)}
data-tooltip-pos="bottom"
>
{searchSource === 'local' ? (
<Database size={12} aria-hidden />
) : (
<Globe size={12} aria-hidden />
)}
<span>
{t(
searchSource === 'local'
? 'search.localIndexBadge'
: 'search.networkSearchBadge',
)}
</span>
</div>
)}
{!hasResults && !loading && (
<div className="search-empty">{t('search.noResults', { query: query.trim() })}</div>
)}
{share.shareMatch && (
<ShareSearchResults
variant="desktop"
shareMatch={share.shareMatch}
shareServerLabel={share.shareServerLabel}
shareCoverServer={share.shareCoverServer}
activeIndex={activeIndex}
shareQueueBusy={share.shareQueueBusy}
onEnqueue={() => void share.enqueueShareMatch()}
onOpenAlbum={share.openShareAlbum}
onOpenArtist={share.openShareArtist}
onOpenComposer={share.openShareComposer}
onContextMenu={(e, item, type) => openContextMenu(e.clientX, e.clientY, item, type)}
shareTrackSong={share.shareTrackSong}
shareTrackResolving={share.shareTrackResolving}
shareTrackUnavailable={share.shareTrackUnavailable}
shareAlbum={share.shareAlbum}
shareAlbumResolving={share.shareAlbumResolving}
shareAlbumUnavailable={share.shareAlbumUnavailable}
shareArtist={share.shareArtist}
shareArtistResolving={share.shareArtistResolving}
shareArtistUnavailable={share.shareArtistUnavailable}
shareComposer={share.shareComposer}
shareComposerResolving={share.shareComposerResolving}
shareComposerUnavailable={share.shareComposerUnavailable}
/>
)}
{(() => {
if (share.shareMatch) return null;
let idx = 0;
return <>
{results?.artists.length ? (
<div className="search-section">
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
{results.artists.map(a => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'artist');
}}
role="option" aria-selected={activeIndex === i}>
<LiveSearchArtistThumb artist={a} />
<div>
<div className="search-result-name">{a.name}</div>
</div>
</button>
);
})}
</div>
) : null}
{results?.albums.length ? (
<div className="search-section">
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
{results.albums.map(a => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigateToAlbum(a.id); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'album');
}}
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} />
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}
<div>
<div className="search-result-name">{a.name}</div>
<div className="search-result-sub">{albumArtistDisplayName(a)}</div>
</div>
</button>
);
})}
</div>
) : null}
{results?.songs.length ? (
<div className="search-section">
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
{results.songs.map(s => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'song' && ctxItemId === s.id;
return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => {
const track = songToTrack(s);
enqueue([track]);
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
setOpen(false); setQuery('');
}}
onContextMenu={(e) => {
e.preventDefault();
// Keep the dropdown open — context menu portal renders above it,
// and closing here would yank the list out from under the user.
openContextMenu(e.clientX, e.clientY, songToTrack(s), 'song');
}}
role="option" aria-selected={activeIndex === i}>
<LiveSearchSongThumb song={s} />
<div>
<div className="search-result-name">{s.title}</div>
<div className="search-result-sub">{s.artist} · {s.album}</div>
</div>
</button>
);
})}
</div>
) : null}
</>;
})()}
</div>
)}
</div>
);
}