From d3e5a6b704fbf1ffcc68d91714efb2b0643a00d7 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Mon, 1 Jun 2026 03:11:27 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20library=20browse=20navigation=20?= =?UTF-8?q?=E2=80=94=20restore=20filters,=20scroll,=20and=20search=20on=20?= =?UTF-8?q?back=20(#936)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- CHANGELOG.md | 10 + .../psysonic-library/src/advanced_search.rs | 2 +- src/app/AppRoutes.tsx | 10 +- src/app/AppShell.tsx | 21 +- src/components/AlbumCard.tsx | 4 +- src/components/AlbumHeader.tsx | 4 +- src/components/AlbumRow.tsx | 83 ++- src/components/ArtistCardLocal.tsx | 10 +- src/components/ArtistRow.tsx | 58 +- src/components/Hero.tsx | 6 +- src/components/LiveSearch.tsx | 34 +- src/components/MobileSearchOverlay.tsx | 10 +- src/components/PasteClipboardHandler.tsx | 5 +- src/components/SongRow.tsx | 10 +- src/components/StarFilterButton.tsx | 2 +- src/components/VirtualSongList.tsx | 220 +------ .../artistDetail/ArtistDetailHero.tsx | 6 +- src/components/artists/ArtistsGridView.tsx | 11 +- src/components/artists/ArtistsListView.tsx | 13 +- .../contextMenu/SongContextItems.tsx | 6 +- src/components/tracks/SongBrowseSection.tsx | 78 +++ .../tracks/TracksPageChrome.tsx} | 91 ++- src/config/settingsCredits.ts | 3 +- src/constants/appScroll.ts | 25 + src/hooks/useAlbumBrowseData.ts | 4 + src/hooks/useAlbumBrowseFilters.ts | 108 +++- src/hooks/useAlbumBrowseScrollRestore.ts | 100 ++++ src/hooks/useAlbumDetailBack.test.tsx | 65 +++ src/hooks/useAlbumDetailBack.ts | 40 ++ src/hooks/useAlbumGridBrowseFilters.ts | 113 ++++ src/hooks/useArtistsBrowseFilters.ts | 122 ++++ src/hooks/useArtistsBrowseScrollRestore.ts | 91 +++ src/hooks/useClientSliceInfiniteScroll.ts | 16 +- src/hooks/useMainScrollingIndicator.ts | 4 +- src/hooks/useNavigateToAlbum.ts | 15 + src/hooks/useNavigateToArtist.ts | 15 + src/hooks/useOrbitSongRowBehavior.ts | 2 +- src/hooks/usePlaybackLibraryNavigate.ts | 8 +- src/hooks/useShareSearch.ts | 6 +- src/hooks/useSongBrowseList.ts | 196 +++++++ src/pages/Albums.tsx | 113 ++-- src/pages/Artists.tsx | 80 ++- src/pages/NewReleases.tsx | 72 ++- src/pages/RandomAlbums.tsx | 261 ++++++--- ...dvancedSearch.tsx => SearchBrowsePage.tsx} | 544 ++++++++++++++++-- src/pages/SearchResults.tsx | 160 ------ src/store/advancedSearchSessionStore.ts | 133 +++++ src/store/albumBrowseSessionStore.test.ts | 67 ++- src/store/albumBrowseSessionStore.ts | 118 ++-- src/store/artistBrowseSessionStore.test.ts | 49 ++ src/store/artistBrowseSessionStore.ts | 91 +++ ...racks-hub-searchresults-advancedsearch.css | 2 +- src/utils/library/advancedSearchLocal.test.ts | 62 +- src/utils/library/advancedSearchLocal.ts | 52 +- src/utils/library/browseTextSearch.ts | 2 +- .../advancedSearchScrollSnapshot.test.ts | 151 +++++ .../advancedSearchScrollSnapshot.ts | 160 ++++++ .../navigation/albumDetailNavigation.test.ts | 171 ++++++ src/utils/navigation/albumDetailNavigation.ts | 193 +++++++ .../navigation/restoreMainViewportScroll.ts | 62 ++ src/utils/share/applySharePaste.ts | 10 +- 61 files changed, 3455 insertions(+), 725 deletions(-) create mode 100644 src/components/tracks/SongBrowseSection.tsx rename src/{pages/Tracks.tsx => components/tracks/TracksPageChrome.tsx} (68%) create mode 100644 src/hooks/useAlbumBrowseScrollRestore.ts create mode 100644 src/hooks/useAlbumDetailBack.test.tsx create mode 100644 src/hooks/useAlbumDetailBack.ts create mode 100644 src/hooks/useAlbumGridBrowseFilters.ts create mode 100644 src/hooks/useArtistsBrowseFilters.ts create mode 100644 src/hooks/useArtistsBrowseScrollRestore.ts create mode 100644 src/hooks/useNavigateToAlbum.ts create mode 100644 src/hooks/useNavigateToArtist.ts create mode 100644 src/hooks/useSongBrowseList.ts rename src/pages/{AdvancedSearch.tsx => SearchBrowsePage.tsx} (56%) delete mode 100644 src/pages/SearchResults.tsx create mode 100644 src/store/advancedSearchSessionStore.ts create mode 100644 src/store/artistBrowseSessionStore.test.ts create mode 100644 src/store/artistBrowseSessionStore.ts create mode 100644 src/utils/navigation/advancedSearchScrollSnapshot.test.ts create mode 100644 src/utils/navigation/advancedSearchScrollSnapshot.ts create mode 100644 src/utils/navigation/albumDetailNavigation.test.ts create mode 100644 src/utils/navigation/albumDetailNavigation.ts create mode 100644 src/utils/navigation/restoreMainViewportScroll.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f53148bf..be047954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index 1f772752..ff9a8a36 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -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 diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index eba782b8..f078f4db 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -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() { } /> } /> - } /> + } /> } /> } /> } /> @@ -63,8 +61,8 @@ export default function AppRoutes() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 23d050e7..26fd1a20 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -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() { 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 ( diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index 0f1a5c98..145e54d9 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -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({ )}
-
diff --git a/src/components/AlbumRow.tsx b/src/components/AlbumRow.tsx index 68847117..edccb51e 100644 --- a/src/components/AlbumRow.tsx +++ b/src/components/AlbumRow.tsx @@ -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; + /** 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; diff --git a/src/components/ArtistCardLocal.tsx b/src/components/ArtistCardLocal.tsx index 0622ffc3..77348dd6 100644 --- a/src/components/ArtistCardLocal.tsx +++ b/src/components/ArtistCardLocal.tsx @@ -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 ( -
navigate(href)}> +
navigateToArtist(artist.id, linkQuery ? { search: linkQuery } : undefined)} + >
{coverRef ? ( void; } export default function ArtistRow({ title, artists, moreLink, moreText, artistLinkQuery, libraryResolve = false, + restoreScrollLeft, + onScrollLeftSnapshot, }: Props) { const scrollRef = useRef(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); diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 6fb782c6..07eed645 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -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 && } diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index b25330ef..91878aee 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -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 (
@@ -103,7 +105,7 @@ function SongRow({ song, showBpm }: Props) { { e.stopPropagation(); navigate(`/album/${song.albumId}`); }} + onClick={(e) => { e.stopPropagation(); navigateToAlbum(song.albumId!); }} title={song.album} >{song.album} ) : {song.album}} diff --git a/src/components/StarFilterButton.tsx b/src/components/StarFilterButton.tsx index bf30a164..c8044a32 100644 --- a/src/components/StarFilterButton.tsx +++ b/src/components/StarFilterButton.tsx @@ -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'; } diff --git a/src/components/VirtualSongList.tsx b/src/components/VirtualSongList.tsx index 43d6f248..316fec25 100644 --- a/src/components/VirtualSongList.tsx +++ b/src/components/VirtualSongList.tsx @@ -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 { - 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([]); - 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 => { - 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 ( -
- {title &&

{title}

} -
-
- - setQuery(e.target.value)} - /> - {query && ( - - )} -
-
- {songs.length > 0 && ( - {t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''} - )} -
-
- - {showEmptyBrowse ? ( -
- {emptyBrowseText ?? t('tracks.browseUnsupported')} -
- ) : ( - - )} -
+ { void browse.loadMore(); }} + /> ); } diff --git a/src/components/artistDetail/ArtistDetailHero.tsx b/src/components/artistDetail/ArtistDetailHero.tsx index 52acf327..14d627e6 100644 --- a/src/components/artistDetail/ArtistDetailHero.tsx +++ b/src/components/artistDetail/ArtistDetailHero.tsx @@ -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(null); const downloadArtist = useOfflineStore(s => s.downloadArtist); @@ -67,7 +67,7 @@ export default function ArtistDetailHero({ <> + )} +
+
+ {songs.length > 0 && ( + {t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''} + )} +
+
+ + {showEmptyBrowse ? ( +
+ {emptyBrowseText ?? t('tracks.browseUnsupported')} +
+ ) : ( + + )} + + ); +} diff --git a/src/pages/Tracks.tsx b/src/components/tracks/TracksPageChrome.tsx similarity index 68% rename from src/pages/Tracks.tsx rename to src/components/tracks/TracksPageChrome.tsx index 5e372c2c..7ae10fee 100644 --- a/src/pages/Tracks.tsx +++ b/src/components/tracks/TracksPageChrome.tsx @@ -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(null); const [heroLoading, setHeroLoading] = useState(false); - const [random, setRandom] = useState([]); const [randomLoading, setRandomLoading] = useState(true); - const [rated, setRated] = useState([]); 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 ( -
+ <> {!perfFlags.disableMainstageStickyHeader && (
@@ -132,7 +138,7 @@ export default function Tracks() { hero.artistId && navigate(`/artist/${hero.artistId}`)} + onClick={() => hero.artistId && navigateToArtist(hero.artistId)} >{hero.artist} {hero.album && ( <> @@ -140,22 +146,16 @@ export default function Tracks() { hero.albumId && navigate(`/album/${hero.albumId}`)} + onClick={() => hero.albumId && navigateToAlbum(hero.albumId)} >{hero.album} )}

- -
+ ); } diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index a20bf056..61199c22 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', diff --git a/src/constants/appScroll.ts b/src/constants/appScroll.ts index 81b8e427..9992fd84 100644 --- a/src/constants/appScroll.ts +++ b/src/constants/appScroll.ts @@ -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> = { '/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 = { + 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]; +} diff --git a/src/hooks/useAlbumBrowseData.ts b/src/hooks/useAlbumBrowseData.ts index d1ae32c0..90380afc 100644 --- a/src/hooks/useAlbumBrowseData.ts +++ b/src/hooks/useAlbumBrowseData.ts @@ -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([]); const [loading, setLoading] = useState(true); @@ -154,6 +157,7 @@ export function useAlbumBrowseData({ ], getScrollRoot, scrollRootEl, + restoreDisplayCount, }); const displayAlbums = useMemo(() => { diff --git a/src/hooks/useAlbumBrowseFilters.ts b/src/hooks/useAlbumBrowseFilters.ts index 45105b32..b1d19eee 100644 --- a/src/hooks/useAlbumBrowseFilters.ts +++ b/src/hooks/useAlbumBrowseFilters.ts @@ -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, + 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 { + const snapshotRef = useRef({ scrollTop: 0, displayCount: 0 }); + useAlbumBrowseScrollSnapshotSync(snapshotRef, scrollBodyEl, displayCount); + return snapshotRef; +} + +export function useAlbumBrowseFilters( + serverId: string, + scrollSnapshotRef?: RefObject, +) { const navigationType = useNavigationType(); + const location = useLocation(); const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId)); const setBrowseSort = useAlbumBrowseSessionStore(s => s.setSort); const [selectedGenres, setSelectedGenres] = useState(() => - 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(() => - 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(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); diff --git a/src/hooks/useAlbumBrowseScrollRestore.ts b/src/hooks/useAlbumBrowseScrollRestore.ts new file mode 100644 index 00000000..8a74a9de --- /dev/null +++ b/src/hooks/useAlbumBrowseScrollRestore.ts @@ -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(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 }; +} diff --git a/src/hooks/useAlbumDetailBack.test.tsx b/src/hooks/useAlbumDetailBack.test.tsx new file mode 100644 index 00000000..d063f88a --- /dev/null +++ b/src/hooks/useAlbumDetailBack.test.tsx @@ -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(); + return { + ...mod, + navigateAlbumDetailBack: vi.fn(mod.navigateAlbumDetailBack), + }; +}); + +function detailWrapper(initialState: unknown) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + + + + ); + }; +} + +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(); + }); +}); diff --git a/src/hooks/useAlbumDetailBack.ts b/src/hooks/useAlbumDetailBack.ts new file mode 100644 index 00000000..b1c4bf89 --- /dev/null +++ b/src/hooks/useAlbumDetailBack.ts @@ -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; +} diff --git a/src/hooks/useAlbumGridBrowseFilters.ts b/src/hooks/useAlbumGridBrowseFilters.ts new file mode 100644 index 00000000..d8a42d00 --- /dev/null +++ b/src/hooks/useAlbumGridBrowseFilters.ts @@ -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, + gridSnapshotRef?: RefObject, +) { + const navigationType = useNavigationType(); + const location = useLocation(); + const initialState = returnStateForNavigation(serverId, surface, navigationType, location.state); + + const [selectedGenres, setSelectedGenres] = useState(() => 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, + }; +} diff --git a/src/hooks/useArtistsBrowseFilters.ts b/src/hooks/useArtistsBrowseFilters.ts new file mode 100644 index 00000000..4eba4431 --- /dev/null +++ b/src/hooks/useArtistsBrowseFilters.ts @@ -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, +) { + 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( + () => returnStateForNavigation(serverId, navigationType, location.state).viewMode, + ); + + const browseStateRef = useRef(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, + }; +} diff --git a/src/hooks/useArtistsBrowseScrollRestore.ts b/src/hooks/useArtistsBrowseScrollRestore.ts new file mode 100644 index 00000000..46d7f07e --- /dev/null +++ b/src/hooks/useArtistsBrowseScrollRestore.ts @@ -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(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 }; +} diff --git a/src/hooks/useClientSliceInfiniteScroll.ts b/src/hooks/useClientSliceInfiniteScroll.ts index 7713e288..864610af 100644 --- a/src/hooks/useClientSliceInfiniteScroll.ts +++ b/src/hooks/useClientSliceInfiniteScroll.ts @@ -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, diff --git a/src/hooks/useMainScrollingIndicator.ts b/src/hooks/useMainScrollingIndicator.ts index 887e26b2..ad434768 100644 --- a/src/hooks/useMainScrollingIndicator.ts +++ b/src/hooks/useMainScrollingIndicator.ts @@ -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('.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); diff --git a/src/hooks/useNavigateToAlbum.ts b/src/hooks/useNavigateToAlbum.ts new file mode 100644 index 00000000..1b802ea7 --- /dev/null +++ b/src/hooks/useNavigateToAlbum.ts @@ -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], + ); +} diff --git a/src/hooks/useNavigateToArtist.ts b/src/hooks/useNavigateToArtist.ts new file mode 100644 index 00000000..5a108e68 --- /dev/null +++ b/src/hooks/useNavigateToArtist.ts @@ -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], + ); +} diff --git a/src/hooks/useOrbitSongRowBehavior.ts b/src/hooks/useOrbitSongRowBehavior.ts index 8ab6ac29..e8279782 100644 --- a/src/hooks/useOrbitSongRowBehavior.ts +++ b/src/hooks/useOrbitSongRowBehavior.ts @@ -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: diff --git a/src/hooks/usePlaybackLibraryNavigate.ts b/src/hooks/usePlaybackLibraryNavigate.ts index a09c9b88..144fef6d 100644 --- a/src/hooks/usePlaybackLibraryNavigate.ts +++ b/src/hooks/usePlaybackLibraryNavigate.ts @@ -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]); } diff --git a/src/hooks/useShareSearch.ts b/src/hooks/useShareSearch.ts index 6525db8d..ea1a4409 100644 --- a/src/hooks/useShareSearch.ts +++ b/src/hooks/useShareSearch.ts @@ -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; diff --git a/src/hooks/useSongBrowseList.ts b/src/hooks/useSongBrowseList.ts new file mode 100644 index 00000000..9d6af37b --- /dev/null +++ b/src/hooks/useSongBrowseList.ts @@ -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 { + 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(() => 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 => { + 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, + }; +} diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index 91506814..3890ea55 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -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({ scrollTop: 0, displayCount: 0 }); + const restoreDisplayCountRef = useRef( + 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(null); const maxGridCols = useAuthStore(s => clampLibraryGridMaxColumns(s.libraryGridMaxColumns)); const [albumCellDisplayCssPx, setAlbumCellDisplayCssPx] = useState(140); @@ -388,39 +419,55 @@ export default function Albums() { {visibleEmptyMessage}
) : ( - <> - {!perfFlags.disableMainstageGridCards && ( -
- a.id} - rowVariant="album" - disableVirtualization={perfFlags.disableMainstageVirtualLists} - layoutSignal={displayAlbums.length} - scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID} - warmGridCovers={albumGridWarmCovers( - albumCellDisplayCssPx, - Math.min(displayAlbums.length, Math.max(albumGridCols * 6, 48)), - )} - renderItem={a => ( - - )} - /> +
+
+ {!perfFlags.disableMainstageGridCards && ( +
+ a.id} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={displayAlbums.length} + scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID} + warmGridCovers={albumGridWarmCovers( + albumCellDisplayCssPx, + Math.min(displayAlbums.length, Math.max(albumGridCols * 6, 48)), + )} + renderItem={a => ( + + )} + /> +
+ )} + {hasMore && ( + + )} +
+ {isScrollRestorePending && ( +
+
)} - {hasMore && ( - - )} - +
)}
diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index 6e450a0c..4c68d23e 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -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({ scrollTop: 0, visibleCount: 0 }); + const restoreVisibleCountRef = useRef( + 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, ]} > +
+
{loading &&
} {!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')}
)} +
+ {isScrollRestorePending && ( +
+
+
+ )} +
); diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx index 4b039d09..5e49ea08 100644 --- a/src/pages/NewReleases.tsx +++ b/src/pages/NewReleases.tsx @@ -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([]); - const [hasMore, setHasMore] = useState(true); - const [selectedGenres, setSelectedGenres] = useState([]); + const scrollSnapshotRef = useRef({ scrollTop: 0, displayCount: 0 }); + const gridSnapshotRef = useRef({ albums: [], hasMore: true }); + const { + selectedGenres, + setSelectedGenres, + initialAlbums, + initialHasMore, + } = useAlbumGridBrowseFilters(serverId, 'new-releases', scrollSnapshotRef, gridSnapshotRef); + const restoringSessionRef = useRef(initialAlbums != null); + + const [albums, setAlbums] = useState(() => 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 (
@@ -218,7 +260,8 @@ export default function NewReleases() { {t('common.libraryEmpty')}
) : ( - <> +
+
a.id} @@ -241,7 +284,22 @@ export default function NewReleases() { {!filtered && hasMore && ( )} - +
+ {isScrollRestorePending && ( +
+
+
+ )} +
)}
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx index 50e27a34..237319b9 100644 --- a/src/pages/RandomAlbums.tsx +++ b/src/pages/RandomAlbums.tsx @@ -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([]); - const [loading, setLoading] = useState(true); - const [selectedGenres, setSelectedGenres] = useState([]); + const navigate = useNavigate(); + const location = useLocation(); + + const scrollSnapshotRef = useRef({ scrollTop: 0, displayCount: 0 }); + const gridSnapshotRef = useRef({ albums: [], hasMore: false }); + const { + selectedGenres, + setSelectedGenres, + initialAlbums, + } = useAlbumGridBrowseFilters(serverId, 'random-albums', scrollSnapshotRef, gridSnapshotRef); + const restoringSessionRef = useRef(initialAlbums != null); + + const [albums, setAlbums] = useState(() => initialAlbums ?? []); + const [loading, setLoading] = useState(() => initialAlbums == null); const loadingRef = useRef(false); const filtered = selectedGenres.length > 0; + const { + scrollBodyEl, + bindScrollBody: bindRandomAlbumsScrollBody, + } = useInpageScrollViewport(); const [selectionMode, setSelectionMode] = useState(false); const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums); @@ -219,90 +243,165 @@ export default function RandomAlbums() { mixMinRatingArtist, ]); - // Keep a ref so the effect closure is always fresh without re-triggering the - // effect on every `load` reference change. The effect must NOT list `load` as a - // dep — Zustand rehydration changes deps (e.g. mixMinRatingFilterEnabled) and - // recreates `load`, which would otherwise double-fire on every page visit and - // show a different random batch ~1.5 s after the first one. const loadRef = useRef(load); loadRef.current = load; - useEffect(() => { loadRef.current(selectedGenres); }, [selectedGenres]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (restoringSessionRef.current) return; + loadRef.current(selectedGenres); + }, [selectedGenres]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleRefresh = useCallback(() => { + if (scrollBodyEl) { + scrollBodyEl.scrollTop = 0; + scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false })); + } + scrollSnapshotRef.current.scrollTop = 0; + load(selectedGenres); + }, [scrollBodyEl, load, selectedGenres]); + + gridSnapshotRef.current = { albums, hasMore: false }; + useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, albums.length); + + const { isScrollRestorePending } = useAlbumBrowseScrollRestore({ + serverId, + surface: 'random-albums', + scrollBodyEl, + displayAlbumsLength: albums.length, + loading, + loadingMore: false, + hasMore: false, + loadMore: () => {}, + }); + + useLayoutEffect(() => { + if (!isScrollRestorePending && restoringSessionRef.current) { + restoringSessionRef.current = false; + } + }, [isScrollRestorePending]); + + useEffect(() => { + if (isScrollRestorePending || !readAlbumBrowseRestore(location.state)) return; + navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null }); + }, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]); + + const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ + filtered, + selectionMode, + selectedGenres, + ]); return ( -
-
-

- {selectionMode && selectedIds.size > 0 - ? t('albums.selectionCount', { count: selectedIds.size }) - : t('randomAlbums.title')} -

-
- {selectionMode && selectedIds.size > 0 ? ( - <> - - - - ) : ( - <> - - - - )} - +
+
+
+

+ {selectionMode && selectedIds.size > 0 + ? t('albums.selectionCount', { count: selectedIds.size }) + : t('randomAlbums.title')} +

+
+ {selectionMode && selectedIds.size > 0 ? ( + <> + + + + ) : ( + <> + + + + )} + +
- {loading && albums.length === 0 ? ( -
-
-
- ) : !loading && albums.length === 0 ? ( -
- {t('common.libraryEmpty')} -
- ) : ( - a.id} - rowVariant="album" - disableVirtualization={perfFlags.disableMainstageVirtualLists} - layoutSignal={albums.length} - warmGridCovers={albumGridWarmCovers()} - renderItem={a => ( - - )} - /> - )} + + {loading && albums.length === 0 ? ( +
+
+
+ ) : !loading && albums.length === 0 ? ( +
+ {t('common.libraryEmpty')} +
+ ) : ( +
+
+ a.id} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={albums.length} + scrollRootId={RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID} + warmGridCovers={albumGridWarmCovers()} + renderItem={a => ( + + )} + /> +
+ {isScrollRestorePending && ( +
+
+
+ )} +
+ )} +
); } diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/SearchBrowsePage.tsx similarity index 56% rename from src/pages/AdvancedSearch.tsx rename to src/pages/SearchBrowsePage.tsx index b74350a4..bc1a6876 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/SearchBrowsePage.tsx @@ -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, + 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('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(() => restoreStash?.resultType ?? 'all'); + const [starredOnly, setStarredOnly] = useState(() => restoreStash?.starredOnly ?? false); const [genres, setGenres] = useState([]); - const [results, setResults] = useState(null); + const [results, setResults] = useState(() => restoreStash?.results ?? null); const starredOverrides = usePlayerStore(s => s.starredOverrides); const filteredResults = useMemo(() => { 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(null); - const [songsServerOffset, setSongsServerOffset] = useState(0); - const [songsHasMore, setSongsHasMore] = useState(false); + const [activeSearch, setActiveSearch] = useState(() => 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( + 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({ + 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 ( -
+
+
+
+ {showTracksChrome ? ( + <> + + {!perfFlags.disableMainstageVirtualLists && ( + { void songBrowse.loadMore(); }} + /> + )} + + ) : ( + <>

- - {t('search.advanced')} + {showAdvancedPanel ? ( + <> + + {t('search.advanced')} + + ) : ( + <> + + {query.trim() ? t('search.resultsFor', { query }) : t('search.title')} + + )}

+ {showAdvancedPanel && ( + <> {/* ── Filter panel ──────────────────────────────────────── */}
@@ -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} />
@@ -622,9 +1027,11 @@ export default function AdvancedSearch() {
+ + )} {/* ── Results ───────────────────────────────────────────── */} - {!hasSearched ? ( + {showAdvancedPanel && !hasSearched ? (
{t('search.advancedEmpty')}
@@ -633,26 +1040,58 @@ export default function AdvancedSearch() {
) : total === 0 ? ( -
{t('search.advancedNoResults')}
+
+ {basicSearchMode && query.trim() + ? t('search.noResults', { query }) + : t('search.advancedNoResults')} +
) : (
{filteredResults && filteredResults.artists.length > 0 && ( +
0 + ? artistRowScrollLeftRestoreRef.current + : undefined + } + onScrollLeftSnapshot={(left) => { + artistRowScrollLeftRef.current = left; + }} /> +
)} {filteredResults && filteredResults.albums.length > 0 && ( +
0 + ? albumRowScrollLeftRestoreRef.current + : undefined + } + onScrollLeftSnapshot={(left) => { + albumRowScrollLeftRef.current = left; + }} /> +
)} {filteredResults && filteredResults.songs.length > 0 && ( @@ -676,6 +1115,25 @@ export default function AdvancedSearch() { )}
)} + + )} + +
+
+ {isLeaveRestorePending && ( +
+
+
+ )}
); } diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx deleted file mode 100644 index b4230353..00000000 --- a/src/pages/SearchResults.tsx +++ /dev/null @@ -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(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 ( -
-
-

- - {query ? t('search.resultsFor', { query }) : t('search.title')} -

-
- - {loading && ( -
- )} - - {!loading && query && !hasResults && ( -
{t('search.noResults', { query })}
- )} - - {!loading && results && ( - <> - {results.artists.length > 0 && ( - - )} - - {results.albums.length > 0 && ( - - )} - - {results.songs.length > 0 && ( -
-

{t('search.songs')}

- -
- )} - - )} -
- ); -} diff --git a/src/store/advancedSearchSessionStore.ts b/src/store/advancedSearchSessionStore.ts new file mode 100644 index 00000000..82fa78c8 --- /dev/null +++ b/src/store/advancedSearchSessionStore.ts @@ -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; + +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((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'; +} diff --git a/src/store/albumBrowseSessionStore.test.ts b/src/store/albumBrowseSessionStore.test.ts index 128a3fe6..d21c5391 100644 --- a/src/store/albumBrowseSessionStore.test.ts +++ b/src/store/albumBrowseSessionStore.test.ts @@ -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(); + }); +}); diff --git a/src/store/albumBrowseSessionStore.ts b/src/store/albumBrowseSessionStore.ts index b0e20da7..370584a2 100644 --- a/src/store/albumBrowseSessionStore.ts +++ b/src/store/albumBrowseSessionStore.ts @@ -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; - /** Stashed when leaving Albums → album detail; consumed on POP back. */ - returnStashByServer: Record; + /** Stashed when leaving a browse surface → album detail; consumed after scroll restore. */ + returnStashByKey: Record; 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((set, get) => ({ sortByServer: {}, - returnStashByServer: {}, + returnStashByKey: {}, setSort: (serverId, sort) => { if (!serverId) return; @@ -57,45 +91,47 @@ export const useAlbumBrowseSessionStore = create((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, 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); +} diff --git a/src/store/artistBrowseSessionStore.test.ts b/src/store/artistBrowseSessionStore.test.ts new file mode 100644 index 00000000..2a42c196 --- /dev/null +++ b/src/store/artistBrowseSessionStore.test.ts @@ -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 }); + }); +}); diff --git a/src/store/artistBrowseSessionStore.ts b/src/store/artistBrowseSessionStore.ts new file mode 100644 index 00000000..39823494 --- /dev/null +++ b/src/store/artistBrowseSessionStore.ts @@ -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; + stashReturnState: (serverId: string, state: ArtistBrowseReturnState) => void; + clearReturnStash: (serverId: string) => void; + peekReturnStash: (serverId: string) => ArtistBrowseReturnState | null; +} + +export const useArtistBrowseSessionStore = create((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'; +} diff --git a/src/styles/tracks/shared-songrow-used-by-tracks-hub-searchresults-advancedsearch.css b/src/styles/tracks/shared-songrow-used-by-tracks-hub-searchresults-advancedsearch.css index eeacd79a..62d33cc5 100644 --- a/src/styles/tracks/shared-songrow-used-by-tracks-hub-searchresults-advancedsearch.css +++ b/src/styles/tracks/shared-songrow-used-by-tracks-hub-searchresults-advancedsearch.css @@ -1,4 +1,4 @@ -/* ─ Shared SongRow (used by Tracks Hub, SearchResults, AdvancedSearch) ─ */ +/* ─ Shared SongRow (used by Tracks hub, SearchBrowsePage) ─ */ .song-list-row { display: grid; diff --git a/src/utils/library/advancedSearchLocal.test.ts b/src/utils/library/advancedSearchLocal.test.ts index 3db0b4be..c5af07b9 100644 --- a/src/utils/library/advancedSearchLocal.test.ts +++ b/src/utils/library/advancedSearchLocal.test.ts @@ -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[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(); + }); +}); diff --git a/src/utils/library/advancedSearchLocal.ts b/src/utils/library/advancedSearchLocal.ts index 469732ba..2f8c29e6 100644 --- a/src/utils/library/advancedSearchLocal.ts +++ b/src/utils/library/advancedSearchLocal.ts @@ -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 { + 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 { + const query = yearOnlyAlbumBrowseQuery(opts); + if (!query) return []; + const page = await fetchAlbumBrowseNetwork(query, 0, pageSize); + return page.albums; +} diff --git a/src/utils/library/browseTextSearch.ts b/src/utils/library/browseTextSearch.ts index bee53252..925a63ad 100644 --- a/src/utils/library/browseTextSearch.ts +++ b/src/utils/library/browseTextSearch.ts @@ -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'; diff --git a/src/utils/navigation/advancedSearchScrollSnapshot.test.ts b/src/utils/navigation/advancedSearchScrollSnapshot.test.ts new file mode 100644 index 00000000..0d438f26 --- /dev/null +++ b/src/utils/navigation/advancedSearchScrollSnapshot.test.ts @@ -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(); + }); +}); diff --git a/src/utils/navigation/advancedSearchScrollSnapshot.ts b/src/utils/navigation/advancedSearchScrollSnapshot.ts new file mode 100644 index 00000000..94e83104 --- /dev/null +++ b/src/utils/navigation/advancedSearchScrollSnapshot.ts @@ -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('[data-advanced-search-album-row] .album-grid'); + return albumGrid?.scrollLeft ?? 0; +} + +function readArtistRowScrollLeftFromDom(): number { + const artistGrid = document.querySelector('[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; + 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 }; +} diff --git a/src/utils/navigation/albumDetailNavigation.test.ts b/src/utils/navigation/albumDetailNavigation.test.ts new file mode 100644 index 00000000..6d0b2cc4 --- /dev/null +++ b/src/utils/navigation/albumDetailNavigation.test.ts @@ -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'); + }); +}); diff --git a/src/utils/navigation/albumDetailNavigation.ts b/src/utils/navigation/albumDetailNavigation.ts new file mode 100644 index 00000000..6b8b101c --- /dev/null +++ b/src/utils/navigation/albumDetailNavigation.ts @@ -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, +): 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, +): string { + const existing = readAlbumDetailReturnTo(location.state); + const onDetail = isAlbumDetailPath(location.pathname) || isArtistDetailPath(location.pathname); + return onDetail && existing ? existing : buildReturnToFromLocation(location); +} + +function saveSearchLeaveIfNeeded( + location: Pick, +): void { + if (isAdvancedSearchPath(location.pathname)) { + saveAdvancedSearchLeaveSnapshot(); + } +} + +export function navigateToAlbumDetail( + navigate: NavigateFunction, + location: Pick, + 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, + 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, + 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, + 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); +} diff --git a/src/utils/navigation/restoreMainViewportScroll.ts b/src/utils/navigation/restoreMainViewportScroll.ts new file mode 100644 index 00000000..dcdf1f3c --- /dev/null +++ b/src/utils/navigation/restoreMainViewportScroll.ts @@ -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; +} diff --git a/src/utils/share/applySharePaste.ts b/src/utils/share/applySharePaste.ts index 63e96842..27372e18 100644 --- a/src/utils/share/applySharePaste.ts +++ b/src/utils/share/applySharePaste.ts @@ -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, ): Promise { const { servers, isLoggedIn, setActiveServer } = useAuthStore.getState(); if (!isLoggedIn) { @@ -134,7 +136,11 @@ export async function applySharePastePayload( showToast(t('sharePaste.albumUnavailable'), 5000, 'error'); return; } - navigate(`/album/${payload.id}`); + if (location) { + navigateToAlbumDetail(navigate, location, payload.id); + } else { + navigate(`/album/${payload.id}`); + } showToast(t('sharePaste.openedAlbum'), 3000, 'info'); return; }