mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
+92
-20
@@ -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(
|
||||
|
||||
+38
-67
@@ -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}
|
||||
|
||||
+86
-25
@@ -1,6 +1,6 @@
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ndListArtistsByRole } from '../api/navidromeBrowse';
|
||||
import { LayoutGrid, List } from 'lucide-react';
|
||||
import StarFilterButton from '../components/StarFilterButton';
|
||||
@@ -12,7 +12,14 @@ import { APP_MAIN_SCROLL_VIEWPORT_ID, COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID } from
|
||||
import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight';
|
||||
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
|
||||
import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch';
|
||||
import { useComposersBrowseFilters, type ComposerBrowseScrollSnapshot } from '../hooks/useComposersBrowseFilters';
|
||||
import { useComposersBrowseScrollRestore } from '../hooks/useComposersBrowseScrollRestore';
|
||||
import { useArtistsBrowseScrollReset } from '../hooks/useArtistsBrowseScrollReset';
|
||||
import { useNavigateToComposer } from '../hooks/useNavigateToComposer';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { peekComposerBrowseScrollRestore } from '../store/composerBrowseSessionStore';
|
||||
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
|
||||
import { readComposerBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import OverlayScrollArea from '../components/OverlayScrollArea';
|
||||
@@ -76,10 +83,24 @@ export default function Composers() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<'unsupported' | 'transient' | null>(null);
|
||||
const [reloadTick, setReloadTick] = useState(0);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
const scrollSnapshotRef = useRef<ComposerBrowseScrollSnapshot>({ scrollTop: 0, visibleCount: 0 });
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const restoreVisibleCountRef = useRef<number | undefined>(
|
||||
peekComposerBrowseScrollRestore(serverId)?.visibleCount,
|
||||
);
|
||||
|
||||
const {
|
||||
letterFilter,
|
||||
setLetterFilter,
|
||||
starredOnly,
|
||||
setStarredOnly,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
} = useComposersBrowseFilters(serverId, scrollSnapshotRef);
|
||||
|
||||
const composersSearchQuery = useScopedBrowseSearchQuery('composers');
|
||||
|
||||
// Compact tiles + initial-letter only → 200 per page is comfortable.
|
||||
const PAGE_SIZE = 200;
|
||||
@@ -89,28 +110,35 @@ export default function Composers() {
|
||||
bindScrollBody: bindComposersScrollBody,
|
||||
getScrollRoot,
|
||||
} = useInpageScrollViewport();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const navigateToComposer = useNavigateToComposer();
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const serverId = useAuthStore(s => s.activeServerId);
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch(
|
||||
filter,
|
||||
composersSearchQuery,
|
||||
indexEnabled,
|
||||
serverId,
|
||||
'composers_browse',
|
||||
);
|
||||
const composerSource = textSearchArtists ?? composers;
|
||||
const textSearchActive = textSearchArtists != null;
|
||||
const composerBrowsePlainLayout =
|
||||
perfFlags.disableMainstageVirtualLists
|
||||
|| textSearchActive
|
||||
|| composersSearchQuery.trim().length > 0;
|
||||
|
||||
const {
|
||||
visibleCount,
|
||||
loadingMore,
|
||||
bindSentinel,
|
||||
loadMore: sliceLoadMore,
|
||||
} = useClientSliceInfiniteScroll({
|
||||
pageSize: PAGE_SIZE,
|
||||
resetDeps: [letterFilter, effectiveFilter, starredOnly, viewMode, composerSource],
|
||||
resetDeps: [composersSearchQuery, letterFilter, starredOnly, viewMode, composerSource, serverId],
|
||||
getScrollRoot,
|
||||
scrollRootEl: scrollBodyEl,
|
||||
restoreDisplayCount: restoreVisibleCountRef.current,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -165,6 +193,26 @@ export default function Composers() {
|
||||
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
|
||||
scrollSnapshotRef.current = {
|
||||
scrollTop: scrollBodyEl?.scrollTop ?? 0,
|
||||
visibleCount,
|
||||
};
|
||||
|
||||
const { isScrollRestorePending } = useComposersBrowseScrollRestore({
|
||||
serverId,
|
||||
scrollBodyEl,
|
||||
visibleCount,
|
||||
loading: loading || textSearchLoading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore: sliceLoadMore,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrollRestorePending || !readComposerBrowseRestore(location.state)) return;
|
||||
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
||||
}, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
|
||||
|
||||
const { groups, letters } = useMemo(() => {
|
||||
if (viewMode !== 'list') return { groups: {} as Record<string, SubsonicArtist[]>, letters: [] as string[] };
|
||||
const g: Record<string, SubsonicArtist[]> = {};
|
||||
@@ -213,14 +261,14 @@ export default function Composers() {
|
||||
composerListWrapRef,
|
||||
getInpageScrollElement,
|
||||
{
|
||||
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'list',
|
||||
active: !composerBrowsePlainLayout && viewMode === 'list',
|
||||
deps: [composerListFlatRows.length],
|
||||
},
|
||||
);
|
||||
|
||||
const composerListVirtualizer = useVirtualizer({
|
||||
count:
|
||||
perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : composerListFlatRows.length,
|
||||
composerBrowsePlainLayout || viewMode !== 'list' ? 0 : composerListFlatRows.length,
|
||||
getScrollElement: getInpageScrollElement,
|
||||
estimateSize: index => {
|
||||
const row = composerListFlatRows[index];
|
||||
@@ -239,12 +287,33 @@ export default function Composers() {
|
||||
});
|
||||
|
||||
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
|
||||
filter,
|
||||
composersSearchQuery,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const browseScrollResetKey = [
|
||||
composersSearchQuery,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
serverId,
|
||||
musicLibraryFilterVersion,
|
||||
textSearchArtists?.length ?? '',
|
||||
textSearchArtists?.[0]?.id ?? '',
|
||||
].join('\0');
|
||||
|
||||
useArtistsBrowseScrollReset({
|
||||
scrollSnapshotRef,
|
||||
getScrollRoot,
|
||||
isScrollRestorePending,
|
||||
resetKey: browseScrollResetKey,
|
||||
viewMode,
|
||||
listVirtualize: !composerBrowsePlainLayout,
|
||||
listVirtualizer: composerListVirtualizer,
|
||||
});
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
@@ -272,14 +341,6 @@ export default function Composers() {
|
||||
<div className="mainstage-inpage-toolbar-row">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('composers.title')}</h1>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 220 }}
|
||||
placeholder={t('composers.search')}
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
id="composer-filter-input"
|
||||
/>
|
||||
{textSearchLoading && (
|
||||
<div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
)}
|
||||
@@ -342,7 +403,7 @@ export default function Composers() {
|
||||
items={visible}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="composer"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
disableVirtualization={composerBrowsePlainLayout}
|
||||
layoutSignal={visible.length}
|
||||
wrapClassName="composer-grid-wrap"
|
||||
gridGap="var(--space-2)"
|
||||
@@ -350,7 +411,7 @@ export default function Composers() {
|
||||
renderItem={artist => (
|
||||
<div
|
||||
className="composer-card"
|
||||
onClick={() => navigate(`/composer/${artist.id}`)}
|
||||
onClick={() => navigateToComposer(artist.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
|
||||
@@ -368,7 +429,7 @@ export default function Composers() {
|
||||
)}
|
||||
|
||||
{!loading && viewMode === 'list' && (
|
||||
perfFlags.disableMainstageVirtualLists ? (
|
||||
composerBrowsePlainLayout ? (
|
||||
<>
|
||||
{letters.map(letter => (
|
||||
<div key={letter} style={{ marginBottom: '1.5rem' }}>
|
||||
@@ -378,7 +439,7 @@ export default function Composers() {
|
||||
<button
|
||||
key={artist.id}
|
||||
className="artist-row"
|
||||
onClick={() => navigate(`/composer/${artist.id}`)}
|
||||
onClick={() => navigateToComposer(artist.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
|
||||
@@ -442,7 +503,7 @@ export default function Composers() {
|
||||
<button
|
||||
type="button"
|
||||
className="artist-row"
|
||||
onClick={() => navigate(`/composer/${artist.id}`)}
|
||||
onClick={() => navigateToComposer(artist.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
|
||||
|
||||
+73
-29
@@ -3,7 +3,7 @@ import { getAlbumsByGenre } from '../api/subsonicGenres';
|
||||
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { dedupeById } from '../utils/dedupeById';
|
||||
import { useEffect, useLayoutEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
@@ -29,8 +29,13 @@ import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
|
||||
import InpageScrollSentinel from '../components/InpageScrollSentinel';
|
||||
import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '../hooks/useAlbumGridBrowseFilters';
|
||||
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
|
||||
import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset';
|
||||
import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch';
|
||||
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
|
||||
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { filterAlbumsByGenres } from '../utils/library/albumBrowseFilters';
|
||||
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
@@ -49,6 +54,7 @@ export default function NewReleases() {
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const auth = useAuthStore();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
@@ -64,6 +70,19 @@ export default function NewReleases() {
|
||||
} = useAlbumGridBrowseFilters(serverId, 'new-releases', scrollSnapshotRef, gridSnapshotRef);
|
||||
const restoringSessionRef = useRef(initialAlbums != null);
|
||||
|
||||
const newReleasesSearchQuery = useScopedBrowseSearchQuery('newReleases');
|
||||
const { textSearchAlbums, textSearchLoading } = useBrowseAlbumTextSearch(
|
||||
newReleasesSearchQuery,
|
||||
indexEnabled,
|
||||
serverId,
|
||||
);
|
||||
const textSearchActive = textSearchAlbums != null;
|
||||
const scopedSearchQuery = newReleasesSearchQuery.trim();
|
||||
const albumBrowsePlainLayout =
|
||||
perfFlags.disableMainstageVirtualLists
|
||||
|| textSearchActive
|
||||
|| scopedSearchQuery.length > 0;
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() => initialAlbums ?? []);
|
||||
const [hasMore, setHasMore] = useState(() => initialHasMore ?? true);
|
||||
const {
|
||||
@@ -80,22 +99,35 @@ export default function NewReleases() {
|
||||
isBlocked,
|
||||
} = useAsyncInpagePagination(PAGE_SIZE, { initialLoading: initialAlbums == null });
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const filtered = selectedGenres.length > 0;
|
||||
const genreFiltered = selectedGenres.length > 0;
|
||||
|
||||
gridSnapshotRef.current = { albums, hasMore };
|
||||
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, albums.length);
|
||||
const displayAlbums = useMemo(() => {
|
||||
if (textSearchActive && textSearchAlbums) {
|
||||
return genreFiltered
|
||||
? filterAlbumsByGenres(textSearchAlbums, selectedGenres)
|
||||
: textSearchAlbums;
|
||||
}
|
||||
return albums;
|
||||
}, [textSearchActive, textSearchAlbums, albums, genreFiltered, selectedGenres]);
|
||||
|
||||
const loadingGrid = textSearchActive ? textSearchLoading : loading;
|
||||
const gridHasMore = textSearchActive ? false : (!genreFiltered && hasMore);
|
||||
|
||||
gridSnapshotRef.current = { albums: displayAlbums, hasMore: gridHasMore };
|
||||
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length);
|
||||
|
||||
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
|
||||
filtered,
|
||||
newReleasesSearchQuery,
|
||||
genreFiltered,
|
||||
selectionMode,
|
||||
selectedGenres,
|
||||
]);
|
||||
|
||||
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
|
||||
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(displayAlbums);
|
||||
|
||||
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
|
||||
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
|
||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(a.id));
|
||||
|
||||
const handleDownloadZips = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
@@ -156,21 +188,21 @@ export default function NewReleases() {
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restoringSessionRef.current) return;
|
||||
if (filtered) loadFiltered(selectedGenres);
|
||||
if (restoringSessionRef.current || scopedSearchQuery) return;
|
||||
if (genreFiltered) loadFiltered(selectedGenres);
|
||||
else {
|
||||
resetPage();
|
||||
void load(0);
|
||||
}
|
||||
}, [filtered, selectedGenres, load, loadFiltered, resetPage]);
|
||||
}, [genreFiltered, selectedGenres, load, loadFiltered, resetPage, scopedSearchQuery]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!hasMore || filtered || isBlocked()) return;
|
||||
if (!gridHasMore || genreFiltered || textSearchActive || isBlocked()) return;
|
||||
requestNextPage(offset => load(offset, true));
|
||||
}, [hasMore, filtered, isBlocked, requestNextPage, load]);
|
||||
}, [gridHasMore, genreFiltered, textSearchActive, isBlocked, requestNextPage, load]);
|
||||
|
||||
const bindLoadMoreSentinel = useInpageScrollSentinel({
|
||||
active: !filtered && hasMore,
|
||||
active: gridHasMore,
|
||||
getScrollRoot,
|
||||
scrollRootEl: scrollBodyEl,
|
||||
onIntersect: loadMore,
|
||||
@@ -180,13 +212,20 @@ export default function NewReleases() {
|
||||
serverId,
|
||||
surface: 'new-releases',
|
||||
scrollBodyEl,
|
||||
displayAlbumsLength: albums.length,
|
||||
loading,
|
||||
loadingMore: loading,
|
||||
hasMore,
|
||||
displayAlbumsLength: displayAlbums.length,
|
||||
loading: loadingGrid,
|
||||
loadingMore: loadingGrid,
|
||||
hasMore: gridHasMore,
|
||||
loadMore,
|
||||
});
|
||||
|
||||
useAlbumBrowseScrollReset({
|
||||
scrollSnapshotRef,
|
||||
getScrollRoot,
|
||||
isScrollRestorePending,
|
||||
resetKey: [newReleasesSearchQuery, selectedGenres.join('\u0001'), serverId].join('|'),
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isScrollRestorePending && restoringSessionRef.current) {
|
||||
restoringSessionRef.current = false;
|
||||
@@ -243,31 +282,36 @@ export default function NewReleases() {
|
||||
viewportRef={bindNewReleasesScrollBody}
|
||||
railInset="panel"
|
||||
measureDeps={[
|
||||
loading,
|
||||
albums.length,
|
||||
filtered,
|
||||
hasMore,
|
||||
loadingGrid,
|
||||
displayAlbums.length,
|
||||
genreFiltered,
|
||||
gridHasMore,
|
||||
selectionMode,
|
||||
perfFlags.disableMainstageVirtualLists,
|
||||
newReleasesSearchQuery,
|
||||
albumBrowsePlainLayout,
|
||||
]}
|
||||
>
|
||||
{loading && albums.length === 0 ? (
|
||||
{loadingGrid && displayAlbums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : !loading && albums.length === 0 && !filtered ? (
|
||||
) : !loadingGrid && displayAlbums.length === 0 && !genreFiltered && !scopedSearchQuery ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('common.libraryEmpty')}
|
||||
</div>
|
||||
) : !loadingGrid && textSearchActive && displayAlbums.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' }}>
|
||||
<VirtualCardGrid
|
||||
items={albums}
|
||||
items={displayAlbums}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
disableVirtualization={albumBrowsePlainLayout}
|
||||
layoutSignal={displayAlbums.length}
|
||||
scrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => (
|
||||
@@ -281,8 +325,8 @@ export default function NewReleases() {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!filtered && hasMore && (
|
||||
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loading} />
|
||||
{gridHasMore && (
|
||||
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loadingGrid} />
|
||||
)}
|
||||
</div>
|
||||
{isScrollRestorePending && (
|
||||
|
||||
@@ -60,6 +60,10 @@ 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;
|
||||
|
||||
@@ -177,8 +181,22 @@ export default function SearchBrowsePage() {
|
||||
}
|
||||
: 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,
|
||||
});
|
||||
|
||||
@@ -188,6 +206,9 @@ export default function SearchBrowsePage() {
|
||||
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);
|
||||
@@ -196,12 +217,16 @@ export default function SearchBrowsePage() {
|
||||
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,
|
||||
() => !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);
|
||||
@@ -210,12 +235,15 @@ export default function SearchBrowsePage() {
|
||||
const finishLeaveRestoreUi = useCallback(() => {
|
||||
if (leaveRestoreUiFinishedRef.current) return;
|
||||
leaveRestoreUiFinishedRef.current = true;
|
||||
clearAdvancedSearchLeaveSnapshots();
|
||||
leaveSnapshotRef.current = null;
|
||||
setIsLeaveRestorePending(false);
|
||||
if (hadRestoreOnMountRef.current) {
|
||||
useAdvancedSearchSessionStore.getState().clearReturnStash();
|
||||
}
|
||||
// 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>({
|
||||
@@ -241,7 +269,7 @@ export default function SearchBrowsePage() {
|
||||
tracksBrowseUnsupported: false,
|
||||
});
|
||||
sessionRef.current = {
|
||||
query: showTracksChrome ? songBrowse.query : query,
|
||||
query: showTracksChrome ? liveSearchQuery : query,
|
||||
genre,
|
||||
yearFrom,
|
||||
yearTo,
|
||||
@@ -585,6 +613,11 @@ export default function SearchBrowsePage() {
|
||||
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);
|
||||
@@ -612,20 +645,47 @@ export default function SearchBrowsePage() {
|
||||
useAdvancedSearchSessionStore.getState().clearReturnStash();
|
||||
}, [navigationType, location.state]);
|
||||
|
||||
const tracksSearchRestoreSynced =
|
||||
!tracksSearchRestorePendingRef.current
|
||||
|| tracksSearchQuery.trim() === (songBrowseInitialRestore?.query.trim() ?? '');
|
||||
|
||||
const leaveRestoreContentReady = showTracksChrome
|
||||
? tracksChromeLayoutReady
|
||||
&& ((hadRestoreOnMountRef.current && songBrowse.hasSearched) || (songBrowse.hasSearched && !songBrowse.loading))
|
||||
&& 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]);
|
||||
}, [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;
|
||||
@@ -795,6 +855,7 @@ export default function SearchBrowsePage() {
|
||||
{showTracksChrome ? (
|
||||
<>
|
||||
<TracksPageChrome
|
||||
hideDiscoveryChrome={tracksDiscoveryHidden}
|
||||
onLayoutReady={
|
||||
isLeaveRestorePending && showTracksChrome ? handleTracksChromeLayoutReady : undefined
|
||||
}
|
||||
@@ -803,8 +864,7 @@ export default function SearchBrowsePage() {
|
||||
<SongBrowseSection
|
||||
title={t('tracks.browseTitle')}
|
||||
emptyBrowseText={t('tracks.browseUnsupported')}
|
||||
query={songBrowse.query}
|
||||
onQueryChange={songBrowse.setQuery}
|
||||
searchActive={tracksSearchActive}
|
||||
songs={songBrowse.songs}
|
||||
hasMore={songBrowse.hasMore}
|
||||
loading={songBrowse.loading}
|
||||
|
||||
Reference in New Issue
Block a user