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)
This commit is contained in:
cucadmuh
2026-06-01 13:04:36 +03:00
committed by GitHub
parent ddf10ee01d
commit 4ac373a65b
59 changed files with 2301 additions and 392 deletions
+92 -20
View File
@@ -36,11 +36,21 @@ import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useAlbumBrowseFilters, useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
import { useAlbumBrowseData } from '../hooks/useAlbumBrowseData';
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset';
import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch';
import { peekAlbumBrowseScrollRestore } from '../store/albumBrowseSessionStore';
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
import { useAlbumCatalogYearBounds } from '../hooks/useAlbumCatalogYearBounds';
import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort';
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
import { resolveAlbumYearBounds } from '../utils/library/albumYearFilter';
import {
filterAlbumsByCompilation,
filterAlbumsByGenres,
filterAlbumsByStarred,
filterAlbumsByYearBounds,
} from '../utils/library/albumBrowseFilters';
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
type SortType = AlbumBrowseSort;
@@ -81,6 +91,14 @@ export default function Albums() {
setLosslessOnly,
} = useAlbumBrowseFilters(serverId, scrollSnapshotRef);
const albumsSearchQuery = useScopedBrowseSearchQuery('albums');
const { textSearchAlbums, textSearchLoading } = useBrowseAlbumTextSearch(
albumsSearchQuery,
indexEnabled,
serverId,
losslessOnly,
);
const {
scrollBodyEl,
bindScrollBody: bindAlbumsScrollBody,
@@ -88,24 +106,7 @@ export default function Albums() {
} = useInpageScrollViewport();
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const {
albums,
loading,
loadingMore,
hasMore,
displayAlbums,
visibleAlbums,
genreFiltered,
serverFilterActive,
narrowGenreList,
genreCatalogOptions,
yearFilterActive,
debouncedYearFields,
compFilterActive,
pendingClientFilterMatch,
bindLoadMoreSentinel,
loadMore,
} = useAlbumBrowseData({
const browseData = useAlbumBrowseData({
serverId,
indexEnabled,
musicLibraryFilterVersion,
@@ -122,6 +123,55 @@ export default function Albums() {
restoreDisplayCount: restoreDisplayCountRef.current,
});
const textSearchActive = textSearchAlbums != null;
const albumBrowsePlainLayout =
perfFlags.disableMainstageVirtualLists
|| textSearchActive
|| albumsSearchQuery.trim().length > 0;
const textSearchYearBounds = useMemo(
() => resolveAlbumYearBounds(browseData.debouncedYearFields.from, browseData.debouncedYearFields.to),
[browseData.debouncedYearFields.from, browseData.debouncedYearFields.to],
);
const textSearchVisibleAlbums = useMemo(() => {
if (!textSearchActive || !textSearchAlbums) return null;
let out = textSearchAlbums;
if (selectedGenres.length > 0) out = filterAlbumsByGenres(out, selectedGenres);
if (textSearchYearBounds.active) out = filterAlbumsByYearBounds(out, textSearchYearBounds.bounds);
if (compFilter !== 'all') out = filterAlbumsByCompilation(out, compFilter);
if (starredOnly) out = filterAlbumsByStarred(out, starredOverrides);
return out;
}, [
textSearchActive,
textSearchAlbums,
selectedGenres,
textSearchYearBounds.active,
textSearchYearBounds.bounds,
compFilter,
starredOnly,
starredOverrides,
]);
const albums = textSearchActive ? (textSearchAlbums ?? []) : browseData.albums;
const loading = textSearchActive ? textSearchLoading : browseData.loading;
const loadingMore = textSearchActive ? false : browseData.loadingMore;
const hasMore = textSearchActive ? false : browseData.hasMore;
const displayAlbums = textSearchActive ? (textSearchVisibleAlbums ?? []) : browseData.displayAlbums;
const visibleAlbums = textSearchActive ? (textSearchVisibleAlbums ?? []) : browseData.visibleAlbums;
const genreFiltered = textSearchActive ? selectedGenres.length > 0 : browseData.genreFiltered;
const serverFilterActive = textSearchActive
? selectedGenres.length > 0 || textSearchYearBounds.active || losslessOnly || starredOnly
: browseData.serverFilterActive;
const narrowGenreList = browseData.narrowGenreList;
const genreCatalogOptions = browseData.genreCatalogOptions;
const yearFilterActive = browseData.yearFilterActive;
const debouncedYearFields = browseData.debouncedYearFields;
const compFilterActive = browseData.compFilterActive;
const pendingClientFilterMatch = textSearchActive ? false : browseData.pendingClientFilterMatch;
const bindLoadMoreSentinel = browseData.bindLoadMoreSentinel;
const loadMore = browseData.loadMore;
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length);
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
@@ -135,6 +185,22 @@ export default function Albums() {
loadMore,
});
useAlbumBrowseScrollReset({
scrollSnapshotRef,
getScrollRoot,
isScrollRestorePending,
resetKey: [
albumsSearchQuery,
sort,
selectedGenres.join('\u0001'),
yearFilterActive ? `${debouncedYearFields.from}:${debouncedYearFields.to}` : '',
compFilter,
starredOnly,
losslessOnly,
serverId,
].join('|'),
});
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
@@ -266,6 +332,7 @@ export default function Albums() {
);
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
albumsSearchQuery,
sort,
genreFiltered,
yearFilterActive,
@@ -394,8 +461,9 @@ export default function Albums() {
hasMore,
selectionMode,
sort,
albumsSearchQuery,
perfFlags.disableMainstageGridCards,
perfFlags.disableMainstageVirtualLists,
albumBrowsePlainLayout,
]}
>
{loading && albums.length === 0 ? (
@@ -418,6 +486,10 @@ export default function Albums() {
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{visibleEmptyMessage}
</div>
) : !loading && textSearchActive && visibleAlbums.length === 0 ? (
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('albums.noMatchingFilters')}
</div>
) : (
<div style={{ position: 'relative' }}>
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
@@ -427,7 +499,7 @@ export default function Albums() {
items={displayAlbums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
disableVirtualization={albumBrowsePlainLayout}
layoutSignal={displayAlbums.length}
scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
warmGridCovers={albumGridWarmCovers(