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:
cucadmuh
2026-06-01 03:11:27 +03:00
committed by GitHub
parent 77ecc8ddfe
commit d3e5a6b704
61 changed files with 3455 additions and 725 deletions
+10
View File
@@ -187,6 +187,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Library browse — restore scroll, filters, and search when returning from detail
**By [@cucadmuh](https://github.com/cucadmuh), PR [#936](https://github.com/Psychotoxical/psysonic/pull/936)**
* **Artists, Search, Tracks, New Releases, Random Albums:** going back from album or artist detail keeps browse filters, list scroll, and search text instead of resetting the page.
* **Search / Advanced / Tracks** share one browse page; the separate quick-search results route is removed.
* In-app **Back** and the browser **mouse back** button on album/artist detail use the same return path to the browse session you left.
## Changed
### CI — hot-path coverage gates block merges
@@ -1,5 +1,5 @@
//! Advanced Search SQL builder (spec §5.13). PR-5d ships the backend only —
//! the `AdvancedSearch.tsx` UI wiring stays PR-7 (F2). Cross-server search
//! the `SearchBrowsePage.tsx` UI wiring stays PR-7 (F2). Cross-server search
//! (§5.5B) lives in the sibling `cross_server` module.
//!
//! The builder turns a `LibraryAdvancedSearchRequest` into one parameterised
+4 -6
View File
@@ -20,11 +20,9 @@ const MostPlayed = lazy(() => import('../pages/MostPlayed'));
const LosslessAlbums = lazy(() => import('../pages/LosslessAlbums'));
const RandomAlbums = lazy(() => import('../pages/RandomAlbums'));
const LuckyMixPage = lazy(() => import('../pages/LuckyMix'));
const SearchResults = lazy(() => import('../pages/SearchResults'));
const Playlists = lazy(() => import('../pages/Playlists'));
const PlaylistDetail = lazy(() => import('../pages/PlaylistDetail'));
const NowPlayingPage = lazy(() => import('../pages/NowPlaying'));
const Tracks = lazy(() => import('../pages/Tracks'));
const Settings = lazy(() => import('../pages/Settings'));
const Statistics = lazy(() => import('../pages/Statistics'));
const Help = lazy(() => import('../pages/Help'));
@@ -32,7 +30,7 @@ const WhatsNew = lazy(() => import('../pages/WhatsNew'));
const DeviceSync = lazy(() => import('../pages/DeviceSync'));
const OfflineLibrary = lazy(() => import('../pages/OfflineLibrary'));
const LabelAlbums = lazy(() => import('../pages/LabelAlbums'));
const AdvancedSearch = lazy(() => import('../pages/AdvancedSearch'));
const SearchBrowsePage = lazy(() => import('../pages/SearchBrowsePage'));
const FolderBrowser = lazy(() => import('../pages/FolderBrowser'));
const InternetRadio = lazy(() => import('../pages/InternetRadio'));
const Genres = lazy(() => import('../pages/Genres'));
@@ -50,7 +48,7 @@ export default function AppRoutes() {
<Routes>
<Route path="/" element={<Home />} />
<Route path="/albums" element={<Albums />} />
<Route path="/tracks" element={<Tracks />} />
<Route path="/tracks" element={<SearchBrowsePage />} />
<Route path="/random" element={<RandomLanding />} />
<Route path="/random/albums" element={<RandomAlbums />} />
<Route path="/album/:id" element={<AlbumDetail />} />
@@ -63,8 +61,8 @@ export default function AppRoutes() {
<Route path="/random/mix" element={<RandomMix />} />
<Route path="/lucky-mix" element={<LuckyMixPage />} />
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} />
<Route path="/search" element={<SearchBrowsePage />} />
<Route path="/search/advanced" element={<SearchBrowsePage />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/player-stats" element={<Statistics />} />
<Route path="/most-played" element={<MostPlayed />} />
+14 -7
View File
@@ -1,6 +1,7 @@
import React, { Suspense, useCallback, useEffect, useState } from 'react';
import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ensurePlaybackServerActive } from '../utils/playback/playbackServer';
import { navigatePathWithAlbumReturnTo, shouldSkipMainScrollResetOnRouteChange } from '../utils/navigation/albumDetailNavigation';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { PanelRight } from 'lucide-react';
@@ -24,7 +25,7 @@ import TooltipPortal from '../components/TooltipPortal';
import OverlayScrollArea from '../components/OverlayScrollArea';
import {
APP_MAIN_SCROLL_VIEWPORT_ID,
MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH,
mainRouteInpageScrollViewportId,
} from '../constants/appScroll';
import ConnectionIndicator from '../components/ConnectionIndicator';
import LastfmIndicator from '../components/LastfmIndicator';
@@ -98,6 +99,7 @@ export function AppShell() {
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
const navigate = useNavigate();
const location = useLocation();
const prevPathnameRef = useRef(location.pathname);
useCoverNavigationPriority();
useNowPlayingPrewarm();
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
@@ -113,17 +115,22 @@ export function AppShell() {
const detail = (e as CustomEvent).detail;
if (!detail?.to) return;
void ensurePlaybackServerActive().then(ok => {
if (ok) navigate(detail.to);
if (ok) navigatePathWithAlbumReturnTo(navigate, location, detail.to);
});
};
window.addEventListener('psy:navigate', onPsyNavigate);
return () => window.removeEventListener('psy:navigate', onPsyNavigate);
}, [navigate]);
}, [navigate, location]);
// Reset scroll position on route change (main viewport is overlay scroll)
// Reset scroll on route change only — not when the same path gets a new location.state
// (Advanced Search strips `advancedSearchRestore` after applying saved scroll).
useEffect(() => {
const pathnameChanged = prevPathnameRef.current !== location.pathname;
prevPathnameRef.current = location.pathname;
if (!pathnameChanged) return;
if (shouldSkipMainScrollResetOnRouteChange(location.pathname, location.state)) return;
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
}, [location.pathname]);
}, [location.pathname, location.state]);
useOfflineAutoNav(connStatus, hasOfflineContent, location.pathname, navigate);
@@ -256,7 +263,7 @@ export function AppShell() {
<OverlayScrollArea
className="app-shell-route-scroll"
viewportClassName={
MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH[location.pathname]
mainRouteInpageScrollViewportId(location.pathname)
? 'app-shell-route-scroll__viewport app-shell-route-scroll__viewport--inpage-split'
: 'app-shell-route-scroll__viewport'
}
+3 -1
View File
@@ -3,6 +3,7 @@ import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
@@ -66,6 +67,7 @@ function AlbumCard({
onLongPress: () => playAlbumShuffled(album.id),
});
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const serverId = useAuthStore(s => s.activeServerId ?? '');
@@ -86,7 +88,7 @@ function AlbumCard({
const handleClick = (opts?: { shiftKey?: boolean }) => {
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
navigate(linkQuery ? `/album/${album.id}?${linkQuery}` : `/album/${album.id}`);
navigateToAlbum(album.id, { search: linkQuery });
};
return (
+3 -1
View File
@@ -8,6 +8,7 @@ import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { useCoverLightboxSrc } from '../cover/lightbox';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
import { useAlbumDetailBack } from '../hooks/useAlbumDetailBack';
import { useThemeStore } from '../store/themeStore';
import StarRating from './StarRating';
import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
@@ -118,6 +119,7 @@ export default function AlbumHeader({
}: AlbumHeaderProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const goBack = useAlbumDetailBack();
const isMobile = useIsMobile();
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
@@ -160,7 +162,7 @@ export default function AlbumHeader({
)}
<div className="album-detail-content">
<button className="btn btn-ghost album-detail-back" onClick={() => navigate(-1)}>
<button className="btn btn-ghost album-detail-back" onClick={goBack}>
<ChevronLeft size={16} /> {t('albumDetail.back')}
</button>
<div className="album-detail-hero">
+82 -1
View File
@@ -1,5 +1,5 @@
import type { SubsonicAlbum } from '../api/subsonicTypes';
import React, { useRef, useState, useEffect, useMemo } from 'react';
import React, { useRef, useState, useEffect, useLayoutEffect, useMemo } from 'react';
import AlbumCard from './AlbumCard';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { NavLink, useNavigate } from 'react-router-dom';
@@ -14,6 +14,12 @@ interface Props {
moreLink?: string;
moreText?: string;
onLoadMore?: () => Promise<void>;
/** Restored horizontal scroll (e.g. Advanced Search session return). */
restoreScrollLeft?: number;
/** Parent stashes horizontal scroll when leaving the page. */
onScrollLeftSnapshot?: (scrollLeft: number) => void;
/** Fired once when `restoreScrollLeft` has been applied (or skipped). */
onScrollRestoreComplete?: () => void;
showRating?: boolean;
/** Optional content rendered in the row header, left of the scroll-nav. */
headerExtra?: React.ReactNode;
@@ -44,6 +50,9 @@ export default function AlbumRow({
initialArtworkBudget = 8,
albumLinkQuery,
libraryResolve = false,
restoreScrollLeft,
onScrollLeftSnapshot,
onScrollRestoreComplete,
}: Props) {
const perfFlags = usePerfProbeFlags();
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
@@ -57,6 +66,8 @@ export default function AlbumRow({
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
const loadingRef = useRef(false);
const scrollRestoreTargetRef = useRef(restoreScrollLeft);
const scrollRestoreDoneRef = useRef(false);
const uniqueAlbums = useMemo(() => dedupeById(albums), [albums]);
const recomputeArtworkBudget = () => {
@@ -86,6 +97,8 @@ export default function AlbumRow({
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
}
onScrollLeftSnapshot?.(scrollLeft);
// Auto-load trigger (native horizontal scroll still works when rail buttons are perf-disabled)
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
triggerLoadMore();
@@ -125,6 +138,74 @@ export default function AlbumRow({
setArtworkBudget(initialArtworkBudget);
}, [initialArtworkBudget, rowArtworkResetKey]);
const notifyRestoreCompletePendingRef = useRef(false);
const [restoreCompleteTick, setRestoreCompleteTick] = useState(0);
useEffect(() => {
if (restoreScrollLeft == null || restoreScrollLeft <= 0) return;
scrollRestoreTargetRef.current = restoreScrollLeft;
scrollRestoreDoneRef.current = false;
notifyRestoreCompletePendingRef.current = false;
}, [restoreScrollLeft]);
useLayoutEffect(() => {
if (scrollRestoreDoneRef.current) return;
const target = scrollRestoreTargetRef.current;
if (target == null || target <= 0) {
scrollRestoreDoneRef.current = true;
onScrollRestoreComplete?.();
return;
}
let attempts = 0;
let cancelled = false;
const finish = () => {
scrollRestoreDoneRef.current = true;
if (windowArtworkByViewport) {
notifyRestoreCompletePendingRef.current = true;
setRestoreCompleteTick(t => t + 1);
return;
}
onScrollRestoreComplete?.();
};
const attempt = () => {
if (cancelled || scrollRestoreDoneRef.current) return;
const el = scrollRef.current;
if (!el) {
if (++attempts < 12) requestAnimationFrame(attempt);
else finish();
return;
}
const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth);
const desired = Math.min(Math.max(0, target), maxScroll);
el.scrollLeft = desired;
if (windowArtworkByViewport) recomputeArtworkBudget();
handleScroll();
const stuck = Math.abs(el.scrollLeft - desired) <= 1;
const layoutStillGrowing = desired > el.scrollLeft + 1 && maxScroll < target;
if ((!stuck || layoutStillGrowing) && ++attempts < 12) {
requestAnimationFrame(attempt);
return;
}
finish();
};
attempt();
return () => {
cancelled = true;
};
}, [rowArtworkResetKey, windowArtworkByViewport, initialArtworkBudget, uniqueAlbums.length]);
useLayoutEffect(() => {
if (!notifyRestoreCompletePendingRef.current) return;
notifyRestoreCompletePendingRef.current = false;
onScrollRestoreComplete?.();
}, [artworkBudget, restoreCompleteTick, onScrollRestoreComplete]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
const amount = scrollRef.current.clientWidth * 0.75;
+6 -4
View File
@@ -1,11 +1,11 @@
import type { SubsonicArtist } from '../api/subsonicTypes';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
interface Props {
artist: SubsonicArtist;
@@ -17,12 +17,14 @@ interface Props {
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const navigateToArtist = useNavigateToArtist();
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { libraryResolve });
const href = linkQuery ? `/artist/${artist.id}?${linkQuery}` : `/artist/${artist.id}`;
return (
<div className="artist-card" onClick={() => navigate(href)}>
<div
className="artist-card"
onClick={() => navigateToArtist(artist.id, linkQuery ? { search: linkQuery } : undefined)}
>
<div className="artist-card-avatar">
{coverRef ? (
<CoverArtImage
+57 -1
View File
@@ -1,5 +1,5 @@
import type { SubsonicArtist } from '../api/subsonicTypes';
import React, { useRef, useState, useEffect } from 'react';
import React, { useRef, useState, useEffect, useLayoutEffect } from 'react';
import ArtistCardLocal from './ArtistCardLocal';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
@@ -12,23 +12,79 @@ interface Props {
artistLinkQuery?: string;
/** Search results: use API coverArt ids only. */
libraryResolve?: boolean;
/** Restored horizontal scroll (e.g. Advanced Search session return). */
restoreScrollLeft?: number;
/** Parent stashes horizontal scroll when leaving the page. */
onScrollLeftSnapshot?: (scrollLeft: number) => void;
}
export default function ArtistRow({
title, artists, moreLink, moreText, artistLinkQuery, libraryResolve = false,
restoreScrollLeft,
onScrollLeftSnapshot,
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const scrollRestoreTargetRef = useRef(restoreScrollLeft);
const scrollRestoreDoneRef = useRef(false);
const rowResetKey = artists[0]?.id ?? '';
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
onScrollLeftSnapshot?.(scrollLeft);
};
useEffect(() => {
if (restoreScrollLeft == null || restoreScrollLeft <= 0) return;
scrollRestoreTargetRef.current = restoreScrollLeft;
scrollRestoreDoneRef.current = false;
}, [restoreScrollLeft]);
useLayoutEffect(() => {
if (scrollRestoreDoneRef.current) return;
const target = scrollRestoreTargetRef.current;
if (target == null || target <= 0) {
scrollRestoreDoneRef.current = true;
return;
}
let attempts = 0;
let cancelled = false;
const attempt = () => {
if (cancelled || scrollRestoreDoneRef.current) return;
const el = scrollRef.current;
if (!el) {
if (++attempts < 12) requestAnimationFrame(attempt);
else scrollRestoreDoneRef.current = true;
return;
}
const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth);
const desired = Math.min(Math.max(0, target), maxScroll);
el.scrollLeft = desired;
handleScroll();
const stuck = Math.abs(el.scrollLeft - desired) <= 1;
const layoutStillGrowing = desired > el.scrollLeft + 1 && maxScroll < target;
if ((!stuck || layoutStillGrowing) && ++attempts < 12) {
requestAnimationFrame(attempt);
return;
}
scrollRestoreDoneRef.current = true;
};
attempt();
return () => {
cancelled = true;
};
}, [rowResetKey, artists.length]);
useEffect(() => {
handleScroll();
window.addEventListener('resize', handleScroll);
+3 -3
View File
@@ -2,7 +2,7 @@ import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { Play, ListPlus, ChevronLeft, ChevronRight } from 'lucide-react';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useCoverArt } from '../cover/useCoverArt';
@@ -70,7 +70,7 @@ interface HeroProps {
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const isMobile = useIsMobile();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
@@ -293,7 +293,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
className="hero"
role="banner"
aria-label={t('hero.eyebrow')}
onClick={() => navigate(`/album/${album.id}`)}
onClick={() => navigateToAlbum(album.id)}
style={{ cursor: 'pointer' }}
>
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
+29 -5
View File
@@ -22,6 +22,7 @@ import {
} from '../utils/library/libraryDevLog';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -108,6 +109,7 @@ export default function LiveSearch() {
const localReadyRef = useRef(false);
const liveSearchGenRef = useRef(0);
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const enqueue = usePlayerStore(state => state.enqueue);
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
@@ -161,6 +163,20 @@ export default function LiveSearch() {
setSearchSource(null);
}, []);
/** Leave live search for a full-page route — cancel in-flight queries and reset overlay state. */
const leaveLiveSearchFor = useCallback((path: string) => {
liveSearchGenRef.current += 1;
setOpen(false);
setQuery('');
setResults(null);
setSearchSource(null);
setActiveIndex(-1);
setLoading(false);
setIsFocused(false);
inputRef.current?.blur();
navigate(path);
}, [navigate]);
const share = useShareSearch(query, closeSearch);
useEffect(() => {
@@ -459,7 +475,7 @@ export default function LiveSearch() {
},
] : results ? [
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id); setOpen(false); setQuery(''); } }))),
...(results.songs.map(s => ({ id: s.id, action: () => {
const track = songToTrack(s);
enqueue([track]);
@@ -486,7 +502,10 @@ export default function LiveSearch() {
return;
}
if (!open || !flatItems.length) {
if (e.key === 'Enter' && query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); }
if (e.key === 'Enter' && query.trim()) {
e.preventDefault();
leaveLiveSearchFor(`/search?q=${encodeURIComponent(query.trim())}`);
}
return;
}
if (e.key === 'ArrowDown') {
@@ -502,7 +521,9 @@ export default function LiveSearch() {
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0) { flatItems[activeIndex].action(); setActiveIndex(-1); }
else if (query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); }
else if (query.trim()) {
leaveLiveSearchFor(`/search?q=${encodeURIComponent(query.trim())}`);
}
} else if (e.key === 'Escape') {
setOpen(false); setActiveIndex(-1);
}
@@ -574,7 +595,10 @@ export default function LiveSearch() {
// remain active long enough for this button click to fire.
e.preventDefault();
}}
onClick={() => navigate(query.trim() ? `/search/advanced?q=${encodeURIComponent(query.trim())}` : '/search/advanced')}
onClick={() => {
const q = query.trim();
leaveLiveSearchFor(q ? `/search/advanced?q=${encodeURIComponent(q)}` : '/search/advanced');
}}
data-tooltip={t('search.advanced')}
data-tooltip-pos="bottom"
aria-label={t('search.advanced')}
@@ -678,7 +702,7 @@ export default function LiveSearch() {
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
onClick={() => { navigateToAlbum(a.id); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'album');
+8 -2
View File
@@ -3,7 +3,8 @@ import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { navigatePathWithAlbumReturnTo } from '../utils/navigation/albumDetailNavigation';
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -89,6 +90,7 @@ function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id'
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const enqueue = usePlayerStore(s => s.enqueue);
const [query, setQuery] = useState('');
@@ -130,7 +132,11 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
};
const goTo = (path: string) => { commit(query); navigate(path); onClose(); };
const goTo = (path: string) => {
commit(query);
navigatePathWithAlbumReturnTo(navigate, location, path);
onClose();
};
const goCategory = (path: string) => { navigate(path); onClose(); };
const enqueueSong = (song: SearchResults['songs'][number]) => {
commit(query);
+3 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import type { EntitySharePayloadV1 } from '../utils/share/shareLink';
@@ -38,6 +38,7 @@ type QueuePastePayload = Extract<EntitySharePayloadV1, { k: 'queue' }>;
export default function PasteClipboardHandler() {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const servers = useAuthStore(s => s.servers);
@@ -171,7 +172,7 @@ export default function PasteClipboardHandler() {
return;
}
busy.current = true;
void applySharePastePayload(share, navigate, t).finally(() => {
void applySharePastePayload(share, navigate, t, location).finally(() => {
busy.current = false;
});
return;
+6 -4
View File
@@ -1,7 +1,8 @@
import type { SubsonicSong } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { Play, ListPlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
@@ -16,7 +17,8 @@ interface Props {
}
function SongRow({ song, showBpm }: Props) {
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const navigateToArtist = useNavigateToArtist();
const { t } = useTranslation();
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
@@ -94,7 +96,7 @@ function SongRow({ song, showBpm }: Props) {
<span
className={song.artistId ? 'track-artist-link' : ''}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={(e) => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
onClick={(e) => { if (song.artistId) { e.stopPropagation(); navigateToArtist(song.artistId); } }}
title={song.artist}
>{song.artist}</span>
</div>
@@ -103,7 +105,7 @@ function SongRow({ song, showBpm }: Props) {
<span
className="track-artist-link"
style={{ cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); navigate(`/album/${song.albumId}`); }}
onClick={(e) => { e.stopPropagation(); navigateToAlbum(song.albumId!); }}
title={song.album}
>{song.album}</span>
) : <span title={song.album}>{song.album}</span>}
+1 -1
View File
@@ -7,7 +7,7 @@ interface Props {
onChange: (next: boolean) => void;
/** 'default' = icon + label, regular padding (Albums toolbar).
* 'compact' = icon-only, 0.5rem padding (Artists view-mode buttons).
* 'small' = icon + label, 4px/14px padding + 12px text (AdvancedSearch tabs). */
* 'small' = icon + label, 4px/14px padding + 12px text (SearchBrowsePage tabs). */
size?: 'default' | 'compact' | 'small';
}
+15 -203
View File
@@ -1,215 +1,27 @@
import { searchSongsPaged } from '../api/subsonicSearch';
import type { SubsonicSong } from '../api/subsonicTypes';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Search as SearchIcon, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ndListSongs } from '../api/navidromeBrowse';
import { runLocalSongBrowse } from '../utils/library/advancedSearchLocal';
import {
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
BROWSE_TEXT_DEBOUNCE_RACE_MS,
browseRaceCountsSongs,
loadMoreLocalBrowseSongs,
raceBrowseWithLocalFallback,
runLocalBrowseSongPage,
runNetworkBrowseSongPage,
} from '../utils/library/browseTextSearch';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import PagedSongList from './PagedSongList';
const PAGE_SIZE = 50;
async function fetchBrowseAllPage(
serverId: string | null | undefined,
offset: number,
): Promise<SubsonicSong[]> {
const local = await runLocalSongBrowse(serverId, offset, PAGE_SIZE);
if (local) return local;
try {
return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC');
} catch {
return searchSongsPaged('', PAGE_SIZE, offset);
}
}
import React from 'react';
import { useSongBrowseList } from '../hooks/useSongBrowseList';
import SongBrowseSection from './tracks/SongBrowseSection';
interface Props {
title?: string;
emptyBrowseText?: string;
}
/**
* Browse-all-tracks list. Renders through the shared `PagedSongList` in the page
* flow (sticky header + plain rows + sentinel paging), so it matches the Search
* pages and the column header can't be painted over while scrolling (issue #841).
*/
/** @deprecated Use SongBrowseSection via SearchBrowsePage (`/tracks`). */
export default function VirtualSongList({ title, emptyBrowseText }: Props) {
const { t } = useTranslation();
const serverId = useAuthStore(s => s.activeServerId);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [browseUnsupported, setBrowseUnsupported] = useState(false);
const requestSeqRef = useRef(0);
const localSearchModeRef = useRef(false);
useEffect(() => {
const debounceMs = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS;
const timer = window.setTimeout(() => setDebouncedQuery(query.trim()), debounceMs);
return () => window.clearTimeout(timer);
}, [query, indexEnabled]);
const fetchSongPage = useCallback(
async (q: string, pageOffset: number, isStale: () => boolean): Promise<SubsonicSong[]> => {
if (q === '') {
return fetchBrowseAllPage(serverId, pageOffset);
}
if (pageOffset === 0 && indexEnabled && serverId) {
const winner = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseSongPage(serverId, q, 0, PAGE_SIZE),
() => runNetworkBrowseSongPage(q, 0, PAGE_SIZE),
{
surface: 'tracks_browse',
query: q,
indexEnabled,
counts: browseRaceCountsSongs,
},
);
if (isStale()) return [];
if (winner) {
localSearchModeRef.current = winner.source === 'local';
return winner.result ?? [];
}
localSearchModeRef.current = false;
return (await runNetworkBrowseSongPage(q, 0, PAGE_SIZE)) ?? [];
}
if (localSearchModeRef.current && serverId) {
try {
return await loadMoreLocalBrowseSongs(serverId, q, pageOffset, PAGE_SIZE);
} catch {
return [];
}
}
return (await runNetworkBrowseSongPage(q, pageOffset, PAGE_SIZE)) ?? [];
},
[indexEnabled, serverId],
);
useEffect(() => {
let cancelled = false;
setSongs([]);
setOffset(0);
setHasMore(true);
setBrowseUnsupported(false);
localSearchModeRef.current = false;
const seq = ++requestSeqRef.current;
const isStale = () => cancelled || seq !== requestSeqRef.current;
setLoading(true);
void (async () => {
try {
const page = await fetchSongPage(debouncedQuery, 0, isStale);
if (isStale()) return;
if (page.length === 0) {
setHasMore(false);
if (debouncedQuery === '') setBrowseUnsupported(true);
} else {
setSongs(page);
setOffset(page.length);
if (page.length < PAGE_SIZE) setHasMore(false);
}
} catch {
if (!isStale()) setHasMore(false);
} finally {
if (!isStale()) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [debouncedQuery, fetchSongPage]);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
const seq = ++requestSeqRef.current;
const isStale = () => seq !== requestSeqRef.current;
try {
const page = await fetchSongPage(debouncedQuery, offset, isStale);
if (isStale()) return;
if (page.length === 0) {
setHasMore(false);
} else {
setSongs(prev => {
const seen = new Set(prev.map(s => s.id));
const merged = [...prev];
for (const s of page) if (!seen.has(s.id)) merged.push(s);
return merged;
});
setOffset(o => o + page.length);
if (page.length < PAGE_SIZE) setHasMore(false);
}
} catch {
setHasMore(false);
} finally {
if (!isStale()) setLoading(false);
}
}, [loading, hasMore, debouncedQuery, offset, fetchSongPage]);
const showEmptyBrowse = !loading && songs.length === 0 && debouncedQuery === '' && (browseUnsupported || !hasMore);
const browse = useSongBrowseList({ enabled: true });
return (
<section className="virtual-song-list-section">
{title && <h2 className="section-title virtual-song-list-title">{title}</h2>}
<div className="virtual-song-list-toolbar">
<div className="virtual-song-list-search">
<SearchIcon size={16} className="virtual-song-list-search-icon" />
<input
type="text"
className="input virtual-song-list-search-input"
placeholder={t('tracks.searchPlaceholder')}
value={query}
onChange={e => setQuery(e.target.value)}
<SongBrowseSection
title={title}
emptyBrowseText={emptyBrowseText}
query={browse.query}
onQueryChange={browse.setQuery}
songs={browse.songs}
hasMore={browse.hasMore}
loading={browse.loading}
browseUnsupported={browse.browseUnsupported}
onLoadMore={() => { void browse.loadMore(); }}
/>
{query && (
<button
className="virtual-song-list-search-clear"
onClick={() => setQuery('')}
aria-label={t('search.clearLabel')}
>
<X size={14} />
</button>
)}
</div>
<div className="virtual-song-list-meta">
{songs.length > 0 && (
<span>{t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''}</span>
)}
</div>
</div>
{showEmptyBrowse ? (
<div className="virtual-song-list-empty">
{emptyBrowseText ?? t('tracks.browseUnsupported')}
</div>
) : (
<PagedSongList
songs={songs}
hasMore={hasMore}
loadingMore={loading}
onLoadMore={loadMore}
/>
)}
</section>
);
}
@@ -1,6 +1,6 @@
import React, { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAlbumDetailBack } from '../../hooks/useAlbumDetailBack';
import {
ArrowLeft, Camera, Check, ExternalLink, HardDriveDownload, Heart,
Loader2, Play, Radio, Share2, Shuffle, Users,
@@ -50,7 +50,7 @@ export default function ArtistDetailHero({
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
}: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const goBack = useAlbumDetailBack();
const isMobile = useIsMobile();
const imageInputRef = useRef<HTMLInputElement>(null);
const downloadArtist = useOfflineStore(s => s.downloadArtist);
@@ -67,7 +67,7 @@ export default function ArtistDetailHero({
<>
<button
className="btn btn-ghost"
onClick={() => navigate(-1)}
onClick={() => goBack()}
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
>
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
+5 -6
View File
@@ -1,5 +1,4 @@
import React from 'react';
import type { NavigateFunction } from 'react-router-dom';
import type { Virtualizer } from '@tanstack/react-virtual';
import { Check } from 'lucide-react';
import type { TFunction } from 'i18next';
@@ -19,7 +18,7 @@ interface TileProps {
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
navigate: NavigateFunction;
onOpenArtist: (id: string) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -34,7 +33,7 @@ function ArtistGridTile({ artist, ...rest }: TileProps) {
if (rest.selectionMode) {
rest.toggleSelect(artist.id);
} else {
rest.navigate(`/artist/${artist.id}`);
rest.onOpenArtist(artist.id);
}
}}
onContextMenu={(e) => {
@@ -79,7 +78,7 @@ interface Props {
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
navigate: NavigateFunction;
onOpenArtist: (id: string) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -98,7 +97,7 @@ export function ArtistsGridView({
selectedArtists,
showArtistImages,
toggleSelect,
navigate,
onOpenArtist,
openContextMenu,
t,
}: Props) {
@@ -108,7 +107,7 @@ export function ArtistsGridView({
selectedArtists,
showArtistImages,
toggleSelect,
navigate,
onOpenArtist,
openContextMenu,
t,
};
+6 -7
View File
@@ -1,5 +1,4 @@
import React from 'react';
import type { NavigateFunction } from 'react-router-dom';
import type { Virtualizer } from '@tanstack/react-virtual';
import type { TFunction } from 'i18next';
import type { SubsonicArtist } from '../../api/subsonicTypes';
@@ -14,7 +13,7 @@ interface RowProps {
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
navigate: NavigateFunction;
onOpenArtist: (id: string) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -26,7 +25,7 @@ function ArtistListRow({
selectedArtists,
showArtistImages,
toggleSelect,
navigate,
onOpenArtist,
openContextMenu,
t,
}: RowProps) {
@@ -38,7 +37,7 @@ function ArtistListRow({
if (selectionMode) {
toggleSelect(artist.id);
} else {
navigate(`/artist/${artist.id}`);
onOpenArtist(artist.id);
}
}}
onContextMenu={(e) => {
@@ -79,7 +78,7 @@ interface Props {
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
navigate: NavigateFunction;
onOpenArtist: (id: string) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -110,13 +109,13 @@ export function ArtistsListView({
selectedArtists,
showArtistImages,
toggleSelect,
navigate,
onOpenArtist,
openContextMenu,
t,
}: Props) {
const rowCommonProps = {
selectionMode, selectedIds, selectedArtists, showArtistImages,
toggleSelect, navigate, openContextMenu, t,
toggleSelect, onOpenArtist, openContextMenu, t,
};
if (!virtualized) {
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
import { getAlbum } from '../../api/subsonicLibrary';
import { queueSongStar } from '../../store/pendingStarSync';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
@@ -30,6 +31,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
const { t } = useTranslation();
const auth = useAuthStore();
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
return (
<>
@@ -101,7 +103,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
)}
<div className="context-menu-divider" />
{song.albumId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
@@ -244,7 +246,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div>
<div className="context-menu-divider" />
{song.albumId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
@@ -0,0 +1,78 @@
import type { SubsonicSong } from '../../api/subsonicTypes';
import React from 'react';
import { Search as SearchIcon, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import PagedSongList from '../PagedSongList';
interface Props {
title?: string;
emptyBrowseText?: string;
query: string;
onQueryChange: (value: string) => void;
songs: SubsonicSong[];
hasMore: boolean;
loading: boolean;
browseUnsupported: boolean;
onLoadMore: () => void;
}
/** Tracks hub toolbar + paginated song list (shared chrome with Search song results). */
export default function SongBrowseSection({
title,
emptyBrowseText,
query,
onQueryChange,
songs,
hasMore,
loading,
browseUnsupported,
onLoadMore,
}: Props) {
const { t } = useTranslation();
const showEmptyBrowse = !loading && songs.length === 0 && query.trim() === '' && (browseUnsupported || !hasMore);
return (
<section className="virtual-song-list-section">
{title && <h2 className="section-title virtual-song-list-title">{title}</h2>}
<div className="virtual-song-list-toolbar">
<div className="virtual-song-list-search">
<SearchIcon size={16} className="virtual-song-list-search-icon" />
<input
type="text"
className="input virtual-song-list-search-input"
placeholder={t('tracks.searchPlaceholder')}
value={query}
onChange={e => onQueryChange(e.target.value)}
/>
{query && (
<button
className="virtual-song-list-search-clear"
onClick={() => onQueryChange('')}
aria-label={t('search.clearLabel')}
>
<X size={14} />
</button>
)}
</div>
<div className="virtual-song-list-meta">
{songs.length > 0 && (
<span>{t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''}</span>
)}
</div>
</div>
{showEmptyBrowse ? (
<div className="virtual-song-list-empty">
{emptyBrowseText ?? t('tracks.browseUnsupported')}
</div>
) : (
<PagedSongList
songs={songs}
hasMore={hasMore}
loadingMore={loading}
onLoadMore={onLoadMore}
/>
)}
</section>
);
}
@@ -1,50 +1,48 @@
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 { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
import { getRandomSongs } from '../../api/subsonicLibrary';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { songToTrack } from '../../utils/playback/songToTrack';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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';
import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore';
import SongRail from '../SongRail';
import { playSongNow } from '../../utils/playback/playSong';
import { ndListSongs, ndInvalidateSongsCache } from '../../api/navidromeBrowse';
import { usePerfProbeFlags } from '../../utils/perf/perfFlags';
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
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() {
/** Tracks hub hero + song rails (above the browse-all list). */
export default function TracksPageChrome({
onLayoutReady,
}: {
/** Fires once when hero + rails finish their initial load (or fail). */
onLayoutReady?: () => void;
}) {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const navigate = useNavigate();
const navigateToArtist = useNavigateToArtist();
const navigateToAlbum = useNavigateToAlbum();
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 layoutReadyNotifiedRef = useRef(false);
const rerollHero = useCallback(async () => {
setHeroLoading(true);
@@ -59,8 +57,7 @@ export default function Tracks() {
const rerollRandom = useCallback(async () => {
setRandomLoading(true);
try {
const r = await getRandomSongs(RANDOM_RAIL_SIZE);
setRandom(r);
setRandom(await getRandomSongs(RANDOM_RAIL_SIZE));
} finally {
setRandomLoading(false);
}
@@ -74,7 +71,6 @@ export default function Tracks() {
setRated(filtered);
setRatedSupported(true);
} catch {
// Non-Navidrome server, or REST endpoint refused → silently hide the rail.
setRated([]);
setRatedSupported(false);
} finally {
@@ -89,15 +85,25 @@ export default function Tracks() {
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).
useEffect(() => {
if (!onLayoutReady || layoutReadyNotifiedRef.current) return;
if (!activeServerId) {
layoutReadyNotifiedRef.current = true;
onLayoutReady();
return;
}
if (heroLoading || randomLoading || ratedLoading) return;
layoutReadyNotifiedRef.current = true;
onLayoutReady();
}, [activeServerId, onLayoutReady, heroLoading, randomLoading, ratedLoading]);
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">
@@ -132,7 +138,7 @@ export default function Tracks() {
<span
className={hero.artistId ? 'track-artist-link' : ''}
style={{ cursor: hero.artistId ? 'pointer' : 'default' }}
onClick={() => hero.artistId && navigate(`/artist/${hero.artistId}`)}
onClick={() => hero.artistId && navigateToArtist(hero.artistId)}
>{hero.artist}</span>
{hero.album && (
<>
@@ -140,22 +146,16 @@ export default function Tracks() {
<span
className={hero.albumId ? 'track-artist-link' : ''}
style={{ cursor: hero.albumId ? 'pointer' : 'default' }}
onClick={() => hero.albumId && navigate(`/album/${hero.albumId}`)}
onClick={() => hero.albumId && navigateToAlbum(hero.albumId)}
>{hero.album}</span>
</>
)}
</p>
<div className="tracks-hero-actions">
<button
className="btn btn-primary"
onClick={() => playSongNow(hero)}
>
<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)])}
>
<button className="btn btn-surface" onClick={() => enqueue([songToTrack(hero)])}>
<ListPlus size={16} /> {t('tracks.enqueueSong')}
</button>
<button
@@ -194,13 +194,6 @@ export default function Tracks() {
initialArtworkBudget={TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET}
/>
)}
{!perfFlags.disableMainstageVirtualLists && (
<VirtualSongList
title={t('tracks.browseTitle')}
emptyBrowseText={t('tracks.browseUnsupported')}
/>
)}
</div>
</>
);
}
+2 -1
View File
@@ -140,6 +140,7 @@ const CONTRIBUTOR_ENTRIES = [
'Analytics: library backfill scan phase/cursor persistence so advanced indexing can finish large libraries (PR #882)',
'Analytics: Opus waveform/LUFS/enrichment decode via symphonia-adapter-libopus in the analysis pipeline (PR #883)',
'Artist page: top-track thumbnails use the same album cover path and warm batch as the albums grid (PR #886)',
'Library browse navigation: session restore on back for Artists, Search/Tracks, New Releases, Random Albums; unified SearchBrowsePage (PR #936)',
],
},
{
@@ -183,7 +184,7 @@ const CONTRIBUTOR_ENTRIES = [
'Albums and playlist headers redesign with improved layout and theme integration (PR #186)',
'Tracklist column picker overflow fix in AlbumTrackList (PR #188)',
'Spotify CSV playlist import (PR #190)',
'Context menu for songs in AdvancedSearch and SearchResults (PR #191)',
'Context menu for songs in SearchBrowsePage (PR #191)',
'Tracklist column picker alignment and toggle fix across Favorites and PlaylistDetail (PR #192)',
'CSV import: dynamic match threshold, cleaned title search, score display in report (PR #199)',
'Discord Rich Presence: configurable text templates for details, state and album tooltip (PR #198)',
+25
View File
@@ -5,14 +5,39 @@ export const APP_MAIN_SCROLL_VIEWPORT_ID = 'app-main-scroll-viewport';
export const ARTISTS_INPAGE_SCROLL_VIEWPORT_ID = 'artists-inpage-scroll-viewport';
export const ALBUMS_INPAGE_SCROLL_VIEWPORT_ID = 'albums-inpage-scroll-viewport';
export const NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID = 'new-releases-inpage-scroll-viewport';
export const RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID = 'random-albums-inpage-scroll-viewport';
export const LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID = 'lossless-albums-inpage-scroll-viewport';
export const COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID = 'composers-inpage-scroll-viewport';
export type AlbumGridInpageScrollSurface = 'albums' | 'new-releases' | 'random-albums';
/** Route pathname → in-page overlay viewport id (must match `viewportId` on each page). */
export const MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH: Readonly<Record<string, string>> = {
'/artists': ARTISTS_INPAGE_SCROLL_VIEWPORT_ID,
'/albums': ALBUMS_INPAGE_SCROLL_VIEWPORT_ID,
'/new-releases': NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID,
'/random/albums': RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID,
'/lossless-albums': LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID,
'/composers': COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID,
};
const INPAGE_VIEWPORT_ID_BY_SURFACE: Record<AlbumGridInpageScrollSurface, string> = {
albums: ALBUMS_INPAGE_SCROLL_VIEWPORT_ID,
'new-releases': NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID,
'random-albums': RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID,
};
export function inpageScrollViewportIdForSurface(surface: AlbumGridInpageScrollSurface): string {
return INPAGE_VIEWPORT_ID_BY_SURFACE[surface];
}
/** Read live in-page scroll offset (prefer over render-time refs when leaving for album detail). */
export function readInpageScrollTop(viewportId: string): number {
return document.getElementById(viewportId)?.scrollTop ?? 0;
}
/** Resolve in-page viewport id for the current route pathname. */
export function mainRouteInpageScrollViewportId(pathname: string): string | undefined {
const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname;
return MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH[path];
}
+4
View File
@@ -59,6 +59,8 @@ export type UseAlbumBrowseDataArgs = {
getScrollRoot?: () => HTMLElement | null;
/** Bumps when the scroll root mounts so the sentinel observer can rebind. */
scrollRootEl?: HTMLElement | null;
/** Bootstrap visible slice size when restoring scroll after album-detail back. */
restoreDisplayCount?: number;
};
function resolveHasMoreAfterPage(
@@ -86,6 +88,7 @@ export function useAlbumBrowseData({
starredOverrides,
getScrollRoot,
scrollRootEl,
restoreDisplayCount,
}: UseAlbumBrowseDataArgs) {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
@@ -154,6 +157,7 @@ export function useAlbumBrowseData({
],
getScrollRoot,
scrollRootEl,
restoreDisplayCount,
});
const displayAlbums = useMemo(() => {
+88 -20
View File
@@ -1,51 +1,104 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigationType, type NavigationType } from 'react-router-dom';
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import {
ALBUMS_INPAGE_SCROLL_VIEWPORT_ID,
readInpageScrollTop,
} from '../constants/appScroll';
import {
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
type AlbumBrowseCompFilter,
type AlbumBrowseReturnFilters,
type AlbumBrowseSurface,
albumBrowseSortForServer,
albumBrowseSurfaceForPath,
isAlbumDetailPath,
useAlbumBrowseSessionStore,
} from '../store/albumBrowseSessionStore';
import type { AlbumBrowseSort } from '../utils/library/browseTextSearch';
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
const ALBUMS_SURFACE: AlbumBrowseSurface = 'albums';
function returnFiltersForNavigation(
serverId: string,
navigationType: NavigationType,
locationState: unknown,
): AlbumBrowseReturnFilters {
if (navigationType !== 'POP' || !serverId) return DEFAULT_ALBUM_BROWSE_RETURN_FILTERS;
if (!shouldRestoreAlbumBrowseSession(navigationType, locationState) || !serverId) {
return DEFAULT_ALBUM_BROWSE_RETURN_FILTERS;
}
return (
useAlbumBrowseSessionStore.getState().peekReturnStash(serverId)
useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, ALBUMS_SURFACE)
?? DEFAULT_ALBUM_BROWSE_RETURN_FILTERS
);
}
export function useAlbumBrowseFilters(serverId: string) {
export type AlbumBrowseScrollSnapshot = {
scrollTop: number;
displayCount: number;
};
/** Keep scroll snapshot in sync with the in-page viewport (not only on React re-renders). */
export function useAlbumBrowseScrollSnapshotSync(
snapshotRef: RefObject<AlbumBrowseScrollSnapshot>,
scrollBodyEl: HTMLElement | null,
displayCount: number,
): void {
useEffect(() => {
snapshotRef.current.displayCount = displayCount;
}, [displayCount, snapshotRef]);
useEffect(() => {
if (!scrollBodyEl) return;
const syncScrollTop = () => {
snapshotRef.current.scrollTop = scrollBodyEl.scrollTop;
};
syncScrollTop();
scrollBodyEl.addEventListener('scroll', syncScrollTop, { passive: true });
return () => scrollBodyEl.removeEventListener('scroll', syncScrollTop);
}, [scrollBodyEl, snapshotRef]);
}
export function useAlbumBrowseScrollSnapshotRef(
scrollBodyEl: HTMLElement | null,
displayCount: number,
): RefObject<AlbumBrowseScrollSnapshot> {
const snapshotRef = useRef<AlbumBrowseScrollSnapshot>({ scrollTop: 0, displayCount: 0 });
useAlbumBrowseScrollSnapshotSync(snapshotRef, scrollBodyEl, displayCount);
return snapshotRef;
}
export function useAlbumBrowseFilters(
serverId: string,
scrollSnapshotRef?: RefObject<AlbumBrowseScrollSnapshot>,
) {
const navigationType = useNavigationType();
const location = useLocation();
const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId));
const setBrowseSort = useAlbumBrowseSessionStore(s => s.setSort);
const [selectedGenres, setSelectedGenres] = useState<string[]>(() =>
returnFiltersForNavigation(serverId, navigationType).selectedGenres,
returnFiltersForNavigation(serverId, navigationType, location.state).selectedGenres,
);
const [yearFrom, setYearFrom] = useState(() =>
returnFiltersForNavigation(serverId, navigationType).yearFrom,
returnFiltersForNavigation(serverId, navigationType, location.state).yearFrom,
);
const [yearTo, setYearTo] = useState(() =>
returnFiltersForNavigation(serverId, navigationType).yearTo,
returnFiltersForNavigation(serverId, navigationType, location.state).yearTo,
);
const [compFilter, setCompFilter] = useState<AlbumBrowseCompFilter>(() =>
returnFiltersForNavigation(serverId, navigationType).compFilter,
returnFiltersForNavigation(serverId, navigationType, location.state).compFilter,
);
const [starredOnly, setStarredOnly] = useState(() =>
returnFiltersForNavigation(serverId, navigationType).starredOnly,
returnFiltersForNavigation(serverId, navigationType, location.state).starredOnly,
);
const [losslessOnly, setLosslessOnly] = useState(() =>
returnFiltersForNavigation(serverId, navigationType).losslessOnly,
returnFiltersForNavigation(serverId, navigationType, location.state).losslessOnly,
);
const filtersRef = useRef<AlbumBrowseReturnFilters>(DEFAULT_ALBUM_BROWSE_RETURN_FILTERS);
/** Guards against re-reset when `albumBrowseRestore` is cleared from location state. */
const restoredFromStashRef = useRef(false);
filtersRef.current = {
selectedGenres,
yearFrom,
@@ -55,11 +108,16 @@ export function useAlbumBrowseFilters(serverId: string) {
losslessOnly,
};
useEffect(() => {
restoredFromStashRef.current = false;
}, [serverId]);
useEffect(() => {
if (!serverId) return;
if (navigationType === 'POP') {
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId);
if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) {
restoredFromStashRef.current = true;
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, ALBUMS_SURFACE);
if (restored) {
setSelectedGenres(restored.selectedGenres);
setYearFrom(restored.yearFrom);
@@ -67,31 +125,41 @@ export function useAlbumBrowseFilters(serverId: string) {
setCompFilter(restored.compFilter);
setStarredOnly(restored.starredOnly);
setLosslessOnly(restored.losslessOnly);
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId);
}
return;
}
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId);
if (restoredFromStashRef.current) return;
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, ALBUMS_SURFACE);
setSelectedGenres([]);
setYearFrom('');
setYearTo('');
setCompFilter('all');
setStarredOnly(false);
setLosslessOnly(false);
}, [serverId, navigationType]);
}, [serverId, navigationType, location.state]);
useEffect(() => {
return () => {
if (!serverId) return;
const path = window.location.pathname;
if (isAlbumDetailPath(path)) {
useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, filtersRef.current);
} else if (path !== '/albums') {
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId);
const snapshot = scrollSnapshotRef?.current;
const scrollTop = Math.max(
readInpageScrollTop(ALBUMS_INPAGE_SCROLL_VIEWPORT_ID),
snapshot?.scrollTop ?? 0,
);
useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, ALBUMS_SURFACE, {
...filtersRef.current,
scrollTop,
displayCount: snapshot?.displayCount,
});
} else if (albumBrowseSurfaceForPath(path) !== ALBUMS_SURFACE) {
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, ALBUMS_SURFACE);
}
};
}, [serverId]);
}, [serverId, scrollSnapshotRef]);
const onSortChange = (value: AlbumBrowseSort) => setBrowseSort(serverId, value);
+100
View File
@@ -0,0 +1,100 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import {
peekAlbumBrowseScrollRestore,
type AlbumBrowseSurface,
useAlbumBrowseSessionStore,
} from '../store/albumBrowseSessionStore';
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
type PendingScroll = {
scrollTop: number;
displayCount: number;
};
export type UseAlbumBrowseScrollRestoreArgs = {
serverId: string;
surface: AlbumBrowseSurface;
scrollBodyEl: HTMLElement | null;
displayAlbumsLength: number;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
loadMore: () => void;
};
export type UseAlbumBrowseScrollRestoreResult = {
/** True until saved scroll position is applied — hide the grid meanwhile. */
isScrollRestorePending: boolean;
};
function readPendingScrollRestore(
serverId: string,
surface: AlbumBrowseSurface,
navigationType: NavigationType,
locationState: unknown,
): PendingScroll | null {
if (!shouldRestoreAlbumBrowseSession(navigationType, locationState) || !serverId) return null;
return peekAlbumBrowseScrollRestore(serverId, surface);
}
/**
* When returning to an album grid browse surface via browser/app back from album
* detail, restore the in-page grid scroll position saved in `albumBrowseSessionStore`.
*/
export function useAlbumBrowseScrollRestore({
serverId,
surface,
scrollBodyEl,
displayAlbumsLength,
loading,
loadingMore,
hasMore,
loadMore,
}: UseAlbumBrowseScrollRestoreArgs): UseAlbumBrowseScrollRestoreResult {
const navigationType = useNavigationType();
const location = useLocation();
const initRef = useRef(false);
const pendingRef = useRef<PendingScroll | null>(null);
const doneRef = useRef(false);
if (!initRef.current) {
initRef.current = true;
pendingRef.current = readPendingScrollRestore(serverId, surface, navigationType, location.state);
}
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
() => readPendingScrollRestore(serverId, surface, navigationType, location.state) !== null,
);
useLayoutEffect(() => {
const pending = pendingRef.current;
if (doneRef.current || !pending) return;
if (!scrollBodyEl || loading) return;
const needsMore = displayAlbumsLength < pending.displayCount && hasMore;
if (needsMore) {
if (!loadingMore) loadMore();
return;
}
if (loadingMore) return;
scrollBodyEl.scrollTop = pending.scrollTop;
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
pendingRef.current = null;
doneRef.current = true;
setIsScrollRestorePending(false);
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
}, [
scrollBodyEl,
displayAlbumsLength,
loading,
loadingMore,
hasMore,
loadMore,
serverId,
surface,
]);
return { isScrollRestorePending };
}
+65
View File
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { useAlbumDetailBack } from './useAlbumDetailBack';
import { navigateAlbumDetailBack } from '../utils/navigation/albumDetailNavigation';
vi.mock('../utils/navigation/albumDetailNavigation', async (importOriginal) => {
const mod = await importOriginal<typeof import('../utils/navigation/albumDetailNavigation')>();
return {
...mod,
navigateAlbumDetailBack: vi.fn(mod.navigateAlbumDetailBack),
};
});
function detailWrapper(initialState: unknown) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<MemoryRouter initialEntries={[{ pathname: '/album/al-1', state: initialState }]}>
<Routes>
<Route path="/album/:id" element={children} />
</Routes>
</MemoryRouter>
);
};
}
describe('useAlbumDetailBack', () => {
beforeEach(() => {
vi.mocked(navigateAlbumDetailBack).mockClear();
});
it('routes browser back through navigateAlbumDetailBack when returnTo exists', () => {
const pushStateSpy = vi.spyOn(window.history, 'pushState');
renderHook(() => useAlbumDetailBack(), {
wrapper: detailWrapper({ returnTo: '/search/advanced' }),
});
expect(pushStateSpy).toHaveBeenCalled();
act(() => {
window.dispatchEvent(new PopStateEvent('popstate'));
});
expect(navigateAlbumDetailBack).toHaveBeenCalledTimes(1);
pushStateSpy.mockRestore();
});
it('does not trap browser back when returnTo is missing', () => {
const pushStateSpy = vi.spyOn(window.history, 'pushState');
renderHook(() => useAlbumDetailBack(), {
wrapper: detailWrapper(null),
});
expect(pushStateSpy).not.toHaveBeenCalled();
act(() => {
window.dispatchEvent(new PopStateEvent('popstate'));
});
expect(navigateAlbumDetailBack).not.toHaveBeenCalled();
pushStateSpy.mockRestore();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
navigateAlbumDetailBack,
readAlbumDetailReturnTo,
} from '../utils/navigation/albumDetailNavigation';
/** Leave album/artist detail for the page that opened it (or history back as fallback). */
export function useAlbumDetailBack(fallback = '/') {
const navigate = useNavigate();
const location = useLocation();
const locationStateRef = useRef(location.state);
locationStateRef.current = location.state;
const goBack = useCallback(
() => navigateAlbumDetailBack(navigate, location, fallback),
[navigate, location, fallback],
);
useEffect(() => {
const returnTo = readAlbumDetailReturnTo(locationStateRef.current);
if (!returnTo) return;
const trapUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.history.pushState({ psysonicDetailBackTrap: true }, '', trapUrl);
const onPopState = () => {
navigateAlbumDetailBack(
navigate,
{ state: locationStateRef.current },
fallback,
);
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, [navigate, location.pathname, location.search, location.hash, fallback]);
return goBack;
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import {
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
type AlbumBrowseReturnFilters,
type AlbumBrowseSurface,
albumBrowseSurfaceForPath,
isAlbumDetailPath,
useAlbumBrowseSessionStore,
} from '../store/albumBrowseSessionStore';
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
import {
inpageScrollViewportIdForSurface,
readInpageScrollTop,
} from '../constants/appScroll';
import type { AlbumBrowseScrollSnapshot } from './useAlbumBrowseFilters';
export type AlbumGridBrowseSnapshot = {
albums: SubsonicAlbum[];
hasMore: boolean;
};
function sameGenreSelection(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function returnStateForNavigation(
serverId: string,
surface: AlbumBrowseSurface,
navigationType: NavigationType,
locationState: unknown,
): AlbumBrowseReturnFilters {
if (!shouldRestoreAlbumBrowseSession(navigationType, locationState) || !serverId) {
return DEFAULT_ALBUM_BROWSE_RETURN_FILTERS;
}
return (
useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, surface)
?? DEFAULT_ALBUM_BROWSE_RETURN_FILTERS
);
}
/** Genre-filter album grid pages (New Releases, Random Albums) — shared leave-restore. */
export function useAlbumGridBrowseFilters(
serverId: string,
surface: AlbumBrowseSurface,
scrollSnapshotRef?: RefObject<AlbumBrowseScrollSnapshot>,
gridSnapshotRef?: RefObject<AlbumGridBrowseSnapshot>,
) {
const navigationType = useNavigationType();
const location = useLocation();
const initialState = returnStateForNavigation(serverId, surface, navigationType, location.state);
const [selectedGenres, setSelectedGenres] = useState<string[]>(() => initialState.selectedGenres);
const restoredFromStashRef = useRef(false);
const filtersRef = useRef({ selectedGenres });
filtersRef.current = { selectedGenres };
useEffect(() => {
restoredFromStashRef.current = false;
}, [serverId, surface]);
useEffect(() => {
if (!serverId) return;
if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) {
restoredFromStashRef.current = true;
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, surface);
if (restored && !sameGenreSelection(restored.selectedGenres, filtersRef.current.selectedGenres)) {
setSelectedGenres(restored.selectedGenres);
}
return;
}
if (restoredFromStashRef.current) return;
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
setSelectedGenres([]);
}, [serverId, surface, navigationType, location.state]);
useEffect(() => {
return () => {
if (!serverId) return;
const path = window.location.pathname;
if (isAlbumDetailPath(path)) {
const scrollSnapshot = scrollSnapshotRef?.current;
const gridSnapshot = gridSnapshotRef?.current;
const viewportId = inpageScrollViewportIdForSurface(surface);
const scrollTop = Math.max(
readInpageScrollTop(viewportId),
scrollSnapshot?.scrollTop ?? 0,
);
useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, surface, {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: filtersRef.current.selectedGenres,
scrollTop,
displayCount: gridSnapshot?.albums.length ?? scrollSnapshot?.displayCount,
albums: gridSnapshot?.albums,
hasMore: gridSnapshot?.hasMore,
});
} else if (albumBrowseSurfaceForPath(path) !== surface) {
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
}
};
}, [serverId, surface, scrollSnapshotRef, gridSnapshotRef]);
return {
selectedGenres,
setSelectedGenres,
initialAlbums: initialState.albums ?? null,
initialHasMore: initialState.hasMore,
};
}
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import {
DEFAULT_ARTIST_BROWSE_RETURN_STATE,
type ArtistBrowseReturnState,
type ArtistBrowseViewMode,
isArtistsBrowsePath,
useArtistBrowseSessionStore,
} from '../store/artistBrowseSessionStore';
import { isArtistDetailPath } from '../store/albumBrowseSessionStore';
import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation';
export type ArtistBrowseScrollSnapshot = {
scrollTop: number;
visibleCount: number;
};
function returnStateForNavigation(
serverId: string,
navigationType: NavigationType,
locationState: unknown,
): ArtistBrowseReturnState {
if (!shouldRestoreArtistBrowseSession(navigationType, locationState) || !serverId) {
return DEFAULT_ARTIST_BROWSE_RETURN_STATE;
}
return (
useArtistBrowseSessionStore.getState().peekReturnStash(serverId)
?? DEFAULT_ARTIST_BROWSE_RETURN_STATE
);
}
export function useArtistsBrowseFilters(
serverId: string,
scrollSnapshotRef?: RefObject<ArtistBrowseScrollSnapshot>,
) {
const navigationType = useNavigationType();
const location = useLocation();
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const [filter, setFilter] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).filter,
);
const [letterFilter, setLetterFilter] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).letterFilter,
);
const [starredOnly, setStarredOnly] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).starredOnly,
);
const [viewMode, setViewMode] = useState<ArtistBrowseViewMode>(
() => returnStateForNavigation(serverId, navigationType, location.state).viewMode,
);
const browseStateRef = useRef<ArtistBrowseReturnState>(DEFAULT_ARTIST_BROWSE_RETURN_STATE);
const restoredFromStashRef = useRef(false);
const showArtistImages = useAuthStore(s => s.showArtistImages);
browseStateRef.current = {
filter,
letterFilter,
starredOnly,
viewMode,
showArtistImages,
};
useEffect(() => {
restoredFromStashRef.current = false;
}, [serverId]);
useEffect(() => {
if (!serverId) return;
if (shouldRestoreArtistBrowseSession(navigationType, location.state)) {
restoredFromStashRef.current = true;
const restored = useArtistBrowseSessionStore.getState().peekReturnStash(serverId);
if (restored) {
setFilter(restored.filter);
setLetterFilter(restored.letterFilter);
setStarredOnly(restored.starredOnly);
setViewMode(restored.viewMode);
setShowArtistImages(restored.showArtistImages);
}
return;
}
if (restoredFromStashRef.current) return;
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
setFilter('');
setLetterFilter(DEFAULT_ARTIST_BROWSE_RETURN_STATE.letterFilter);
setStarredOnly(false);
setViewMode('grid');
}, [serverId, navigationType, location.state, setShowArtistImages]);
useEffect(() => {
return () => {
if (!serverId) return;
const path = window.location.pathname;
if (isArtistDetailPath(path)) {
const snapshot = scrollSnapshotRef?.current;
useArtistBrowseSessionStore.getState().stashReturnState(serverId, {
...browseStateRef.current,
scrollTop: snapshot?.scrollTop,
visibleCount: snapshot?.visibleCount,
});
} else if (!isArtistsBrowsePath(path)) {
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
}
};
}, [serverId, scrollSnapshotRef]);
return {
filter,
setFilter,
letterFilter,
setLetterFilter,
starredOnly,
setStarredOnly,
viewMode,
setViewMode,
};
}
@@ -0,0 +1,91 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import {
peekArtistBrowseScrollRestore,
useArtistBrowseSessionStore,
} from '../store/artistBrowseSessionStore';
import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation';
type PendingScroll = {
scrollTop: number;
visibleCount: number;
};
export type UseArtistsBrowseScrollRestoreArgs = {
serverId: string;
scrollBodyEl: HTMLElement | null;
visibleCount: number;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
loadMore: () => void;
};
export type UseArtistsBrowseScrollRestoreResult = {
isScrollRestorePending: boolean;
};
function readPendingScrollRestore(
serverId: string,
navigationType: NavigationType,
locationState: unknown,
): PendingScroll | null {
if (!shouldRestoreArtistBrowseSession(navigationType, locationState) || !serverId) return null;
return peekArtistBrowseScrollRestore(serverId);
}
/** Restore Artists in-page scroll after returning from artist detail. */
export function useArtistsBrowseScrollRestore({
serverId,
scrollBodyEl,
visibleCount,
loading,
loadingMore,
hasMore,
loadMore,
}: UseArtistsBrowseScrollRestoreArgs): UseArtistsBrowseScrollRestoreResult {
const navigationType = useNavigationType();
const location = useLocation();
const initRef = useRef(false);
const pendingRef = useRef<PendingScroll | null>(null);
const doneRef = useRef(false);
if (!initRef.current) {
initRef.current = true;
pendingRef.current = readPendingScrollRestore(serverId, navigationType, location.state);
}
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
() => readPendingScrollRestore(serverId, navigationType, location.state) !== null,
);
useLayoutEffect(() => {
const pending = pendingRef.current;
if (doneRef.current || !pending) return;
if (!scrollBodyEl || loading) return;
const needsMore = visibleCount < pending.visibleCount && hasMore;
if (needsMore) {
if (!loadingMore) loadMore();
return;
}
if (loadingMore) return;
scrollBodyEl.scrollTop = pending.scrollTop;
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
pendingRef.current = null;
doneRef.current = true;
setIsScrollRestorePending(false);
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
}, [
scrollBodyEl,
visibleCount,
loading,
loadingMore,
hasMore,
loadMore,
serverId,
]);
return { isScrollRestorePending };
}
+13 -3
View File
@@ -8,8 +8,15 @@ export type UseClientSliceInfiniteScrollArgs = {
getScrollRoot?: () => HTMLElement | null;
scrollRootEl?: HTMLElement | null;
rootMargin?: string;
/** One-shot bootstrap when restoring browse scroll (All Albums back navigation). */
restoreDisplayCount?: number;
};
function sliceVisibleCount(pageSize: number, restoreDisplayCount?: number): number {
if (restoreDisplayCount == null || restoreDisplayCount <= 0) return pageSize;
return Math.max(pageSize, restoreDisplayCount);
}
export type UseClientSliceInfiniteScrollResult = {
visibleCount: number;
loadingMore: boolean;
@@ -29,8 +36,11 @@ export function useClientSliceInfiniteScroll({
getScrollRoot,
scrollRootEl,
rootMargin = '200px',
restoreDisplayCount,
}: UseClientSliceInfiniteScrollArgs): UseClientSliceInfiniteScrollResult {
const [visibleCount, setVisibleCount] = useState(pageSize);
const [visibleCount, setVisibleCount] = useState(() =>
sliceVisibleCount(pageSize, restoreDisplayCount),
);
const [loadingMore, setLoadingMore] = useState(false);
const loadPendingRef = useRef(false);
@@ -47,10 +57,10 @@ export function useClientSliceInfiniteScroll({
}, [visibleCount]);
useEffect(() => {
setVisibleCount(pageSize);
setVisibleCount(sliceVisibleCount(pageSize, restoreDisplayCount));
// resetDeps is intentionally spread into the dep array.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, ...resetDeps]);
}, [pageSize, restoreDisplayCount, ...resetDeps]);
const bindSentinel = useInpageScrollSentinel({
active: true,
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import {
APP_MAIN_SCROLL_VIEWPORT_ID,
MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH,
mainRouteInpageScrollViewportId,
} from '../constants/appScroll';
const SCROLL_IDLE_MS = 180;
@@ -25,7 +25,7 @@ export function useMainScrollingIndicator(pathname: string): boolean {
if (appViewport) viewports.add(appViewport);
const nowPlayingViewport = document.querySelector<HTMLElement>('.np-main__viewport');
if (nowPlayingViewport) viewports.add(nowPlayingViewport);
const inpageId = MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH[pathname];
const inpageId = mainRouteInpageScrollViewportId(pathname);
if (inpageId) {
const inpageVp = document.getElementById(inpageId);
if (inpageVp) viewports.add(inpageVp);
+15
View File
@@ -0,0 +1,15 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { navigateToAlbumDetail } from '../utils/navigation/albumDetailNavigation';
/** Navigate to album detail, remembering the current page for the back button. */
export function useNavigateToAlbum() {
const navigate = useNavigate();
const location = useLocation();
return useCallback(
(albumId: string, opts?: { search?: string }) => {
navigateToAlbumDetail(navigate, location, albumId, opts);
},
[navigate, location],
);
}
+15
View File
@@ -0,0 +1,15 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { navigateToArtistDetail } from '../utils/navigation/albumDetailNavigation';
/** Navigate to artist detail, remembering the current page for the back button. */
export function useNavigateToArtist() {
const navigate = useNavigate();
const location = useLocation();
return useCallback(
(artistId: string, opts?: { search?: string }) => {
navigateToArtistDetail(navigate, location, artistId, opts);
},
[navigate, location],
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { showToast } from '../utils/ui/toast';
/**
* Shared behaviour for song rows that in "normal mode" swallow a full list
* into the queue on single-click (AlbumDetail, PlaylistDetail, Favorites,
* ArtistDetail top-songs, SearchResults, RandomMix, AdvancedSearch).
* ArtistDetail top-songs, SearchBrowsePage, RandomMix).
*
* In an active Orbit session this is too destructive the list would
* propagate to every guest's player. Instead:
+5 -3
View File
@@ -1,12 +1,14 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { ensurePlaybackServerActive } from '../utils/playback/playbackServer';
import { navigatePathWithAlbumReturnTo } from '../utils/navigation/albumDetailNavigation';
/** Navigate to library routes for the playing queue — switches to {@link queueServerId} when needed. */
export function usePlaybackLibraryNavigate() {
const navigate = useNavigate();
const location = useLocation();
return useCallback(async (path: string) => {
await ensurePlaybackServerActive();
navigate(path);
}, [navigate]);
navigatePathWithAlbumReturnTo(navigate, location, path);
}, [navigate, location]);
}
+4 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from './useNavigateToAlbum';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import {
@@ -16,6 +17,7 @@ import { useShareSearchPreview } from './useShareSearchPreview';
export function useShareSearch(query: string, onSuccess?: () => void) {
const { t } = useTranslation();
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const servers = useAuthStore(s => s.servers);
const activeServerId = useAuthStore(s => s.activeServerId);
const shareMatch = useMemo(() => parseShareSearchText(query), [query]);
@@ -52,9 +54,9 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
const openShareAlbum = useCallback(() => {
if (shareMatch?.type !== 'album' || !preview.shareAlbum) return;
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
navigate(`/album/${preview.shareAlbum.id}`);
navigateToAlbum(preview.shareAlbum.id);
onSuccess?.();
}, [shareMatch, preview.shareAlbum, navigate, t, onSuccess]);
}, [shareMatch, preview.shareAlbum, navigateToAlbum, t, onSuccess]);
const openShareArtist = useCallback(() => {
if (shareMatch?.type !== 'artist' || !preview.shareArtist) return;
+196
View File
@@ -0,0 +1,196 @@
import { searchSongsPaged } from '../api/subsonicSearch';
import type { SubsonicSong } from '../api/subsonicTypes';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ndListSongs } from '../api/navidromeBrowse';
import { runLocalSongBrowse } from '../utils/library/advancedSearchLocal';
import {
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
BROWSE_TEXT_DEBOUNCE_RACE_MS,
browseRaceCountsSongs,
loadMoreLocalBrowseSongs,
raceBrowseWithLocalFallback,
runLocalBrowseSongPage,
runNetworkBrowseSongPage,
} from '../utils/library/browseTextSearch';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
const PAGE_SIZE = 50;
async function fetchBrowseAllPage(
serverId: string | null | undefined,
offset: number,
): Promise<SubsonicSong[]> {
const local = await runLocalSongBrowse(serverId, offset, PAGE_SIZE);
if (local) return local;
try {
return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC');
} catch {
return searchSongsPaged('', PAGE_SIZE, offset);
}
}
export type SongBrowseListRestore = {
query: string;
songs: SubsonicSong[];
offset: number;
hasMore: boolean;
localSearchMode: boolean;
browseUnsupported: boolean;
hasSearched: boolean;
};
type UseSongBrowseListArgs = {
enabled: boolean;
initialRestore?: SongBrowseListRestore | null;
};
/** Tracks hub song browse — all-library paging or filtered text search. */
export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseListArgs) {
const serverId = useAuthStore(s => s.activeServerId);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const [query, setQuery] = useState(() => initialRestore?.query ?? '');
const [debouncedQuery, setDebouncedQuery] = useState(() => initialRestore?.query.trim() ?? '');
const [songs, setSongs] = useState<SubsonicSong[]>(() => initialRestore?.songs ?? []);
const [offset, setOffset] = useState(() => initialRestore?.offset ?? 0);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(() => initialRestore?.hasMore ?? true);
const [browseUnsupported, setBrowseUnsupported] = useState(
() => initialRestore?.browseUnsupported ?? false,
);
const [hasSearched, setHasSearched] = useState(() => initialRestore?.hasSearched ?? false);
const requestSeqRef = useRef(0);
const localSearchModeRef = useRef(initialRestore?.localSearchMode ?? false);
const skipInitialFetchRef = useRef(initialRestore != null);
useEffect(() => {
if (!enabled) return;
const debounceMs = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS;
const timer = window.setTimeout(() => setDebouncedQuery(query.trim()), debounceMs);
return () => window.clearTimeout(timer);
}, [query, indexEnabled, enabled]);
const fetchSongPage = useCallback(
async (q: string, pageOffset: number, isStale: () => boolean): Promise<SubsonicSong[]> => {
if (q === '') {
return fetchBrowseAllPage(serverId, pageOffset);
}
if (pageOffset === 0 && indexEnabled && serverId) {
const winner = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseSongPage(serverId, q, 0, PAGE_SIZE),
() => runNetworkBrowseSongPage(q, 0, PAGE_SIZE),
{
surface: 'tracks_browse',
query: q,
indexEnabled,
counts: browseRaceCountsSongs,
},
);
if (isStale()) return [];
if (winner) {
localSearchModeRef.current = winner.source === 'local';
return winner.result ?? [];
}
localSearchModeRef.current = false;
return (await runNetworkBrowseSongPage(q, 0, PAGE_SIZE)) ?? [];
}
if (localSearchModeRef.current && serverId) {
try {
return await loadMoreLocalBrowseSongs(serverId, q, pageOffset, PAGE_SIZE);
} catch {
return [];
}
}
return (await runNetworkBrowseSongPage(q, pageOffset, PAGE_SIZE)) ?? [];
},
[indexEnabled, serverId],
);
useEffect(() => {
if (!enabled) return;
if (skipInitialFetchRef.current) {
skipInitialFetchRef.current = false;
return;
}
let cancelled = false;
setSongs([]);
setOffset(0);
setHasMore(true);
setBrowseUnsupported(false);
localSearchModeRef.current = false;
const seq = ++requestSeqRef.current;
const isStale = () => cancelled || seq !== requestSeqRef.current;
setLoading(true);
void (async () => {
try {
const page = await fetchSongPage(debouncedQuery, 0, isStale);
if (isStale()) return;
if (page.length === 0) {
setHasMore(false);
if (debouncedQuery === '') setBrowseUnsupported(true);
} else {
setSongs(page);
setOffset(page.length);
if (page.length < PAGE_SIZE) setHasMore(false);
}
setHasSearched(true);
} catch {
if (!isStale()) setHasMore(false);
} finally {
if (!isStale()) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [debouncedQuery, fetchSongPage, enabled]);
const loadMore = useCallback(async () => {
if (!enabled || loading || !hasMore) return;
setLoading(true);
const seq = ++requestSeqRef.current;
const isStale = () => seq !== requestSeqRef.current;
try {
const page = await fetchSongPage(debouncedQuery, offset, isStale);
if (isStale()) return;
if (page.length === 0) {
setHasMore(false);
} else {
setSongs(prev => {
const seen = new Set(prev.map(s => s.id));
const merged = [...prev];
for (const s of page) if (!seen.has(s.id)) merged.push(s);
return merged;
});
setOffset(o => o + page.length);
if (page.length < PAGE_SIZE) setHasMore(false);
}
} catch {
setHasMore(false);
} finally {
if (!isStale()) setLoading(false);
}
}, [enabled, loading, hasMore, debouncedQuery, offset, fetchSongPage]);
return {
query,
setQuery,
songs,
offset,
loading,
hasMore,
browseUnsupported,
hasSearched,
localSearchMode: localSearchModeRef.current,
loadMore,
};
}
+51 -4
View File
@@ -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,7 +419,8 @@ export default function Albums() {
{visibleEmptyMessage}
</div>
) : (
<>
<div style={{ position: 'relative' }}>
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
{!perfFlags.disableMainstageGridCards && (
<div ref={gridMeasureRef}>
<VirtualCardGrid
@@ -420,7 +452,22 @@ export default function Albums() {
{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>
)}
</div>
)}
</OverlayScrollArea>
</div>
+69 -11
View File
@@ -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>
);
+65 -7
View File
@@ -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>
+113 -14
View File
@@ -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,18 +243,57 @@ 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' }}>
<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 })
@@ -253,7 +316,7 @@ export default function RandomAlbums() {
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<button
className="btn btn-surface"
onClick={() => load(selectedGenres)}
onClick={handleRefresh}
disabled={loading}
data-tooltip={t('randomAlbums.refresh')}
>
@@ -274,9 +337,24 @@ export default function RandomAlbums() {
</button>
</div>
</div>
</div>
<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: '4rem' }}>
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
) : !loading && albums.length === 0 ? (
@@ -284,16 +362,20 @@ export default function RandomAlbums() {
{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}
@@ -302,7 +384,24 @@ export default function RandomAlbums() {
/>
)}
/>
</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' }}>
{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>
);
}
-160
View File
@@ -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>
);
}
+133
View File
@@ -0,0 +1,133 @@
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonicTypes';
import { create } from 'zustand';
import type { AdvancedSearchLeaveSnapshot } from '../utils/navigation/advancedSearchScrollSnapshot';
export type AdvancedSearchResultType = 'all' | 'artists' | 'albums' | 'songs';
export type AdvancedSearchFormStash = {
query: string;
genre: string;
yearFrom: string;
yearTo: string;
bpmFrom: string;
bpmTo: string;
moodGroup: string;
losslessOnly: boolean;
resultType: AdvancedSearchResultType;
starredOnly: boolean;
};
export type AdvancedSearchQueryStash = Omit<AdvancedSearchFormStash, 'starredOnly'>;
export type AdvancedSearchResultsStash = {
artists: SubsonicArtist[];
albums: SubsonicAlbum[];
songs: SubsonicSong[];
};
/** Session snapshot when leaving Search → album/artist detail. */
export type AdvancedSearchSessionStash = AdvancedSearchFormStash & {
results: AdvancedSearchResultsStash | null;
hasSearched: boolean;
activeSearch: AdvancedSearchQueryStash | null;
localMode: boolean;
songsServerOffset: number;
songsHasMore: boolean;
genreNote: boolean;
/** `/search?q=` quick results (no advanced filter panel). */
basicSearchMode: boolean;
/** `/tracks` hub — browse-all list with toolbar filter. */
tracksBrowseMode: boolean;
tracksBrowseUnsupported?: boolean;
scrollTop?: number;
albumRowScrollLeft?: number;
artistRowScrollLeft?: number;
};
interface AdvancedSearchSessionStore {
returnStash: AdvancedSearchSessionStash | null;
leaveScrollSnapshot: AdvancedSearchLeaveSnapshot | null;
stashReturnSession: (stash: AdvancedSearchSessionStash) => void;
peekReturnStash: () => AdvancedSearchSessionStash | null;
clearReturnStash: () => void;
setLeaveScrollSnapshot: (snapshot: AdvancedSearchLeaveSnapshot) => void;
peekLeaveScrollSnapshot: () => AdvancedSearchLeaveSnapshot | null;
clearLeaveScrollSnapshot: () => void;
}
export const useAdvancedSearchSessionStore = create<AdvancedSearchSessionStore>((set, get) => ({
returnStash: null,
leaveScrollSnapshot: null,
stashReturnSession: (stash) => {
set({
returnStash: {
...stash,
results: stash.results
? {
artists: [...stash.results.artists],
albums: [...stash.results.albums],
songs: [...stash.results.songs],
}
: null,
activeSearch: stash.activeSearch ? { ...stash.activeSearch } : null,
...(typeof stash.scrollTop === 'number' ? { scrollTop: stash.scrollTop } : {}),
...(typeof stash.albumRowScrollLeft === 'number'
? { albumRowScrollLeft: stash.albumRowScrollLeft }
: {}),
...(typeof stash.artistRowScrollLeft === 'number'
? { artistRowScrollLeft: stash.artistRowScrollLeft }
: {}),
},
});
},
clearReturnStash: () => set({ returnStash: null }),
setLeaveScrollSnapshot: (snapshot) => set({ leaveScrollSnapshot: { ...snapshot } }),
clearLeaveScrollSnapshot: () => set({ leaveScrollSnapshot: null }),
peekLeaveScrollSnapshot: () => {
const snapshot = get().leaveScrollSnapshot;
return snapshot ? { ...snapshot } : null;
},
peekReturnStash: () => {
const stash = get().returnStash;
if (!stash) return null;
return {
...stash,
results: stash.results
? {
artists: [...stash.results.artists],
albums: [...stash.results.albums],
songs: [...stash.results.songs],
}
: null,
activeSearch: stash.activeSearch ? { ...stash.activeSearch } : null,
...(typeof stash.scrollTop === 'number' ? { scrollTop: stash.scrollTop } : {}),
...(typeof stash.albumRowScrollLeft === 'number'
? { albumRowScrollLeft: stash.albumRowScrollLeft }
: {}),
...(typeof stash.artistRowScrollLeft === 'number'
? { artistRowScrollLeft: stash.artistRowScrollLeft }
: {}),
};
},
}));
/** True when pathname is the unified search page (`/search`, `/search/advanced`, or `/tracks`). */
export function isAdvancedSearchPath(pathname: string): boolean {
return pathname === '/search' || pathname === '/search/advanced' || pathname === '/tracks';
}
/** True when pathname is the Tracks hub (`/tracks`). */
export function isTracksBrowsePath(pathname: string): boolean {
return pathname === '/tracks';
}
/** True when the advanced filter panel should be shown. */
export function isAdvancedSearchPanelPath(pathname: string): boolean {
return pathname === '/search/advanced';
}
+58 -9
View File
@@ -1,15 +1,18 @@
import { describe, expect, it, beforeEach } from 'vitest';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import {
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
DEFAULT_ALBUM_BROWSE_SORT,
albumBrowseSortForServer,
albumBrowseSurfaceForPath,
isAlbumDetailPath,
peekAlbumBrowseScrollRestore,
useAlbumBrowseSessionStore,
} from './albumBrowseSessionStore';
describe('albumBrowseSessionStore', () => {
beforeEach(() => {
useAlbumBrowseSessionStore.setState({ sortByServer: {}, returnStashByServer: {} });
useAlbumBrowseSessionStore.setState({ sortByServer: {}, returnStashByKey: {} });
});
it('keeps sort per server for the session', () => {
@@ -22,35 +25,72 @@ describe('albumBrowseSessionStore', () => {
expect(albumBrowseSortForServer(sortByServer, 'srv-b')).toBe('alphabeticalByName');
});
it('stashes and peeks return filters', () => {
it('stashes and peeks return filters with scroll snapshot per surface', () => {
const { stashReturnFilters, peekReturnStash } = useAlbumBrowseSessionStore.getState();
stashReturnFilters('srv-a', {
stashReturnFilters('srv-a', 'albums', {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: ['Rock'],
yearFrom: '1990',
yearTo: '2000',
starredOnly: true,
scrollTop: 840,
displayCount: 120,
});
expect(peekReturnStash('srv-a')).toEqual({
expect(peekReturnStash('srv-a', 'albums')).toEqual({
selectedGenres: ['Rock'],
yearFrom: '1990',
yearTo: '2000',
compFilter: 'all',
starredOnly: true,
losslessOnly: false,
scrollTop: 840,
displayCount: 120,
});
expect(peekReturnStash('srv-a')).not.toBeNull();
expect(peekReturnStash('srv-a', 'new-releases')).toBeNull();
});
it('clears return stash', () => {
it('peeks scroll restore target for a surface', () => {
const { stashReturnFilters } = useAlbumBrowseSessionStore.getState();
stashReturnFilters('srv-a', 'new-releases', {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
scrollTop: 420,
displayCount: 180,
});
expect(peekAlbumBrowseScrollRestore('srv-a', 'new-releases')).toEqual({
scrollTop: 420,
displayCount: 180,
});
expect(peekAlbumBrowseScrollRestore('srv-b', 'new-releases')).toBeNull();
});
it('stashes cached album rows for grid surfaces', () => {
const albums = [{ id: 'a1', name: 'A', artist: 'X', artistId: 'x' }] as SubsonicAlbum[];
const { stashReturnFilters, peekReturnStash } = useAlbumBrowseSessionStore.getState();
stashReturnFilters('srv-a', 'random-albums', {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: ['Jazz'],
albums,
hasMore: false,
scrollTop: 100,
displayCount: 1,
});
expect(peekReturnStash('srv-a', 'random-albums')?.albums).toEqual(albums);
});
it('clears return stash for a surface only', () => {
const { stashReturnFilters, clearReturnStash, peekReturnStash } = useAlbumBrowseSessionStore.getState();
stashReturnFilters('srv-a', {
stashReturnFilters('srv-a', 'albums', {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: ['Jazz'],
});
clearReturnStash('srv-a');
expect(peekReturnStash('srv-a')).toBeNull();
stashReturnFilters('srv-a', 'new-releases', {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: ['Rock'],
});
clearReturnStash('srv-a', 'albums');
expect(peekReturnStash('srv-a', 'albums')).toBeNull();
expect(peekReturnStash('srv-a', 'new-releases')?.selectedGenres).toEqual(['Rock']);
});
it('defaults sort when server has no entry', () => {
@@ -68,3 +108,12 @@ describe('isAlbumDetailPath', () => {
expect(isAlbumDetailPath('/album/abc/tracks')).toBe(false);
});
});
describe('albumBrowseSurfaceForPath', () => {
it('maps album grid browse routes', () => {
expect(albumBrowseSurfaceForPath('/albums')).toBe('albums');
expect(albumBrowseSurfaceForPath('/new-releases')).toBe('new-releases');
expect(albumBrowseSurfaceForPath('/random/albums')).toBe('random-albums');
expect(albumBrowseSurfaceForPath('/artists')).toBeNull();
});
});
+86 -32
View File
@@ -1,11 +1,15 @@
import { create } from 'zustand';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import type { AlbumBrowseSort } from '../utils/library/browseTextSearch';
export const DEFAULT_ALBUM_BROWSE_SORT: AlbumBrowseSort = 'alphabeticalByName';
export type AlbumBrowseCompFilter = 'all' | 'only' | 'hide';
/** Filters restored only when returning to Albums via browser/app back from album detail. */
/** Album grid browse surfaces that share leave-restore session behavior. */
export type AlbumBrowseSurface = 'albums' | 'new-releases' | 'random-albums';
/** Browse state restored when returning via browser/app back from album detail. */
export interface AlbumBrowseReturnFilters {
selectedGenres: string[];
yearFrom: string;
@@ -13,6 +17,13 @@ export interface AlbumBrowseReturnFilters {
compFilter: AlbumBrowseCompFilter;
starredOnly: boolean;
losslessOnly: boolean;
/** In-page grid scroll position when leaving the browse surface. */
scrollTop?: number;
/** Row count at leave time — preload at least this many rows before scroll. */
displayCount?: number;
/** Cached grid rows (New Releases / Random Albums). */
albums?: SubsonicAlbum[];
hasMore?: boolean;
}
export const DEFAULT_ALBUM_BROWSE_RETURN_FILTERS: AlbumBrowseReturnFilters = {
@@ -31,12 +42,20 @@ interface ServerAlbumBrowseSession {
interface AlbumBrowseSessionStore {
/** Session-lifetime sort per server (sidebar ↔ album detail). */
sortByServer: Record<string, AlbumBrowseSort>;
/** Stashed when leaving Albums → album detail; consumed on POP back. */
returnStashByServer: Record<string, AlbumBrowseReturnFilters>;
/** Stashed when leaving a browse surface → album detail; consumed after scroll restore. */
returnStashByKey: Record<string, AlbumBrowseReturnFilters>;
setSort: (serverId: string, sort: AlbumBrowseSort) => void;
stashReturnFilters: (serverId: string, filters: AlbumBrowseReturnFilters) => void;
clearReturnStash: (serverId: string) => void;
peekReturnStash: (serverId: string) => AlbumBrowseReturnFilters | null;
stashReturnFilters: (
serverId: string,
surface: AlbumBrowseSurface,
filters: AlbumBrowseReturnFilters,
) => void;
clearReturnStash: (serverId: string, surface: AlbumBrowseSurface) => void;
peekReturnStash: (serverId: string, surface: AlbumBrowseSurface) => AlbumBrowseReturnFilters | null;
}
function returnStashKey(serverId: string, surface: AlbumBrowseSurface): string {
return `${serverId}:${surface}`;
}
function sortEntryFor(
@@ -46,9 +65,24 @@ function sortEntryFor(
return sortByServer[serverId] ?? DEFAULT_ALBUM_BROWSE_SORT;
}
function cloneReturnFilters(filters: AlbumBrowseReturnFilters): AlbumBrowseReturnFilters {
return {
selectedGenres: [...filters.selectedGenres],
yearFrom: filters.yearFrom,
yearTo: filters.yearTo,
compFilter: filters.compFilter,
starredOnly: filters.starredOnly,
losslessOnly: filters.losslessOnly,
...(typeof filters.scrollTop === 'number' ? { scrollTop: filters.scrollTop } : {}),
...(typeof filters.displayCount === 'number' ? { displayCount: filters.displayCount } : {}),
...(filters.albums ? { albums: [...filters.albums] } : {}),
...(typeof filters.hasMore === 'boolean' ? { hasMore: filters.hasMore } : {}),
};
}
export const useAlbumBrowseSessionStore = create<AlbumBrowseSessionStore>((set, get) => ({
sortByServer: {},
returnStashByServer: {},
returnStashByKey: {},
setSort: (serverId, sort) => {
if (!serverId) return;
@@ -57,45 +91,47 @@ export const useAlbumBrowseSessionStore = create<AlbumBrowseSessionStore>((set,
}));
},
stashReturnFilters: (serverId, filters) => {
stashReturnFilters: (serverId, surface, filters) => {
if (!serverId) return;
const key = returnStashKey(serverId, surface);
set((s) => ({
returnStashByServer: {
...s.returnStashByServer,
[serverId]: {
selectedGenres: [...filters.selectedGenres],
yearFrom: filters.yearFrom,
yearTo: filters.yearTo,
compFilter: filters.compFilter,
starredOnly: filters.starredOnly,
losslessOnly: filters.losslessOnly,
},
returnStashByKey: {
...s.returnStashByKey,
[key]: cloneReturnFilters(filters),
},
}));
},
clearReturnStash: (serverId) => {
clearReturnStash: (serverId, surface) => {
if (!serverId) return;
const next = { ...get().returnStashByServer };
delete next[serverId];
set({ returnStashByServer: next });
const key = returnStashKey(serverId, surface);
const next = { ...get().returnStashByKey };
delete next[key];
set({ returnStashByKey: next });
},
peekReturnStash: (serverId) => {
peekReturnStash: (serverId, surface) => {
if (!serverId) return null;
const stash = get().returnStashByServer[serverId];
const stash = get().returnStashByKey[returnStashKey(serverId, surface)];
if (!stash) return null;
return {
selectedGenres: [...stash.selectedGenres],
yearFrom: stash.yearFrom,
yearTo: stash.yearTo,
compFilter: stash.compFilter,
starredOnly: stash.starredOnly,
losslessOnly: stash.losslessOnly,
};
return cloneReturnFilters(stash);
},
}));
/** Scroll-restore target saved when leaving a browse surface for album detail. */
export function peekAlbumBrowseScrollRestore(
serverId: string,
surface: AlbumBrowseSurface,
): { scrollTop: number; displayCount: number } | null {
const stash = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, surface);
if (!stash) return null;
if (typeof stash.scrollTop !== 'number' || typeof stash.displayCount !== 'number') return null;
return {
scrollTop: Math.max(0, stash.scrollTop),
displayCount: Math.max(0, stash.displayCount),
};
}
export function albumBrowseSortForServer(
sortByServer: Record<string, AlbumBrowseSort>,
serverId: string,
@@ -104,7 +140,25 @@ export function albumBrowseSortForServer(
return sortEntryFor(sortByServer, serverId);
}
/** Map pathname to album grid browse surface, if any. */
export function albumBrowseSurfaceForPath(pathname: string): AlbumBrowseSurface | null {
const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname;
if (path === '/albums') return 'albums';
if (path === '/new-releases') return 'new-releases';
if (path === '/random/albums') return 'random-albums';
return null;
}
/** True when pathname is a single album detail route (`/album/:id`). */
export function isAlbumDetailPath(pathname: string): boolean {
return /^\/album\/[^/]+\/?$/.test(pathname);
}
/** True when pathname is a single artist detail route (`/artist/:id`). */
export function isArtistDetailPath(pathname: string): boolean {
return /^\/artist\/[^/]+\/?$/.test(pathname);
}
export function isAdvancedSearchLeaveTargetPath(pathname: string): boolean {
return isAlbumDetailPath(pathname) || isArtistDetailPath(pathname);
}
@@ -0,0 +1,49 @@
import { describe, expect, it, beforeEach } from 'vitest';
import {
DEFAULT_ARTIST_BROWSE_RETURN_STATE,
peekArtistBrowseScrollRestore,
useArtistBrowseSessionStore,
} from './artistBrowseSessionStore';
describe('artistBrowseSessionStore', () => {
beforeEach(() => {
useArtistBrowseSessionStore.setState({ returnStashByServer: {} });
});
it('stashes and peeks return state per server', () => {
const { stashReturnState, peekReturnStash } = useArtistBrowseSessionStore.getState();
stashReturnState('s1', {
...DEFAULT_ARTIST_BROWSE_RETURN_STATE,
filter: 'mozart',
letterFilter: 'M',
viewMode: 'list',
scrollTop: 240,
visibleCount: 120,
});
expect(peekReturnStash('s1')).toEqual({
filter: 'mozart',
letterFilter: 'M',
starredOnly: false,
viewMode: 'list',
showArtistImages: true,
scrollTop: 240,
visibleCount: 120,
});
});
it('clears return stash for a server', () => {
const { stashReturnState, clearReturnStash, peekReturnStash } = useArtistBrowseSessionStore.getState();
stashReturnState('s1', DEFAULT_ARTIST_BROWSE_RETURN_STATE);
clearReturnStash('s1');
expect(peekReturnStash('s1')).toBeNull();
});
it('exposes scroll restore target when scroll fields are present', () => {
useArtistBrowseSessionStore.getState().stashReturnState('s1', {
...DEFAULT_ARTIST_BROWSE_RETURN_STATE,
scrollTop: 512,
visibleCount: 80,
});
expect(peekArtistBrowseScrollRestore('s1')).toEqual({ scrollTop: 512, visibleCount: 80 });
});
});
+91
View File
@@ -0,0 +1,91 @@
import { create } from 'zustand';
import { ALL_SENTINEL } from '../utils/componentHelpers/artistsHelpers';
export type ArtistBrowseViewMode = 'grid' | 'list';
/** Browse state restored when returning to Artists via back from artist detail. */
export interface ArtistBrowseReturnState {
filter: string;
letterFilter: string;
starredOnly: boolean;
viewMode: ArtistBrowseViewMode;
showArtistImages: boolean;
scrollTop?: number;
visibleCount?: number;
}
export const DEFAULT_ARTIST_BROWSE_RETURN_STATE: ArtistBrowseReturnState = {
filter: '',
letterFilter: ALL_SENTINEL,
starredOnly: false,
viewMode: 'grid',
showArtistImages: true,
};
interface ArtistBrowseSessionStore {
returnStashByServer: Record<string, ArtistBrowseReturnState>;
stashReturnState: (serverId: string, state: ArtistBrowseReturnState) => void;
clearReturnStash: (serverId: string) => void;
peekReturnStash: (serverId: string) => ArtistBrowseReturnState | null;
}
export const useArtistBrowseSessionStore = create<ArtistBrowseSessionStore>((set, get) => ({
returnStashByServer: {},
stashReturnState: (serverId, state) => {
if (!serverId) return;
set((s) => ({
returnStashByServer: {
...s.returnStashByServer,
[serverId]: {
filter: state.filter,
letterFilter: state.letterFilter,
starredOnly: state.starredOnly,
viewMode: state.viewMode,
showArtistImages: state.showArtistImages,
...(typeof state.scrollTop === 'number' ? { scrollTop: state.scrollTop } : {}),
...(typeof state.visibleCount === 'number' ? { visibleCount: state.visibleCount } : {}),
},
},
}));
},
clearReturnStash: (serverId) => {
if (!serverId) return;
const next = { ...get().returnStashByServer };
delete next[serverId];
set({ returnStashByServer: next });
},
peekReturnStash: (serverId) => {
if (!serverId) return null;
const stash = get().returnStashByServer[serverId];
if (!stash) return null;
return {
filter: stash.filter,
letterFilter: stash.letterFilter,
starredOnly: stash.starredOnly,
viewMode: stash.viewMode,
showArtistImages: stash.showArtistImages,
...(typeof stash.scrollTop === 'number' ? { scrollTop: stash.scrollTop } : {}),
...(typeof stash.visibleCount === 'number' ? { visibleCount: stash.visibleCount } : {}),
};
},
}));
export function peekArtistBrowseScrollRestore(
serverId: string,
): { scrollTop: number; visibleCount: number } | null {
const stash = useArtistBrowseSessionStore.getState().peekReturnStash(serverId);
if (!stash) return null;
if (typeof stash.scrollTop !== 'number' || typeof stash.visibleCount !== 'number') return null;
return {
scrollTop: Math.max(0, stash.scrollTop),
visibleCount: Math.max(0, stash.visibleCount),
};
}
/** True when pathname is the Artists browse route (`/artists`). */
export function isArtistsBrowsePath(pathname: string): boolean {
return pathname === '/artists';
}
@@ -1,4 +1,4 @@
/* ─ Shared SongRow (used by Tracks Hub, SearchResults, AdvancedSearch) ─ */
/* ─ Shared SongRow (used by Tracks hub, SearchBrowsePage) ─ */
.song-list-row {
display: grid;
+61 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
@@ -6,8 +6,11 @@ import {
resolveTrackCoverArtId,
runLocalAdvancedSearch,
runLocalSongBrowse,
runNetworkAdvancedYearAlbums,
trackToSong,
tryRunLocalAdvancedSearch,
} from './advancedSearchLocal';
import * as albumBrowseNetwork from './albumBrowseNetwork';
const opts = (over: Partial<Parameters<typeof runLocalAdvancedSearch>[1]> = {}) => ({
query: '',
@@ -263,3 +266,60 @@ describe('runLocalSongBrowse', () => {
expect(await runLocalSongBrowse('s1', 0, 50)).toBeNull();
});
});
describe('tryRunLocalAdvancedSearch', () => {
beforeEach(() => {
useLibraryIndexStore.setState({ masterEnabled: true });
});
it('retries without the ready gate when sync is still in progress', async () => {
onInvoke('library_get_status', () => ({
serverId: 's1',
libraryScope: '',
syncPhase: 'initial_sync',
localTrackCount: 100,
serverTrackCount: 1000,
capabilityFlags: 0,
libraryTier: 'unknown',
syncedAt: 0,
}));
let searchCalls = 0;
onInvoke('library_advanced_search', () => {
searchCalls += 1;
return {
source: 'local',
artists: [],
albums: [],
tracks: [],
totals: { artists: 0, albums: 0, tracks: 0 },
appliedFilters: ['year'],
};
});
const res = await tryRunLocalAdvancedSearch('s1', opts({ yearFrom: '2020' }), 100);
expect(res).not.toBeNull();
expect(searchCalls).toBe(1);
});
});
describe('runNetworkAdvancedYearAlbums', () => {
it('passes open-ended year bounds to album browse (not 1900…now defaults)', async () => {
const spy = vi.spyOn(albumBrowseNetwork, 'fetchAlbumBrowseNetwork').mockResolvedValue({
albums: [{
id: 'a1',
name: 'Al',
artist: 'Ar',
artistId: 'ar1',
songCount: 1,
duration: 100,
}],
hasMore: false,
});
await runNetworkAdvancedYearAlbums(opts({ yearTo: '1990' }), 100);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({ year: { to: 1990 } }),
0,
100,
);
spy.mockRestore();
});
});
+50 -2
View File
@@ -1,7 +1,7 @@
/**
* Advanced Search against the local library index (spec §5.13 / F2).
*
* Maps the AdvancedSearch UI inputs to a `library_advanced_search` request and
* Maps the SearchBrowsePage filter inputs to a `library_advanced_search` request and
* the response back to the Subsonic shapes the existing rows render. The sync
* engine stores each entity's original Subsonic JSON in `rawJson` (ADR-7), so
* that's preferred verbatim; the flat hot columns are a fallback when a row's
@@ -23,12 +23,17 @@ import {
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
import { search } from '../../api/subsonicSearch';
import { libraryScopeForServer } from '../../api/subsonicClient';
import { fetchAlbumBrowseNetwork } from './albumBrowseNetwork';
import type { AlbumBrowseQuery } from './albumBrowseTypes';
import { resolveAlbumYearBounds } from './albumYearFilter';
import { libraryIsReady } from './libraryReady';
import { logLibrarySearch, timed } from './libraryDevLog';
import { isLosslessSuffix } from './losslessFormats';
import { albumIsCompilation } from './albumCompilation';
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment';
export const ADVANCED_SEARCH_YEAR_ALBUM_LIMIT = 100;
export type AdvancedResultType = 'all' | 'artists' | 'albums' | 'songs';
/** UI opts for Advanced Search — BPM/mood filters require local index. */
@@ -238,7 +243,7 @@ export function artistToArtist(ar: LibraryArtistDto): SubsonicArtist {
}
/**
* Network search3 path for Advanced Search free-text (mirrors AdvancedSearch.tsx filters).
* Network search3 path for Advanced Search free-text (mirrors SearchBrowsePage.tsx filters).
*/
export async function runNetworkAdvancedTextSearch(
opts: LocalSearchOpts,
@@ -393,3 +398,46 @@ export async function loadMoreLocalSongs(
const resp = await libraryAdvancedSearch(req);
return resp.tracks.map(trackToSong);
}
/** Local index first; retry without the ready gate when sync is still catching up. */
export async function tryRunLocalAdvancedSearch(
serverId: string | null | undefined,
opts: LocalSearchOpts,
songsLimit: number,
suppressLog = false,
): Promise<LocalAdvancedSearchPage | null> {
const readyPage = await runLocalAdvancedSearch(
serverId,
opts,
songsLimit,
false,
true,
suppressLog,
);
if (readyPage) return readyPage;
return runLocalAdvancedSearch(serverId, opts, songsLimit, true, true, suppressLog);
}
function yearOnlyAlbumBrowseQuery(opts: LocalSearchOpts): AlbumBrowseQuery | null {
const { active, bounds } = resolveAlbumYearBounds(opts.yearFrom, opts.yearTo);
if (!active) return null;
return {
sort: 'alphabeticalByName',
genres: [],
year: bounds,
losslessOnly: !!opts.losslessOnly,
starredOnly: false,
compFilter: 'all',
};
}
/** Network fallback for year-only Advanced Search albums (open-ended year bounds). */
export async function runNetworkAdvancedYearAlbums(
opts: LocalSearchOpts,
pageSize = ADVANCED_SEARCH_YEAR_ALBUM_LIMIT,
): Promise<SubsonicAlbum[]> {
const query = yearOnlyAlbumBrowseQuery(opts);
if (!query) return [];
const page = await fetchAlbumBrowseNetwork(query, 0, pageSize);
return page.albums;
}
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Browse-page text search local index vs network race (LiveSearch / AdvancedSearch pattern).
* Browse-page text search local index vs network race (LiveSearch / SearchBrowsePage pattern).
*/
import { getStarred } from '../../api/subsonicStarRating';
import { search, searchSongsPaged } from '../../api/subsonicSearch';
@@ -0,0 +1,151 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
import {
clearAdvancedSearchLeaveSnapshots,
peekPersistedAdvancedSearchLeaveSnapshot,
readAdvancedSearchLeaveSnapshot,
registerAdvancedSearchLeaveScrollProvider,
registerAdvancedSearchSessionProvider,
resolveAdvancedSearchLeaveSnapshot,
saveAdvancedSearchLeaveSnapshot,
} from './advancedSearchScrollSnapshot';
import { useAdvancedSearchSessionStore } from '../../store/advancedSearchSessionStore';
describe('advancedSearchScrollSnapshot', () => {
afterEach(() => {
clearAdvancedSearchLeaveSnapshots();
useAdvancedSearchSessionStore.getState().clearReturnStash();
sessionStorage.clear();
document.body.innerHTML = '';
});
it('persists and peeks leave snapshot in sessionStorage', () => {
const viewport = document.createElement('div');
viewport.id = APP_MAIN_SCROLL_VIEWPORT_ID;
Object.defineProperty(viewport, 'scrollTop', { value: 640, writable: true });
document.body.appendChild(viewport);
saveAdvancedSearchLeaveSnapshot();
expect(peekPersistedAdvancedSearchLeaveSnapshot()).toEqual({
scrollTop: 640,
albumRowScrollLeft: 0,
artistRowScrollLeft: 0,
});
});
it('reads leave snapshot from provider merged with DOM', () => {
const viewport = document.createElement('div');
viewport.id = APP_MAIN_SCROLL_VIEWPORT_ID;
Object.defineProperty(viewport, 'scrollTop', { value: 512, writable: true });
document.body.appendChild(viewport);
const albumGrid = document.createElement('div');
albumGrid.className = 'album-grid';
Object.defineProperty(albumGrid, 'scrollLeft', { value: 80, writable: true });
const row = document.createElement('div');
row.setAttribute('data-advanced-search-album-row', '');
row.appendChild(albumGrid);
document.body.appendChild(row);
const unregister = registerAdvancedSearchLeaveScrollProvider(() => ({
scrollTop: 100,
albumRowScrollLeft: 45,
artistRowScrollLeft: 10,
}));
expect(readAdvancedSearchLeaveSnapshot()).toEqual({
scrollTop: 512,
albumRowScrollLeft: 80,
artistRowScrollLeft: 10,
});
unregister();
});
it('reads artist row scroll from DOM', () => {
const artistGrid = document.createElement('div');
artistGrid.className = 'album-grid';
Object.defineProperty(artistGrid, 'scrollLeft', { value: 120, writable: true });
const row = document.createElement('div');
row.setAttribute('data-advanced-search-artist-row', '');
row.appendChild(artistGrid);
document.body.appendChild(row);
expect(readAdvancedSearchLeaveSnapshot()).toEqual({
scrollTop: 0,
albumRowScrollLeft: 0,
artistRowScrollLeft: 120,
});
});
it('merges leave snapshot, sessionStorage, and stash scroll fields', () => {
useAdvancedSearchSessionStore.getState().setLeaveScrollSnapshot({
scrollTop: 300,
albumRowScrollLeft: 0,
artistRowScrollLeft: 0,
});
sessionStorage.setItem(
'psysonic:advanced-search-leave-v1',
JSON.stringify({ scrollTop: 100, albumRowScrollLeft: 80, artistRowScrollLeft: 55 }),
);
expect(resolveAdvancedSearchLeaveSnapshot({
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,
scrollTop: 50,
albumRowScrollLeft: 20,
artistRowScrollLeft: 15,
})).toEqual({ scrollTop: 300, albumRowScrollLeft: 80, artistRowScrollLeft: 55 });
});
it('saves session stash together with leave snapshot on navigate away', () => {
const viewport = document.createElement('div');
viewport.id = APP_MAIN_SCROLL_VIEWPORT_ID;
Object.defineProperty(viewport, 'scrollTop', { value: 640, writable: true });
document.body.appendChild(viewport);
const unregister = registerAdvancedSearchSessionProvider(() => ({
query: 'rock',
genre: 'Jazz',
yearFrom: '',
yearTo: '',
bpmFrom: '',
bpmTo: '',
moodGroup: '',
losslessOnly: false,
resultType: 'all',
starredOnly: false,
results: null,
hasSearched: true,
activeSearch: null,
localMode: false,
songsServerOffset: 0,
songsHasMore: false,
genreNote: false,
basicSearchMode: false,
tracksBrowseMode: false,
}));
saveAdvancedSearchLeaveSnapshot();
expect(useAdvancedSearchSessionStore.getState().peekReturnStash()?.query).toBe('rock');
expect(useAdvancedSearchSessionStore.getState().peekReturnStash()?.genre).toBe('Jazz');
expect(useAdvancedSearchSessionStore.getState().peekReturnStash()?.scrollTop).toBe(640);
unregister();
});
});
@@ -0,0 +1,160 @@
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
import {
useAdvancedSearchSessionStore,
type AdvancedSearchSessionStash,
} from '../../store/advancedSearchSessionStore';
export type AdvancedSearchLeaveSnapshot = {
scrollTop: number;
albumRowScrollLeft: number;
artistRowScrollLeft: number;
};
const STORAGE_KEY = 'psysonic:advanced-search-leave-v1';
type LeaveScrollProvider = () => AdvancedSearchLeaveSnapshot;
type SessionProvider = () => AdvancedSearchSessionStash;
let leaveScrollProvider: LeaveScrollProvider | null = null;
let sessionProvider: SessionProvider | null = null;
let leavingAdvancedSearchForDetail = false;
export function registerAdvancedSearchLeaveScrollProvider(
provider: LeaveScrollProvider,
): () => void {
leaveScrollProvider = provider;
return () => {
if (leaveScrollProvider === provider) leaveScrollProvider = null;
};
}
export function registerAdvancedSearchSessionProvider(
provider: SessionProvider,
): () => void {
sessionProvider = provider;
return () => {
if (sessionProvider === provider) sessionProvider = null;
};
}
export function markAdvancedSearchLeavingForDetail(): void {
leavingAdvancedSearchForDetail = true;
}
export function consumeAdvancedSearchLeavingForDetail(): boolean {
const value = leavingAdvancedSearchForDetail;
leavingAdvancedSearchForDetail = false;
return value;
}
function readAlbumRowScrollLeftFromDom(): number {
const albumGrid = document.querySelector<HTMLElement>('[data-advanced-search-album-row] .album-grid');
return albumGrid?.scrollLeft ?? 0;
}
function readArtistRowScrollLeftFromDom(): number {
const artistGrid = document.querySelector<HTMLElement>('[data-advanced-search-artist-row] .album-grid');
return artistGrid?.scrollLeft ?? 0;
}
function readMainScrollTopFromDom(): number {
return document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTop ?? 0;
}
export function readAdvancedSearchLeaveSnapshot(): AdvancedSearchLeaveSnapshot {
const providerSnap = leaveScrollProvider?.();
return {
scrollTop: Math.max(readMainScrollTopFromDom(), providerSnap?.scrollTop ?? 0),
albumRowScrollLeft: Math.max(
readAlbumRowScrollLeftFromDom(),
providerSnap?.albumRowScrollLeft ?? 0,
),
artistRowScrollLeft: Math.max(
readArtistRowScrollLeftFromDom(),
providerSnap?.artistRowScrollLeft ?? 0,
),
};
}
function persistLeaveSnapshot(snapshot: AdvancedSearchLeaveSnapshot): void {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
/* quota / private mode */
}
}
export function peekPersistedAdvancedSearchLeaveSnapshot(): AdvancedSearchLeaveSnapshot | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<AdvancedSearchLeaveSnapshot>;
const scrollTop = typeof parsed.scrollTop === 'number' ? Math.max(0, parsed.scrollTop) : 0;
const albumRowScrollLeft = typeof parsed.albumRowScrollLeft === 'number'
? Math.max(0, parsed.albumRowScrollLeft)
: 0;
const artistRowScrollLeft = typeof parsed.artistRowScrollLeft === 'number'
? Math.max(0, parsed.artistRowScrollLeft)
: 0;
if (scrollTop <= 0 && albumRowScrollLeft <= 0 && artistRowScrollLeft <= 0) return null;
return { scrollTop, albumRowScrollLeft, artistRowScrollLeft };
} catch {
return null;
}
}
export function clearPersistedAdvancedSearchLeaveSnapshot(): void {
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
}
export function saveAdvancedSearchLeaveSnapshot(): AdvancedSearchLeaveSnapshot {
const snapshot = readAdvancedSearchLeaveSnapshot();
persistLeaveSnapshot(snapshot);
const store = useAdvancedSearchSessionStore.getState();
store.setLeaveScrollSnapshot(snapshot);
const session = sessionProvider?.();
if (session) {
store.stashReturnSession({
...session,
scrollTop: snapshot.scrollTop,
albumRowScrollLeft: snapshot.albumRowScrollLeft,
artistRowScrollLeft: snapshot.artistRowScrollLeft,
});
}
markAdvancedSearchLeavingForDetail();
return snapshot;
}
export function clearAdvancedSearchLeaveSnapshots(): void {
clearPersistedAdvancedSearchLeaveSnapshot();
useAdvancedSearchSessionStore.getState().clearLeaveScrollSnapshot();
}
/** Merge zustand leave snapshot, sessionStorage, and session stash scroll fields. */
export function resolveAdvancedSearchLeaveSnapshot(
stash: AdvancedSearchSessionStash | null,
): AdvancedSearchLeaveSnapshot | null {
const leave = useAdvancedSearchSessionStore.getState().peekLeaveScrollSnapshot();
const persisted = peekPersistedAdvancedSearchLeaveSnapshot();
const scrollTop = Math.max(
leave?.scrollTop ?? 0,
persisted?.scrollTop ?? 0,
stash?.scrollTop ?? 0,
);
const albumRowScrollLeft = Math.max(
leave?.albumRowScrollLeft ?? 0,
persisted?.albumRowScrollLeft ?? 0,
stash?.albumRowScrollLeft ?? 0,
);
const artistRowScrollLeft = Math.max(
leave?.artistRowScrollLeft ?? 0,
persisted?.artistRowScrollLeft ?? 0,
stash?.artistRowScrollLeft ?? 0,
);
if (scrollTop <= 0 && albumRowScrollLeft <= 0 && artistRowScrollLeft <= 0) return null;
return { scrollTop, albumRowScrollLeft, artistRowScrollLeft };
}
@@ -0,0 +1,171 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import type { NavigationType } from 'react-router-dom';
import {
buildReturnToFromLocation,
navigateAlbumDetailBack,
navigatePathWithAlbumReturnTo,
navigateToAlbumDetail,
navigateToArtistDetail,
readAlbumDetailReturnTo,
shouldRestoreAlbumBrowseSession,
shouldRestoreArtistBrowseSession,
shouldSkipMainScrollResetOnRouteChange,
} from './albumDetailNavigation';
import { useAdvancedSearchSessionStore } from '../../store/advancedSearchSessionStore';
describe('albumDetailNavigation', () => {
afterEach(() => {
useAdvancedSearchSessionStore.getState().clearLeaveScrollSnapshot();
});
it('reads returnTo from location state', () => {
expect(readAlbumDetailReturnTo({ returnTo: '/artist/abc' })).toBe('/artist/abc');
expect(readAlbumDetailReturnTo({ returnTo: 'bad' })).toBeNull();
expect(readAlbumDetailReturnTo(null)).toBeNull();
});
it('detects album browse restore navigation', () => {
expect(shouldRestoreAlbumBrowseSession('POP' as NavigationType, null)).toBe(true);
expect(shouldRestoreAlbumBrowseSession('PUSH' as NavigationType, { albumBrowseRestore: true })).toBe(true);
expect(shouldRestoreAlbumBrowseSession('PUSH' as NavigationType, null)).toBe(false);
});
it('navigates to album with returnTo snapshot', () => {
const navigate = vi.fn();
navigateToAlbumDetail(navigate, { pathname: '/artist/a', search: '', hash: '', state: null }, 'alb-1');
expect(navigate).toHaveBeenCalledWith('/album/alb-1', { state: { returnTo: '/artist/a' } });
});
it('preserves returnTo when opening a related album', () => {
const navigate = vi.fn();
navigateToAlbumDetail(
navigate,
{
pathname: '/album/parent',
search: '',
hash: '',
state: { returnTo: '/albums' },
},
'child',
);
expect(navigate).toHaveBeenCalledWith('/album/child', { state: { returnTo: '/albums' } });
});
it('routes album paths through returnTo helper', () => {
const navigate = vi.fn();
navigatePathWithAlbumReturnTo(
navigate,
{ pathname: '/', search: '', hash: '', state: null },
'/album/x?lossless=1',
);
expect(navigate).toHaveBeenCalledWith('/album/x?lossless=1', { state: { returnTo: '/' } });
});
it('navigates back to saved returnTo', () => {
const navigate = vi.fn();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/genres/Rock' } });
expect(navigate).toHaveBeenCalledWith('/genres/Rock', undefined);
});
it('flags All Albums return for browse restore', () => {
const navigate = vi.fn();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/albums' } });
expect(navigate).toHaveBeenCalledWith('/albums', { state: { albumBrowseRestore: true } });
});
it('flags New Releases and Random Albums return for browse restore', () => {
const navigate = vi.fn();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/new-releases' } });
expect(navigate).toHaveBeenCalledWith('/new-releases', { state: { albumBrowseRestore: true } });
navigate.mockClear();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/random/albums' } });
expect(navigate).toHaveBeenCalledWith('/random/albums', { state: { albumBrowseRestore: true } });
});
it('flags Artists browse return for session restore', () => {
const navigate = vi.fn();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/artists' } });
expect(navigate).toHaveBeenCalledWith('/artists', { state: { artistBrowseRestore: true } });
});
it('detects artist browse restore navigation', () => {
expect(shouldRestoreArtistBrowseSession('POP' as NavigationType, null)).toBe(true);
expect(shouldRestoreArtistBrowseSession('PUSH' as NavigationType, { artistBrowseRestore: true })).toBe(true);
expect(shouldRestoreArtistBrowseSession('PUSH' as NavigationType, null)).toBe(false);
});
it('flags Advanced Search return for session restore', () => {
const navigate = vi.fn();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/search/advanced?q=rock' } });
expect(navigate).toHaveBeenCalledWith('/search/advanced?q=rock', {
state: { advancedSearchRestore: true },
});
});
it('flags Search return for session restore (basic, advanced, and tracks paths)', () => {
const navigate = vi.fn();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/search?q=rock' } });
expect(navigate).toHaveBeenCalledWith('/search?q=rock', {
state: { advancedSearchRestore: true },
});
navigate.mockClear();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/search/advanced?q=rock' } });
expect(navigate).toHaveBeenCalledWith('/search/advanced?q=rock', {
state: { advancedSearchRestore: true },
});
navigate.mockClear();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/tracks' } });
expect(navigate).toHaveBeenCalledWith('/tracks', {
state: { advancedSearchRestore: true },
});
});
it('navigates to artist with returnTo snapshot from Advanced Search', () => {
const navigate = vi.fn();
navigateToArtistDetail(
navigate,
{ pathname: '/search/advanced', search: '?q=rock', hash: '', state: null },
'art-1',
);
expect(navigate).toHaveBeenCalledWith('/artist/art-1', {
state: { returnTo: '/search/advanced?q=rock' },
});
});
it('skips main scroll reset when All Albums browse restore is pending', () => {
expect(shouldSkipMainScrollResetOnRouteChange('/albums', { albumBrowseRestore: true })).toBe(true);
expect(shouldSkipMainScrollResetOnRouteChange('/new-releases', { albumBrowseRestore: true })).toBe(true);
expect(shouldSkipMainScrollResetOnRouteChange('/random/albums', { albumBrowseRestore: true })).toBe(true);
expect(shouldSkipMainScrollResetOnRouteChange('/tracks', null)).toBe(false);
});
it('skips main scroll reset when Artists browse restore is pending', () => {
expect(shouldSkipMainScrollResetOnRouteChange('/artists', { artistBrowseRestore: true })).toBe(true);
});
it('skips main scroll reset when Advanced Search session restore is pending', () => {
expect(shouldSkipMainScrollResetOnRouteChange('/search/advanced', { advancedSearchRestore: true })).toBe(true);
});
it('skips main scroll reset when Search session restore is pending', () => {
expect(shouldSkipMainScrollResetOnRouteChange('/search', { advancedSearchRestore: true })).toBe(true);
expect(shouldSkipMainScrollResetOnRouteChange('/tracks', { advancedSearchRestore: true })).toBe(true);
});
it('skips main scroll reset when Advanced Search vertical scroll restore is pending', () => {
useAdvancedSearchSessionStore.getState().setLeaveScrollSnapshot({
scrollTop: 420,
albumRowScrollLeft: 0,
artistRowScrollLeft: 0,
});
expect(shouldSkipMainScrollResetOnRouteChange('/search/advanced', null)).toBe(true);
});
it('builds return path with search and hash', () => {
expect(buildReturnToFromLocation({
pathname: '/tracks',
search: '?q=test',
hash: '#top',
})).toBe('/tracks?q=test#top');
});
});
@@ -0,0 +1,193 @@
import type { Location, NavigateFunction, NavigationType } from 'react-router-dom';
import {
isAdvancedSearchPath,
useAdvancedSearchSessionStore,
} from '../../store/advancedSearchSessionStore';
import {
isAlbumDetailPath,
isArtistDetailPath,
} from '../../store/albumBrowseSessionStore';
import {
peekPersistedAdvancedSearchLeaveSnapshot,
saveAdvancedSearchLeaveSnapshot,
} from './advancedSearchScrollSnapshot';
export type AlbumDetailLocationState = {
returnTo?: string;
};
export type AlbumsBrowseRestoreLocationState = {
albumBrowseRestore?: boolean;
artistBrowseRestore?: boolean;
advancedSearchRestore?: boolean;
};
export function readAlbumDetailReturnTo(state: unknown): string | null {
const returnTo = (state as AlbumDetailLocationState | null)?.returnTo;
if (typeof returnTo !== 'string' || returnTo.length === 0) return null;
if (!returnTo.startsWith('/')) return null;
return returnTo;
}
export function readAlbumBrowseRestore(state: unknown): boolean {
return (state as AlbumsBrowseRestoreLocationState | null)?.albumBrowseRestore === true;
}
export function readArtistBrowseRestore(state: unknown): boolean {
return (state as AlbumsBrowseRestoreLocationState | null)?.artistBrowseRestore === true;
}
export function readAdvancedSearchRestore(state: unknown): boolean {
return (state as AlbumsBrowseRestoreLocationState | null)?.advancedSearchRestore === true;
}
export function buildReturnToFromLocation(
location: Pick<Location, 'pathname' | 'search' | 'hash'>,
): string {
return `${location.pathname}${location.search}${location.hash}`;
}
export function albumBrowseRestoreNavigationState(): AlbumsBrowseRestoreLocationState {
return { albumBrowseRestore: true };
}
export function artistBrowseRestoreNavigationState(): AlbumsBrowseRestoreLocationState {
return { artistBrowseRestore: true };
}
export function advancedSearchRestoreNavigationState(): AlbumsBrowseRestoreLocationState {
return { advancedSearchRestore: true };
}
export function shouldRestoreAdvancedSearchSession(
navigationType: NavigationType,
locationState: unknown,
): boolean {
return navigationType === 'POP' || readAdvancedSearchRestore(locationState);
}
export function shouldRestoreAlbumBrowseSession(
navigationType: NavigationType,
locationState: unknown,
): boolean {
return navigationType === 'POP' || readAlbumBrowseRestore(locationState);
}
export function shouldRestoreArtistBrowseSession(
navigationType: NavigationType,
locationState: unknown,
): boolean {
return navigationType === 'POP' || readArtistBrowseRestore(locationState);
}
/** Skip AppShell main scroll reset when a child route will restore scroll itself. */
export function shouldSkipMainScrollResetOnRouteChange(
pathname: string,
locationState: unknown,
): boolean {
if (readAlbumBrowseRestore(locationState)) return true;
if (readArtistBrowseRestore(locationState)) return true;
if (readAdvancedSearchRestore(locationState)) return true;
const leave = useAdvancedSearchSessionStore.getState().peekLeaveScrollSnapshot();
if ((leave?.scrollTop ?? 0) > 0) return true;
if (isAdvancedSearchPath(pathname)) {
const persisted = peekPersistedAdvancedSearchLeaveSnapshot();
if ((persisted?.scrollTop ?? 0) > 0) return true;
}
return false;
}
function isAlbumGridBrowseReturnPath(path: string): boolean {
return path === '/albums' || path.startsWith('/albums?')
|| path === '/new-releases' || path.startsWith('/new-releases?')
|| path === '/random/albums' || path.startsWith('/random/albums?');
}
function isSearchReturnPath(path: string): boolean {
return path === '/search' || path.startsWith('/search?')
|| path === '/search/advanced' || path.startsWith('/search/advanced?')
|| path === '/tracks' || path.startsWith('/tracks?');
}
function isArtistsBrowseReturnPath(path: string): boolean {
return path === '/artists' || path.startsWith('/artists?');
}
function browseReturnRestoreState(returnTo: string): AlbumsBrowseRestoreLocationState | undefined {
if (isAlbumGridBrowseReturnPath(returnTo)) return albumBrowseRestoreNavigationState();
if (isArtistsBrowseReturnPath(returnTo)) return artistBrowseRestoreNavigationState();
if (isSearchReturnPath(returnTo)) return advancedSearchRestoreNavigationState();
return undefined;
}
function buildReturnTo(
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
): string {
const existing = readAlbumDetailReturnTo(location.state);
const onDetail = isAlbumDetailPath(location.pathname) || isArtistDetailPath(location.pathname);
return onDetail && existing ? existing : buildReturnToFromLocation(location);
}
function saveSearchLeaveIfNeeded(
location: Pick<Location, 'pathname' | 'search' | 'hash'>,
): void {
if (isAdvancedSearchPath(location.pathname)) {
saveAdvancedSearchLeaveSnapshot();
}
}
export function navigateToAlbumDetail(
navigate: NavigateFunction,
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
albumId: string,
opts?: { search?: string },
): void {
saveSearchLeaveIfNeeded(location);
const returnTo = buildReturnTo(location);
const raw = opts?.search ?? '';
const qs = raw ? (raw.startsWith('?') ? raw : `?${raw}`) : '';
navigate(`/album/${albumId}${qs}`, { state: { returnTo } satisfies AlbumDetailLocationState });
}
export function navigateToArtistDetail(
navigate: NavigateFunction,
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
artistId: string,
opts?: { search?: string },
): void {
saveSearchLeaveIfNeeded(location);
const returnTo = buildReturnTo(location);
const raw = opts?.search ?? '';
const qs = raw ? (raw.startsWith('?') ? raw : `?${raw}`) : '';
navigate(`/artist/${artistId}${qs}`, { state: { returnTo } satisfies AlbumDetailLocationState });
}
/** Route any path; album detail links get a `returnTo` snapshot in location state. */
export function navigatePathWithAlbumReturnTo(
navigate: NavigateFunction,
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
path: string,
): void {
const albumMatch = path.match(/^\/album\/([^/?#]+)(\?[^#]*)?/);
if (!albumMatch) {
navigate(path);
return;
}
const [, albumId, search = ''] = albumMatch;
navigateToAlbumDetail(navigate, location, albumId, { search });
}
export function navigateAlbumDetailBack(
navigate: NavigateFunction,
location: Pick<Location, 'state'>,
fallback = '/',
): void {
const returnTo = readAlbumDetailReturnTo(location.state);
if (returnTo) {
const restoreState = browseReturnRestoreState(returnTo);
navigate(returnTo, restoreState ? { state: restoreState } : undefined);
return;
}
if (window.history.length > 1) navigate(-1);
else navigate(fallback);
}
@@ -0,0 +1,62 @@
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
const SAFETY_TIMEOUT_MS = 3000;
function clampScrollTop(el: HTMLElement, scrollTop: number): number {
const maxScroll = Math.max(0, el.scrollHeight - el.clientHeight);
return Math.min(Math.max(0, scrollTop), maxScroll);
}
function scrollRestoreMatches(el: HTMLElement, targetScrollTop: number): boolean {
const maxScroll = Math.max(0, el.scrollHeight - el.clientHeight);
if (targetScrollTop > maxScroll + 1) return false;
const desired = clampScrollTop(el, targetScrollTop);
return Math.abs(el.scrollTop - desired) <= 1;
}
/** Apply main viewport scroll after route content is ready; retry until layout can reach target. */
export function restoreMainViewportScroll(
targetScrollTop: number,
onComplete: () => void,
): () => void {
let cancelled = false;
let ro: ResizeObserver | null = null;
let safetyTimeoutId = 0;
const finish = () => {
if (cancelled) return;
cancelled = true;
ro?.disconnect();
ro = null;
if (safetyTimeoutId) window.clearTimeout(safetyTimeoutId);
onComplete();
};
const apply = () => {
if (cancelled) return;
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (!el) return;
const desired = clampScrollTop(el, targetScrollTop);
el.scrollTop = desired;
el.dispatchEvent(new Event('scroll', { bubbles: false }));
if (scrollRestoreMatches(el, targetScrollTop)) finish();
};
const scheduleApply = () => {
requestAnimationFrame(apply);
};
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (el && typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(scheduleApply);
ro.observe(el);
}
apply();
scheduleApply();
safetyTimeoutId = window.setTimeout(finish, SAFETY_TIMEOUT_MS);
return finish;
}
+7 -1
View File
@@ -2,10 +2,11 @@ import { getArtist } from '../../api/subsonicArtists';
import { getAlbum, getSong } from '../../api/subsonicLibrary';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { songToTrack } from '../playback/songToTrack';
import type { NavigateFunction } from 'react-router-dom';
import type { Location, NavigateFunction } from 'react-router-dom';
import type { TFunction } from 'i18next';
import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore';
import { navigateToAlbumDetail } from '../navigation/albumDetailNavigation';
import { findServerIdForShareUrl, type EntitySharePayloadV1 } from './shareLink';
import { showToast } from '../ui/toast';
@@ -96,6 +97,7 @@ export async function applySharePastePayload(
payload: EntitySharePayloadV1,
navigate: NavigateFunction,
t: TFunction,
location?: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
): Promise<void> {
const { servers, isLoggedIn, setActiveServer } = useAuthStore.getState();
if (!isLoggedIn) {
@@ -134,7 +136,11 @@ export async function applySharePastePayload(
showToast(t('sharePaste.albumUnavailable'), 5000, 'error');
return;
}
if (location) {
navigateToAlbumDetail(navigate, location, payload.id);
} else {
navigate(`/album/${payload.id}`);
}
showToast(t('sharePaste.openedAlbum'), 3000, 'info');
return;
}