feat: library browse navigation — restore filters, scroll, and search on back (#936)

* feat(albums): restore scroll position when returning from album detail

Save in-page scroll and grid depth when opening an album from All Albums,
then on browser back restore filters, preload enough rows, and apply scroll
before revealing the grid to avoid a visible jump from the top.

* feat(albums): smart back navigation and restore browse session on return

Remember the originating route when opening album detail, restore All Albums
filters/scroll on back (including explicit returnTo navigation), hide the grid
until scroll is applied, and fix filters being cleared after albumBrowseRestore
state is stripped from the location.

* feat(search): restore Advanced Search session when returning from album

Stash filters and results when leaving /search/advanced for album detail,
then restore them on back navigation (POP or returnTo with advancedSearchRestore).

* feat(search): restore Advanced Search album row scroll on return from album

Save horizontal scrollLeft when opening an album from Advanced Search and
reapply it via AlbumRow on return; keep main viewport at top. Add snapshot
helpers and session stash fields; extend AlbumRow with restoreScrollLeft.

* feat(search): restore Advanced Search session scroll and artist return path

Save filters, main scroll, and album-row scroll when leaving to album or
artist; restore without flash via hidden-until-ready. Add useNavigateToArtist,
restoreMainViewportScroll helper, and AppShell scroll reset only on pathname change.

* feat(search): speed up Advanced Search back restore and year-only queries

Reveal the page right after sync scroll instead of blocking on full viewport
and album-row restore. Retry local index without the ready gate during sync;
use open-ended byYear params on network fallback, matching All Albums browse.

* feat(search): restore Advanced Search artist row scroll on back

Save leave snapshot when opening artist from ArtistCardLocal, persist
artistRowScrollLeft in session stash, and keep row restore targets in refs
so horizontal scroll survives finishLeaveRestoreUi like vertical scrollTop.

* feat(nav): route mouse back on album/artist detail like UI back

Trap history popstate when returnTo is set and call navigateAlbumDetailBack
so browser/mouse back restores browse/search session the same way as the header button.

* feat(artists): restore browse filters and scroll on back from artist detail

Persist Artists page filters, view settings, and vertical scroll when opening
an artist and returning via UI or mouse back, matching All Albums behavior.

* feat(search): unify quick and advanced search; fix LiveSearch dismiss on Enter

Serve /search and /search/advanced from one page with shared session restore
and scroll snapshot. Reset live search overlay state when navigating to full
search so the dropdown does not linger or reopen.

* feat(tracks): unify with search session and restore scroll on back

Route /tracks through AdvancedSearch with shared leave snapshot, song
browse stash, and main-viewport scroll restore when returning from album
or artist detail. Wait for hero/rails layout before applying scroll.

* refactor(search): rename AdvancedSearch page to SearchBrowsePage

The shared route shell serves /search, /search/advanced, and /tracks;
rename the page component and refresh stale file references in comments.

* feat(albums): restore New Releases and Random Albums on back from detail

Unify album grid leave-restore with surface-scoped session stash, live scroll
snapshot sync, and in-page scroll for Random Albums. Keep the same random
batch when returning from album detail; Refresh fetches anew and scrolls up.

* docs: add CHANGELOG and credits for PR #936
This commit is contained in:
cucadmuh
2026-06-01 03:11:27 +03:00
committed by GitHub
parent 77ecc8ddfe
commit d3e5a6b704
61 changed files with 3455 additions and 725 deletions
+4
View File
@@ -59,6 +59,8 @@ export type UseAlbumBrowseDataArgs = {
getScrollRoot?: () => HTMLElement | null;
/** Bumps when the scroll root mounts so the sentinel observer can rebind. */
scrollRootEl?: HTMLElement | null;
/** Bootstrap visible slice size when restoring scroll after album-detail back. */
restoreDisplayCount?: number;
};
function resolveHasMoreAfterPage(
@@ -86,6 +88,7 @@ export function useAlbumBrowseData({
starredOverrides,
getScrollRoot,
scrollRootEl,
restoreDisplayCount,
}: UseAlbumBrowseDataArgs) {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
@@ -154,6 +157,7 @@ export function useAlbumBrowseData({
],
getScrollRoot,
scrollRootEl,
restoreDisplayCount,
});
const displayAlbums = useMemo(() => {
+88 -20
View File
@@ -1,51 +1,104 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigationType, type NavigationType } from 'react-router-dom';
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import {
ALBUMS_INPAGE_SCROLL_VIEWPORT_ID,
readInpageScrollTop,
} from '../constants/appScroll';
import {
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
type AlbumBrowseCompFilter,
type AlbumBrowseReturnFilters,
type AlbumBrowseSurface,
albumBrowseSortForServer,
albumBrowseSurfaceForPath,
isAlbumDetailPath,
useAlbumBrowseSessionStore,
} from '../store/albumBrowseSessionStore';
import type { AlbumBrowseSort } from '../utils/library/browseTextSearch';
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
const ALBUMS_SURFACE: AlbumBrowseSurface = 'albums';
function returnFiltersForNavigation(
serverId: string,
navigationType: NavigationType,
locationState: unknown,
): AlbumBrowseReturnFilters {
if (navigationType !== 'POP' || !serverId) return DEFAULT_ALBUM_BROWSE_RETURN_FILTERS;
if (!shouldRestoreAlbumBrowseSession(navigationType, locationState) || !serverId) {
return DEFAULT_ALBUM_BROWSE_RETURN_FILTERS;
}
return (
useAlbumBrowseSessionStore.getState().peekReturnStash(serverId)
useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, ALBUMS_SURFACE)
?? DEFAULT_ALBUM_BROWSE_RETURN_FILTERS
);
}
export function useAlbumBrowseFilters(serverId: string) {
export type AlbumBrowseScrollSnapshot = {
scrollTop: number;
displayCount: number;
};
/** Keep scroll snapshot in sync with the in-page viewport (not only on React re-renders). */
export function useAlbumBrowseScrollSnapshotSync(
snapshotRef: RefObject<AlbumBrowseScrollSnapshot>,
scrollBodyEl: HTMLElement | null,
displayCount: number,
): void {
useEffect(() => {
snapshotRef.current.displayCount = displayCount;
}, [displayCount, snapshotRef]);
useEffect(() => {
if (!scrollBodyEl) return;
const syncScrollTop = () => {
snapshotRef.current.scrollTop = scrollBodyEl.scrollTop;
};
syncScrollTop();
scrollBodyEl.addEventListener('scroll', syncScrollTop, { passive: true });
return () => scrollBodyEl.removeEventListener('scroll', syncScrollTop);
}, [scrollBodyEl, snapshotRef]);
}
export function useAlbumBrowseScrollSnapshotRef(
scrollBodyEl: HTMLElement | null,
displayCount: number,
): RefObject<AlbumBrowseScrollSnapshot> {
const snapshotRef = useRef<AlbumBrowseScrollSnapshot>({ scrollTop: 0, displayCount: 0 });
useAlbumBrowseScrollSnapshotSync(snapshotRef, scrollBodyEl, displayCount);
return snapshotRef;
}
export function useAlbumBrowseFilters(
serverId: string,
scrollSnapshotRef?: RefObject<AlbumBrowseScrollSnapshot>,
) {
const navigationType = useNavigationType();
const location = useLocation();
const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId));
const setBrowseSort = useAlbumBrowseSessionStore(s => s.setSort);
const [selectedGenres, setSelectedGenres] = useState<string[]>(() =>
returnFiltersForNavigation(serverId, navigationType).selectedGenres,
returnFiltersForNavigation(serverId, navigationType, location.state).selectedGenres,
);
const [yearFrom, setYearFrom] = useState(() =>
returnFiltersForNavigation(serverId, navigationType).yearFrom,
returnFiltersForNavigation(serverId, navigationType, location.state).yearFrom,
);
const [yearTo, setYearTo] = useState(() =>
returnFiltersForNavigation(serverId, navigationType).yearTo,
returnFiltersForNavigation(serverId, navigationType, location.state).yearTo,
);
const [compFilter, setCompFilter] = useState<AlbumBrowseCompFilter>(() =>
returnFiltersForNavigation(serverId, navigationType).compFilter,
returnFiltersForNavigation(serverId, navigationType, location.state).compFilter,
);
const [starredOnly, setStarredOnly] = useState(() =>
returnFiltersForNavigation(serverId, navigationType).starredOnly,
returnFiltersForNavigation(serverId, navigationType, location.state).starredOnly,
);
const [losslessOnly, setLosslessOnly] = useState(() =>
returnFiltersForNavigation(serverId, navigationType).losslessOnly,
returnFiltersForNavigation(serverId, navigationType, location.state).losslessOnly,
);
const filtersRef = useRef<AlbumBrowseReturnFilters>(DEFAULT_ALBUM_BROWSE_RETURN_FILTERS);
/** Guards against re-reset when `albumBrowseRestore` is cleared from location state. */
const restoredFromStashRef = useRef(false);
filtersRef.current = {
selectedGenres,
yearFrom,
@@ -55,11 +108,16 @@ export function useAlbumBrowseFilters(serverId: string) {
losslessOnly,
};
useEffect(() => {
restoredFromStashRef.current = false;
}, [serverId]);
useEffect(() => {
if (!serverId) return;
if (navigationType === 'POP') {
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId);
if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) {
restoredFromStashRef.current = true;
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, ALBUMS_SURFACE);
if (restored) {
setSelectedGenres(restored.selectedGenres);
setYearFrom(restored.yearFrom);
@@ -67,31 +125,41 @@ export function useAlbumBrowseFilters(serverId: string) {
setCompFilter(restored.compFilter);
setStarredOnly(restored.starredOnly);
setLosslessOnly(restored.losslessOnly);
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId);
}
return;
}
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId);
if (restoredFromStashRef.current) return;
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, ALBUMS_SURFACE);
setSelectedGenres([]);
setYearFrom('');
setYearTo('');
setCompFilter('all');
setStarredOnly(false);
setLosslessOnly(false);
}, [serverId, navigationType]);
}, [serverId, navigationType, location.state]);
useEffect(() => {
return () => {
if (!serverId) return;
const path = window.location.pathname;
if (isAlbumDetailPath(path)) {
useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, filtersRef.current);
} else if (path !== '/albums') {
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId);
const snapshot = scrollSnapshotRef?.current;
const scrollTop = Math.max(
readInpageScrollTop(ALBUMS_INPAGE_SCROLL_VIEWPORT_ID),
snapshot?.scrollTop ?? 0,
);
useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, ALBUMS_SURFACE, {
...filtersRef.current,
scrollTop,
displayCount: snapshot?.displayCount,
});
} else if (albumBrowseSurfaceForPath(path) !== ALBUMS_SURFACE) {
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, ALBUMS_SURFACE);
}
};
}, [serverId]);
}, [serverId, scrollSnapshotRef]);
const onSortChange = (value: AlbumBrowseSort) => setBrowseSort(serverId, value);
+100
View File
@@ -0,0 +1,100 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import {
peekAlbumBrowseScrollRestore,
type AlbumBrowseSurface,
useAlbumBrowseSessionStore,
} from '../store/albumBrowseSessionStore';
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
type PendingScroll = {
scrollTop: number;
displayCount: number;
};
export type UseAlbumBrowseScrollRestoreArgs = {
serverId: string;
surface: AlbumBrowseSurface;
scrollBodyEl: HTMLElement | null;
displayAlbumsLength: number;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
loadMore: () => void;
};
export type UseAlbumBrowseScrollRestoreResult = {
/** True until saved scroll position is applied — hide the grid meanwhile. */
isScrollRestorePending: boolean;
};
function readPendingScrollRestore(
serverId: string,
surface: AlbumBrowseSurface,
navigationType: NavigationType,
locationState: unknown,
): PendingScroll | null {
if (!shouldRestoreAlbumBrowseSession(navigationType, locationState) || !serverId) return null;
return peekAlbumBrowseScrollRestore(serverId, surface);
}
/**
* When returning to an album grid browse surface via browser/app back from album
* detail, restore the in-page grid scroll position saved in `albumBrowseSessionStore`.
*/
export function useAlbumBrowseScrollRestore({
serverId,
surface,
scrollBodyEl,
displayAlbumsLength,
loading,
loadingMore,
hasMore,
loadMore,
}: UseAlbumBrowseScrollRestoreArgs): UseAlbumBrowseScrollRestoreResult {
const navigationType = useNavigationType();
const location = useLocation();
const initRef = useRef(false);
const pendingRef = useRef<PendingScroll | null>(null);
const doneRef = useRef(false);
if (!initRef.current) {
initRef.current = true;
pendingRef.current = readPendingScrollRestore(serverId, surface, navigationType, location.state);
}
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
() => readPendingScrollRestore(serverId, surface, navigationType, location.state) !== null,
);
useLayoutEffect(() => {
const pending = pendingRef.current;
if (doneRef.current || !pending) return;
if (!scrollBodyEl || loading) return;
const needsMore = displayAlbumsLength < pending.displayCount && hasMore;
if (needsMore) {
if (!loadingMore) loadMore();
return;
}
if (loadingMore) return;
scrollBodyEl.scrollTop = pending.scrollTop;
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
pendingRef.current = null;
doneRef.current = true;
setIsScrollRestorePending(false);
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
}, [
scrollBodyEl,
displayAlbumsLength,
loading,
loadingMore,
hasMore,
loadMore,
serverId,
surface,
]);
return { isScrollRestorePending };
}
+65
View File
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { useAlbumDetailBack } from './useAlbumDetailBack';
import { navigateAlbumDetailBack } from '../utils/navigation/albumDetailNavigation';
vi.mock('../utils/navigation/albumDetailNavigation', async (importOriginal) => {
const mod = await importOriginal<typeof import('../utils/navigation/albumDetailNavigation')>();
return {
...mod,
navigateAlbumDetailBack: vi.fn(mod.navigateAlbumDetailBack),
};
});
function detailWrapper(initialState: unknown) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<MemoryRouter initialEntries={[{ pathname: '/album/al-1', state: initialState }]}>
<Routes>
<Route path="/album/:id" element={children} />
</Routes>
</MemoryRouter>
);
};
}
describe('useAlbumDetailBack', () => {
beforeEach(() => {
vi.mocked(navigateAlbumDetailBack).mockClear();
});
it('routes browser back through navigateAlbumDetailBack when returnTo exists', () => {
const pushStateSpy = vi.spyOn(window.history, 'pushState');
renderHook(() => useAlbumDetailBack(), {
wrapper: detailWrapper({ returnTo: '/search/advanced' }),
});
expect(pushStateSpy).toHaveBeenCalled();
act(() => {
window.dispatchEvent(new PopStateEvent('popstate'));
});
expect(navigateAlbumDetailBack).toHaveBeenCalledTimes(1);
pushStateSpy.mockRestore();
});
it('does not trap browser back when returnTo is missing', () => {
const pushStateSpy = vi.spyOn(window.history, 'pushState');
renderHook(() => useAlbumDetailBack(), {
wrapper: detailWrapper(null),
});
expect(pushStateSpy).not.toHaveBeenCalled();
act(() => {
window.dispatchEvent(new PopStateEvent('popstate'));
});
expect(navigateAlbumDetailBack).not.toHaveBeenCalled();
pushStateSpy.mockRestore();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
navigateAlbumDetailBack,
readAlbumDetailReturnTo,
} from '../utils/navigation/albumDetailNavigation';
/** Leave album/artist detail for the page that opened it (or history back as fallback). */
export function useAlbumDetailBack(fallback = '/') {
const navigate = useNavigate();
const location = useLocation();
const locationStateRef = useRef(location.state);
locationStateRef.current = location.state;
const goBack = useCallback(
() => navigateAlbumDetailBack(navigate, location, fallback),
[navigate, location, fallback],
);
useEffect(() => {
const returnTo = readAlbumDetailReturnTo(locationStateRef.current);
if (!returnTo) return;
const trapUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.history.pushState({ psysonicDetailBackTrap: true }, '', trapUrl);
const onPopState = () => {
navigateAlbumDetailBack(
navigate,
{ state: locationStateRef.current },
fallback,
);
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, [navigate, location.pathname, location.search, location.hash, fallback]);
return goBack;
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import {
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
type AlbumBrowseReturnFilters,
type AlbumBrowseSurface,
albumBrowseSurfaceForPath,
isAlbumDetailPath,
useAlbumBrowseSessionStore,
} from '../store/albumBrowseSessionStore';
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
import {
inpageScrollViewportIdForSurface,
readInpageScrollTop,
} from '../constants/appScroll';
import type { AlbumBrowseScrollSnapshot } from './useAlbumBrowseFilters';
export type AlbumGridBrowseSnapshot = {
albums: SubsonicAlbum[];
hasMore: boolean;
};
function sameGenreSelection(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function returnStateForNavigation(
serverId: string,
surface: AlbumBrowseSurface,
navigationType: NavigationType,
locationState: unknown,
): AlbumBrowseReturnFilters {
if (!shouldRestoreAlbumBrowseSession(navigationType, locationState) || !serverId) {
return DEFAULT_ALBUM_BROWSE_RETURN_FILTERS;
}
return (
useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, surface)
?? DEFAULT_ALBUM_BROWSE_RETURN_FILTERS
);
}
/** Genre-filter album grid pages (New Releases, Random Albums) — shared leave-restore. */
export function useAlbumGridBrowseFilters(
serverId: string,
surface: AlbumBrowseSurface,
scrollSnapshotRef?: RefObject<AlbumBrowseScrollSnapshot>,
gridSnapshotRef?: RefObject<AlbumGridBrowseSnapshot>,
) {
const navigationType = useNavigationType();
const location = useLocation();
const initialState = returnStateForNavigation(serverId, surface, navigationType, location.state);
const [selectedGenres, setSelectedGenres] = useState<string[]>(() => initialState.selectedGenres);
const restoredFromStashRef = useRef(false);
const filtersRef = useRef({ selectedGenres });
filtersRef.current = { selectedGenres };
useEffect(() => {
restoredFromStashRef.current = false;
}, [serverId, surface]);
useEffect(() => {
if (!serverId) return;
if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) {
restoredFromStashRef.current = true;
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, surface);
if (restored && !sameGenreSelection(restored.selectedGenres, filtersRef.current.selectedGenres)) {
setSelectedGenres(restored.selectedGenres);
}
return;
}
if (restoredFromStashRef.current) return;
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
setSelectedGenres([]);
}, [serverId, surface, navigationType, location.state]);
useEffect(() => {
return () => {
if (!serverId) return;
const path = window.location.pathname;
if (isAlbumDetailPath(path)) {
const scrollSnapshot = scrollSnapshotRef?.current;
const gridSnapshot = gridSnapshotRef?.current;
const viewportId = inpageScrollViewportIdForSurface(surface);
const scrollTop = Math.max(
readInpageScrollTop(viewportId),
scrollSnapshot?.scrollTop ?? 0,
);
useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, surface, {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: filtersRef.current.selectedGenres,
scrollTop,
displayCount: gridSnapshot?.albums.length ?? scrollSnapshot?.displayCount,
albums: gridSnapshot?.albums,
hasMore: gridSnapshot?.hasMore,
});
} else if (albumBrowseSurfaceForPath(path) !== surface) {
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
}
};
}, [serverId, surface, scrollSnapshotRef, gridSnapshotRef]);
return {
selectedGenres,
setSelectedGenres,
initialAlbums: initialState.albums ?? null,
initialHasMore: initialState.hasMore,
};
}
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import {
DEFAULT_ARTIST_BROWSE_RETURN_STATE,
type ArtistBrowseReturnState,
type ArtistBrowseViewMode,
isArtistsBrowsePath,
useArtistBrowseSessionStore,
} from '../store/artistBrowseSessionStore';
import { isArtistDetailPath } from '../store/albumBrowseSessionStore';
import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation';
export type ArtistBrowseScrollSnapshot = {
scrollTop: number;
visibleCount: number;
};
function returnStateForNavigation(
serverId: string,
navigationType: NavigationType,
locationState: unknown,
): ArtistBrowseReturnState {
if (!shouldRestoreArtistBrowseSession(navigationType, locationState) || !serverId) {
return DEFAULT_ARTIST_BROWSE_RETURN_STATE;
}
return (
useArtistBrowseSessionStore.getState().peekReturnStash(serverId)
?? DEFAULT_ARTIST_BROWSE_RETURN_STATE
);
}
export function useArtistsBrowseFilters(
serverId: string,
scrollSnapshotRef?: RefObject<ArtistBrowseScrollSnapshot>,
) {
const navigationType = useNavigationType();
const location = useLocation();
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const [filter, setFilter] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).filter,
);
const [letterFilter, setLetterFilter] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).letterFilter,
);
const [starredOnly, setStarredOnly] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).starredOnly,
);
const [viewMode, setViewMode] = useState<ArtistBrowseViewMode>(
() => returnStateForNavigation(serverId, navigationType, location.state).viewMode,
);
const browseStateRef = useRef<ArtistBrowseReturnState>(DEFAULT_ARTIST_BROWSE_RETURN_STATE);
const restoredFromStashRef = useRef(false);
const showArtistImages = useAuthStore(s => s.showArtistImages);
browseStateRef.current = {
filter,
letterFilter,
starredOnly,
viewMode,
showArtistImages,
};
useEffect(() => {
restoredFromStashRef.current = false;
}, [serverId]);
useEffect(() => {
if (!serverId) return;
if (shouldRestoreArtistBrowseSession(navigationType, location.state)) {
restoredFromStashRef.current = true;
const restored = useArtistBrowseSessionStore.getState().peekReturnStash(serverId);
if (restored) {
setFilter(restored.filter);
setLetterFilter(restored.letterFilter);
setStarredOnly(restored.starredOnly);
setViewMode(restored.viewMode);
setShowArtistImages(restored.showArtistImages);
}
return;
}
if (restoredFromStashRef.current) return;
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
setFilter('');
setLetterFilter(DEFAULT_ARTIST_BROWSE_RETURN_STATE.letterFilter);
setStarredOnly(false);
setViewMode('grid');
}, [serverId, navigationType, location.state, setShowArtistImages]);
useEffect(() => {
return () => {
if (!serverId) return;
const path = window.location.pathname;
if (isArtistDetailPath(path)) {
const snapshot = scrollSnapshotRef?.current;
useArtistBrowseSessionStore.getState().stashReturnState(serverId, {
...browseStateRef.current,
scrollTop: snapshot?.scrollTop,
visibleCount: snapshot?.visibleCount,
});
} else if (!isArtistsBrowsePath(path)) {
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
}
};
}, [serverId, scrollSnapshotRef]);
return {
filter,
setFilter,
letterFilter,
setLetterFilter,
starredOnly,
setStarredOnly,
viewMode,
setViewMode,
};
}
@@ -0,0 +1,91 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import {
peekArtistBrowseScrollRestore,
useArtistBrowseSessionStore,
} from '../store/artistBrowseSessionStore';
import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation';
type PendingScroll = {
scrollTop: number;
visibleCount: number;
};
export type UseArtistsBrowseScrollRestoreArgs = {
serverId: string;
scrollBodyEl: HTMLElement | null;
visibleCount: number;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
loadMore: () => void;
};
export type UseArtistsBrowseScrollRestoreResult = {
isScrollRestorePending: boolean;
};
function readPendingScrollRestore(
serverId: string,
navigationType: NavigationType,
locationState: unknown,
): PendingScroll | null {
if (!shouldRestoreArtistBrowseSession(navigationType, locationState) || !serverId) return null;
return peekArtistBrowseScrollRestore(serverId);
}
/** Restore Artists in-page scroll after returning from artist detail. */
export function useArtistsBrowseScrollRestore({
serverId,
scrollBodyEl,
visibleCount,
loading,
loadingMore,
hasMore,
loadMore,
}: UseArtistsBrowseScrollRestoreArgs): UseArtistsBrowseScrollRestoreResult {
const navigationType = useNavigationType();
const location = useLocation();
const initRef = useRef(false);
const pendingRef = useRef<PendingScroll | null>(null);
const doneRef = useRef(false);
if (!initRef.current) {
initRef.current = true;
pendingRef.current = readPendingScrollRestore(serverId, navigationType, location.state);
}
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
() => readPendingScrollRestore(serverId, navigationType, location.state) !== null,
);
useLayoutEffect(() => {
const pending = pendingRef.current;
if (doneRef.current || !pending) return;
if (!scrollBodyEl || loading) return;
const needsMore = visibleCount < pending.visibleCount && hasMore;
if (needsMore) {
if (!loadingMore) loadMore();
return;
}
if (loadingMore) return;
scrollBodyEl.scrollTop = pending.scrollTop;
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
pendingRef.current = null;
doneRef.current = true;
setIsScrollRestorePending(false);
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
}, [
scrollBodyEl,
visibleCount,
loading,
loadingMore,
hasMore,
loadMore,
serverId,
]);
return { isScrollRestorePending };
}
+13 -3
View File
@@ -8,8 +8,15 @@ export type UseClientSliceInfiniteScrollArgs = {
getScrollRoot?: () => HTMLElement | null;
scrollRootEl?: HTMLElement | null;
rootMargin?: string;
/** One-shot bootstrap when restoring browse scroll (All Albums back navigation). */
restoreDisplayCount?: number;
};
function sliceVisibleCount(pageSize: number, restoreDisplayCount?: number): number {
if (restoreDisplayCount == null || restoreDisplayCount <= 0) return pageSize;
return Math.max(pageSize, restoreDisplayCount);
}
export type UseClientSliceInfiniteScrollResult = {
visibleCount: number;
loadingMore: boolean;
@@ -29,8 +36,11 @@ export function useClientSliceInfiniteScroll({
getScrollRoot,
scrollRootEl,
rootMargin = '200px',
restoreDisplayCount,
}: UseClientSliceInfiniteScrollArgs): UseClientSliceInfiniteScrollResult {
const [visibleCount, setVisibleCount] = useState(pageSize);
const [visibleCount, setVisibleCount] = useState(() =>
sliceVisibleCount(pageSize, restoreDisplayCount),
);
const [loadingMore, setLoadingMore] = useState(false);
const loadPendingRef = useRef(false);
@@ -47,10 +57,10 @@ export function useClientSliceInfiniteScroll({
}, [visibleCount]);
useEffect(() => {
setVisibleCount(pageSize);
setVisibleCount(sliceVisibleCount(pageSize, restoreDisplayCount));
// resetDeps is intentionally spread into the dep array.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, ...resetDeps]);
}, [pageSize, restoreDisplayCount, ...resetDeps]);
const bindSentinel = useInpageScrollSentinel({
active: true,
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import {
APP_MAIN_SCROLL_VIEWPORT_ID,
MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH,
mainRouteInpageScrollViewportId,
} from '../constants/appScroll';
const SCROLL_IDLE_MS = 180;
@@ -25,7 +25,7 @@ export function useMainScrollingIndicator(pathname: string): boolean {
if (appViewport) viewports.add(appViewport);
const nowPlayingViewport = document.querySelector<HTMLElement>('.np-main__viewport');
if (nowPlayingViewport) viewports.add(nowPlayingViewport);
const inpageId = MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH[pathname];
const inpageId = mainRouteInpageScrollViewportId(pathname);
if (inpageId) {
const inpageVp = document.getElementById(inpageId);
if (inpageVp) viewports.add(inpageVp);
+15
View File
@@ -0,0 +1,15 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { navigateToAlbumDetail } from '../utils/navigation/albumDetailNavigation';
/** Navigate to album detail, remembering the current page for the back button. */
export function useNavigateToAlbum() {
const navigate = useNavigate();
const location = useLocation();
return useCallback(
(albumId: string, opts?: { search?: string }) => {
navigateToAlbumDetail(navigate, location, albumId, opts);
},
[navigate, location],
);
}
+15
View File
@@ -0,0 +1,15 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { navigateToArtistDetail } from '../utils/navigation/albumDetailNavigation';
/** Navigate to artist detail, remembering the current page for the back button. */
export function useNavigateToArtist() {
const navigate = useNavigate();
const location = useLocation();
return useCallback(
(artistId: string, opts?: { search?: string }) => {
navigateToArtistDetail(navigate, location, artistId, opts);
},
[navigate, location],
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { showToast } from '../utils/ui/toast';
/**
* Shared behaviour for song rows that in "normal mode" swallow a full list
* into the queue on single-click (AlbumDetail, PlaylistDetail, Favorites,
* ArtistDetail top-songs, SearchResults, RandomMix, AdvancedSearch).
* ArtistDetail top-songs, SearchBrowsePage, RandomMix).
*
* In an active Orbit session this is too destructive — the list would
* propagate to every guest's player. Instead:
+5 -3
View File
@@ -1,12 +1,14 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { ensurePlaybackServerActive } from '../utils/playback/playbackServer';
import { navigatePathWithAlbumReturnTo } from '../utils/navigation/albumDetailNavigation';
/** Navigate to library routes for the playing queue — switches to {@link queueServerId} when needed. */
export function usePlaybackLibraryNavigate() {
const navigate = useNavigate();
const location = useLocation();
return useCallback(async (path: string) => {
await ensurePlaybackServerActive();
navigate(path);
}, [navigate]);
navigatePathWithAlbumReturnTo(navigate, location, path);
}, [navigate, location]);
}
+4 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from './useNavigateToAlbum';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import {
@@ -16,6 +17,7 @@ import { useShareSearchPreview } from './useShareSearchPreview';
export function useShareSearch(query: string, onSuccess?: () => void) {
const { t } = useTranslation();
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const servers = useAuthStore(s => s.servers);
const activeServerId = useAuthStore(s => s.activeServerId);
const shareMatch = useMemo(() => parseShareSearchText(query), [query]);
@@ -52,9 +54,9 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
const openShareAlbum = useCallback(() => {
if (shareMatch?.type !== 'album' || !preview.shareAlbum) return;
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
navigate(`/album/${preview.shareAlbum.id}`);
navigateToAlbum(preview.shareAlbum.id);
onSuccess?.();
}, [shareMatch, preview.shareAlbum, navigate, t, onSuccess]);
}, [shareMatch, preview.shareAlbum, navigateToAlbum, t, onSuccess]);
const openShareArtist = useCallback(() => {
if (shareMatch?.type !== 'artist' || !preview.shareArtist) return;
+196
View File
@@ -0,0 +1,196 @@
import { searchSongsPaged } from '../api/subsonicSearch';
import type { SubsonicSong } from '../api/subsonicTypes';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ndListSongs } from '../api/navidromeBrowse';
import { runLocalSongBrowse } from '../utils/library/advancedSearchLocal';
import {
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
BROWSE_TEXT_DEBOUNCE_RACE_MS,
browseRaceCountsSongs,
loadMoreLocalBrowseSongs,
raceBrowseWithLocalFallback,
runLocalBrowseSongPage,
runNetworkBrowseSongPage,
} from '../utils/library/browseTextSearch';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
const PAGE_SIZE = 50;
async function fetchBrowseAllPage(
serverId: string | null | undefined,
offset: number,
): Promise<SubsonicSong[]> {
const local = await runLocalSongBrowse(serverId, offset, PAGE_SIZE);
if (local) return local;
try {
return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC');
} catch {
return searchSongsPaged('', PAGE_SIZE, offset);
}
}
export type SongBrowseListRestore = {
query: string;
songs: SubsonicSong[];
offset: number;
hasMore: boolean;
localSearchMode: boolean;
browseUnsupported: boolean;
hasSearched: boolean;
};
type UseSongBrowseListArgs = {
enabled: boolean;
initialRestore?: SongBrowseListRestore | null;
};
/** Tracks hub song browse — all-library paging or filtered text search. */
export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseListArgs) {
const serverId = useAuthStore(s => s.activeServerId);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const [query, setQuery] = useState(() => initialRestore?.query ?? '');
const [debouncedQuery, setDebouncedQuery] = useState(() => initialRestore?.query.trim() ?? '');
const [songs, setSongs] = useState<SubsonicSong[]>(() => initialRestore?.songs ?? []);
const [offset, setOffset] = useState(() => initialRestore?.offset ?? 0);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(() => initialRestore?.hasMore ?? true);
const [browseUnsupported, setBrowseUnsupported] = useState(
() => initialRestore?.browseUnsupported ?? false,
);
const [hasSearched, setHasSearched] = useState(() => initialRestore?.hasSearched ?? false);
const requestSeqRef = useRef(0);
const localSearchModeRef = useRef(initialRestore?.localSearchMode ?? false);
const skipInitialFetchRef = useRef(initialRestore != null);
useEffect(() => {
if (!enabled) return;
const debounceMs = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS;
const timer = window.setTimeout(() => setDebouncedQuery(query.trim()), debounceMs);
return () => window.clearTimeout(timer);
}, [query, indexEnabled, enabled]);
const fetchSongPage = useCallback(
async (q: string, pageOffset: number, isStale: () => boolean): Promise<SubsonicSong[]> => {
if (q === '') {
return fetchBrowseAllPage(serverId, pageOffset);
}
if (pageOffset === 0 && indexEnabled && serverId) {
const winner = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseSongPage(serverId, q, 0, PAGE_SIZE),
() => runNetworkBrowseSongPage(q, 0, PAGE_SIZE),
{
surface: 'tracks_browse',
query: q,
indexEnabled,
counts: browseRaceCountsSongs,
},
);
if (isStale()) return [];
if (winner) {
localSearchModeRef.current = winner.source === 'local';
return winner.result ?? [];
}
localSearchModeRef.current = false;
return (await runNetworkBrowseSongPage(q, 0, PAGE_SIZE)) ?? [];
}
if (localSearchModeRef.current && serverId) {
try {
return await loadMoreLocalBrowseSongs(serverId, q, pageOffset, PAGE_SIZE);
} catch {
return [];
}
}
return (await runNetworkBrowseSongPage(q, pageOffset, PAGE_SIZE)) ?? [];
},
[indexEnabled, serverId],
);
useEffect(() => {
if (!enabled) return;
if (skipInitialFetchRef.current) {
skipInitialFetchRef.current = false;
return;
}
let cancelled = false;
setSongs([]);
setOffset(0);
setHasMore(true);
setBrowseUnsupported(false);
localSearchModeRef.current = false;
const seq = ++requestSeqRef.current;
const isStale = () => cancelled || seq !== requestSeqRef.current;
setLoading(true);
void (async () => {
try {
const page = await fetchSongPage(debouncedQuery, 0, isStale);
if (isStale()) return;
if (page.length === 0) {
setHasMore(false);
if (debouncedQuery === '') setBrowseUnsupported(true);
} else {
setSongs(page);
setOffset(page.length);
if (page.length < PAGE_SIZE) setHasMore(false);
}
setHasSearched(true);
} catch {
if (!isStale()) setHasMore(false);
} finally {
if (!isStale()) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [debouncedQuery, fetchSongPage, enabled]);
const loadMore = useCallback(async () => {
if (!enabled || loading || !hasMore) return;
setLoading(true);
const seq = ++requestSeqRef.current;
const isStale = () => seq !== requestSeqRef.current;
try {
const page = await fetchSongPage(debouncedQuery, offset, isStale);
if (isStale()) return;
if (page.length === 0) {
setHasMore(false);
} else {
setSongs(prev => {
const seen = new Set(prev.map(s => s.id));
const merged = [...prev];
for (const s of page) if (!seen.has(s.id)) merged.push(s);
return merged;
});
setOffset(o => o + page.length);
if (page.length < PAGE_SIZE) setHasMore(false);
}
} catch {
setHasMore(false);
} finally {
if (!isStale()) setLoading(false);
}
}, [enabled, loading, hasMore, debouncedQuery, offset, fetchSongPage]);
return {
query,
setQuery,
songs,
offset,
loading,
hasMore,
browseUnsupported,
hasSearched,
localSearchMode: localSearchModeRef.current,
loadMore,
};
}