refactor(search): co-locate search feature into features/search

This commit is contained in:
Psychotoxical
2026-06-29 23:42:22 +02:00
parent 931c47e19e
commit fbc37db64e
19 changed files with 155 additions and 140 deletions
@@ -0,0 +1,830 @@
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 '@/ui/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 '@/features/search/hooks/useShareSearch';
import ShareSearchResults from '@/features/search/components/ShareSearchResults';
import {
LiveSearchScopeBadge,
LiveSearchScopeGhostBadge,
} from '@/features/search/components/liveSearchScopeUi';
import {
createLiveSearchScopeBackspaceState,
handleLiveSearchScopeBackspace,
handleLiveSearchScopeUndo,
isLiveSearchDropdownBlocked,
liveSearchScopePlaceholderKey,
noteLiveSearchScopeQueryInput,
resetLiveSearchScopeBackspaceState,
resolveLiveSearchScopeGhost,
} from '@/features/search/components/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>
);
}
@@ -0,0 +1,42 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import MobileSearchOverlay from '@/features/search/components/MobileSearchOverlay';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
// The overlay's only behaviour-bearing change in PR #1165 was renaming the
// recent-search handler `useRecent` → `applyRecentSearch` (it was a plain
// function mis-flagged as a hook). Smoke-test that the recent-search path still
// applies the term to the live-search store. Heavy collaborators are stubbed.
vi.mock('@/features/search/hooks/useShareSearch', () => ({ useShareSearch: () => ({ shareMatch: null }) }));
vi.mock('@/api/subsonicSearch', () => ({
search: vi.fn(() => Promise.resolve({ artists: [], albums: [], songs: [] })),
}));
vi.mock('@/cover/AlbumCoverArtImage', () => ({ AlbumCoverArtImage: () => null }));
vi.mock('@/cover/ArtistCoverArtImage', () => ({ ArtistCoverArtImage: () => null }));
vi.mock('@/cover/CoverArtImage', () => ({ CoverArtImage: () => null }));
const RECENT_KEY = 'psysonic_recent_searches';
describe('MobileSearchOverlay — recent search (applyRecentSearch, PR #1165)', () => {
beforeEach(() => {
useLiveSearchScopeStore.setState({ query: '', scope: null, undoStack: [] });
localStorage.setItem(RECENT_KEY, JSON.stringify(['first query', 'second query']));
});
it('lists stored recent searches in the empty state', () => {
renderWithProviders(<MobileSearchOverlay onClose={vi.fn()} />);
expect(screen.getByText('first query')).toBeInTheDocument();
expect(screen.getByText('second query')).toBeInTheDocument();
});
it('applies a recent search term to the live-search store on click', async () => {
const user = userEvent.setup();
renderWithProviders(<MobileSearchOverlay onClose={vi.fn()} />);
await user.click(screen.getByText('first query'));
expect(useLiveSearchScopeStore.getState().query).toBe('first query');
});
});
@@ -0,0 +1,436 @@
import { search } from '@/api/subsonicSearch';
import type { SearchResults, SubsonicArtist } from '@/api/subsonicTypes';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { navigatePathWithAlbumReturnTo } from '@/utils/navigation/albumDetailNavigation';
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { useTranslation } from 'react-i18next';
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '@/ui/CachedImage';
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
import { ArtistCoverArtImage } from '@/cover/ArtistCoverArtImage';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { albumCoverRefForSong } from '@/cover/ref';
import { showToast } from '@/utils/ui/toast';
import { albumArtistDisplayName } from '@/utils/album/deriveAlbumHeaderArtistRefs';
import { useShareSearch } from '@/features/search/hooks/useShareSearch';
import ShareSearchResults from '@/features/search/components/ShareSearchResults';
import {
LiveSearchScopeBadge,
LiveSearchScopeGhostBadge,
} from '@/features/search/components/liveSearchScopeUi';
import {
createLiveSearchScopeBackspaceState,
handleLiveSearchScopeBackspace,
handleLiveSearchScopeUndo,
isLiveSearchDropdownBlocked,
liveSearchScopePlaceholderKey,
noteLiveSearchScopeQueryInput,
resetLiveSearchScopeBackspaceState,
resolveLiveSearchScopeGhost,
} from '@/features/search/components/liveSearchScope';
const STORAGE_KEY = 'psysonic_recent_searches';
const MAX_RECENT = 6;
function loadRecent(): string[] {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
}
function saveRecent(q: string, prev: string[]): string[] {
const updated = [q.trim(), ...prev.filter(s => s !== q.trim())].slice(0, MAX_RECENT);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
}
function debounce<A extends unknown[]>(
fn: (...args: A) => void,
ms: number,
): (...args: A) => void {
let timer: ReturnType<typeof setTimeout>;
return (...args: A) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
}
/** Mobile search row thumb — larger than desktop live search (32px). */
const MOBILE_SEARCH_THUMB_CSS_PX = 80;
function MobileSearchSongThumb({
song,
}: {
song: Pick<SearchResults['songs'][number], 'id' | 'albumId' | 'coverArt' | 'discNumber'>;
}) {
const coverRef = useMemo(
() => (song.albumId?.trim() ? albumCoverRefForSong(song) : undefined),
// Keyed on song's identity fields; depending on the `song` object would
// recompute the ref on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
[song.id, song.albumId, song.coverArt, song.discNumber],
);
if (!coverRef) return null;
return (
<CoverArtImage
coverRef={coverRef}
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
surface="dense"
className="mobile-search-thumb"
alt=""
ensurePriority="high"
/>
);
}
function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
const [failed, setFailed] = useState(false);
// 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
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
if (failed) {
return (
<div className="mobile-search-avatar mobile-search-avatar--circle">
<Users size={20} />
</div>
);
}
return (
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
libraryResolve={false}
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
surface="dense"
className="mobile-search-thumb mobile-search-thumb--artist-round"
alt=""
loading="eager"
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
onError={() => setFailed(true)}
/>
);
}
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const enqueue = usePlayerStore(s => s.enqueue);
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 ghostScope = resolveLiveSearchScopeGhost(location.pathname, scope);
const [results, setResults] = useState<SearchResults | null>(null);
const [loading, setLoading] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
const inputRef = useRef<HTMLInputElement>(null);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const share = useShareSearch(query, onClose);
useEffect(() => { inputRef.current?.focus(); }, []);
useEffect(() => {
resetLiveSearchScopeBackspaceState(scopeBackspaceRef.current);
}, [scope]);
useEffect(() => {
noteLiveSearchScopeQueryInput(scopeBackspaceRef.current, query);
}, [query]);
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
// doSearch wraps a debounce() result, so the useCallback argument is not an
// inline function and its deps can't be statically analysed. It is recreated
// only on musicLibraryFilterVersion (search() reads the active filter state).
// eslint-disable-next-line react-hooks/exhaustive-deps
const doSearch = useCallback(
// React Compiler rule: memoization shape is intentional here.
// eslint-disable-next-line react-hooks/use-memo
debounce(async (q: string) => {
if (!q.trim()) { setResults(null); setLoading(false); return; }
setLoading(true);
try {
setResults(await search(q));
} finally { setLoading(false); }
}, 300),
[musicLibraryFilterVersion],
);
useEffect(() => {
if (isLiveSearchDropdownBlocked(scope)) {
// 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
setResults(null);
setLoading(false);
return;
}
if (share.shareMatch) {
setResults(null);
setLoading(false);
return;
}
doSearch(query);
}, [query, scope, doSearch, share.shareMatch]);
const commit = (q: string) => {
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
};
const goTo = (path: string) => {
commit(query);
navigatePathWithAlbumReturnTo(navigate, location, path);
onClose();
};
const goCategory = (path: string) => { navigate(path); onClose(); };
const enqueueSong = (song: SearchResults['songs'][number]) => {
commit(query);
const track = songToTrack(song);
enqueue([track]);
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
onClose();
};
const applyRecentSearch = (term: string) => {
setQuery(term, { recordUndo: true });
inputRef.current?.focus();
};
const removeRecent = (term: string, e: React.MouseEvent) => {
e.stopPropagation();
setRecentSearches(prev => {
const updated = prev.filter(s => s !== term);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
});
};
const hasResults =
!isLiveSearchDropdownBlocked(scope)
&& (
!!share.shareMatch
|| (results && (results.artists.length || results.albums.length || results.songs.length))
);
const showEmpty = !query && !scope;
return createPortal(
<div className="mobile-search-overlay">
{/* ── Search bar ── */}
<div className="mobile-search-bar">
<div className={`mobile-search-field${scope ? ' mobile-search-field--scoped' : ''}`}>
{loading ? (
<div className="mobile-search-spinner" />
) : (
<Search size={16} className="mobile-search-icon" />
)}
{scope && (
<LiveSearchScopeBadge
scope={scope}
className="mobile-search-scope-badge"
clearScope={clearScope}
/>
)}
{ghostScope && (
<LiveSearchScopeGhostBadge
scope={ghostScope}
className="mobile-search-scope-badge mobile-search-scope-badge--ghost"
setScope={setScope}
/>
)}
<input
ref={inputRef}
className="mobile-search-input"
type="search"
placeholder={t(liveSearchScopePlaceholderKey(scope))}
data-tooltip={scope ? t(liveSearchScopePlaceholderKey(scope)) : undefined}
data-tooltip-pos="bottom"
value={query}
onChange={e => setQuery(e.target.value, { recordUndo: true })}
onKeyDown={(e) => {
if (handleLiveSearchScopeUndo(e, undoLiveSearch)) return;
if (handleLiveSearchScopeBackspace(e, query, scope, clearScope, scopeBackspaceRef.current)) return;
}}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
{query && (
<button
className="mobile-search-clear"
onClick={() => { setQuery('', { recordUndo: true }); setResults(null); inputRef.current?.focus(); }}
aria-label={t('search.clearLabel')}
>
<X size={15} />
</button>
)}
</div>
<button className="mobile-search-cancel" onClick={onClose}>
{t('common.cancel')}
</button>
</div>
<div className="mobile-search-results">
{/* ── Empty state ── */}
{showEmpty && (
<div className="mobile-search-empty-state">
{recentSearches.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.recentSearches')}</div>
{recentSearches.map(term => (
<button key={term} className="mobile-search-item" onClick={() => applyRecentSearch(term)}>
<div className="mobile-search-avatar">
<Clock size={18} />
</div>
<div className="mobile-search-item-info" style={{ flex: 1 }}>
<span className="mobile-search-item-title">{term}</span>
</div>
<button
className="mobile-search-recent-remove"
onClick={e => removeRecent(term, e)}
aria-label={t('search.clearLabel')}
>
<X size={14} />
</button>
</button>
))}
</div>
)}
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.browse')}</div>
<div className="mobile-search-chips">
<button className="mobile-search-chip" onClick={() => goCategory('/albums')}>
<Music2 size={15} /> {t('search.albums')}
</button>
<button className="mobile-search-chip" onClick={() => goCategory('/artists')}>
<Users size={15} /> {t('search.artists')}
</button>
<button className="mobile-search-chip" onClick={() => goCategory('/genres')}>
<Music size={15} /> {t('search.genres')}
</button>
</div>
</div>
<div className="mobile-search-hint">
<Search size={52} className="mobile-search-hint-icon" />
<span className="mobile-search-hint-text">{t('search.emptyHint')}</span>
</div>
</div>
)}
{/* ── No results ── */}
{!loading && query && !hasResults && !isLiveSearchDropdownBlocked(scope) && (
<div className="mobile-search-noresults">
{t('search.noResults', { query: query.trim() })}
</div>
)}
{share.shareMatch && (
<ShareSearchResults
variant="mobile"
shareMatch={share.shareMatch}
shareServerLabel={share.shareServerLabel}
shareCoverServer={share.shareCoverServer}
shareQueueBusy={share.shareQueueBusy}
onEnqueue={() => void share.enqueueShareMatch()}
onOpenAlbum={share.openShareAlbum}
onOpenArtist={share.openShareArtist}
onOpenComposer={share.openShareComposer}
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}
/>
)}
{/* ── Results ── */}
{hasResults && !share.shareMatch && (
<>
{results!.artists.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.artists')}</div>
{results!.artists.map(a => (
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/artist/${a.id}`)}>
<MobileSearchArtistThumb artist={a} />
<div className="mobile-search-item-info">
<span className="mobile-search-item-title">{a.name}</span>
<span className="mobile-search-item-sub">{t('search.artists')}</span>
</div>
<ChevronRight size={16} className="mobile-search-item-chevron" />
</button>
))}
</div>
)}
{results!.albums.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.albums')}</div>
{results!.albums.map(a => (
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
{a.coverArt ? (
<AlbumCoverArtImage
albumId={a.id}
coverArt={a.coverArt}
libraryResolve={false}
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
surface="dense"
className="mobile-search-thumb"
alt=""
ensurePriority="high"
/>
) : (
<div className="mobile-search-avatar">
<Disc3 size={20} />
</div>
)}
<div className="mobile-search-item-info">
<span className="mobile-search-item-title">{a.name}</span>
<span className="mobile-search-item-sub">{albumArtistDisplayName(a)}</span>
</div>
<ChevronRight size={16} className="mobile-search-item-chevron" />
</button>
))}
</div>
)}
{results!.songs.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.songs')}</div>
{results!.songs.map(s => (
<button key={s.id} className="mobile-search-item" onClick={() => enqueueSong(s)}>
{s.albumId && (s.coverArt ?? s.albumId) ? (
<MobileSearchSongThumb song={s} />
) : (
<div className="mobile-search-avatar">
<Music size={20} />
</div>
)}
<div className="mobile-search-item-info">
<span className="mobile-search-item-title">{s.title}</span>
<span className="mobile-search-item-sub">{s.artist}{s.album ? ` · ${s.album}` : ''}</span>
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>,
document.body
);
}
@@ -0,0 +1,226 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Music, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { SubsonicSong } from '@/api/subsonicTypes';
import type { ServerProfile } from '@/store/authStoreTypes';
import { formatTrackTime } from '@/utils/format/formatDuration';
import type { ShareQueuePreviewState } from '@/features/search/hooks/useShareQueuePreview';
import { sharePayloadTotal, type QueueableShareSearchPayload } from '@/utils/share/shareSearch';
import OverlayScrollArea from '@/ui/OverlayScrollArea';
import { usePlayerStore } from '@/store/playerStore';
import { COVER_DENSE_SEARCH_CSS_PX } from '@/cover/layoutSizes';
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '@/cover/types';
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
type ShareQueuePreviewModalProps = {
open: boolean;
onClose: () => void;
payload: Extract<QueueableShareSearchPayload, { k: 'queue' }>;
preview: ShareQueuePreviewState;
shareServerLabel?: string | null;
coverServer?: ServerProfile | null;
onEnqueue: () => void;
enqueueBusy: boolean;
confirmLabel?: string;
confirmBusyLabel?: string;
};
function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerScope {
if (coverServer) {
return {
kind: 'server',
serverId: coverServer.id,
url: coverServer.url,
username: coverServer.username,
password: coverServer.password,
};
}
return COVER_SCOPE_ACTIVE;
}
function QueuePreviewTrackRow({
song,
coverServer,
}: {
song: SubsonicSong;
coverServer?: ServerProfile | null;
}) {
const coverId = song.coverArt || song.id;
return (
<li className="share-queue-preview-track">
{song.coverArt ? (
<AlbumCoverArtImage
albumId={song.albumId ?? coverId}
coverArt={song.coverArt}
serverScope={shareCoverServerScope(coverServer)}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
className="share-queue-preview-track__thumb"
alt=""
/>
) : (
<div className="share-queue-preview-track__icon">
<Music size={16} />
</div>
)}
<div className="share-queue-preview-track__meta">
<div className="share-queue-preview-track__title">{song.title}</div>
<div className="share-queue-preview-track__sub">
{song.artist}{song.album ? ` · ${song.album}` : ''}
</div>
</div>
<span className="share-queue-preview-track__dur">{formatTrackTime(song.duration)}</span>
</li>
);
}
function PreviewBody({
preview,
coverServer,
}: {
preview: ShareQueuePreviewState;
coverServer?: ServerProfile | null;
}) {
const { t } = useTranslation();
if (preview.status === 'loading' || preview.status === 'idle') {
return <div className="share-queue-preview-modal__status">{t('search.shareQueuePreviewLoading')}</div>;
}
if (preview.status === 'error') {
const msg =
preview.result.type === 'not-logged-in'
? t('sharePaste.notLoggedIn')
: preview.result.type === 'no-matching-server'
? t('sharePaste.noMatchingServer', { url: preview.result.url })
: preview.result.type === 'all-unavailable'
? t('search.shareQueuePreviewEmpty')
: t('sharePaste.genericError');
return <div className="share-queue-preview-modal__status share-queue-preview-modal__status--error">{msg}</div>;
}
return (
<>
{preview.skipped > 0 && (
<p className="share-queue-preview-modal__skipped">
{t('search.shareQueuePreviewSkipped', { skipped: preview.skipped, total: preview.total })}
</p>
)}
<OverlayScrollArea
className="share-queue-preview-modal__list-wrap"
viewportClassName="share-queue-preview-modal__list-viewport"
measureDeps={[preview.songs.length, preview.skipped]}
railInset="panel"
>
<ul className="share-queue-preview-modal__list">
{preview.songs.map(song => (
<QueuePreviewTrackRow key={song.id} song={song} coverServer={coverServer} />
))}
</ul>
</OverlayScrollArea>
</>
);
}
export default function ShareQueuePreviewModal({
open,
onClose,
payload,
preview,
shareServerLabel,
coverServer,
onEnqueue,
enqueueBusy,
confirmLabel,
confirmBusyLabel,
}: ShareQueuePreviewModalProps) {
const { t } = useTranslation();
const actionLabel = confirmLabel ?? t('search.shareQueueAction');
const actionBusyLabel = confirmBusyLabel ?? t('search.shareQueueing');
useEffect(() => {
if (!open) return;
usePlayerStore.getState().closeContextMenu();
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const blockContextMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
usePlayerStore.getState().closeContextMenu();
};
document.addEventListener('keydown', handler);
document.addEventListener('contextmenu', blockContextMenu, true);
return () => {
document.removeEventListener('keydown', handler);
document.removeEventListener('contextmenu', blockContextMenu, true);
};
}, [open, onClose]);
if (!open) return null;
const count = sharePayloadTotal(payload);
const canEnqueue = preview.status === 'ok' && preview.songs.length > 0;
return createPortal(
<div
className="modal-overlay share-queue-preview-modal-overlay"
role="presentation"
onContextMenu={e => {
e.preventDefault();
e.stopPropagation();
}}
onMouseDown={e => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className="modal-content share-queue-preview-modal"
role="dialog"
aria-modal="true"
aria-labelledby="share-queue-preview-title"
onContextMenu={e => {
e.preventDefault();
e.stopPropagation();
}}
onMouseDown={e => e.stopPropagation()}
>
<button type="button" className="modal-close" onClick={onClose} aria-label={t('common.close')}>
<X size={18} />
</button>
<header className="share-queue-preview-modal__header">
<h2 id="share-queue-preview-title" className="share-queue-preview-modal__title">
{t('search.shareQueueTitle', { count })}
</h2>
{shareServerLabel && (
<p className="share-queue-preview-modal__server">
{t('search.shareFromServer', { server: shareServerLabel })}
</p>
)}
</header>
<div className="share-queue-preview-modal__body">
<PreviewBody preview={preview} coverServer={coverServer} />
</div>
<footer className="share-queue-preview-modal__footer">
<button type="button" className="btn btn-ghost" onClick={onClose}>
{t('common.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
disabled={!canEnqueue || enqueueBusy}
onClick={() => void onEnqueue()}
>
{enqueueBusy ? actionBusyLabel : actionLabel}
</button>
</footer>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,464 @@
import React, { useEffect, useState } from 'react';
import { Disc3, Eye, Link2, ListPlus, Music, Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import type { SubsonicArtist } from '@/api/subsonicTypes';
import type { ServerProfile } from '@/store/authStoreTypes';
import { songToTrack } from '@/utils/playback/songToTrack';
import { activateShareSearchServer } from '@/utils/share/enqueueShareSearchPayload';
import { sharePayloadTotal, type ShareSearchMatch } from '@/utils/share/shareSearch';
import type { ShareSearchPreviewState } from '@/features/search/hooks/useShareSearchPreview';
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '@/ui/CachedImage';
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
import { ArtistCoverArtImage } from '@/cover/ArtistCoverArtImage';
import { COVER_DENSE_SEARCH_CSS_PX } from '@/cover/layoutSizes';
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '@/cover/types';
import { useShareQueuePreview } from '@/features/search/hooks/useShareQueuePreview';
import ShareQueuePreviewModal from '@/features/search/components/ShareQueuePreviewModal';
type ShareSearchResultsProps = {
variant: 'desktop' | 'mobile';
shareMatch: ShareSearchMatch;
/** Saved server display name when the link is from a non-active server. */
shareServerLabel?: string | null;
/** Saved server profile for cover art when the link is from a non-active server. */
shareCoverServer?: ServerProfile | null;
activeIndex?: number;
shareQueueBusy: boolean;
onEnqueue: () => void | Promise<boolean>;
onOpenAlbum: () => void;
onOpenArtist: () => void;
onOpenComposer: () => void;
onContextMenu?: (e: React.MouseEvent, item: unknown, type: 'song' | 'album' | 'artist') => void;
} & ShareSearchPreviewState;
function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerScope {
if (coverServer) {
return {
kind: 'server',
serverId: coverServer.id,
url: coverServer.url,
username: coverServer.username,
password: coverServer.password,
};
}
return COVER_SCOPE_ACTIVE;
}
function ShareAlbumThumb({
albumId,
coverArt,
displayCssPx,
coverServer,
}: {
albumId: string;
coverArt: string;
displayCssPx: number;
coverServer?: ServerProfile | null;
}) {
const cls = displayCssPx >= 64 ? 'mobile-search-thumb' : 'search-result-thumb';
return (
<AlbumCoverArtImage
albumId={albumId}
coverArt={coverArt}
serverScope={shareCoverServerScope(coverServer)}
displayCssPx={displayCssPx}
surface="dense"
className={cls}
alt=""
/>
);
}
function ShareArtistThumb({
artist,
displayCssPx,
coverServer,
}: {
artist: Pick<SubsonicArtist, 'id' | 'coverArt'>;
displayCssPx: number;
coverServer?: ServerProfile | null;
}) {
const [failed, setFailed] = useState(false);
// 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
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
if (failed) {
if (displayCssPx >= 64) {
return (
<div className="mobile-search-avatar mobile-search-avatar--circle">
<Users size={20} />
</div>
);
}
return (
<div className="search-result-icon">
<Users size={14} />
</div>
);
}
const cls =
displayCssPx >= 64
? 'mobile-search-thumb mobile-search-thumb--artist-round'
: 'search-result-thumb';
return (
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
serverScope={shareCoverServerScope(coverServer)}
displayCssPx={displayCssPx}
surface="dense"
className={cls}
alt=""
loading="eager"
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
onError={() => setFailed(true)}
/>
);
}
function StaticIcon({ className, children }: { className: string; children: React.ReactNode }) {
return <div className={className}>{children}</div>;
}
function withShareServer(
shareMatch: ShareSearchMatch,
t: TFunction,
fn: () => void,
): void {
if (shareMatch.type === 'unsupported') return;
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
fn();
}
function shareSubLine(primary: string, serverLabel: string | null | undefined, t: TFunction): string {
if (!serverLabel) return primary;
const hint = t('search.shareFromServer', { server: serverLabel });
return primary ? `${primary} · ${hint}` : hint;
}
export default function ShareSearchResults(props: ShareSearchResultsProps) {
const {
variant,
shareMatch,
shareServerLabel = null,
shareCoverServer = null,
activeIndex = 0,
shareQueueBusy,
onEnqueue,
onOpenAlbum,
onOpenArtist,
onOpenComposer,
onContextMenu,
shareTrackSong,
shareTrackResolving,
shareTrackUnavailable,
shareAlbum,
shareAlbumResolving,
shareAlbumUnavailable,
shareArtist,
shareArtistResolving,
shareArtistUnavailable,
shareComposer,
shareComposerResolving,
shareComposerUnavailable,
} = props;
const { t } = useTranslation();
const desktop = variant === 'desktop';
const thumbDisplayCssPx = desktop ? COVER_DENSE_SEARCH_CSS_PX : 80;
const sectionCls = desktop ? 'search-section' : 'mobile-search-section';
const labelCls = desktop ? 'search-section-label' : 'mobile-search-section-label';
const mutedCls = desktop ? 'search-result-item search-result-item--muted' : 'mobile-search-item mobile-search-item--muted';
const iconCls = desktop ? 'search-result-icon' : 'mobile-search-avatar';
const nameCls = desktop ? 'search-result-name' : 'mobile-search-item-title';
const subCls = desktop ? 'search-result-sub' : 'mobile-search-item-sub';
const infoWrap = desktop ? undefined : 'mobile-search-item-info';
const wrap = (content: React.ReactNode) => (
<div className={sectionCls}>
<div className={labelCls}>
{desktop && <Link2 size={12} />} {t('search.shareLink')}
</div>
{content}
</div>
);
const itemCls = (active: boolean) =>
desktop ? `search-result-item${active ? ' active' : ''}` : 'mobile-search-item';
const sub = (primary: string) => shareSubLine(primary, shareServerLabel, t);
const showEntityKindSub = !desktop || !!shareServerLabel;
const [queuePreviewOpen, setQueuePreviewOpen] = useState(false);
const queuePayload =
shareMatch.type === 'queueable' && shareMatch.payload.k === 'queue' ? shareMatch.payload : null;
const queuePreview = useShareQueuePreview(queuePayload, queuePreviewOpen);
const unsupportedRow = (
<div className={mutedCls}>
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>{t('search.shareUnsupportedTitle')}</div>
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
</div>
</div>
);
if (shareMatch.type === 'unsupported') {
return wrap(unsupportedRow);
}
if (shareMatch.type === 'artist') {
if (shareArtistResolving) {
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Users size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>{t('common.loading')}</div>
<div className={subCls}>{sub(t('search.artists'))}</div>
</div>
</div>,
);
}
if (shareArtist) {
return wrap(
<button
type="button"
className={itemCls(activeIndex === 0)}
onClick={onOpenArtist}
onContextMenu={e => {
e.preventDefault();
withShareServer(shareMatch, t, () => onContextMenu?.(e, shareArtist, 'artist'));
}}
role={desktop ? 'option' : undefined}
aria-selected={desktop ? activeIndex === 0 : undefined}
>
<ShareArtistThumb artist={shareArtist} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
<div className={infoWrap}>
<div className={nameCls}>{shareArtist.name}</div>
{showEntityKindSub && <div className={subCls}>{sub(!desktop ? t('search.artists') : '')}</div>}
</div>
</button>,
);
}
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>
{shareArtistUnavailable ? t('sharePaste.artistUnavailable') : t('sharePaste.genericError')}
</div>
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
</div>
</div>,
);
}
if (shareMatch.type === 'composer') {
if (shareComposerResolving) {
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Users size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>{t('common.loading')}</div>
<div className={subCls}>{sub(t('sidebar.composers'))}</div>
</div>
</div>,
);
}
if (shareComposer) {
return wrap(
<button
type="button"
className={itemCls(activeIndex === 0)}
onClick={onOpenComposer}
role={desktop ? 'option' : undefined}
aria-selected={desktop ? activeIndex === 0 : undefined}
>
<ShareArtistThumb artist={shareComposer} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
<div className={infoWrap}>
<div className={nameCls}>{shareComposer.name}</div>
{showEntityKindSub && <div className={subCls}>{sub(!desktop ? t('sidebar.composers') : '')}</div>}
</div>
</button>,
);
}
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>
{shareComposerUnavailable ? t('sharePaste.composerUnavailable') : t('sharePaste.genericError')}
</div>
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
</div>
</div>,
);
}
if (shareMatch.type === 'album') {
if (shareAlbumResolving) {
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Disc3 size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>{t('common.loading')}</div>
<div className={subCls}>{sub(t('search.album'))}</div>
</div>
</div>,
);
}
if (shareAlbum) {
return wrap(
<button
type="button"
className={itemCls(activeIndex === 0)}
onClick={onOpenAlbum}
onContextMenu={e => {
e.preventDefault();
withShareServer(shareMatch, t, () => onContextMenu?.(e, shareAlbum, 'album'));
}}
role={desktop ? 'option' : undefined}
aria-selected={desktop ? activeIndex === 0 : undefined}
>
{shareAlbum.coverArt ? (
<ShareAlbumThumb albumId={shareAlbum.id} coverArt={shareAlbum.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
) : (
<StaticIcon className={iconCls}><Disc3 size={desktop ? 14 : 20} /></StaticIcon>
)}
<div className={infoWrap}>
<div className={nameCls}>{shareAlbum.name}</div>
<div className={subCls}>{sub(shareAlbum.artist)}</div>
</div>
</button>,
);
}
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>
{shareAlbumUnavailable ? t('sharePaste.albumUnavailable') : t('sharePaste.genericError')}
</div>
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
</div>
</div>,
);
}
if (shareMatch.type === 'queueable' && shareMatch.payload.k === 'track') {
if (shareTrackResolving) {
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Music size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>{t('common.loading')}</div>
<div className={subCls}>{sub(t('search.shareTrackTitle'))}</div>
</div>
</div>,
);
}
if (shareTrackSong) {
return wrap(
<button
type="button"
className={itemCls(activeIndex === 0)}
onClick={onEnqueue}
onContextMenu={e => {
e.preventDefault();
withShareServer(shareMatch, t, () => onContextMenu?.(e, songToTrack(shareTrackSong), 'song'));
}}
disabled={shareQueueBusy}
role={desktop ? 'option' : undefined}
aria-selected={desktop ? activeIndex === 0 : undefined}
>
{shareTrackSong.coverArt ? (
<ShareAlbumThumb albumId={shareTrackSong.albumId} coverArt={shareTrackSong.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
) : (
<StaticIcon className={iconCls}><Music size={desktop ? 14 : 20} /></StaticIcon>
)}
<div className={infoWrap}>
<div className={nameCls}>{shareTrackSong.title}</div>
<div className={subCls}>
{shareQueueBusy
? sub(t('search.shareQueueing'))
: sub(`${shareTrackSong.artist}${shareTrackSong.album ? ` · ${shareTrackSong.album}` : ''}`)}
</div>
</div>
</button>,
);
}
return wrap(
<div className={mutedCls}>
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>
{shareTrackUnavailable ? t('sharePaste.trackUnavailable') : t('sharePaste.genericError')}
</div>
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
</div>
</div>,
);
}
if (shareMatch.type === 'queueable' && shareMatch.payload.k === 'queue') {
const count = sharePayloadTotal(shareMatch.payload);
const rowCls = desktop ? 'search-share-queue-row' : 'mobile-search-share-queue-row';
const handleModalEnqueue = () => {
void Promise.resolve(onEnqueue()).then(ok => {
if (ok) setQueuePreviewOpen(false);
});
};
return (
<>
{wrap(
<div
className={`${rowCls}${activeIndex === 0 ? ' active' : ''}`}
role={desktop ? 'option' : undefined}
>
<button
type="button"
className={desktop ? 'search-share-queue-main' : 'mobile-search-item search-share-queue-main'}
onClick={() => void onEnqueue()}
disabled={shareQueueBusy}
aria-selected={desktop ? activeIndex === 0 : undefined}
>
<StaticIcon className={iconCls}><ListPlus size={desktop ? 14 : 20} /></StaticIcon>
<div className={infoWrap}>
<div className={nameCls}>{t('search.shareQueueTitle', { count })}</div>
<div className={subCls}>
{shareQueueBusy ? sub(t('search.shareQueueing')) : sub(t('search.shareQueueAction'))}
</div>
</div>
</button>
<button
type="button"
className="search-share-queue-preview-btn"
onClick={e => {
e.stopPropagation();
setQueuePreviewOpen(true);
}}
aria-label={t('search.shareQueuePreview')}
>
<Eye size={desktop ? 16 : 18} />
</button>
</div>,
)}
<ShareQueuePreviewModal
open={queuePreviewOpen}
onClose={() => setQueuePreviewOpen(false)}
payload={shareMatch.payload}
preview={queuePreview}
shareServerLabel={shareServerLabel}
coverServer={shareCoverServer}
onEnqueue={handleModalEnqueue}
enqueueBusy={shareQueueBusy}
/>
</>
);
}
return null;
}
@@ -0,0 +1,175 @@
import type { KeyboardEvent, MouseEvent } from 'react';
import { ALL_NAV_ITEMS } from '@/config/navItems';
import type { LiveSearchScope } from '@/store/liveSearchScopeStore';
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/store/albumBrowseSessionStore';
import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore';
import { isArtistsBrowsePath } from '@/store/artistBrowseSessionStore';
import { isComposersBrowsePath } from '@/store/composerBrowseSessionStore';
export const SCOPE_NAV_ITEM: Record<LiveSearchScope, keyof typeof ALL_NAV_ITEMS> = {
artists: 'artists',
albums: 'allAlbums',
newReleases: 'newReleases',
tracks: 'tracks',
composers: 'composers',
};
/** Scope to restore when on a browse route but the badge was cleared (global search mode). */
export function resolveLiveSearchScopeGhost(
pathname: string,
activeScope: LiveSearchScope | null,
): LiveSearchScope | null {
if (activeScope != null) return null;
if (isArtistsBrowsePath(pathname)) return 'artists';
if (isAlbumsBrowsePath(pathname)) return 'albums';
if (isNewReleasesBrowsePath(pathname)) return 'newReleases';
if (isTracksBrowsePath(pathname)) return 'tracks';
if (isComposersBrowsePath(pathname)) return 'composers';
return null;
}
export function liveSearchScopePlaceholderKey(scope: LiveSearchScope | null): string {
switch (scope) {
case 'artists':
return 'search.scopeArtistsPlaceholder';
case 'albums':
return 'search.scopeAlbumsPlaceholder';
case 'newReleases':
return 'search.scopeNewReleasesPlaceholder';
case 'tracks':
return 'search.scopeTracksPlaceholder';
case 'composers':
return 'search.scopeComposersPlaceholder';
default:
return 'search.placeholder';
}
}
/** Scoped browse mode filters the page only — no live-search dropdown. */
export function isLiveSearchDropdownBlocked(scope: LiveSearchScope | null): boolean {
return scope != null;
}
export function liveSearchScopeBadgeTooltipKey(scope: LiveSearchScope): string {
switch (scope) {
case 'artists':
return 'search.scopeArtistsBadgeTooltip';
case 'albums':
return 'search.scopeAlbumsBadgeTooltip';
case 'newReleases':
return 'search.scopeNewReleasesBadgeTooltip';
case 'tracks':
return 'search.scopeTracksBadgeTooltip';
case 'composers':
return 'search.scopeComposersBadgeTooltip';
default:
return 'search.scopeArtistsBadgeTooltip';
}
}
export function liveSearchScopeGhostTooltipKey(scope: LiveSearchScope): string {
switch (scope) {
case 'artists':
return 'search.scopeArtistsGhostTooltip';
case 'albums':
return 'search.scopeAlbumsGhostTooltip';
case 'newReleases':
return 'search.scopeNewReleasesGhostTooltip';
case 'tracks':
return 'search.scopeTracksGhostTooltip';
case 'composers':
return 'search.scopeComposersGhostTooltip';
default:
return 'search.scopeArtistsGhostTooltip';
}
}
/** Tracks Backspace-on-empty badge removal (double after prior text input). */
export type LiveSearchScopeBackspaceState = {
hadQueryInput: boolean;
emptyBackspaceStreak: number;
};
export function createLiveSearchScopeBackspaceState(): LiveSearchScopeBackspaceState {
return { hadQueryInput: false, emptyBackspaceStreak: 0 };
}
export function resetLiveSearchScopeBackspaceState(state: LiveSearchScopeBackspaceState): void {
state.hadQueryInput = false;
state.emptyBackspaceStreak = 0;
}
/** Call when the scoped field query changes (typing, paste, clear button, undo). */
export function noteLiveSearchScopeQueryInput(
state: LiveSearchScopeBackspaceState,
query: string,
): void {
if (query !== '') state.hadQueryInput = true;
}
/**
* Backspace on an empty scoped field removes the badge.
* After the user typed text (even if cleared), two consecutive Backspaces on empty are required.
*/
export function handleLiveSearchScopeBackspace(
e: KeyboardEvent<HTMLInputElement>,
query: string,
scope: LiveSearchScope | null,
clearScope: (options?: { recordUndo?: boolean }) => void,
state: LiveSearchScopeBackspaceState,
): boolean {
if (e.key !== 'Backspace' || !scope) return false;
if (query !== '') {
state.emptyBackspaceStreak = 0;
return false;
}
e.preventDefault();
if (!state.hadQueryInput) {
clearScope({ recordUndo: true });
resetLiveSearchScopeBackspaceState(state);
return true;
}
state.emptyBackspaceStreak += 1;
if (state.emptyBackspaceStreak >= 2) {
clearScope({ recordUndo: true });
resetLiveSearchScopeBackspaceState(state);
return true;
}
return true;
}
/** Single click removes the scope badge. */
export function handleLiveSearchScopeBadgeClick(
e: MouseEvent<HTMLElement>,
clearScope: (options?: { recordUndo?: boolean }) => void,
): void {
e.preventDefault();
e.stopPropagation();
clearScope({ recordUndo: true });
}
/** Single click restores scope after the user cleared the badge on this browse route. */
export function handleLiveSearchScopeGhostClick(
e: MouseEvent<HTMLElement>,
scope: LiveSearchScope,
setScope: (scope: LiveSearchScope, options?: { recordUndo?: boolean }) => void,
): void {
e.preventDefault();
e.stopPropagation();
setScope(scope, { recordUndo: true });
}
/** Field-local undo (Ctrl/Cmd+Z) for live search query and scope badge. */
export function handleLiveSearchScopeUndo(
e: KeyboardEvent<HTMLInputElement>,
undo: () => boolean,
): boolean {
const isUndoKey = e.code === 'KeyZ' || e.key.toLowerCase() === 'z';
if (!isUndoKey || !(e.ctrlKey || e.metaKey) || e.shiftKey) return false;
e.preventDefault();
return undo();
}
@@ -0,0 +1,156 @@
import type { KeyboardEvent, MouseEvent } from 'react';
import { describe, expect, it, vi } from 'vitest';
import {
createLiveSearchScopeBackspaceState,
handleLiveSearchScopeBackspace,
handleLiveSearchScopeBadgeClick,
handleLiveSearchScopeGhostClick,
handleLiveSearchScopeUndo,
isLiveSearchDropdownBlocked,
liveSearchScopePlaceholderKey,
noteLiveSearchScopeQueryInput,
resetLiveSearchScopeBackspaceState,
resolveLiveSearchScopeGhost,
} from '@/features/search/components/liveSearchScope';
function keyEvent(
key: string,
mods: Partial<KeyboardEvent<HTMLInputElement>> & { code?: string } = {},
) {
const { code, ...rest } = mods;
return {
key,
code: code ?? key,
ctrlKey: false,
metaKey: false,
shiftKey: false,
preventDefault: vi.fn(),
...rest,
} as unknown as KeyboardEvent<HTMLInputElement>;
}
describe('resolveLiveSearchScopeGhost', () => {
it('offers artists ghost on artists browse when scope was cleared', () => {
expect(resolveLiveSearchScopeGhost('/artists', null)).toBe('artists');
expect(resolveLiveSearchScopeGhost('/artists', 'artists')).toBeNull();
expect(resolveLiveSearchScopeGhost('/albums', null)).toBe('albums');
expect(resolveLiveSearchScopeGhost('/albums', 'albums')).toBeNull();
expect(resolveLiveSearchScopeGhost('/new-releases', null)).toBe('newReleases');
expect(resolveLiveSearchScopeGhost('/new-releases', 'newReleases')).toBeNull();
expect(resolveLiveSearchScopeGhost('/tracks', null)).toBe('tracks');
expect(resolveLiveSearchScopeGhost('/tracks', 'tracks')).toBeNull();
expect(resolveLiveSearchScopeGhost('/composers', null)).toBe('composers');
expect(resolveLiveSearchScopeGhost('/composers', 'composers')).toBeNull();
});
});
describe('handleLiveSearchScopeBadgeClick', () => {
it('clears scope with undo', () => {
const clearScope = vi.fn();
const e = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as MouseEvent<HTMLElement>;
handleLiveSearchScopeBadgeClick(e, clearScope);
expect(clearScope).toHaveBeenCalledWith({ recordUndo: true });
});
});
describe('handleLiveSearchScopeGhostClick', () => {
it('restores scope with undo', () => {
const setScope = vi.fn();
const e = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as MouseEvent<HTMLElement>;
handleLiveSearchScopeGhostClick(e, 'artists', setScope);
expect(setScope).toHaveBeenCalledWith('artists', { recordUndo: true });
});
});
describe('handleLiveSearchScopeBackspace', () => {
it('clears scope on first Backspace when the field was never filled', () => {
const clearScope = vi.fn();
const state = createLiveSearchScopeBackspaceState();
const e = keyEvent('Backspace');
expect(handleLiveSearchScopeBackspace(e, '', 'artists', clearScope, state)).toBe(true);
expect(clearScope).toHaveBeenCalledWith({ recordUndo: true });
});
it('requires two Backspaces on empty after prior input', () => {
const clearScope = vi.fn();
const state = createLiveSearchScopeBackspaceState();
noteLiveSearchScopeQueryInput(state, 'ab');
const first = keyEvent('Backspace');
expect(handleLiveSearchScopeBackspace(first, '', 'artists', clearScope, state)).toBe(true);
expect(clearScope).not.toHaveBeenCalled();
const second = keyEvent('Backspace');
expect(handleLiveSearchScopeBackspace(second, '', 'artists', clearScope, state)).toBe(true);
expect(clearScope).toHaveBeenCalledWith({ recordUndo: true });
});
it('does not clear scope while text remains', () => {
const clearScope = vi.fn();
const state = createLiveSearchScopeBackspaceState();
expect(handleLiveSearchScopeBackspace(keyEvent('Backspace'), 'a', 'artists', clearScope, state)).toBe(false);
expect(clearScope).not.toHaveBeenCalled();
});
it('resets empty streak when deleting characters', () => {
const clearScope = vi.fn();
const state = createLiveSearchScopeBackspaceState();
noteLiveSearchScopeQueryInput(state, 'a');
handleLiveSearchScopeBackspace(keyEvent('Backspace'), '', 'artists', clearScope, state);
expect(state.emptyBackspaceStreak).toBe(1);
handleLiveSearchScopeBackspace(keyEvent('Backspace'), 'x', 'artists', clearScope, state);
expect(state.emptyBackspaceStreak).toBe(0);
});
});
describe('resetLiveSearchScopeBackspaceState', () => {
it('clears hadQueryInput and streak', () => {
const state = createLiveSearchScopeBackspaceState();
noteLiveSearchScopeQueryInput(state, 'x');
state.emptyBackspaceStreak = 1;
resetLiveSearchScopeBackspaceState(state);
expect(state.hadQueryInput).toBe(false);
expect(state.emptyBackspaceStreak).toBe(0);
});
});
describe('isLiveSearchDropdownBlocked', () => {
it('blocks dropdown when a browse scope badge is active', () => {
expect(isLiveSearchDropdownBlocked('artists')).toBe(true);
expect(isLiveSearchDropdownBlocked(null)).toBe(false);
});
});
describe('liveSearchScopePlaceholderKey', () => {
it('uses scoped placeholders when a browse scope badge is active', () => {
expect(liveSearchScopePlaceholderKey('artists')).toBe('search.scopeArtistsPlaceholder');
expect(liveSearchScopePlaceholderKey('albums')).toBe('search.scopeAlbumsPlaceholder');
expect(liveSearchScopePlaceholderKey('newReleases')).toBe('search.scopeNewReleasesPlaceholder');
expect(liveSearchScopePlaceholderKey('tracks')).toBe('search.scopeTracksPlaceholder');
expect(liveSearchScopePlaceholderKey('composers')).toBe('search.scopeComposersPlaceholder');
expect(liveSearchScopePlaceholderKey(null)).toBe('search.placeholder');
});
});
describe('handleLiveSearchScopeUndo', () => {
it('calls undo on Ctrl+Z (English layout)', () => {
const undo = vi.fn(() => true);
const e = keyEvent('z', { ctrlKey: true, code: 'KeyZ' });
expect(handleLiveSearchScopeUndo(e, undo)).toBe(true);
expect(e.preventDefault).toHaveBeenCalled();
expect(undo).toHaveBeenCalled();
});
it('calls undo on Ctrl+Z with non-Latin key label (e.g. Russian Я)', () => {
const undo = vi.fn(() => true);
const e = keyEvent('я', { ctrlKey: true, code: 'KeyZ' });
expect(handleLiveSearchScopeUndo(e, undo)).toBe(true);
expect(undo).toHaveBeenCalled();
});
it('ignores plain z', () => {
const undo = vi.fn();
expect(handleLiveSearchScopeUndo(keyEvent('z'), undo)).toBe(false);
expect(undo).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,71 @@
import { useTranslation } from 'react-i18next';
import { ALL_NAV_ITEMS } from '@/config/navItems';
import type { LiveSearchScope } from '@/store/liveSearchScopeStore';
import {
SCOPE_NAV_ITEM,
handleLiveSearchScopeBadgeClick,
handleLiveSearchScopeGhostClick,
liveSearchScopeBadgeTooltipKey,
liveSearchScopeGhostTooltipKey,
} from '@/features/search/components/liveSearchScope';
type LiveSearchScopeIconProps = {
scope: LiveSearchScope;
size?: number;
};
/** Sidebar nav icon for the scoped browse page (e.g. Users for Artists). */
export function LiveSearchScopeIcon({ scope, size = 14 }: LiveSearchScopeIconProps) {
const Icon = ALL_NAV_ITEMS[SCOPE_NAV_ITEM[scope]].icon;
return <Icon size={size} aria-hidden />;
}
type LiveSearchScopeBadgeProps = {
scope: LiveSearchScope;
className: string;
clearScope: (options?: { recordUndo?: boolean }) => void;
};
export function LiveSearchScopeBadge({ scope, className, clearScope }: LiveSearchScopeBadgeProps) {
const { t } = useTranslation();
const tooltip = t(liveSearchScopeBadgeTooltipKey(scope));
return (
<span
className={className}
role="button"
tabIndex={-1}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
aria-label={tooltip}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => handleLiveSearchScopeBadgeClick(e, clearScope)}
>
<LiveSearchScopeIcon scope={scope} size={14} />
</span>
);
}
type LiveSearchScopeGhostBadgeProps = {
scope: LiveSearchScope;
className: string;
setScope: (scope: LiveSearchScope, options?: { recordUndo?: boolean }) => void;
};
export function LiveSearchScopeGhostBadge({ scope, className, setScope }: LiveSearchScopeGhostBadgeProps) {
const { t } = useTranslation();
const tooltip = t(liveSearchScopeGhostTooltipKey(scope));
return (
<span
className={className}
role="button"
tabIndex={-1}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
aria-label={tooltip}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => handleLiveSearchScopeGhostClick(e, scope, setScope)}
>
<LiveSearchScopeIcon scope={scope} size={14} />
</span>
);
}
@@ -0,0 +1,47 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
import { syncLiveSearchRouteScope } from '@/features/search/hooks/useLiveSearchRouteScope';
describe('syncLiveSearchRouteScope', () => {
beforeEach(() => {
useLiveSearchScopeStore.setState({ query: '', scope: null, undoStack: [] });
});
it('activates scope on supported browse routes', () => {
syncLiveSearchRouteScope('/albums');
expect(useLiveSearchScopeStore.getState().scope).toBe('albums');
syncLiveSearchRouteScope('/tracks');
expect(useLiveSearchScopeStore.getState().scope).toBe('tracks');
syncLiveSearchRouteScope('/composers');
expect(useLiveSearchScopeStore.getState().scope).toBe('composers');
});
it('clears scope and query when leaving browse routes', () => {
useLiveSearchScopeStore.setState({ query: 'beatles', scope: 'albums' });
syncLiveSearchRouteScope('/album/abc123');
expect(useLiveSearchScopeStore.getState().scope).toBeNull();
expect(useLiveSearchScopeStore.getState().query).toBe('');
});
it('clears query when leaving browse with scope already cleared (ghost mode)', () => {
useLiveSearchScopeStore.setState({ query: 'beatles', scope: null });
syncLiveSearchRouteScope('/album/abc123');
expect(useLiveSearchScopeStore.getState().scope).toBeNull();
expect(useLiveSearchScopeStore.getState().query).toBe('');
});
it('preserves query when switching between browse routes', () => {
useLiveSearchScopeStore.setState({ query: 'jazz', scope: 'albums' });
syncLiveSearchRouteScope('/artists');
expect(useLiveSearchScopeStore.getState().scope).toBe('artists');
expect(useLiveSearchScopeStore.getState().query).toBe('jazz');
});
});
@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/store/albumBrowseSessionStore';
import { isArtistsBrowsePath } from '@/store/artistBrowseSessionStore';
import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore';
import { isComposersBrowsePath } from '@/store/composerBrowseSessionStore';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
/** Keep scope badge in sync with browse routes; clear field text when leaving browse. */
export function syncLiveSearchRouteScope(pathname: string): void {
const store = useLiveSearchScopeStore.getState();
if (isArtistsBrowsePath(pathname)) {
store.setScope('artists');
} else if (isAlbumsBrowsePath(pathname)) {
store.setScope('albums');
} else if (isNewReleasesBrowsePath(pathname)) {
store.setScope('newReleases');
} else if (isTracksBrowsePath(pathname)) {
store.setScope('tracks');
} else if (isComposersBrowsePath(pathname)) {
store.setScope('composers');
} else {
if (store.scope != null) store.clearScope();
if (store.query !== '') store.setQuery('');
}
}
/** Activate the browse scope badge when a supported route is open; clear on leave. */
export function useLiveSearchRouteScope() {
const location = useLocation();
useEffect(() => {
syncLiveSearchRouteScope(location.pathname);
}, [location.pathname]);
}
@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import type { SubsonicSong } from '@/api/subsonicTypes';
import {
resolveShareSearchPayload,
type ShareSearchResolveResult,
} from '@/utils/share/enqueueShareSearchPayload';
import type { QueueableShareSearchPayload } from '@/utils/share/shareSearch';
export type ShareQueuePreviewState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ok'; songs: SubsonicSong[]; total: number; skipped: number }
| { status: 'error'; result: Exclude<ShareSearchResolveResult, { type: 'ok' }> };
const IDLE: ShareQueuePreviewState = { status: 'idle' };
export function useShareQueuePreview(
payload: Extract<QueueableShareSearchPayload, { k: 'queue' }> | null,
open: boolean,
): ShareQueuePreviewState {
const [state, setState] = useState<ShareQueuePreviewState>(IDLE);
useEffect(() => {
if (!open || !payload) {
// 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
setState(IDLE);
return;
}
let cancelled = false;
setState({ status: 'loading' });
void resolveShareSearchPayload(payload)
.then(result => {
if (cancelled) return;
if (result.type === 'ok') {
setState({
status: 'ok',
songs: result.songs,
total: result.total,
skipped: result.skipped,
});
return;
}
setState({ status: 'error', result });
})
.catch(() => {
if (!cancelled) setState({ status: 'error', result: { type: 'error' } });
});
return () => {
cancelled = true;
};
}, [open, payload]);
return state;
}
+103
View File
@@ -0,0 +1,103 @@
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '@/hooks/useNavigateToAlbum';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
import {
activateShareSearchServer,
enqueueShareSearchPayload,
} from '@/utils/share/enqueueShareSearchPayload';
import type { ServerProfile } from '@/store/authStoreTypes';
import { findServerIdForShareUrl } from '@/utils/share/shareLink';
import { shareServerOriginLabel } from '@/utils/share/shareServerOriginLabel';
import { parseShareSearchText } from '@/utils/share/shareSearch';
import { serverIndexKeyFromUrl } from '@/utils/server/serverIndexKey';
import { useShareSearchPreview } from '@/features/search/hooks/useShareSearchPreview';
export function useShareSearch(query: string, onSuccess?: () => void) {
const { t } = useTranslation();
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const servers = useAuthStore(s => s.servers);
const activeServerId = useAuthStore(s => s.activeServerId);
const shareMatch = useMemo(() => parseShareSearchText(query), [query]);
const shareServerLabel = useMemo(
() => shareServerOriginLabel(shareMatch, servers, activeServerId),
[shareMatch, servers, activeServerId],
);
const shareCoverServer = useMemo((): ServerProfile | null => {
if (!shareMatch || shareMatch.type === 'unsupported') return null;
const serverId = findServerIdForShareUrl(servers, shareMatch.payload.srv);
if (!serverId || serverId === activeServerId) return null;
return servers.find(s => s.id === serverId)
?? servers.find(s => serverIndexKeyFromUrl(s.url) === serverId)
?? null;
}, [shareMatch, servers, activeServerId]);
const preview = useShareSearchPreview(shareMatch);
const [shareQueueBusy, setShareQueueBusy] = useState(false);
const canQueueShareMatch =
shareMatch?.type === 'queueable' &&
(shareMatch.payload.k === 'queue' ||
(!preview.shareTrackResolving && !!preview.shareTrackSong));
const canOpenShareAlbum =
shareMatch?.type === 'album' && !!preview.shareAlbum && !preview.shareAlbumResolving;
const canOpenShareArtist =
shareMatch?.type === 'artist' && !!preview.shareArtist && !preview.shareArtistResolving;
const canOpenShareComposer =
shareMatch?.type === 'composer' && !!preview.shareComposer && !preview.shareComposerResolving;
const hasShareKeyboardTarget =
canQueueShareMatch || canOpenShareAlbum || canOpenShareArtist || canOpenShareComposer;
const openShareAlbum = useCallback(() => {
if (shareMatch?.type !== 'album' || !preview.shareAlbum) return;
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
navigateToAlbum(preview.shareAlbum.id);
onSuccess?.();
}, [shareMatch, preview.shareAlbum, navigateToAlbum, t, onSuccess]);
const openShareArtist = useCallback(() => {
if (shareMatch?.type !== 'artist' || !preview.shareArtist) return;
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
navigate(`/artist/${preview.shareArtist.id}`);
onSuccess?.();
}, [shareMatch, preview.shareArtist, navigate, t, onSuccess]);
const openShareComposer = useCallback(() => {
if (shareMatch?.type !== 'composer' || !preview.shareComposer) return;
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
navigate(`/composer/${preview.shareComposer.id}`);
onSuccess?.();
}, [shareMatch, preview.shareComposer, navigate, t, onSuccess]);
const enqueueShareMatch = useCallback(async () => {
if (shareMatch?.type !== 'queueable' || shareQueueBusy) return false;
if (shareMatch.payload.k === 'track' && (!preview.shareTrackSong || preview.shareTrackResolving)) {
return false;
}
setShareQueueBusy(true);
const ok = await enqueueShareSearchPayload(shareMatch.payload, t);
setShareQueueBusy(false);
if (ok) onSuccess?.();
return ok;
}, [shareMatch, shareQueueBusy, preview.shareTrackSong, preview.shareTrackResolving, t, onSuccess]);
return {
shareMatch,
shareServerLabel,
shareCoverServer,
shareQueueBusy,
canQueueShareMatch,
canOpenShareAlbum,
canOpenShareArtist,
canOpenShareComposer,
hasShareKeyboardTarget,
openShareAlbum,
openShareArtist,
openShareComposer,
enqueueShareMatch,
...preview,
};
}
@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/api/subsonicTypes';
import {
resolveShareSearchAlbum,
resolveShareSearchArtist,
resolveShareSearchPayload,
} from '@/utils/share/enqueueShareSearchPayload';
import type { ShareSearchMatch } from '@/utils/share/shareSearch';
export interface ShareSearchPreviewState {
shareTrackSong: SubsonicSong | null;
shareTrackResolving: boolean;
shareTrackUnavailable: boolean;
shareAlbum: SubsonicAlbum | null;
shareAlbumResolving: boolean;
shareAlbumUnavailable: boolean;
shareArtist: SubsonicArtist | null;
shareArtistResolving: boolean;
shareArtistUnavailable: boolean;
shareComposer: SubsonicArtist | null;
shareComposerResolving: boolean;
shareComposerUnavailable: boolean;
}
const EMPTY_PREVIEW: ShareSearchPreviewState = {
shareTrackSong: null,
shareTrackResolving: false,
shareTrackUnavailable: false,
shareAlbum: null,
shareAlbumResolving: false,
shareAlbumUnavailable: false,
shareArtist: null,
shareArtistResolving: false,
shareArtistUnavailable: false,
shareComposer: null,
shareComposerResolving: false,
shareComposerUnavailable: false,
};
export function useShareSearchPreview(shareMatch: ShareSearchMatch | null): ShareSearchPreviewState {
const [preview, setPreview] = useState<ShareSearchPreviewState>(EMPTY_PREVIEW);
useEffect(() => {
let cancelled = 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
setPreview(EMPTY_PREVIEW);
if (shareMatch?.type === 'queueable' && shareMatch.payload.k === 'track') {
setPreview({ ...EMPTY_PREVIEW, shareTrackResolving: true });
void resolveShareSearchPayload(shareMatch.payload)
.then(result => {
if (cancelled) return;
setPreview({
...EMPTY_PREVIEW,
shareTrackSong: result.type === 'ok' ? (result.songs[0] ?? null) : null,
shareTrackUnavailable: result.type !== 'ok' || result.songs.length === 0,
});
})
.finally(() => {
if (!cancelled) {
setPreview(current => ({ ...current, shareTrackResolving: false }));
}
});
return () => {
cancelled = true;
};
}
if (shareMatch?.type === 'artist') {
setPreview({ ...EMPTY_PREVIEW, shareArtistResolving: true });
void resolveShareSearchArtist(shareMatch.payload)
.then(result => {
if (cancelled) return;
setPreview({
...EMPTY_PREVIEW,
shareArtist: result.type === 'ok' ? result.artist : null,
shareArtistUnavailable: result.type !== 'ok',
});
})
.finally(() => {
if (!cancelled) {
setPreview(current => ({ ...current, shareArtistResolving: false }));
}
});
return () => {
cancelled = true;
};
}
if (shareMatch?.type === 'composer') {
setPreview({ ...EMPTY_PREVIEW, shareComposerResolving: true });
void resolveShareSearchArtist(shareMatch.payload)
.then(result => {
if (cancelled) return;
setPreview({
...EMPTY_PREVIEW,
shareComposer: result.type === 'ok' ? result.artist : null,
shareComposerUnavailable: result.type !== 'ok',
});
})
.finally(() => {
if (!cancelled) {
setPreview(current => ({ ...current, shareComposerResolving: false }));
}
});
return () => {
cancelled = true;
};
}
if (shareMatch?.type === 'album') {
setPreview({ ...EMPTY_PREVIEW, shareAlbumResolving: true });
void resolveShareSearchAlbum(shareMatch.payload)
.then(result => {
if (cancelled) return;
setPreview({
...EMPTY_PREVIEW,
shareAlbum: result.type === 'ok' ? result.album : null,
shareAlbumUnavailable: result.type !== 'ok',
});
})
.finally(() => {
if (!cancelled) {
setPreview(current => ({ ...current, shareAlbumResolving: false }));
}
});
return () => {
cancelled = true;
};
}
return () => {
cancelled = true;
};
}, [shareMatch]);
return preview;
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Search feature — the live search dropdown, the mobile search overlay, the
* search/browse page (lazy via the deep path `pages/SearchBrowsePage`), live
* search scope UI/state, and the share-link search/queue-preview surfaces.
*
* The local-index query engine (`utils/library/*search*`), the shared Subsonic
* search API, the cross-cutting `liveSearchScopeStore`, and the
* `advancedSearch*` session/scroll state live outside this feature
* (library-core / cross-cutting) — this feature consumes them.
*/
export { default as LiveSearch } from './components/LiveSearch';
export { default as MobileSearchOverlay } from './components/MobileSearchOverlay';
export { default as ShareQueuePreviewModal } from './components/ShareQueuePreviewModal';
export { useLiveSearchRouteScope } from './hooks/useLiveSearchRouteScope';
export { useShareQueuePreview } from './hooks/useShareQueuePreview';
File diff suppressed because it is too large Load Diff