mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(search): co-locate search feature into features/search
This commit is contained in:
@@ -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 effect’s 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 effect’s 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 effect’s 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user