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
+38 -67
View File
@@ -9,8 +9,6 @@ import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID, ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight';
import { useCardGridMetrics } from '../hooks/useCardGridMetrics';
import { useRemeasureGridVirtualizer } from '../hooks/useRemeasureGridVirtualizer';
import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import {
@@ -32,10 +30,12 @@ import { ArtistsListView } from '../components/artists/ArtistsListView';
import InpageScrollSentinel from '../components/InpageScrollSentinel';
import { useArtistsBrowseFilters, type ArtistBrowseScrollSnapshot } from '../hooks/useArtistsBrowseFilters';
import { useArtistsBrowseScrollRestore } from '../hooks/useArtistsBrowseScrollRestore';
import { useArtistsBrowseScrollReset } from '../hooks/useArtistsBrowseScrollReset';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { peekArtistBrowseScrollRestore } from '../store/artistBrowseSessionStore';
import { readArtistBrowseRestore } from '../utils/navigation/albumDetailNavigation';
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
export default function Artists() {
@@ -51,8 +51,6 @@ export default function Artists() {
);
const {
filter,
setFilter,
letterFilter,
setLetterFilter,
starredOnly,
@@ -61,6 +59,8 @@ export default function Artists() {
setViewMode,
} = useArtistsBrowseFilters(serverId, scrollSnapshotRef);
const artistsSearchQuery = useScopedBrowseSearchQuery('artists');
const {
scrollBodyEl: artistsScrollBodyEl,
bindScrollBody: bindArtistsScrollBody,
@@ -91,13 +91,18 @@ export default function Artists() {
});
const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch(
filter,
artistsSearchQuery,
indexEnabled,
serverId,
);
const artists = textSearchArtists ?? catalogArtists;
const loading = catalogLoading || textSearchLoading;
const textSearchActive = textSearchArtists != null;
/** Scoped/plain text filter — canonical CSS grid, not row virtualization (small result sets). */
const artistBrowsePlainLayout =
perfFlags.disableMainstageVirtualLists
|| textSearchActive
|| artistsSearchQuery.trim().length > 0;
const {
visibleCount,
@@ -105,7 +110,7 @@ export default function Artists() {
loadMore: sliceLoadMore,
} = useClientSliceInfiniteScroll({
pageSize: PAGE_SIZE,
resetDeps: [filter, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId],
resetDeps: [artistsSearchQuery, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId],
getScrollRoot: getArtistsScrollRoot,
scrollRootEl: artistsScrollBodyEl,
restoreDisplayCount: restoreVisibleCountRef.current,
@@ -224,7 +229,7 @@ export default function Artists() {
});
const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [
filter,
artistsSearchQuery,
letterFilter,
starredOnly,
viewMode,
@@ -243,48 +248,6 @@ export default function Artists() {
[getArtistsScrollRoot],
);
const artistGridMeasureRef = useRef<HTMLDivElement>(null);
const { gridCols: artistGridCols, rowHeightEst: artistGridRowHeightEst } = useCardGridMetrics(
artistGridMeasureRef,
viewMode === 'grid',
'artist',
visible.length,
);
const artistVirtualRowCount = Math.max(0, Math.ceil(visible.length / Math.max(1, artistGridCols)));
const artistGridOverscan = Math.max(
2,
Math.ceil(artistsInpageScrollHeight / Math.max(1, artistGridRowHeightEst)),
);
const artistGridScrollMargin = useVirtualizerScrollMargin(
artistGridMeasureRef,
getInpageScrollElement,
{
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'grid',
deps: [artistVirtualRowCount, artistGridCols],
},
);
const artistGridVirtualizer = useVirtualizer({
count:
perfFlags.disableMainstageVirtualLists || viewMode !== 'grid'
? 0
: artistVirtualRowCount,
getScrollElement: getInpageScrollElement,
estimateSize: () => artistGridRowHeightEst,
overscan: artistGridOverscan,
scrollMargin: artistGridScrollMargin,
});
useRemeasureGridVirtualizer(artistGridVirtualizer, {
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'grid' && artistVirtualRowCount > 0,
gridCols: artistGridCols,
rowHeightEst: artistGridRowHeightEst,
virtualRowCount: artistVirtualRowCount,
});
const artistListOverscan = Math.max(
12,
Math.ceil(artistsInpageScrollHeight / ARTIST_LIST_ROW_EST),
@@ -295,14 +258,14 @@ export default function Artists() {
artistListWrapRef,
getInpageScrollElement,
{
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'list',
active: !artistBrowsePlainLayout && viewMode === 'list',
deps: [artistListFlatRows.length],
},
);
const artistListVirtualizer = useVirtualizer({
count:
perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : artistListFlatRows.length,
artistBrowsePlainLayout || viewMode !== 'list' ? 0 : artistListFlatRows.length,
getScrollElement: getInpageScrollElement,
estimateSize: index => {
const row = artistListFlatRows[index];
@@ -320,6 +283,27 @@ export default function Artists() {
scrollMargin: artistListScrollMargin,
});
const browseScrollResetKey = [
artistsSearchQuery,
letterFilter,
starredOnly,
viewMode,
serverId,
musicLibraryFilterVersion,
textSearchArtists?.length ?? '',
textSearchArtists?.[0]?.id ?? '',
].join('\0');
useArtistsBrowseScrollReset({
scrollSnapshotRef,
getScrollRoot: getArtistsScrollRoot,
isScrollRestorePending,
resetKey: browseScrollResetKey,
viewMode,
listVirtualize: !artistBrowsePlainLayout,
listVirtualizer: artistListVirtualizer,
});
return (
<div
className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}
@@ -333,14 +317,6 @@ export default function Artists() {
? t('artists.selectionCount', { count: selectedIds.size })
: t('artists.title')}
</h1>
<input
className="input"
style={{ maxWidth: 220 }}
placeholder={t('artists.search')}
value={filter}
onChange={e => setFilter(e.target.value)}
id="artist-filter-input"
/>
{textSearchLoading && (
<div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} />
)}
@@ -432,13 +408,8 @@ export default function Artists() {
{!loading && !pendingLetterMatch && viewMode === 'grid' && (
<ArtistsGridView
visible={visible}
gridCols={artistGridCols}
measureRef={artistGridMeasureRef}
virtualization={
perfFlags.disableMainstageVirtualLists
? null
: { virtualizer: artistGridVirtualizer, scrollMargin: artistGridScrollMargin }
}
disableVirtualization={artistBrowsePlainLayout}
layoutKey={browseScrollResetKey}
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedArtists={selectedArtists}
@@ -452,7 +423,7 @@ export default function Artists() {
{!loading && !pendingLetterMatch && viewMode === 'list' && (
<ArtistsListView
virtualized={!perfFlags.disableMainstageVirtualLists}
virtualized={!artistBrowsePlainLayout}
groups={groups}
letters={letters}
artistListFlatRows={artistListFlatRows}