mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
4ac373a65b
* feat(artists): scoped live search badge replaces page filter Move Artists browse text search into the header Live Search with a page scope badge (Users icon), field-local undo, and double-click/backspace to clear scope. Block the live-search dropdown while scoped so results only filter the Artists grid; mobile overlay follows the same rules. * fix(artists): plain grid for scoped search fixes broken card layout Route the browse grid through VirtualCardGrid, switch to non-virtual CSS grid when live search filters the catalog, reset scroll on filter changes, and skip content-visibility on plain tiles to avoid blank/black cards. * fix(search): scope badge double-Backspace and single clear control Require two Backspaces on an empty scoped field after prior text input; one Backspace still clears the badge when the field was never filled. Move live-search clear/advanced controls inside the field pill, drop the extra outer clear button, and use type=text to avoid native search clears. * fix(search): drop duplicate outer live-search clear button Keep the native in-field clear on type=search and the original pill layout; remove only the extra × control outside the search border. Reset dropdown state when the query is cleared via the native control. * refactor(search): generic scoped browse query helper, drop dead code Rename artistsBrowseSearchQuery to scopedBrowseSearchQuery with an expectedScope argument; wire Artists via useScopedBrowseSearchQuery. Remove unused liveSearchScoped dropdown helper (scoped mode blocks it). * feat(search): ghost scope badge and single-click badge remove After clearing the artists scope on /artists, show a faded ghost chip to restore page-only search while keeping the global search placeholder. Active badge removes on one click; tooltips and styles updated. * feat(search): scoped live search for All Albums and New Releases Wire albums and newReleases scope badges with debounced album title search (local index title-only FTS + filtered search3). Plain grid, scroll reset, and session query restore on album grid browse pages. * fix(browse): preserve scroll restore after album/artist detail back Only reset in-page scroll when filter resetKey changes, not when isScrollRestorePending clears after session restore. * feat(search): scoped live search for Tracks browse Wire /tracks to header live search with wide title/artist/album FTS, hide hero and discovery rails while search is active, and remove the inline search field from the browse list. * fix(search): clear header query when leaving scoped browse pages Prevent global live search from firing on album/detail routes after a scoped browse query; browse session stashes still restore on back. * fix(tracks): restore scroll after back from detail during scoped search Hold stashed song results across fetchSongPage churn, defer leave-stash teardown past AppShell scroll reset, restore tracks scroll after the list is ready, and save scroll snapshot when opening artist from song context menu. * fix(tracks): hide discovery headings during scoped search Hide the page subtitle and "Browse all tracks" section title when tracks search is active, matching hero/rails chrome behavior. * feat(search): scoped live search for Composers browse Wire /composers to header live search with composers scope badge, session stash, scroll restore, and plain grid/list during text filter. Remove the in-page filter input; add i18n and navigation helpers. * docs: CHANGELOG and credits for scoped browse live search (PR #938)
1200 lines
44 KiB
TypeScript
1200 lines
44 KiB
TypeScript
import { getGenres, getAlbumsByGenre } from '../api/subsonicGenres';
|
||
import { search, searchSongsPaged } from '../api/subsonicSearch';
|
||
import { getRandomSongs } from '../api/subsonicLibrary';
|
||
import type { SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||
import { useLocation, useNavigate, useNavigationType, useSearchParams } from 'react-router-dom';
|
||
import { SlidersVertical, Search, X } from 'lucide-react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import AlbumRow from '../components/AlbumRow';
|
||
import ArtistRow from '../components/ArtistRow';
|
||
import PagedSongList from '../components/PagedSongList';
|
||
import CustomSelect from '../components/CustomSelect';
|
||
import StarFilterButton from '../components/StarFilterButton';
|
||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||
import { useAuthStore } from '../store/authStore';
|
||
import { usePlayerStore } from '../store/playerStore';
|
||
import { isAdvancedSearchLeaveTargetPath } from '../store/albumBrowseSessionStore';
|
||
import {
|
||
isAdvancedSearchPath,
|
||
isAdvancedSearchPanelPath,
|
||
isTracksBrowsePath,
|
||
useAdvancedSearchSessionStore,
|
||
type AdvancedSearchSessionStash,
|
||
} from '../store/advancedSearchSessionStore';
|
||
import {
|
||
readAdvancedSearchRestore,
|
||
shouldRestoreAdvancedSearchSession,
|
||
} from '../utils/navigation/albumDetailNavigation';
|
||
import {
|
||
clearAdvancedSearchLeaveSnapshots,
|
||
consumeAdvancedSearchLeavingForDetail,
|
||
readAdvancedSearchLeaveSnapshot,
|
||
registerAdvancedSearchLeaveScrollProvider,
|
||
registerAdvancedSearchSessionProvider,
|
||
resolveAdvancedSearchLeaveSnapshot,
|
||
type AdvancedSearchLeaveSnapshot,
|
||
} from '../utils/navigation/advancedSearchScrollSnapshot';
|
||
import { restoreMainViewportScroll } from '../utils/navigation/restoreMainViewportScroll';
|
||
import {
|
||
loadMoreLocalSongs,
|
||
runNetworkAdvancedTextSearch,
|
||
runNetworkAdvancedYearAlbums,
|
||
tryRunLocalAdvancedSearch,
|
||
} from '../utils/library/advancedSearchLocal';
|
||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from '../utils/library/trackEnrichment';
|
||
import { raceSearchSources } from '../utils/library/searchRace';
|
||
import { logLibrarySearch } from '../utils/library/libraryDevLog';
|
||
import {
|
||
browseRaceCountsFullSearch,
|
||
loadMoreLocalBrowseSongs,
|
||
raceBrowseWithLocalFallback,
|
||
runLocalBrowseFullSearch,
|
||
runNetworkBrowseFullSearch,
|
||
} from '../utils/library/browseTextSearch';
|
||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||
import { MOOD_GROUP_IDS } from '../config/moodGroups';
|
||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||
import { useSongBrowseList, type SongBrowseListRestore } from '../hooks/useSongBrowseList';
|
||
import TracksPageChrome from '../components/tracks/TracksPageChrome';
|
||
import SongBrowseSection from '../components/tracks/SongBrowseSection';
|
||
import {
|
||
useLiveSearchScopeStore,
|
||
useScopedBrowseSearchQuery,
|
||
} from '../store/liveSearchScopeStore';
|
||
|
||
const MOOD_UI_ENABLED = OXIMEDIA_MOOD_SEARCH_ENABLED;
|
||
|
||
type ResultType = 'all' | 'artists' | 'albums' | 'songs';
|
||
|
||
interface SearchOpts {
|
||
query: string;
|
||
genre: string;
|
||
yearFrom: string;
|
||
yearTo: string;
|
||
bpmFrom: string;
|
||
bpmTo: string;
|
||
moodGroup: string;
|
||
losslessOnly: boolean;
|
||
resultType: ResultType;
|
||
}
|
||
|
||
interface Results {
|
||
artists: SubsonicArtist[];
|
||
albums: SubsonicAlbum[];
|
||
songs: SubsonicSong[];
|
||
}
|
||
|
||
function parseBpmInput(raw: string): number | null {
|
||
const trimmed = raw.trim();
|
||
if (!trimmed) return null;
|
||
const n = parseInt(trimmed, 10);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
function peekAdvancedSearchRestoreStash(
|
||
navigationType: ReturnType<typeof useNavigationType>,
|
||
locationState: unknown,
|
||
): AdvancedSearchSessionStash | null {
|
||
if (!shouldRestoreAdvancedSearchSession(navigationType, locationState)) return null;
|
||
return useAdvancedSearchSessionStore.getState().peekReturnStash();
|
||
}
|
||
|
||
/** Shared shell for `/search`, `/search/advanced`, and `/tracks` (pathname picks chrome). */
|
||
export default function SearchBrowsePage() {
|
||
const perfFlags = usePerfProbeFlags();
|
||
const { t } = useTranslation();
|
||
const navigationType = useNavigationType();
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
const [params] = useSearchParams();
|
||
const qFromUrl = params.get('q') ?? '';
|
||
const showTracksChrome = isTracksBrowsePath(location.pathname);
|
||
const showAdvancedPanel = isAdvancedSearchPanelPath(location.pathname);
|
||
const restoreStash = peekAdvancedSearchRestoreStash(navigationType, location.state);
|
||
const hadRestoreOnMountRef = useRef(restoreStash != null);
|
||
const restoredFromStashRef = useRef(restoreStash != null);
|
||
|
||
const [query, setQuery] = useState(() => restoreStash?.query ?? qFromUrl);
|
||
const [genre, setGenre] = useState(() => restoreStash?.genre ?? '');
|
||
const [yearFrom, setYearFrom] = useState(() => restoreStash?.yearFrom ?? '');
|
||
const [yearTo, setYearTo] = useState(() => restoreStash?.yearTo ?? '');
|
||
const [bpmFrom, setBpmFrom] = useState(() => restoreStash?.bpmFrom ?? '');
|
||
const [bpmTo, setBpmTo] = useState(() => restoreStash?.bpmTo ?? '');
|
||
const [moodGroup, setMoodGroup] = useState(() => restoreStash?.moodGroup ?? '');
|
||
const [losslessOnly, setLosslessOnly] = useState(() => restoreStash?.losslessOnly ?? false);
|
||
const [resultType, setResultType] = useState<ResultType>(() => restoreStash?.resultType ?? 'all');
|
||
const [starredOnly, setStarredOnly] = useState(() => restoreStash?.starredOnly ?? false);
|
||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||
const [results, setResults] = useState<Results | null>(() => restoreStash?.results ?? null);
|
||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||
const filteredResults = useMemo<Results | null>(() => {
|
||
if (!results) return null;
|
||
if (!starredOnly) return results;
|
||
const isFav = (id: string, base: boolean | string | undefined) =>
|
||
id in starredOverrides ? !!starredOverrides[id] : !!base;
|
||
return {
|
||
artists: results.artists.filter(a => isFav(a.id, a.starred)),
|
||
albums: results.albums.filter(a => isFav(a.id, a.starred)),
|
||
songs: results.songs.filter(s => isFav(s.id, s.starred)),
|
||
};
|
||
}, [results, starredOnly, starredOverrides]);
|
||
const total = filteredResults
|
||
? filteredResults.artists.length + filteredResults.albums.length + filteredResults.songs.length
|
||
: 0;
|
||
const [loading, setLoading] = useState(false);
|
||
const [hasSearched, setHasSearched] = useState(() => restoreStash?.hasSearched ?? false);
|
||
const [genreNote, setGenreNote] = useState(() => restoreStash?.genreNote ?? false);
|
||
// True while the current results came from the local index (drives the
|
||
// pagination branch — local pages every result type, network only free-text).
|
||
const [localMode, setLocalMode] = useState(() => restoreStash?.localMode ?? false);
|
||
const [basicSearchMode, setBasicSearchMode] = useState(
|
||
() => restoreStash?.basicSearchMode ?? (!showAdvancedPanel && !showTracksChrome),
|
||
);
|
||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||
const serverId = useAuthStore(s => s.activeServerId);
|
||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||
const searchRunRef = useRef(0);
|
||
|
||
// Pagination — basic quick search uses smaller pages than advanced form search.
|
||
const BASIC_SONGS_INITIAL = 50;
|
||
const BASIC_SONGS_PAGE_SIZE = 50;
|
||
const SONGS_INITIAL = 100;
|
||
const SONGS_PAGE_SIZE = 50;
|
||
const [activeSearch, setActiveSearch] = useState<SearchOpts | null>(() => restoreStash?.activeSearch ?? null);
|
||
const [songsServerOffset, setSongsServerOffset] = useState(() => restoreStash?.songsServerOffset ?? 0);
|
||
const [songsHasMore, setSongsHasMore] = useState(() => restoreStash?.songsHasMore ?? false);
|
||
const [loadingMoreSongs, setLoadingMoreSongs] = useState(false);
|
||
|
||
const songBrowseInitialRestore: SongBrowseListRestore | null =
|
||
restoreStash && showTracksChrome
|
||
? {
|
||
query: restoreStash.query,
|
||
songs: restoreStash.results?.songs ?? [],
|
||
offset: restoreStash.songsServerOffset,
|
||
hasMore: restoreStash.songsHasMore,
|
||
localSearchMode: restoreStash.localMode,
|
||
browseUnsupported: restoreStash.tracksBrowseUnsupported ?? false,
|
||
hasSearched: restoreStash.hasSearched,
|
||
}
|
||
: null;
|
||
|
||
const tracksLiveSearchInitRef = useRef(false);
|
||
if (!tracksLiveSearchInitRef.current && restoreStash && showTracksChrome) {
|
||
tracksLiveSearchInitRef.current = true;
|
||
const store = useLiveSearchScopeStore.getState();
|
||
store.setScope('tracks');
|
||
if (restoreStash.query) store.setQuery(restoreStash.query);
|
||
}
|
||
|
||
const tracksSearchQuery = useScopedBrowseSearchQuery('tracks');
|
||
const liveSearchQuery = useLiveSearchScopeStore(s => s.query);
|
||
const tracksSearchActive =
|
||
tracksSearchQuery.trim().length > 0 || liveSearchQuery.trim().length > 0;
|
||
|
||
const songBrowse = useSongBrowseList({
|
||
enabled: showTracksChrome,
|
||
searchQuery: tracksSearchQuery,
|
||
initialRestore: songBrowseInitialRestore,
|
||
});
|
||
|
||
const restoringSession =
|
||
shouldRestoreAdvancedSearchSession(navigationType, location.state) || restoreStash != null;
|
||
const leaveSnapshotRef = useRef<AdvancedSearchLeaveSnapshot | null>(
|
||
restoringSession ? resolveAdvancedSearchLeaveSnapshot(restoreStash) : null,
|
||
);
|
||
const scrollTopRestoreTargetRef = useRef(leaveSnapshotRef.current?.scrollTop ?? 0);
|
||
const tracksSearchRestorePendingRef = useRef(
|
||
!!(songBrowseInitialRestore?.query.trim()),
|
||
);
|
||
const albumRowScrollLeftRestoreRef = useRef(leaveSnapshotRef.current?.albumRowScrollLeft ?? 0);
|
||
const artistRowScrollLeftRestoreRef = useRef(leaveSnapshotRef.current?.artistRowScrollLeft ?? 0);
|
||
const mainScrollTopRef = useRef(0);
|
||
const albumRowScrollLeftRef = useRef(0);
|
||
const artistRowScrollLeftRef = useRef(0);
|
||
const skipSearchAutoFocusRef = useRef(restoreStash != null);
|
||
const skipEnterAnimationRef = useRef(restoreStash != null || leaveSnapshotRef.current != null);
|
||
const leaveRestoreUiFinishedRef = useRef(leaveSnapshotRef.current == null);
|
||
const restoringTracksSearch = !!(restoreStash?.query.trim() && showTracksChrome);
|
||
const [tracksChromeLayoutReady, setTracksChromeLayoutReady] = useState(
|
||
() => !showTracksChrome || leaveSnapshotRef.current == null || restoringTracksSearch,
|
||
);
|
||
const [isLeaveRestorePending, setIsLeaveRestorePending] = useState(
|
||
() => leaveSnapshotRef.current != null,
|
||
);
|
||
const tracksDiscoveryHidden =
|
||
tracksSearchActive
|
||
|| (isLeaveRestorePending && !!(restoreStash?.query.trim() || songBrowseInitialRestore?.query.trim()));
|
||
|
||
const handleTracksChromeLayoutReady = useCallback(() => {
|
||
setTracksChromeLayoutReady(true);
|
||
}, []);
|
||
|
||
const finishLeaveRestoreUi = useCallback(() => {
|
||
if (leaveRestoreUiFinishedRef.current) return;
|
||
leaveRestoreUiFinishedRef.current = true;
|
||
leaveSnapshotRef.current = null;
|
||
setIsLeaveRestorePending(false);
|
||
// Defer stash teardown until after AppShell's route-change scroll reset effect.
|
||
window.setTimeout(() => {
|
||
clearAdvancedSearchLeaveSnapshots();
|
||
if (hadRestoreOnMountRef.current) {
|
||
useAdvancedSearchSessionStore.getState().clearReturnStash();
|
||
}
|
||
}, 0);
|
||
}, []);
|
||
|
||
const sessionRef = useRef<AdvancedSearchSessionStash>({
|
||
query: '',
|
||
genre: '',
|
||
yearFrom: '',
|
||
yearTo: '',
|
||
bpmFrom: '',
|
||
bpmTo: '',
|
||
moodGroup: '',
|
||
losslessOnly: false,
|
||
resultType: 'all',
|
||
starredOnly: false,
|
||
results: null,
|
||
hasSearched: false,
|
||
activeSearch: null,
|
||
localMode: false,
|
||
songsServerOffset: 0,
|
||
songsHasMore: false,
|
||
genreNote: false,
|
||
basicSearchMode: false,
|
||
tracksBrowseMode: false,
|
||
tracksBrowseUnsupported: false,
|
||
});
|
||
sessionRef.current = {
|
||
query: showTracksChrome ? liveSearchQuery : query,
|
||
genre,
|
||
yearFrom,
|
||
yearTo,
|
||
bpmFrom,
|
||
bpmTo,
|
||
moodGroup,
|
||
losslessOnly,
|
||
resultType,
|
||
starredOnly,
|
||
results: showTracksChrome
|
||
? { artists: [], albums: [], songs: songBrowse.songs }
|
||
: results,
|
||
hasSearched: showTracksChrome ? songBrowse.hasSearched : hasSearched,
|
||
activeSearch,
|
||
localMode: showTracksChrome ? songBrowse.localSearchMode : localMode,
|
||
songsServerOffset: showTracksChrome ? songBrowse.offset : songsServerOffset,
|
||
songsHasMore: showTracksChrome ? songBrowse.hasMore : songsHasMore,
|
||
genreNote,
|
||
basicSearchMode: showTracksChrome ? false : basicSearchMode,
|
||
tracksBrowseMode: showTracksChrome,
|
||
tracksBrowseUnsupported: showTracksChrome ? songBrowse.browseUnsupported : false,
|
||
};
|
||
|
||
useEffect(() => {
|
||
const unregisterScroll = registerAdvancedSearchLeaveScrollProvider(() => ({
|
||
scrollTop: mainScrollTopRef.current,
|
||
albumRowScrollLeft: albumRowScrollLeftRef.current,
|
||
artistRowScrollLeft: artistRowScrollLeftRef.current,
|
||
}));
|
||
const unregisterSession = registerAdvancedSearchSessionProvider(() => sessionRef.current);
|
||
return () => {
|
||
unregisterScroll();
|
||
unregisterSession();
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
||
if (!el) return;
|
||
const syncScroll = () => {
|
||
mainScrollTopRef.current = el.scrollTop;
|
||
};
|
||
syncScroll();
|
||
el.addEventListener('scroll', syncScroll, { passive: true });
|
||
return () => el.removeEventListener('scroll', syncScroll);
|
||
}, []);
|
||
|
||
const applySongFilters = (
|
||
list: SubsonicSong[],
|
||
g: string,
|
||
from: number | null,
|
||
to: number | null,
|
||
bpmLo: number | null,
|
||
bpmHi: number | null,
|
||
lossless = false,
|
||
): SubsonicSong[] => {
|
||
let r = list;
|
||
if (g) r = r.filter(s => s.genre?.toLowerCase() === g.toLowerCase());
|
||
if (from !== null) r = r.filter(s => !s.year || s.year >= from);
|
||
if (to !== null) r = r.filter(s => !s.year || s.year <= to);
|
||
if (bpmLo !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm >= bpmLo);
|
||
if (bpmHi !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm <= bpmHi);
|
||
if (lossless) r = r.filter(s => isLosslessSuffix(s.suffix));
|
||
return r;
|
||
};
|
||
|
||
const runBasicSearch = async (rawQuery: string) => {
|
||
const q = rawQuery.trim();
|
||
const runId = ++searchRunRef.current;
|
||
const isStale = () => runId !== searchRunRef.current;
|
||
|
||
setLoading(true);
|
||
setHasSearched(true);
|
||
setGenreNote(false);
|
||
setBasicSearchMode(true);
|
||
setQuery(q);
|
||
setActiveSearch({
|
||
query: q,
|
||
genre: '',
|
||
yearFrom: '',
|
||
yearTo: '',
|
||
bpmFrom: '',
|
||
bpmTo: '',
|
||
moodGroup: '',
|
||
losslessOnly: false,
|
||
resultType: 'all',
|
||
});
|
||
setSongsServerOffset(0);
|
||
setSongsHasMore(false);
|
||
setLocalMode(false);
|
||
|
||
if (!q) {
|
||
setResults(null);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (serverId && indexEnabled) {
|
||
const outcome = await raceBrowseWithLocalFallback(
|
||
isStale,
|
||
() => runLocalBrowseFullSearch(serverId, q, BASIC_SONGS_INITIAL),
|
||
() => runNetworkBrowseFullSearch(q, BASIC_SONGS_INITIAL),
|
||
{
|
||
surface: 'search_results',
|
||
query: q,
|
||
indexEnabled,
|
||
counts: browseRaceCountsFullSearch,
|
||
},
|
||
);
|
||
if (isStale()) return;
|
||
if (outcome) {
|
||
setResults(outcome.result);
|
||
setSongsServerOffset(outcome.result.songs.length);
|
||
setSongsHasMore(outcome.result.songs.length >= BASIC_SONGS_INITIAL);
|
||
setLocalMode(outcome.source === 'local');
|
||
return;
|
||
}
|
||
}
|
||
|
||
const network = await runNetworkBrowseFullSearch(q, BASIC_SONGS_INITIAL);
|
||
if (isStale()) return;
|
||
if (network) {
|
||
setResults(network);
|
||
setSongsServerOffset(network.songs.length);
|
||
setSongsHasMore(network.songs.length >= BASIC_SONGS_INITIAL);
|
||
} else {
|
||
setResults({ artists: [], albums: [], songs: [] });
|
||
}
|
||
} catch {
|
||
if (!isStale()) setResults(null);
|
||
} finally {
|
||
if (!isStale()) setLoading(false);
|
||
}
|
||
};
|
||
|
||
const runSearch = async (opts: SearchOpts) => {
|
||
const runId = ++searchRunRef.current;
|
||
const isStale = () => runId !== searchRunRef.current;
|
||
|
||
setLoading(true);
|
||
setHasSearched(true);
|
||
setGenreNote(false);
|
||
setBasicSearchMode(false);
|
||
setActiveSearch(opts);
|
||
setSongsServerOffset(0);
|
||
setSongsHasMore(false);
|
||
const q = opts.query.trim();
|
||
const searchT0 = performance.now();
|
||
const moodFilterActive = MOOD_UI_ENABLED && !!opts.moodGroup;
|
||
const bpmFilterActive = !!(opts.bpmFrom || opts.bpmTo);
|
||
const losslessFilterActive = opts.losslessOnly;
|
||
const trackOnlyFilterActive = moodFilterActive || bpmFilterActive;
|
||
|
||
// Track-only filters (BPM dual-storage, mood) need the local index for full coverage.
|
||
// Lossless skips the race — network search3 cannot filter albums by format reliably.
|
||
if (q && serverId && indexEnabled && !trackOnlyFilterActive && !losslessFilterActive) {
|
||
try {
|
||
const winner = await raceSearchSources(
|
||
[
|
||
{
|
||
source: 'local',
|
||
run: () => tryRunLocalAdvancedSearch(serverId, opts, SONGS_INITIAL, true),
|
||
},
|
||
{
|
||
source: 'network',
|
||
run: () => runNetworkAdvancedTextSearch(opts, SONGS_INITIAL),
|
||
},
|
||
],
|
||
isStale,
|
||
);
|
||
if (isStale()) return;
|
||
if (winner) {
|
||
setResults({
|
||
artists: winner.result.artists,
|
||
albums: winner.result.albums,
|
||
songs: winner.result.songs,
|
||
});
|
||
setSongsServerOffset(winner.result.songs.length);
|
||
setSongsHasMore(winner.result.songs.length >= SONGS_INITIAL);
|
||
setLocalMode(winner.source === 'local');
|
||
logLibrarySearch({
|
||
at: new Date().toISOString(),
|
||
query: q,
|
||
path: 'search_race',
|
||
surface: 'advanced_search',
|
||
durationMs: Math.round(performance.now() - searchT0),
|
||
indexEnabled,
|
||
raceWinner: winner.source,
|
||
raceWinnerMs: winner.durationMs,
|
||
counts: {
|
||
artists: winner.result.artists.length,
|
||
albums: winner.result.albums.length,
|
||
songs: winner.result.songs.length,
|
||
},
|
||
});
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
} catch {
|
||
if (isStale()) return;
|
||
}
|
||
setLocalMode(false);
|
||
} else if (serverId && indexEnabled) {
|
||
const localPage = await tryRunLocalAdvancedSearch(serverId, opts, SONGS_INITIAL);
|
||
if (isStale()) return;
|
||
if (localPage) {
|
||
setResults({
|
||
artists: localPage.artists,
|
||
albums: localPage.albums,
|
||
songs: localPage.songs,
|
||
});
|
||
setSongsServerOffset(localPage.songs.length);
|
||
setSongsHasMore(localPage.songs.length >= SONGS_INITIAL);
|
||
setLocalMode(true);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
if (trackOnlyFilterActive) {
|
||
setResults({ artists: [], albums: [], songs: [] });
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
setLocalMode(false);
|
||
} else {
|
||
setLocalMode(false);
|
||
}
|
||
|
||
if ((trackOnlyFilterActive || losslessFilterActive) && !indexEnabled) {
|
||
setResults({ artists: [], albums: [], songs: [] });
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
const { genre: g, yearFrom: yf, yearTo: yt, bpmFrom: bf, bpmTo: bt, losslessOnly: lossless, resultType: rt } = opts;
|
||
const from = yf ? parseInt(yf) : null;
|
||
const to = yt ? parseInt(yt) : null;
|
||
const bpmLo = bf ? parseInt(bf) : null;
|
||
const bpmHi = bt ? parseInt(bt) : null;
|
||
|
||
let artists: SubsonicArtist[] = [];
|
||
let albums: SubsonicAlbum[] = [];
|
||
let songs: SubsonicSong[] = [];
|
||
|
||
try {
|
||
if (q.trim()) {
|
||
const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL });
|
||
artists = r.artists;
|
||
albums = r.albums;
|
||
songs = applySongFilters(r.songs, g, from, to, bpmLo, bpmHi, lossless);
|
||
|
||
if (g) {
|
||
albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
|
||
}
|
||
if (from !== null) {
|
||
albums = albums.filter(a => !a.year || a.year >= from);
|
||
}
|
||
if (to !== null) {
|
||
albums = albums.filter(a => !a.year || a.year <= to);
|
||
}
|
||
if (lossless) {
|
||
const albumIds = new Set(songs.map(s => s.albumId).filter(Boolean));
|
||
albums = albums.filter(a => albumIds.has(a.id));
|
||
const artistIds = new Set(songs.map(s => s.artistId).filter(Boolean));
|
||
artists = artists.filter(a => artistIds.has(a.id));
|
||
}
|
||
|
||
// Only the free-text branch supports server-side pagination via search3 offset.
|
||
// If the server returned a full page, more probably exist.
|
||
setSongsServerOffset(r.songs.length);
|
||
setSongsHasMore(r.songs.length === SONGS_INITIAL);
|
||
} else if (g) {
|
||
const [albumRes, songRes] = await Promise.all([
|
||
rt === 'songs' || rt === 'artists' ? Promise.resolve([]) : getAlbumsByGenre(g, 50),
|
||
rt === 'albums' || rt === 'artists' ? Promise.resolve([]) : getRandomSongs(100, g),
|
||
]);
|
||
albums = albumRes as SubsonicAlbum[];
|
||
songs = songRes as SubsonicSong[];
|
||
songs = applySongFilters(songs, g, from, to, bpmLo, bpmHi, lossless);
|
||
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
|
||
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
|
||
if (songs.length > 0) setGenreNote(true);
|
||
} else if (from !== null || to !== null) {
|
||
if (rt !== 'artists' && rt !== 'songs') {
|
||
albums = await runNetworkAdvancedYearAlbums(opts, 100);
|
||
}
|
||
}
|
||
|
||
const finalResults = {
|
||
artists: rt === 'albums' || rt === 'songs' ? [] : artists,
|
||
albums: rt === 'artists' || rt === 'songs' ? [] : albums,
|
||
songs: rt === 'artists' || rt === 'albums' ? [] : songs,
|
||
};
|
||
setResults(finalResults);
|
||
if (q.trim()) {
|
||
logLibrarySearch({
|
||
at: new Date().toISOString(),
|
||
query: q,
|
||
path: 'search3',
|
||
surface: 'advanced_search',
|
||
source: 'network',
|
||
durationMs: Math.round(performance.now() - searchT0),
|
||
indexEnabled,
|
||
counts: {
|
||
artists: finalResults.artists.length,
|
||
albums: finalResults.albums.length,
|
||
songs: finalResults.songs.length,
|
||
},
|
||
});
|
||
}
|
||
} catch {
|
||
setResults({ artists: [], albums: [], songs: [] });
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
const path = window.location.pathname;
|
||
const leaving = consumeAdvancedSearchLeavingForDetail();
|
||
const existingLeave = useAdvancedSearchSessionStore.getState().peekLeaveScrollSnapshot();
|
||
if (isAdvancedSearchLeaveTargetPath(path) || leaving || existingLeave) {
|
||
const snapshot = existingLeave ?? readAdvancedSearchLeaveSnapshot();
|
||
useAdvancedSearchSessionStore.getState().setLeaveScrollSnapshot(snapshot);
|
||
useAdvancedSearchSessionStore.getState().stashReturnSession({
|
||
...sessionRef.current,
|
||
scrollTop: snapshot.scrollTop,
|
||
albumRowScrollLeft: snapshot.albumRowScrollLeft,
|
||
artistRowScrollLeft: snapshot.artistRowScrollLeft,
|
||
});
|
||
} else if (!isAdvancedSearchPath(path)) {
|
||
useAdvancedSearchSessionStore.getState().clearReturnStash();
|
||
clearAdvancedSearchLeaveSnapshots();
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (shouldRestoreAdvancedSearchSession(navigationType, location.state)) {
|
||
restoredFromStashRef.current = true;
|
||
const stash = useAdvancedSearchSessionStore.getState().peekReturnStash();
|
||
if (stash) {
|
||
setQuery(stash.query);
|
||
if (showTracksChrome) {
|
||
const store = useLiveSearchScopeStore.getState();
|
||
store.setScope('tracks');
|
||
store.setQuery(stash.query);
|
||
}
|
||
setGenre(stash.genre);
|
||
setYearFrom(stash.yearFrom);
|
||
setYearTo(stash.yearTo);
|
||
setBpmFrom(stash.bpmFrom);
|
||
setBpmTo(stash.bpmTo);
|
||
setMoodGroup(stash.moodGroup);
|
||
setLosslessOnly(stash.losslessOnly);
|
||
setResultType(stash.resultType);
|
||
setStarredOnly(stash.starredOnly);
|
||
setResults(stash.results);
|
||
setHasSearched(stash.hasSearched);
|
||
setActiveSearch(stash.activeSearch);
|
||
setLocalMode(stash.localMode);
|
||
setSongsServerOffset(stash.songsServerOffset);
|
||
setSongsHasMore(stash.songsHasMore);
|
||
setGenreNote(stash.genreNote);
|
||
setBasicSearchMode(stash.basicSearchMode);
|
||
}
|
||
if (!leaveSnapshotRef.current) {
|
||
useAdvancedSearchSessionStore.getState().clearReturnStash();
|
||
}
|
||
return;
|
||
}
|
||
if (restoredFromStashRef.current) return;
|
||
useAdvancedSearchSessionStore.getState().clearReturnStash();
|
||
}, [navigationType, location.state]);
|
||
|
||
const tracksSearchRestoreSynced =
|
||
!tracksSearchRestorePendingRef.current
|
||
|| tracksSearchQuery.trim() === (songBrowseInitialRestore?.query.trim() ?? '');
|
||
|
||
const leaveRestoreContentReady = showTracksChrome
|
||
? tracksChromeLayoutReady
|
||
&& tracksSearchRestoreSynced
|
||
&& (
|
||
(hadRestoreOnMountRef.current && songBrowseInitialRestore != null)
|
||
|| (songBrowse.hasSearched && !songBrowse.loading)
|
||
)
|
||
: ((hadRestoreOnMountRef.current && results !== null) || (hasSearched && !loading));
|
||
|
||
useLayoutEffect(() => {
|
||
if (!leaveRestoreContentReady || leaveRestoreUiFinishedRef.current) return;
|
||
if (showTracksChrome) return;
|
||
const target = scrollTopRestoreTargetRef.current;
|
||
if (target <= 0) {
|
||
finishLeaveRestoreUi();
|
||
return;
|
||
}
|
||
return restoreMainViewportScroll(target, finishLeaveRestoreUi);
|
||
}, [leaveRestoreContentReady, finishLeaveRestoreUi, showTracksChrome]);
|
||
|
||
useEffect(() => {
|
||
if (!showTracksChrome || leaveRestoreUiFinishedRef.current) return;
|
||
if (!leaveRestoreContentReady) return;
|
||
const target = scrollTopRestoreTargetRef.current;
|
||
if (target <= 0) {
|
||
finishLeaveRestoreUi();
|
||
return;
|
||
}
|
||
if (songBrowse.songs.length === 0) return;
|
||
return restoreMainViewportScroll(target, finishLeaveRestoreUi);
|
||
}, [
|
||
showTracksChrome,
|
||
leaveRestoreContentReady,
|
||
finishLeaveRestoreUi,
|
||
songBrowse.songs.length,
|
||
tracksSearchRestoreSynced,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (isLeaveRestorePending || !readAdvancedSearchRestore(location.state)) return;
|
||
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
||
}, [isLeaveRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
|
||
|
||
useEffect(() => {
|
||
getGenres().then(data =>
|
||
setGenres(data.sort((a, b) => a.value.localeCompare(b.value)))
|
||
).catch(() => {});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (hadRestoreOnMountRef.current) return;
|
||
if (showTracksChrome) return;
|
||
const q = qFromUrl.trim();
|
||
if (!q) {
|
||
if (!showAdvancedPanel) {
|
||
setResults(null);
|
||
setHasSearched(false);
|
||
}
|
||
return;
|
||
}
|
||
if (showAdvancedPanel) {
|
||
runSearch({
|
||
query: q,
|
||
genre: '',
|
||
yearFrom: '',
|
||
yearTo: '',
|
||
bpmFrom: '',
|
||
bpmTo: '',
|
||
moodGroup: '',
|
||
losslessOnly: false,
|
||
resultType: 'all',
|
||
});
|
||
} else {
|
||
void runBasicSearch(q);
|
||
}
|
||
}, [musicLibraryFilterVersion, qFromUrl, showAdvancedPanel, showTracksChrome, serverId, indexEnabled]);
|
||
|
||
const loadMoreSongs = useCallback(async () => {
|
||
if (loadingMoreSongs || !songsHasMore || !activeSearch) return;
|
||
|
||
if (basicSearchMode) {
|
||
const q = activeSearch.query.trim();
|
||
if (!q) return;
|
||
setLoadingMoreSongs(true);
|
||
try {
|
||
const page = localMode && serverId
|
||
? await loadMoreLocalBrowseSongs(serverId, q, songsServerOffset, BASIC_SONGS_PAGE_SIZE)
|
||
: await searchSongsPaged(q, BASIC_SONGS_PAGE_SIZE, songsServerOffset);
|
||
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...page] } : prev);
|
||
setSongsServerOffset(o => o + page.length);
|
||
if (page.length < BASIC_SONGS_PAGE_SIZE) setSongsHasMore(false);
|
||
} catch {
|
||
setSongsHasMore(false);
|
||
} finally {
|
||
setLoadingMoreSongs(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Local mode pages every result type (genre/year too), not just free-text.
|
||
if (localMode) {
|
||
if (!serverId) return;
|
||
setLoadingMoreSongs(true);
|
||
try {
|
||
const more = await loadMoreLocalSongs(serverId, activeSearch, songsServerOffset, SONGS_PAGE_SIZE);
|
||
setResults(prev => (prev ? { ...prev, songs: [...prev.songs, ...more] } : prev));
|
||
setSongsServerOffset(o => o + more.length);
|
||
if (more.length < SONGS_PAGE_SIZE) setSongsHasMore(false);
|
||
} catch {
|
||
setSongsHasMore(false);
|
||
} finally {
|
||
setLoadingMoreSongs(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!activeSearch.query.trim()) return;
|
||
setLoadingMoreSongs(true);
|
||
try {
|
||
const q = activeSearch.query.trim();
|
||
const g = activeSearch.genre;
|
||
const from = activeSearch.yearFrom ? parseInt(activeSearch.yearFrom) : null;
|
||
const to = activeSearch.yearTo ? parseInt(activeSearch.yearTo) : null;
|
||
const bpmLo = activeSearch.bpmFrom ? parseInt(activeSearch.bpmFrom) : null;
|
||
const bpmHi = activeSearch.bpmTo ? parseInt(activeSearch.bpmTo) : null;
|
||
const page = await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset);
|
||
const filtered = applySongFilters(
|
||
page,
|
||
g,
|
||
from,
|
||
to,
|
||
bpmLo,
|
||
bpmHi,
|
||
activeSearch.losslessOnly,
|
||
);
|
||
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...filtered] } : prev);
|
||
setSongsServerOffset(o => o + page.length);
|
||
// No more pages when the server returned a non-full page (regardless of how many survived filtering).
|
||
if (page.length < SONGS_PAGE_SIZE) setSongsHasMore(false);
|
||
} catch {
|
||
setSongsHasMore(false);
|
||
} finally {
|
||
setLoadingMoreSongs(false);
|
||
}
|
||
}, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset, localMode, serverId, basicSearchMode]);
|
||
|
||
const trackFilterActive =
|
||
(MOOD_UI_ENABLED && !!moodGroup) || !!(bpmFrom || bpmTo);
|
||
|
||
const bpmFilterDraftActive = !!(bpmFrom || bpmTo);
|
||
|
||
const clearBpmFilter = () => {
|
||
setBpmFrom('');
|
||
setBpmTo('');
|
||
};
|
||
|
||
const handleSubmit = (e?: React.FormEvent) => {
|
||
e?.preventDefault();
|
||
const effectiveType = trackFilterActive ? 'songs' : resultType;
|
||
runSearch({
|
||
query,
|
||
genre,
|
||
yearFrom,
|
||
yearTo,
|
||
bpmFrom,
|
||
bpmTo,
|
||
moodGroup,
|
||
losslessOnly,
|
||
resultType: effectiveType,
|
||
});
|
||
};
|
||
|
||
const typeOptions: { id: ResultType; label: string }[] = [
|
||
{ id: 'all', label: t('search.advancedAll') },
|
||
{ id: 'artists', label: t('search.artists') },
|
||
{ id: 'albums', label: t('search.albums') },
|
||
{ id: 'songs', label: t('search.songs') },
|
||
];
|
||
|
||
const genreSelectOptions = [
|
||
{ value: '', label: t('search.advancedAllGenres') },
|
||
...genres.map(g => ({ value: g.value, label: g.value })),
|
||
];
|
||
|
||
const moodSelectOptions = useMemo(
|
||
() => [
|
||
{ value: '', label: t('search.advancedAllMoods') },
|
||
...MOOD_GROUP_IDS.map(id => ({
|
||
value: id,
|
||
label: t(`search.moodGroups.${id}`),
|
||
})),
|
||
],
|
||
[t],
|
||
);
|
||
|
||
return (
|
||
<div
|
||
className={`content-body${skipEnterAnimationRef.current ? '' : ' animate-fade-in'}${showTracksChrome ? ' tracks-page' : ''}`}
|
||
style={{ position: 'relative' }}
|
||
data-advanced-search-root
|
||
>
|
||
<div style={{ visibility: isLeaveRestorePending ? 'hidden' : 'visible' }}>
|
||
<div>
|
||
{showTracksChrome ? (
|
||
<>
|
||
<TracksPageChrome
|
||
hideDiscoveryChrome={tracksDiscoveryHidden}
|
||
onLayoutReady={
|
||
isLeaveRestorePending && showTracksChrome ? handleTracksChromeLayoutReady : undefined
|
||
}
|
||
/>
|
||
{!perfFlags.disableMainstageVirtualLists && (
|
||
<SongBrowseSection
|
||
title={t('tracks.browseTitle')}
|
||
emptyBrowseText={t('tracks.browseUnsupported')}
|
||
searchActive={tracksSearchActive}
|
||
songs={songBrowse.songs}
|
||
hasMore={songBrowse.hasMore}
|
||
loading={songBrowse.loading}
|
||
browseUnsupported={songBrowse.browseUnsupported}
|
||
onLoadMore={() => { void songBrowse.loadMore(); }}
|
||
/>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<div style={{ marginBottom: '1.5rem' }}>
|
||
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
{showAdvancedPanel ? (
|
||
<>
|
||
<SlidersVertical size={22} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||
{t('search.advanced')}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Search size={22} />
|
||
{query.trim() ? t('search.resultsFor', { query }) : t('search.title')}
|
||
</>
|
||
)}
|
||
</h1>
|
||
</div>
|
||
|
||
{showAdvancedPanel && (
|
||
<>
|
||
{/* ── Filter panel ──────────────────────────────────────── */}
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="settings-card" style={{ padding: '1.25rem', marginBottom: '2rem' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.9rem' }}>
|
||
|
||
{/* Row 1: Search term */}
|
||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
||
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
|
||
{t('search.advancedSearchTerm')}
|
||
</span>
|
||
<input
|
||
className="input"
|
||
type="text"
|
||
value={query}
|
||
onChange={e => setQuery(e.target.value)}
|
||
placeholder={t('search.advancedSearchPlaceholder')}
|
||
style={{ flex: 1 }}
|
||
autoFocus={!skipSearchAutoFocusRef.current}
|
||
/>
|
||
</div>
|
||
|
||
{/* Row 2: Genre + Year */}
|
||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
|
||
{t('search.advancedGenre')}
|
||
</span>
|
||
<div style={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360 }}>
|
||
<CustomSelect
|
||
value={genre}
|
||
options={genreSelectOptions}
|
||
onChange={setGenre}
|
||
/>
|
||
</div>
|
||
|
||
<span style={{ fontSize: 13, color: 'var(--text-muted)', marginLeft: '0.75rem', flexShrink: 0 }}>
|
||
{t('search.advancedYear')}
|
||
</span>
|
||
<input
|
||
className="input"
|
||
type="number"
|
||
min={1900}
|
||
max={new Date().getFullYear()}
|
||
value={yearFrom}
|
||
onChange={e => setYearFrom(e.target.value)}
|
||
placeholder={t('search.advancedYearFrom')}
|
||
style={{ width: 96 }}
|
||
/>
|
||
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>–</span>
|
||
<input
|
||
className="input"
|
||
type="number"
|
||
min={1900}
|
||
max={new Date().getFullYear()}
|
||
value={yearTo}
|
||
onChange={e => setYearTo(e.target.value)}
|
||
placeholder={t('search.advancedYearTo')}
|
||
style={{ width: 96 }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Row 3: BPM (tag + measured enrichment) */}
|
||
{indexEnabled && (
|
||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
|
||
{t('search.advancedBpm')}
|
||
</span>
|
||
<input
|
||
className="input"
|
||
type="number"
|
||
min={20}
|
||
max={999}
|
||
value={bpmFrom}
|
||
onChange={e => setBpmFrom(e.target.value)}
|
||
onBlur={e => {
|
||
const from = parseBpmInput(e.target.value);
|
||
const to = parseBpmInput(bpmTo);
|
||
if (from != null && to != null && from > to) setBpmTo('');
|
||
}}
|
||
placeholder={t('search.advancedYearFrom')}
|
||
style={{ width: 96 }}
|
||
/>
|
||
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>–</span>
|
||
<input
|
||
className="input"
|
||
type="number"
|
||
min={20}
|
||
max={999}
|
||
value={bpmTo}
|
||
onChange={e => setBpmTo(e.target.value)}
|
||
onBlur={e => {
|
||
const to = parseBpmInput(e.target.value);
|
||
const from = parseBpmInput(bpmFrom);
|
||
if (from != null && to != null && to < from) setBpmFrom('');
|
||
}}
|
||
placeholder={t('search.advancedYearTo')}
|
||
style={{ width: 96 }}
|
||
/>
|
||
{bpmFilterDraftActive && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost"
|
||
onClick={clearBpmFilter}
|
||
style={{
|
||
padding: '0.3rem 0.55rem',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '0.3rem',
|
||
fontSize: '0.8rem',
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<X size={13} />
|
||
{t('search.advancedBpmClear')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Lossless — suffix allowlist (FLAC, WAV, …) */}
|
||
{indexEnabled && (
|
||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
|
||
{t('search.advancedLossless')}
|
||
</span>
|
||
<label
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '0.45rem',
|
||
fontSize: 13,
|
||
cursor: 'pointer',
|
||
userSelect: 'none',
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={losslessOnly}
|
||
onChange={e => setLosslessOnly(e.target.checked)}
|
||
/>
|
||
{t('search.advancedLosslessOnly')}
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
{/* Mood — hidden while oximedia mood analysis is disabled */}
|
||
{indexEnabled && MOOD_UI_ENABLED && (
|
||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
|
||
{t('search.advancedMoodGroup')}
|
||
</span>
|
||
<div style={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360 }}>
|
||
<CustomSelect
|
||
value={moodGroup}
|
||
options={moodSelectOptions}
|
||
onChange={setMoodGroup}
|
||
/>
|
||
</div>
|
||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||
{t('search.advancedMoodLocalNote')}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Row 4: Result type + Search button */}
|
||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||
{!trackFilterActive && typeOptions.map(opt => (
|
||
<button
|
||
key={opt.id}
|
||
type="button"
|
||
className={`btn ${resultType === opt.id ? 'btn-primary' : 'btn-surface'}`}
|
||
style={{ fontSize: 12, padding: '4px 14px' }}
|
||
onClick={() => setResultType(opt.id)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
<StarFilterButton size="small" active={starredOnly} onChange={setStarredOnly} />
|
||
</div>
|
||
<button
|
||
className="btn btn-primary"
|
||
type="submit"
|
||
disabled={loading}
|
||
style={{ minWidth: 100 }}
|
||
>
|
||
{loading
|
||
? <div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
|
||
: t('search.advancedSearch')
|
||
}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Results ───────────────────────────────────────────── */}
|
||
{showAdvancedPanel && !hasSearched ? (
|
||
<div className="empty-state" style={{ opacity: 0.6 }}>
|
||
{t('search.advancedEmpty')}
|
||
</div>
|
||
) : loading ? (
|
||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||
<div className="spinner" />
|
||
</div>
|
||
) : total === 0 ? (
|
||
<div className="empty-state">
|
||
{basicSearchMode && query.trim()
|
||
? t('search.noResults', { query })
|
||
: t('search.advancedNoResults')}
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||
|
||
{filteredResults && filteredResults.artists.length > 0 && (
|
||
<div data-advanced-search-artist-row>
|
||
<ArtistRow
|
||
title={
|
||
basicSearchMode
|
||
? t('search.artists')
|
||
: `${t('search.artists')} (${filteredResults.artists.length})`
|
||
}
|
||
artists={filteredResults.artists}
|
||
artistLinkQuery={activeSearch?.losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
|
||
restoreScrollLeft={
|
||
artistRowScrollLeftRestoreRef.current > 0
|
||
? artistRowScrollLeftRestoreRef.current
|
||
: undefined
|
||
}
|
||
onScrollLeftSnapshot={(left) => {
|
||
artistRowScrollLeftRef.current = left;
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{filteredResults && filteredResults.albums.length > 0 && (
|
||
<div data-advanced-search-album-row>
|
||
<AlbumRow
|
||
title={
|
||
basicSearchMode
|
||
? t('search.albums')
|
||
: `${t('search.albums')} (${filteredResults.albums.length})`
|
||
}
|
||
albums={filteredResults.albums}
|
||
albumLinkQuery={activeSearch?.losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
|
||
windowArtworkByViewport
|
||
initialArtworkBudget={12}
|
||
restoreScrollLeft={
|
||
albumRowScrollLeftRestoreRef.current > 0
|
||
? albumRowScrollLeftRestoreRef.current
|
||
: undefined
|
||
}
|
||
onScrollLeftSnapshot={(left) => {
|
||
albumRowScrollLeftRef.current = left;
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{filteredResults && filteredResults.songs.length > 0 && (
|
||
<section>
|
||
<h2 className="section-title" style={{ marginBottom: '0.75rem' }}>
|
||
{t('search.songs')}
|
||
{genreNote && (
|
||
<span style={{ fontSize: 12, fontWeight: 400, color: 'var(--text-muted)', marginLeft: '0.75rem' }}>
|
||
— {t('search.advancedGenreNote')}
|
||
</span>
|
||
)}
|
||
</h2>
|
||
<PagedSongList
|
||
songs={filteredResults.songs}
|
||
hasMore={songsHasMore}
|
||
loadingMore={loadingMoreSongs}
|
||
onLoadMore={loadMoreSongs}
|
||
showBpm={!!(activeSearch?.bpmFrom || activeSearch?.bpmTo)}
|
||
/>
|
||
</section>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
{isLeaveRestorePending && (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
pointerEvents: 'none',
|
||
}}
|
||
>
|
||
<div className="spinner" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|