Files
Psychotoxical-psysonic/src/pages/SearchBrowsePage.tsx
T
cucadmuh 4ac373a65b feat(search): scoped live search on browse pages (#938)
* 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)
2026-06-01 13:04:36 +03:00

1200 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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>
);
}