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; }