mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat: library browse navigation — restore filters, scroll, and search on back (#936)
* feat(albums): restore scroll position when returning from album detail Save in-page scroll and grid depth when opening an album from All Albums, then on browser back restore filters, preload enough rows, and apply scroll before revealing the grid to avoid a visible jump from the top. * feat(albums): smart back navigation and restore browse session on return Remember the originating route when opening album detail, restore All Albums filters/scroll on back (including explicit returnTo navigation), hide the grid until scroll is applied, and fix filters being cleared after albumBrowseRestore state is stripped from the location. * feat(search): restore Advanced Search session when returning from album Stash filters and results when leaving /search/advanced for album detail, then restore them on back navigation (POP or returnTo with advancedSearchRestore). * feat(search): restore Advanced Search album row scroll on return from album Save horizontal scrollLeft when opening an album from Advanced Search and reapply it via AlbumRow on return; keep main viewport at top. Add snapshot helpers and session stash fields; extend AlbumRow with restoreScrollLeft. * feat(search): restore Advanced Search session scroll and artist return path Save filters, main scroll, and album-row scroll when leaving to album or artist; restore without flash via hidden-until-ready. Add useNavigateToArtist, restoreMainViewportScroll helper, and AppShell scroll reset only on pathname change. * feat(search): speed up Advanced Search back restore and year-only queries Reveal the page right after sync scroll instead of blocking on full viewport and album-row restore. Retry local index without the ready gate during sync; use open-ended byYear params on network fallback, matching All Albums browse. * feat(search): restore Advanced Search artist row scroll on back Save leave snapshot when opening artist from ArtistCardLocal, persist artistRowScrollLeft in session stash, and keep row restore targets in refs so horizontal scroll survives finishLeaveRestoreUi like vertical scrollTop. * feat(nav): route mouse back on album/artist detail like UI back Trap history popstate when returnTo is set and call navigateAlbumDetailBack so browser/mouse back restores browse/search session the same way as the header button. * feat(artists): restore browse filters and scroll on back from artist detail Persist Artists page filters, view settings, and vertical scroll when opening an artist and returning via UI or mouse back, matching All Albums behavior. * feat(search): unify quick and advanced search; fix LiveSearch dismiss on Enter Serve /search and /search/advanced from one page with shared session restore and scroll snapshot. Reset live search overlay state when navigating to full search so the dropdown does not linger or reopen. * feat(tracks): unify with search session and restore scroll on back Route /tracks through AdvancedSearch with shared leave snapshot, song browse stash, and main-viewport scroll restore when returning from album or artist detail. Wait for hero/rails layout before applying scroll. * refactor(search): rename AdvancedSearch page to SearchBrowsePage The shared route shell serves /search, /search/advanced, and /tracks; rename the page component and refresh stale file references in comments. * feat(albums): restore New Releases and Random Albums on back from detail Unify album grid leave-restore with surface-scoped session stash, live scroll snapshot sync, and in-page scroll for Random Albums. Keep the same random batch when returning from album detail; Refresh fetches anew and scrolls up. * docs: add CHANGELOG and credits for PR #936
This commit is contained in:
+80
-33
@@ -14,6 +14,7 @@ import StarFilterButton from '../components/StarFilterButton';
|
||||
import LosslessFilterButton from '../components/LosslessFilterButton';
|
||||
import SortDropdown from '../components/SortDropdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -32,8 +33,11 @@ import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import OverlayScrollArea from '../components/OverlayScrollArea';
|
||||
import { ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { useAlbumBrowseFilters } from '../hooks/useAlbumBrowseFilters';
|
||||
import { useAlbumBrowseFilters, useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
|
||||
import { useAlbumBrowseData } from '../hooks/useAlbumBrowseData';
|
||||
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
|
||||
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';
|
||||
@@ -55,6 +59,11 @@ export default function Albums() {
|
||||
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
|
||||
const scrollSnapshotRef = useRef<AlbumBrowseScrollSnapshot>({ scrollTop: 0, displayCount: 0 });
|
||||
const restoreDisplayCountRef = useRef<number | undefined>(
|
||||
peekAlbumBrowseScrollRestore(serverId, 'albums')?.displayCount,
|
||||
);
|
||||
|
||||
const {
|
||||
sort,
|
||||
onSortChange,
|
||||
@@ -70,7 +79,7 @@ export default function Albums() {
|
||||
setStarredOnly,
|
||||
losslessOnly,
|
||||
setLosslessOnly,
|
||||
} = useAlbumBrowseFilters(serverId);
|
||||
} = useAlbumBrowseFilters(serverId, scrollSnapshotRef);
|
||||
|
||||
const {
|
||||
scrollBodyEl,
|
||||
@@ -95,6 +104,7 @@ export default function Albums() {
|
||||
compFilterActive,
|
||||
pendingClientFilterMatch,
|
||||
bindLoadMoreSentinel,
|
||||
loadMore,
|
||||
} = useAlbumBrowseData({
|
||||
serverId,
|
||||
indexEnabled,
|
||||
@@ -109,8 +119,29 @@ export default function Albums() {
|
||||
starredOverrides,
|
||||
getScrollRoot,
|
||||
scrollRootEl: scrollBodyEl,
|
||||
restoreDisplayCount: restoreDisplayCountRef.current,
|
||||
});
|
||||
|
||||
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length);
|
||||
|
||||
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
|
||||
serverId,
|
||||
surface: 'albums',
|
||||
scrollBodyEl,
|
||||
displayAlbumsLength: displayAlbums.length,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
});
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
if (isScrollRestorePending || !readAlbumBrowseRestore(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 gridMeasureRef = useRef<HTMLDivElement>(null);
|
||||
const maxGridCols = useAuthStore(s => clampLibraryGridMaxColumns(s.libraryGridMaxColumns));
|
||||
const [albumCellDisplayCssPx, setAlbumCellDisplayCssPx] = useState(140);
|
||||
@@ -388,39 +419,55 @@ export default function Albums() {
|
||||
{visibleEmptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!perfFlags.disableMainstageGridCards && (
|
||||
<div ref={gridMeasureRef}>
|
||||
<VirtualCardGrid
|
||||
items={displayAlbums}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={displayAlbums.length}
|
||||
scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
warmGridCovers={albumGridWarmCovers(
|
||||
albumCellDisplayCssPx,
|
||||
Math.min(displayAlbums.length, Math.max(albumGridCols * 6, 48)),
|
||||
)}
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
displayCssPx={albumCellDisplayCssPx}
|
||||
observeScrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
|
||||
{!perfFlags.disableMainstageGridCards && (
|
||||
<div ref={gridMeasureRef}>
|
||||
<VirtualCardGrid
|
||||
items={displayAlbums}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={displayAlbums.length}
|
||||
scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
warmGridCovers={albumGridWarmCovers(
|
||||
albumCellDisplayCssPx,
|
||||
Math.min(displayAlbums.length, Math.max(albumGridCols * 6, 48)),
|
||||
)}
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
displayCssPx={albumCellDisplayCssPx}
|
||||
observeScrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loadingMore} />
|
||||
)}
|
||||
</div>
|
||||
{isScrollRestorePending && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '3rem',
|
||||
background: 'var(--ctp-base)',
|
||||
}}
|
||||
>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loadingMore} />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</OverlayScrollArea>
|
||||
</div>
|
||||
|
||||
+69
-11
@@ -1,6 +1,5 @@
|
||||
import { getArtists } from '../api/subsonicArtists';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { LayoutGrid, List, Images, CheckSquare2 } from 'lucide-react';
|
||||
import StarFilterButton from '../components/StarFilterButton';
|
||||
import OverlayScrollArea from '../components/OverlayScrollArea';
|
||||
@@ -31,15 +30,36 @@ import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
|
||||
import { ArtistsGridView } from '../components/artists/ArtistsGridView';
|
||||
import { ArtistsListView } from '../components/artists/ArtistsListView';
|
||||
import InpageScrollSentinel from '../components/InpageScrollSentinel';
|
||||
import { useArtistsBrowseFilters, type ArtistBrowseScrollSnapshot } from '../hooks/useArtistsBrowseFilters';
|
||||
import { useArtistsBrowseScrollRestore } from '../hooks/useArtistsBrowseScrollRestore';
|
||||
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
||||
import { peekArtistBrowseScrollRestore } from '../store/artistBrowseSessionStore';
|
||||
import { readArtistBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
|
||||
export default function Artists() {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState('');
|
||||
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
|
||||
const scrollSnapshotRef = useRef<ArtistBrowseScrollSnapshot>({ scrollTop: 0, visibleCount: 0 });
|
||||
const restoreVisibleCountRef = useRef<number | undefined>(
|
||||
peekArtistBrowseScrollRestore(serverId)?.visibleCount,
|
||||
);
|
||||
|
||||
const {
|
||||
filter,
|
||||
setFilter,
|
||||
letterFilter,
|
||||
setLetterFilter,
|
||||
starredOnly,
|
||||
setStarredOnly,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
} = useArtistsBrowseFilters(serverId, scrollSnapshotRef);
|
||||
|
||||
const {
|
||||
scrollBodyEl: artistsScrollBodyEl,
|
||||
@@ -49,12 +69,11 @@ export default function Artists() {
|
||||
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const serverId = useAuthStore(s => s.activeServerId);
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
|
||||
const {
|
||||
catalogArtists,
|
||||
@@ -89,6 +108,7 @@ export default function Artists() {
|
||||
resetDeps: [filter, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId],
|
||||
getScrollRoot: getArtistsScrollRoot,
|
||||
scrollRootEl: artistsScrollBodyEl,
|
||||
restoreDisplayCount: restoreVisibleCountRef.current,
|
||||
});
|
||||
|
||||
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||
@@ -151,6 +171,26 @@ export default function Artists() {
|
||||
|
||||
loadMoreRef.current = loadMoreGrid;
|
||||
|
||||
scrollSnapshotRef.current = {
|
||||
scrollTop: artistsScrollBodyEl?.scrollTop ?? 0,
|
||||
visibleCount,
|
||||
};
|
||||
|
||||
const { isScrollRestorePending } = useArtistsBrowseScrollRestore({
|
||||
serverId,
|
||||
scrollBodyEl: artistsScrollBodyEl,
|
||||
visibleCount,
|
||||
loading: loading || pendingLetterMatch,
|
||||
loadingMore: gridLoadingMore,
|
||||
hasMore: gridHasMore,
|
||||
loadMore: loadMoreGrid,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrollRestorePending || !readArtistBrowseRestore(location.state)) return;
|
||||
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
||||
}, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingLetterMatch || catalogLoadingRef.current) return;
|
||||
void loadCatalogChunk(true);
|
||||
@@ -379,6 +419,8 @@ export default function Artists() {
|
||||
selectionMode,
|
||||
]}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
|
||||
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
|
||||
|
||||
{!loading && pendingLetterMatch && (
|
||||
@@ -402,7 +444,7 @@ export default function Artists() {
|
||||
selectedArtists={selectedArtists}
|
||||
showArtistImages={showArtistImages}
|
||||
toggleSelect={toggleSelect}
|
||||
navigate={navigate}
|
||||
onOpenArtist={navigateToArtist}
|
||||
openContextMenu={openContextMenu}
|
||||
t={t}
|
||||
/>
|
||||
@@ -422,7 +464,7 @@ export default function Artists() {
|
||||
selectedArtists={selectedArtists}
|
||||
showArtistImages={showArtistImages}
|
||||
toggleSelect={toggleSelect}
|
||||
navigate={navigate}
|
||||
onOpenArtist={navigateToArtist}
|
||||
openContextMenu={openContextMenu}
|
||||
t={t}
|
||||
/>
|
||||
@@ -437,6 +479,22 @@ export default function Artists() {
|
||||
{t('artists.notFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isScrollRestorePending && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</OverlayScrollArea>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,11 +3,12 @@ import { getAlbumsByGenre } from '../api/subsonicGenres';
|
||||
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { dedupeById } from '../utils/dedupeById';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useLayoutEffect, useState, useCallback, useRef } from 'react';
|
||||
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
@@ -26,6 +27,10 @@ import { useAsyncInpagePagination } from '../hooks/useAsyncInpagePagination';
|
||||
import { useInpageScrollSentinel } from '../hooks/useInpageScrollSentinel';
|
||||
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
|
||||
import InpageScrollSentinel from '../components/InpageScrollSentinel';
|
||||
import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '../hooks/useAlbumGridBrowseFilters';
|
||||
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
|
||||
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
|
||||
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
@@ -46,10 +51,21 @@ export default function NewReleases() {
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||
const scrollSnapshotRef = useRef<AlbumBrowseScrollSnapshot>({ scrollTop: 0, displayCount: 0 });
|
||||
const gridSnapshotRef = useRef<AlbumGridBrowseSnapshot>({ albums: [], hasMore: true });
|
||||
const {
|
||||
selectedGenres,
|
||||
setSelectedGenres,
|
||||
initialAlbums,
|
||||
initialHasMore,
|
||||
} = useAlbumGridBrowseFilters(serverId, 'new-releases', scrollSnapshotRef, gridSnapshotRef);
|
||||
const restoringSessionRef = useRef(initialAlbums != null);
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() => initialAlbums ?? []);
|
||||
const [hasMore, setHasMore] = useState(() => initialHasMore ?? true);
|
||||
const {
|
||||
scrollBodyEl,
|
||||
bindScrollBody: bindNewReleasesScrollBody,
|
||||
@@ -62,10 +78,13 @@ export default function NewReleases() {
|
||||
runLoad,
|
||||
requestNextPage,
|
||||
isBlocked,
|
||||
} = useAsyncInpagePagination(PAGE_SIZE, { initialLoading: true });
|
||||
} = useAsyncInpagePagination(PAGE_SIZE, { initialLoading: initialAlbums == null });
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const filtered = selectedGenres.length > 0;
|
||||
|
||||
gridSnapshotRef.current = { albums, hasMore };
|
||||
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, albums.length);
|
||||
|
||||
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
|
||||
filtered,
|
||||
selectionMode,
|
||||
@@ -137,6 +156,7 @@ export default function NewReleases() {
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restoringSessionRef.current) return;
|
||||
if (filtered) loadFiltered(selectedGenres);
|
||||
else {
|
||||
resetPage();
|
||||
@@ -156,6 +176,28 @@ export default function NewReleases() {
|
||||
onIntersect: loadMore,
|
||||
});
|
||||
|
||||
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
|
||||
serverId,
|
||||
surface: 'new-releases',
|
||||
scrollBodyEl,
|
||||
displayAlbumsLength: albums.length,
|
||||
loading,
|
||||
loadingMore: loading,
|
||||
hasMore,
|
||||
loadMore,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isScrollRestorePending && restoringSessionRef.current) {
|
||||
restoringSessionRef.current = false;
|
||||
}
|
||||
}, [isScrollRestorePending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrollRestorePending || !readAlbumBrowseRestore(location.state)) return;
|
||||
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
||||
}, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
|
||||
|
||||
return (
|
||||
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
|
||||
<div className="mainstage-inpage-toolbar">
|
||||
@@ -218,7 +260,8 @@ export default function NewReleases() {
|
||||
{t('common.libraryEmpty')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
|
||||
<VirtualCardGrid
|
||||
items={albums}
|
||||
itemKey={(a, _i) => a.id}
|
||||
@@ -241,7 +284,22 @@ export default function NewReleases() {
|
||||
{!filtered && hasMore && (
|
||||
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loading} />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
{isScrollRestorePending && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '3rem',
|
||||
background: 'var(--ctp-base)',
|
||||
}}
|
||||
>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</OverlayScrollArea>
|
||||
</div>
|
||||
|
||||
+180
-81
@@ -4,11 +4,12 @@ import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { dedupeById } from '../utils/dedupeById';
|
||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import React, { useEffect, useLayoutEffect, useState, useCallback, useRef } from 'react';
|
||||
import { RefreshCw, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mix/mixRatingFilter';
|
||||
@@ -26,6 +27,14 @@ import {
|
||||
primeAlbumCoversForDisplay,
|
||||
} from '../cover/warmDiskPeek';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import OverlayScrollArea from '../components/OverlayScrollArea';
|
||||
import { RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
|
||||
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
|
||||
import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '../hooks/useAlbumGridBrowseFilters';
|
||||
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
|
||||
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
|
||||
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||
|
||||
const ALBUM_COUNT = 30;
|
||||
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
|
||||
@@ -134,11 +143,26 @@ export default function RandomAlbums() {
|
||||
const serverId = auth.activeServerId ?? '';
|
||||
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const scrollSnapshotRef = useRef<AlbumBrowseScrollSnapshot>({ scrollTop: 0, displayCount: 0 });
|
||||
const gridSnapshotRef = useRef<AlbumGridBrowseSnapshot>({ albums: [], hasMore: false });
|
||||
const {
|
||||
selectedGenres,
|
||||
setSelectedGenres,
|
||||
initialAlbums,
|
||||
} = useAlbumGridBrowseFilters(serverId, 'random-albums', scrollSnapshotRef, gridSnapshotRef);
|
||||
const restoringSessionRef = useRef(initialAlbums != null);
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() => initialAlbums ?? []);
|
||||
const [loading, setLoading] = useState(() => initialAlbums == null);
|
||||
const loadingRef = useRef(false);
|
||||
const filtered = selectedGenres.length > 0;
|
||||
const {
|
||||
scrollBodyEl,
|
||||
bindScrollBody: bindRandomAlbumsScrollBody,
|
||||
} = useInpageScrollViewport();
|
||||
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
|
||||
@@ -219,90 +243,165 @@ export default function RandomAlbums() {
|
||||
mixMinRatingArtist,
|
||||
]);
|
||||
|
||||
// Keep a ref so the effect closure is always fresh without re-triggering the
|
||||
// effect on every `load` reference change. The effect must NOT list `load` as a
|
||||
// dep — Zustand rehydration changes deps (e.g. mixMinRatingFilterEnabled) and
|
||||
// recreates `load`, which would otherwise double-fire on every page visit and
|
||||
// show a different random batch ~1.5 s after the first one.
|
||||
const loadRef = useRef(load);
|
||||
loadRef.current = load;
|
||||
useEffect(() => { loadRef.current(selectedGenres); }, [selectedGenres]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
if (restoringSessionRef.current) return;
|
||||
loadRef.current(selectedGenres);
|
||||
}, [selectedGenres]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (scrollBodyEl) {
|
||||
scrollBodyEl.scrollTop = 0;
|
||||
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
}
|
||||
scrollSnapshotRef.current.scrollTop = 0;
|
||||
load(selectedGenres);
|
||||
}, [scrollBodyEl, load, selectedGenres]);
|
||||
|
||||
gridSnapshotRef.current = { albums, hasMore: false };
|
||||
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, albums.length);
|
||||
|
||||
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
|
||||
serverId,
|
||||
surface: 'random-albums',
|
||||
scrollBodyEl,
|
||||
displayAlbumsLength: albums.length,
|
||||
loading,
|
||||
loadingMore: false,
|
||||
hasMore: false,
|
||||
loadMore: () => {},
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isScrollRestorePending && restoringSessionRef.current) {
|
||||
restoringSessionRef.current = false;
|
||||
}
|
||||
}, [isScrollRestorePending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrollRestorePending || !readAlbumBrowseRestore(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 mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
|
||||
filtered,
|
||||
selectionMode,
|
||||
selectedGenres,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
: t('randomAlbums.title')}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{selectionMode && selectedIds.size > 0 ? (
|
||||
<>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
|
||||
<HardDriveDownload size={15} />
|
||||
{t('albums.addOffline')}
|
||||
</button>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
|
||||
<Download size={15} />
|
||||
{t('albums.downloadZips')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => load(selectedGenres)}
|
||||
disabled={loading}
|
||||
data-tooltip={t('randomAlbums.refresh')}
|
||||
>
|
||||
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
|
||||
{t('randomAlbums.refresh')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
|
||||
</button>
|
||||
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
|
||||
<div className="mainstage-inpage-toolbar">
|
||||
<div className="page-sticky-header mainstage-inpage-toolbar-row">
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
: t('randomAlbums.title')}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{selectionMode && selectedIds.size > 0 ? (
|
||||
<>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
|
||||
<HardDriveDownload size={15} />
|
||||
{t('albums.addOffline')}
|
||||
</button>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
|
||||
<Download size={15} />
|
||||
{t('albums.downloadZips')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
data-tooltip={t('randomAlbums.refresh')}
|
||||
>
|
||||
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
|
||||
{t('randomAlbums.refresh')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : !loading && albums.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('common.libraryEmpty')}
|
||||
</div>
|
||||
) : (
|
||||
<VirtualCardGrid
|
||||
items={albums}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
ensurePriority="high"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<OverlayScrollArea
|
||||
className="mainstage-inpage-scroll"
|
||||
viewportClassName="mainstage-inpage-scroll__viewport"
|
||||
viewportId={RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
viewportRef={bindRandomAlbumsScrollBody}
|
||||
railInset="panel"
|
||||
measureDeps={[
|
||||
loading,
|
||||
albums.length,
|
||||
filtered,
|
||||
selectionMode,
|
||||
perfFlags.disableMainstageVirtualLists,
|
||||
]}
|
||||
>
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : !loading && albums.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('common.libraryEmpty')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
|
||||
<VirtualCardGrid
|
||||
items={albums}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
scrollRootId={RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
observeScrollRootId={RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
ensurePriority="high"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isScrollRestorePending && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '3rem',
|
||||
background: 'var(--ctp-base)',
|
||||
}}
|
||||
>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</OverlayScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,65 @@
|
||||
import { getGenres, getAlbumsByGenre } from '../api/subsonicGenres';
|
||||
import { search, searchSongsPaged } from '../api/subsonicSearch';
|
||||
import { getAlbumList, getRandomSongs } from '../api/subsonicLibrary';
|
||||
import { getRandomSongs } from '../api/subsonicLibrary';
|
||||
import type { SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SlidersVertical, X } from 'lucide-react';
|
||||
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 { runLocalAdvancedSearch, loadMoreLocalSongs, runNetworkAdvancedTextSearch } from '../utils/library/advancedSearchLocal';
|
||||
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';
|
||||
|
||||
const MOOD_UI_ENABLED = OXIMEDIA_MOOD_SEARCH_ENABLED;
|
||||
|
||||
@@ -51,22 +90,41 @@ function parseBpmInput(raw: string): number | null {
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export default function AdvancedSearch() {
|
||||
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 [query, setQuery] = useState(params.get('q') ?? '');
|
||||
const [genre, setGenre] = useState('');
|
||||
const [yearFrom, setYearFrom] = useState('');
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
const [bpmFrom, setBpmFrom] = useState('');
|
||||
const [bpmTo, setBpmTo] = useState('');
|
||||
const [moodGroup, setMoodGroup] = useState('');
|
||||
const [losslessOnly, setLosslessOnly] = useState(false);
|
||||
const [resultType, setResultType] = useState<ResultType>('all');
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
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>(null);
|
||||
const [results, setResults] = useState<Results | null>(() => restoreStash?.results ?? null);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const filteredResults = useMemo<Results | null>(() => {
|
||||
if (!results) return null;
|
||||
@@ -83,24 +141,154 @@ export default function AdvancedSearch() {
|
||||
? filteredResults.artists.length + filteredResults.albums.length + filteredResults.songs.length
|
||||
: 0;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [genreNote, setGenreNote] = 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(false);
|
||||
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 — only the free-text-query branch uses search3 with offset
|
||||
// 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>(null);
|
||||
const [songsServerOffset, setSongsServerOffset] = useState(0);
|
||||
const [songsHasMore, setSongsHasMore] = useState(false);
|
||||
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 songBrowse = useSongBrowseList({
|
||||
enabled: showTracksChrome,
|
||||
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 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 [tracksChromeLayoutReady, setTracksChromeLayoutReady] = useState(
|
||||
() => !showTracksChrome || leaveSnapshotRef.current == null,
|
||||
);
|
||||
const [isLeaveRestorePending, setIsLeaveRestorePending] = useState(
|
||||
() => leaveSnapshotRef.current != null,
|
||||
);
|
||||
|
||||
const handleTracksChromeLayoutReady = useCallback(() => {
|
||||
setTracksChromeLayoutReady(true);
|
||||
}, []);
|
||||
|
||||
const finishLeaveRestoreUi = useCallback(() => {
|
||||
if (leaveRestoreUiFinishedRef.current) return;
|
||||
leaveRestoreUiFinishedRef.current = true;
|
||||
clearAdvancedSearchLeaveSnapshots();
|
||||
leaveSnapshotRef.current = null;
|
||||
setIsLeaveRestorePending(false);
|
||||
if (hadRestoreOnMountRef.current) {
|
||||
useAdvancedSearchSessionStore.getState().clearReturnStash();
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 ? songBrowse.query : 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,
|
||||
@@ -120,6 +308,76 @@ export default function AdvancedSearch() {
|
||||
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;
|
||||
@@ -127,10 +385,10 @@ export default function AdvancedSearch() {
|
||||
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;
|
||||
@@ -146,8 +404,7 @@ export default function AdvancedSearch() {
|
||||
[
|
||||
{
|
||||
source: 'local',
|
||||
run: () =>
|
||||
runLocalAdvancedSearch(serverId, opts, SONGS_INITIAL, false, true, true),
|
||||
run: () => tryRunLocalAdvancedSearch(serverId, opts, SONGS_INITIAL, true),
|
||||
},
|
||||
{
|
||||
source: 'network',
|
||||
@@ -189,7 +446,7 @@ export default function AdvancedSearch() {
|
||||
}
|
||||
setLocalMode(false);
|
||||
} else if (serverId && indexEnabled) {
|
||||
const localPage = await runLocalAdvancedSearch(serverId, opts, SONGS_INITIAL);
|
||||
const localPage = await tryRunLocalAdvancedSearch(serverId, opts, SONGS_INITIAL);
|
||||
if (isStale()) return;
|
||||
if (localPage) {
|
||||
setResults({
|
||||
@@ -268,9 +525,9 @@ export default function AdvancedSearch() {
|
||||
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
|
||||
if (songs.length > 0) setGenreNote(true);
|
||||
} else if (from !== null || to !== null) {
|
||||
const fromYear = from ?? 1900;
|
||||
const toYear = to ?? new Date().getFullYear();
|
||||
albums = await getAlbumList('byYear', 100, 0, { fromYear, toYear });
|
||||
if (rt !== 'artists' && rt !== 'songs') {
|
||||
albums = await runNetworkAdvancedYearAlbums(opts, 100);
|
||||
}
|
||||
}
|
||||
|
||||
const finalResults = {
|
||||
@@ -301,13 +558,100 @@ export default function AdvancedSearch() {
|
||||
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);
|
||||
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 leaveRestoreContentReady = showTracksChrome
|
||||
? tracksChromeLayoutReady
|
||||
&& ((hadRestoreOnMountRef.current && songBrowse.hasSearched) || (songBrowse.hasSearched && !songBrowse.loading))
|
||||
: ((hadRestoreOnMountRef.current && results !== null) || (hasSearched && !loading));
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!leaveRestoreContentReady || leaveRestoreUiFinishedRef.current) return;
|
||||
const target = scrollTopRestoreTargetRef.current;
|
||||
if (target <= 0) {
|
||||
finishLeaveRestoreUi();
|
||||
return;
|
||||
}
|
||||
return restoreMainViewportScroll(target, finishLeaveRestoreUi);
|
||||
}, [leaveRestoreContentReady, finishLeaveRestoreUi]);
|
||||
|
||||
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(() => {});
|
||||
if (qFromUrl) {
|
||||
}, []);
|
||||
|
||||
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: qFromUrl,
|
||||
query: q,
|
||||
genre: '',
|
||||
yearFrom: '',
|
||||
yearTo: '',
|
||||
@@ -317,12 +661,33 @@ export default function AdvancedSearch() {
|
||||
losslessOnly: false,
|
||||
resultType: 'all',
|
||||
});
|
||||
} else {
|
||||
void runBasicSearch(q);
|
||||
}
|
||||
}, [musicLibraryFilterVersion, qFromUrl]);
|
||||
}, [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;
|
||||
@@ -368,7 +733,7 @@ export default function AdvancedSearch() {
|
||||
} finally {
|
||||
setLoadingMoreSongs(false);
|
||||
}
|
||||
}, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset, localMode, serverId]);
|
||||
}, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset, localMode, serverId, basicSearchMode]);
|
||||
|
||||
const trackFilterActive =
|
||||
(MOOD_UI_ENABLED && !!moodGroup) || !!(bpmFrom || bpmTo);
|
||||
@@ -420,14 +785,54 @@ export default function AdvancedSearch() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<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
|
||||
onLayoutReady={
|
||||
isLeaveRestorePending && showTracksChrome ? handleTracksChromeLayoutReady : undefined
|
||||
}
|
||||
/>
|
||||
{!perfFlags.disableMainstageVirtualLists && (
|
||||
<SongBrowseSection
|
||||
title={t('tracks.browseTitle')}
|
||||
emptyBrowseText={t('tracks.browseUnsupported')}
|
||||
query={songBrowse.query}
|
||||
onQueryChange={songBrowse.setQuery}
|
||||
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' }}>
|
||||
<SlidersVertical size={22} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
{t('search.advanced')}
|
||||
{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' }}>
|
||||
@@ -445,7 +850,7 @@ export default function AdvancedSearch() {
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('search.advancedSearchPlaceholder')}
|
||||
style={{ flex: 1 }}
|
||||
autoFocus
|
||||
autoFocus={!skipSearchAutoFocusRef.current}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -622,9 +1027,11 @@ export default function AdvancedSearch() {
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Results ───────────────────────────────────────────── */}
|
||||
{!hasSearched ? (
|
||||
{showAdvancedPanel && !hasSearched ? (
|
||||
<div className="empty-state" style={{ opacity: 0.6 }}>
|
||||
{t('search.advancedEmpty')}
|
||||
</div>
|
||||
@@ -633,26 +1040,58 @@ export default function AdvancedSearch() {
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : total === 0 ? (
|
||||
<div className="empty-state">{t('search.advancedNoResults')}</div>
|
||||
<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={`${t('search.artists')} (${filteredResults.artists.length})`}
|
||||
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={`${t('search.albums')} (${filteredResults.albums.length})`}
|
||||
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 && (
|
||||
@@ -676,6 +1115,25 @@ export default function AdvancedSearch() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{isLeaveRestorePending && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { searchSongsPaged } from '../api/subsonicSearch';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Search } from 'lucide-react';
|
||||
import type { SearchResults as ISearchResults } from '../api/subsonicTypes';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
import PagedSongList from '../components/PagedSongList';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import {
|
||||
browseRaceCountsFullSearch,
|
||||
loadMoreLocalBrowseSongs,
|
||||
raceBrowseWithLocalFallback,
|
||||
runLocalBrowseFullSearch,
|
||||
runNetworkBrowseFullSearch,
|
||||
} from '../utils/library/browseTextSearch';
|
||||
|
||||
const SONGS_INITIAL = 50;
|
||||
const SONGS_PAGE_SIZE = 50;
|
||||
|
||||
export default function SearchResults() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const query = params.get('q') ?? '';
|
||||
const [results, setResults] = useState<ISearchResults | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [songsServerOffset, setSongsServerOffset] = useState(0);
|
||||
const [songsHasMore, setSongsHasMore] = useState(false);
|
||||
const [loadingMoreSongs, setLoadingMoreSongs] = useState(false);
|
||||
const [localMode, setLocalMode] = useState(false);
|
||||
const searchRunRef = useRef(0);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const serverId = useAuthStore(s => s.activeServerId);
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
setSongsServerOffset(0);
|
||||
setSongsHasMore(false);
|
||||
setLocalMode(false);
|
||||
if (!q) {
|
||||
setResults(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = ++searchRunRef.current;
|
||||
const isStale = () => runId !== searchRunRef.current;
|
||||
setLoading(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (serverId && indexEnabled) {
|
||||
const outcome = await raceBrowseWithLocalFallback(
|
||||
isStale,
|
||||
() => runLocalBrowseFullSearch(serverId, q, SONGS_INITIAL),
|
||||
() => runNetworkBrowseFullSearch(q, 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 >= SONGS_INITIAL);
|
||||
setLocalMode(outcome.source === 'local');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const network = await runNetworkBrowseFullSearch(q, SONGS_INITIAL);
|
||||
if (isStale()) return;
|
||||
if (network) {
|
||||
setResults(network);
|
||||
setSongsServerOffset(network.songs.length);
|
||||
setSongsHasMore(network.songs.length >= SONGS_INITIAL);
|
||||
} else {
|
||||
setResults({ artists: [], albums: [], songs: [] });
|
||||
}
|
||||
} catch {
|
||||
if (!isStale()) setResults(null);
|
||||
} finally {
|
||||
if (!isStale()) setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [query, musicLibraryFilterVersion, serverId, indexEnabled]);
|
||||
|
||||
const loadMoreSongs = useCallback(async () => {
|
||||
const q = query.trim();
|
||||
if (loadingMoreSongs || !songsHasMore || !q) return;
|
||||
setLoadingMoreSongs(true);
|
||||
try {
|
||||
const page = localMode && serverId
|
||||
? await loadMoreLocalBrowseSongs(serverId, q, songsServerOffset, SONGS_PAGE_SIZE)
|
||||
: await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset);
|
||||
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...page] } : prev);
|
||||
setSongsServerOffset(o => o + page.length);
|
||||
if (page.length < SONGS_PAGE_SIZE) setSongsHasMore(false);
|
||||
} catch {
|
||||
setSongsHasMore(false);
|
||||
} finally {
|
||||
setLoadingMoreSongs(false);
|
||||
}
|
||||
}, [loadingMoreSongs, songsHasMore, query, songsServerOffset, localMode, serverId]);
|
||||
|
||||
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
<div style={{ marginBottom: '-1.5rem' }}>
|
||||
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<Search size={22} />
|
||||
{query ? t('search.resultsFor', { query }) : t('search.title')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="loading-center"><div className="spinner" /></div>
|
||||
)}
|
||||
|
||||
{!loading && query && !hasResults && (
|
||||
<div className="empty-state">{t('search.noResults', { query })}</div>
|
||||
)}
|
||||
|
||||
{!loading && results && (
|
||||
<>
|
||||
{results.artists.length > 0 && (
|
||||
<ArtistRow title={t('search.artists')} artists={results.artists} />
|
||||
)}
|
||||
|
||||
{results.albums.length > 0 && (
|
||||
<AlbumRow
|
||||
title={t('search.albums')}
|
||||
albums={results.albums}
|
||||
windowArtworkByViewport
|
||||
initialArtworkBudget={12}
|
||||
/>
|
||||
)}
|
||||
|
||||
{results.songs.length > 0 && (
|
||||
<section>
|
||||
<h2 className="section-title" style={{ marginBottom: '0.75rem' }}>{t('search.songs')}</h2>
|
||||
<PagedSongList
|
||||
songs={results.songs}
|
||||
hasMore={songsHasMore}
|
||||
loadingMore={loadingMoreSongs}
|
||||
onLoadMore={loadMoreSongs}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
|
||||
import { getRandomSongs } from '../api/subsonicLibrary';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import SongRail from '../components/SongRail';
|
||||
import VirtualSongList from '../components/VirtualSongList';
|
||||
import { playSongNow } from '../utils/playback/playSong';
|
||||
import { ndListSongs, ndInvalidateSongsCache } from '../api/navidromeBrowse';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
|
||||
const RANDOM_RAIL_SIZE = 18;
|
||||
/** Over-fetch buffer so the client-side `userRating > 0` filter still leaves
|
||||
* enough cards for the rail. Server-side rating filter on Navidrome's REST
|
||||
* is finicky and not yet wired through — revisit when verified. */
|
||||
const RATED_RAIL_FETCH = 60;
|
||||
const RATED_RAIL_DISPLAY = 30;
|
||||
/** Stay-fresh window for the Highly Rated rail. Cleared on rating mutation, so
|
||||
* the only staleness path is a reroll-button click after >60 s. */
|
||||
const RATED_RAIL_CACHE_MS = 60_000;
|
||||
/** Match Home: only mount artwork for cards near the horizontal viewport. */
|
||||
const TRACKS_SONG_RAIL_WINDOWING = true;
|
||||
const TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 14;
|
||||
|
||||
export default function Tracks() {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
|
||||
const [hero, setHero] = useState<SubsonicSong | null>(null);
|
||||
const [heroLoading, setHeroLoading] = useState(false);
|
||||
|
||||
const [random, setRandom] = useState<SubsonicSong[]>([]);
|
||||
const [randomLoading, setRandomLoading] = useState(true);
|
||||
|
||||
const [rated, setRated] = useState<SubsonicSong[]>([]);
|
||||
const [ratedLoading, setRatedLoading] = useState(true);
|
||||
/** Hide the rail entirely on non-Navidrome servers (REST call throws) so we don't show an empty section. */
|
||||
const [ratedSupported, setRatedSupported] = useState(true);
|
||||
|
||||
const rerollHero = useCallback(async () => {
|
||||
setHeroLoading(true);
|
||||
try {
|
||||
const picks = await getRandomSongs(1);
|
||||
if (picks[0]) setHero(picks[0]);
|
||||
} finally {
|
||||
setHeroLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rerollRandom = useCallback(async () => {
|
||||
setRandomLoading(true);
|
||||
try {
|
||||
const r = await getRandomSongs(RANDOM_RAIL_SIZE);
|
||||
setRandom(r);
|
||||
} finally {
|
||||
setRandomLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadRated = useCallback(async () => {
|
||||
setRatedLoading(true);
|
||||
try {
|
||||
const songs = await ndListSongs(0, RATED_RAIL_FETCH, 'rating', 'DESC', RATED_RAIL_CACHE_MS);
|
||||
const filtered = songs.filter(s => (s.userRating ?? 0) > 0).slice(0, RATED_RAIL_DISPLAY);
|
||||
setRated(filtered);
|
||||
setRatedSupported(true);
|
||||
} catch {
|
||||
// Non-Navidrome server, or REST endpoint refused → silently hide the rail.
|
||||
setRated([]);
|
||||
setRatedSupported(false);
|
||||
} finally {
|
||||
setRatedLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeServerId) return;
|
||||
rerollHero();
|
||||
rerollRandom();
|
||||
reloadRated();
|
||||
}, [activeServerId, rerollHero, rerollRandom, reloadRated]);
|
||||
|
||||
// Hide the hero song from the random rail if the server happens to return it in
|
||||
// both fetches (Navidrome's getRandomSongs sometimes overlaps within a short window).
|
||||
const railSongs = useMemo(
|
||||
() => (hero ? random.filter(s => s.id !== hero.id) : random),
|
||||
[random, hero],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in tracks-page">
|
||||
{!perfFlags.disableMainstageStickyHeader && (
|
||||
<header className="tracks-header">
|
||||
<div className="tracks-header-text">
|
||||
<h1 className="page-title">{t('tracks.title')}</h1>
|
||||
<p className="tracks-subtitle">{t('tracks.subtitle')}</p>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{!perfFlags.disableMainstageHero && hero && (
|
||||
<section className="tracks-hero">
|
||||
<div className="tracks-hero-cover">
|
||||
{hero.albumId && hero.coverArt ? (
|
||||
<AlbumCoverArtImage
|
||||
albumId={hero.albumId}
|
||||
coverArt={hero.coverArt}
|
||||
displayCssPx={600}
|
||||
surface="sparse"
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="tracks-hero-cover-placeholder" />
|
||||
)}
|
||||
</div>
|
||||
<div className="tracks-hero-content">
|
||||
<span className="tracks-hero-eyebrow">
|
||||
<Sparkles size={14} />
|
||||
{t('tracks.heroEyebrow')}
|
||||
</span>
|
||||
<h2 className="tracks-hero-title" title={hero.title}>{hero.title}</h2>
|
||||
<p className="tracks-hero-meta">
|
||||
<span
|
||||
className={hero.artistId ? 'track-artist-link' : ''}
|
||||
style={{ cursor: hero.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => hero.artistId && navigate(`/artist/${hero.artistId}`)}
|
||||
>{hero.artist}</span>
|
||||
{hero.album && (
|
||||
<>
|
||||
<span className="tracks-hero-meta-dot">·</span>
|
||||
<span
|
||||
className={hero.albumId ? 'track-artist-link' : ''}
|
||||
style={{ cursor: hero.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => hero.albumId && navigate(`/album/${hero.albumId}`)}
|
||||
>{hero.album}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="tracks-hero-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => playSongNow(hero)}
|
||||
>
|
||||
<Play size={16} fill="currentColor" /> {t('tracks.playSong')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => enqueue([songToTrack(hero)])}
|
||||
>
|
||||
<ListPlus size={16} /> {t('tracks.enqueueSong')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={rerollHero}
|
||||
disabled={heroLoading}
|
||||
aria-label={t('tracks.heroReroll')}
|
||||
data-tooltip={t('tracks.heroReroll')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<RefreshCw size={16} className={heroLoading ? 'is-spinning' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!perfFlags.disableMainstageRails && ratedSupported && (ratedLoading || rated.length > 0) && (
|
||||
<SongRail
|
||||
title={t('tracks.railHighlyRated')}
|
||||
songs={rated}
|
||||
loading={ratedLoading}
|
||||
onReroll={() => { ndInvalidateSongsCache(); return reloadRated(); }}
|
||||
windowArtworkByViewport={TRACKS_SONG_RAIL_WINDOWING}
|
||||
initialArtworkBudget={TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!perfFlags.disableMainstageRails && (
|
||||
<SongRail
|
||||
title={t('tracks.railRandom')}
|
||||
songs={railSongs}
|
||||
loading={randomLoading}
|
||||
onReroll={rerollRandom}
|
||||
windowArtworkByViewport={TRACKS_SONG_RAIL_WINDOWING}
|
||||
initialArtworkBudget={TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!perfFlags.disableMainstageVirtualLists && (
|
||||
<VirtualSongList
|
||||
title={t('tracks.browseTitle')}
|
||||
emptyBrowseText={t('tracks.browseUnsupported')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user