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
+61 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
@@ -6,8 +6,11 @@ import {
resolveTrackCoverArtId,
runLocalAdvancedSearch,
runLocalSongBrowse,
runNetworkAdvancedYearAlbums,
trackToSong,
tryRunLocalAdvancedSearch,
} from './advancedSearchLocal';
import * as albumBrowseNetwork from './albumBrowseNetwork';
const opts = (over: Partial<Parameters<typeof runLocalAdvancedSearch>[1]> = {}) => ({
query: '',
@@ -263,3 +266,60 @@ describe('runLocalSongBrowse', () => {
expect(await runLocalSongBrowse('s1', 0, 50)).toBeNull();
});
});
describe('tryRunLocalAdvancedSearch', () => {
beforeEach(() => {
useLibraryIndexStore.setState({ masterEnabled: true });
});
it('retries without the ready gate when sync is still in progress', async () => {
onInvoke('library_get_status', () => ({
serverId: 's1',
libraryScope: '',
syncPhase: 'initial_sync',
localTrackCount: 100,
serverTrackCount: 1000,
capabilityFlags: 0,
libraryTier: 'unknown',
syncedAt: 0,
}));
let searchCalls = 0;
onInvoke('library_advanced_search', () => {
searchCalls += 1;
return {
source: 'local',
artists: [],
albums: [],
tracks: [],
totals: { artists: 0, albums: 0, tracks: 0 },
appliedFilters: ['year'],
};
});
const res = await tryRunLocalAdvancedSearch('s1', opts({ yearFrom: '2020' }), 100);
expect(res).not.toBeNull();
expect(searchCalls).toBe(1);
});
});
describe('runNetworkAdvancedYearAlbums', () => {
it('passes open-ended year bounds to album browse (not 1900…now defaults)', async () => {
const spy = vi.spyOn(albumBrowseNetwork, 'fetchAlbumBrowseNetwork').mockResolvedValue({
albums: [{
id: 'a1',
name: 'Al',
artist: 'Ar',
artistId: 'ar1',
songCount: 1,
duration: 100,
}],
hasMore: false,
});
await runNetworkAdvancedYearAlbums(opts({ yearTo: '1990' }), 100);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({ year: { to: 1990 } }),
0,
100,
);
spy.mockRestore();
});
});
+50 -2
View File
@@ -1,7 +1,7 @@
/**
* Advanced Search against the local library index (spec §5.13 / F2).
*
* Maps the AdvancedSearch UI inputs to a `library_advanced_search` request and
* Maps the SearchBrowsePage filter inputs to a `library_advanced_search` request and
* the response back to the Subsonic shapes the existing rows render. The sync
* engine stores each entity's original Subsonic JSON in `rawJson` (ADR-7), so
* that's preferred verbatim; the flat hot columns are a fallback when a row's
@@ -23,12 +23,17 @@ import {
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
import { search } from '../../api/subsonicSearch';
import { libraryScopeForServer } from '../../api/subsonicClient';
import { fetchAlbumBrowseNetwork } from './albumBrowseNetwork';
import type { AlbumBrowseQuery } from './albumBrowseTypes';
import { resolveAlbumYearBounds } from './albumYearFilter';
import { libraryIsReady } from './libraryReady';
import { logLibrarySearch, timed } from './libraryDevLog';
import { isLosslessSuffix } from './losslessFormats';
import { albumIsCompilation } from './albumCompilation';
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment';
export const ADVANCED_SEARCH_YEAR_ALBUM_LIMIT = 100;
export type AdvancedResultType = 'all' | 'artists' | 'albums' | 'songs';
/** UI opts for Advanced Search — BPM/mood filters require local index. */
@@ -238,7 +243,7 @@ export function artistToArtist(ar: LibraryArtistDto): SubsonicArtist {
}
/**
* Network search3 path for Advanced Search free-text (mirrors AdvancedSearch.tsx filters).
* Network search3 path for Advanced Search free-text (mirrors SearchBrowsePage.tsx filters).
*/
export async function runNetworkAdvancedTextSearch(
opts: LocalSearchOpts,
@@ -393,3 +398,46 @@ export async function loadMoreLocalSongs(
const resp = await libraryAdvancedSearch(req);
return resp.tracks.map(trackToSong);
}
/** Local index first; retry without the ready gate when sync is still catching up. */
export async function tryRunLocalAdvancedSearch(
serverId: string | null | undefined,
opts: LocalSearchOpts,
songsLimit: number,
suppressLog = false,
): Promise<LocalAdvancedSearchPage | null> {
const readyPage = await runLocalAdvancedSearch(
serverId,
opts,
songsLimit,
false,
true,
suppressLog,
);
if (readyPage) return readyPage;
return runLocalAdvancedSearch(serverId, opts, songsLimit, true, true, suppressLog);
}
function yearOnlyAlbumBrowseQuery(opts: LocalSearchOpts): AlbumBrowseQuery | null {
const { active, bounds } = resolveAlbumYearBounds(opts.yearFrom, opts.yearTo);
if (!active) return null;
return {
sort: 'alphabeticalByName',
genres: [],
year: bounds,
losslessOnly: !!opts.losslessOnly,
starredOnly: false,
compFilter: 'all',
};
}
/** Network fallback for year-only Advanced Search albums (open-ended year bounds). */
export async function runNetworkAdvancedYearAlbums(
opts: LocalSearchOpts,
pageSize = ADVANCED_SEARCH_YEAR_ALBUM_LIMIT,
): Promise<SubsonicAlbum[]> {
const query = yearOnlyAlbumBrowseQuery(opts);
if (!query) return [];
const page = await fetchAlbumBrowseNetwork(query, 0, pageSize);
return page.albums;
}
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Browse-page text search — local index vs network race (LiveSearch / AdvancedSearch pattern).
* Browse-page text search — local index vs network race (LiveSearch / SearchBrowsePage pattern).
*/
import { getStarred } from '../../api/subsonicStarRating';
import { search, searchSongsPaged } from '../../api/subsonicSearch';
@@ -0,0 +1,151 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
import {
clearAdvancedSearchLeaveSnapshots,
peekPersistedAdvancedSearchLeaveSnapshot,
readAdvancedSearchLeaveSnapshot,
registerAdvancedSearchLeaveScrollProvider,
registerAdvancedSearchSessionProvider,
resolveAdvancedSearchLeaveSnapshot,
saveAdvancedSearchLeaveSnapshot,
} from './advancedSearchScrollSnapshot';
import { useAdvancedSearchSessionStore } from '../../store/advancedSearchSessionStore';
describe('advancedSearchScrollSnapshot', () => {
afterEach(() => {
clearAdvancedSearchLeaveSnapshots();
useAdvancedSearchSessionStore.getState().clearReturnStash();
sessionStorage.clear();
document.body.innerHTML = '';
});
it('persists and peeks leave snapshot in sessionStorage', () => {
const viewport = document.createElement('div');
viewport.id = APP_MAIN_SCROLL_VIEWPORT_ID;
Object.defineProperty(viewport, 'scrollTop', { value: 640, writable: true });
document.body.appendChild(viewport);
saveAdvancedSearchLeaveSnapshot();
expect(peekPersistedAdvancedSearchLeaveSnapshot()).toEqual({
scrollTop: 640,
albumRowScrollLeft: 0,
artistRowScrollLeft: 0,
});
});
it('reads leave snapshot from provider merged with DOM', () => {
const viewport = document.createElement('div');
viewport.id = APP_MAIN_SCROLL_VIEWPORT_ID;
Object.defineProperty(viewport, 'scrollTop', { value: 512, writable: true });
document.body.appendChild(viewport);
const albumGrid = document.createElement('div');
albumGrid.className = 'album-grid';
Object.defineProperty(albumGrid, 'scrollLeft', { value: 80, writable: true });
const row = document.createElement('div');
row.setAttribute('data-advanced-search-album-row', '');
row.appendChild(albumGrid);
document.body.appendChild(row);
const unregister = registerAdvancedSearchLeaveScrollProvider(() => ({
scrollTop: 100,
albumRowScrollLeft: 45,
artistRowScrollLeft: 10,
}));
expect(readAdvancedSearchLeaveSnapshot()).toEqual({
scrollTop: 512,
albumRowScrollLeft: 80,
artistRowScrollLeft: 10,
});
unregister();
});
it('reads artist row scroll from DOM', () => {
const artistGrid = document.createElement('div');
artistGrid.className = 'album-grid';
Object.defineProperty(artistGrid, 'scrollLeft', { value: 120, writable: true });
const row = document.createElement('div');
row.setAttribute('data-advanced-search-artist-row', '');
row.appendChild(artistGrid);
document.body.appendChild(row);
expect(readAdvancedSearchLeaveSnapshot()).toEqual({
scrollTop: 0,
albumRowScrollLeft: 0,
artistRowScrollLeft: 120,
});
});
it('merges leave snapshot, sessionStorage, and stash scroll fields', () => {
useAdvancedSearchSessionStore.getState().setLeaveScrollSnapshot({
scrollTop: 300,
albumRowScrollLeft: 0,
artistRowScrollLeft: 0,
});
sessionStorage.setItem(
'psysonic:advanced-search-leave-v1',
JSON.stringify({ scrollTop: 100, albumRowScrollLeft: 80, artistRowScrollLeft: 55 }),
);
expect(resolveAdvancedSearchLeaveSnapshot({
query: '',
genre: '',
yearFrom: '',
yearTo: '',
bpmFrom: '',
bpmTo: '',
moodGroup: '',
losslessOnly: false,
resultType: 'all',
starredOnly: false,
results: null,
hasSearched: false,
activeSearch: null,
localMode: false,
songsServerOffset: 0,
songsHasMore: false,
genreNote: false,
basicSearchMode: false,
tracksBrowseMode: false,
tracksBrowseUnsupported: false,
scrollTop: 50,
albumRowScrollLeft: 20,
artistRowScrollLeft: 15,
})).toEqual({ scrollTop: 300, albumRowScrollLeft: 80, artistRowScrollLeft: 55 });
});
it('saves session stash together with leave snapshot on navigate away', () => {
const viewport = document.createElement('div');
viewport.id = APP_MAIN_SCROLL_VIEWPORT_ID;
Object.defineProperty(viewport, 'scrollTop', { value: 640, writable: true });
document.body.appendChild(viewport);
const unregister = registerAdvancedSearchSessionProvider(() => ({
query: 'rock',
genre: 'Jazz',
yearFrom: '',
yearTo: '',
bpmFrom: '',
bpmTo: '',
moodGroup: '',
losslessOnly: false,
resultType: 'all',
starredOnly: false,
results: null,
hasSearched: true,
activeSearch: null,
localMode: false,
songsServerOffset: 0,
songsHasMore: false,
genreNote: false,
basicSearchMode: false,
tracksBrowseMode: false,
}));
saveAdvancedSearchLeaveSnapshot();
expect(useAdvancedSearchSessionStore.getState().peekReturnStash()?.query).toBe('rock');
expect(useAdvancedSearchSessionStore.getState().peekReturnStash()?.genre).toBe('Jazz');
expect(useAdvancedSearchSessionStore.getState().peekReturnStash()?.scrollTop).toBe(640);
unregister();
});
});
@@ -0,0 +1,160 @@
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
import {
useAdvancedSearchSessionStore,
type AdvancedSearchSessionStash,
} from '../../store/advancedSearchSessionStore';
export type AdvancedSearchLeaveSnapshot = {
scrollTop: number;
albumRowScrollLeft: number;
artistRowScrollLeft: number;
};
const STORAGE_KEY = 'psysonic:advanced-search-leave-v1';
type LeaveScrollProvider = () => AdvancedSearchLeaveSnapshot;
type SessionProvider = () => AdvancedSearchSessionStash;
let leaveScrollProvider: LeaveScrollProvider | null = null;
let sessionProvider: SessionProvider | null = null;
let leavingAdvancedSearchForDetail = false;
export function registerAdvancedSearchLeaveScrollProvider(
provider: LeaveScrollProvider,
): () => void {
leaveScrollProvider = provider;
return () => {
if (leaveScrollProvider === provider) leaveScrollProvider = null;
};
}
export function registerAdvancedSearchSessionProvider(
provider: SessionProvider,
): () => void {
sessionProvider = provider;
return () => {
if (sessionProvider === provider) sessionProvider = null;
};
}
export function markAdvancedSearchLeavingForDetail(): void {
leavingAdvancedSearchForDetail = true;
}
export function consumeAdvancedSearchLeavingForDetail(): boolean {
const value = leavingAdvancedSearchForDetail;
leavingAdvancedSearchForDetail = false;
return value;
}
function readAlbumRowScrollLeftFromDom(): number {
const albumGrid = document.querySelector<HTMLElement>('[data-advanced-search-album-row] .album-grid');
return albumGrid?.scrollLeft ?? 0;
}
function readArtistRowScrollLeftFromDom(): number {
const artistGrid = document.querySelector<HTMLElement>('[data-advanced-search-artist-row] .album-grid');
return artistGrid?.scrollLeft ?? 0;
}
function readMainScrollTopFromDom(): number {
return document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTop ?? 0;
}
export function readAdvancedSearchLeaveSnapshot(): AdvancedSearchLeaveSnapshot {
const providerSnap = leaveScrollProvider?.();
return {
scrollTop: Math.max(readMainScrollTopFromDom(), providerSnap?.scrollTop ?? 0),
albumRowScrollLeft: Math.max(
readAlbumRowScrollLeftFromDom(),
providerSnap?.albumRowScrollLeft ?? 0,
),
artistRowScrollLeft: Math.max(
readArtistRowScrollLeftFromDom(),
providerSnap?.artistRowScrollLeft ?? 0,
),
};
}
function persistLeaveSnapshot(snapshot: AdvancedSearchLeaveSnapshot): void {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
/* quota / private mode */
}
}
export function peekPersistedAdvancedSearchLeaveSnapshot(): AdvancedSearchLeaveSnapshot | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<AdvancedSearchLeaveSnapshot>;
const scrollTop = typeof parsed.scrollTop === 'number' ? Math.max(0, parsed.scrollTop) : 0;
const albumRowScrollLeft = typeof parsed.albumRowScrollLeft === 'number'
? Math.max(0, parsed.albumRowScrollLeft)
: 0;
const artistRowScrollLeft = typeof parsed.artistRowScrollLeft === 'number'
? Math.max(0, parsed.artistRowScrollLeft)
: 0;
if (scrollTop <= 0 && albumRowScrollLeft <= 0 && artistRowScrollLeft <= 0) return null;
return { scrollTop, albumRowScrollLeft, artistRowScrollLeft };
} catch {
return null;
}
}
export function clearPersistedAdvancedSearchLeaveSnapshot(): void {
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
}
export function saveAdvancedSearchLeaveSnapshot(): AdvancedSearchLeaveSnapshot {
const snapshot = readAdvancedSearchLeaveSnapshot();
persistLeaveSnapshot(snapshot);
const store = useAdvancedSearchSessionStore.getState();
store.setLeaveScrollSnapshot(snapshot);
const session = sessionProvider?.();
if (session) {
store.stashReturnSession({
...session,
scrollTop: snapshot.scrollTop,
albumRowScrollLeft: snapshot.albumRowScrollLeft,
artistRowScrollLeft: snapshot.artistRowScrollLeft,
});
}
markAdvancedSearchLeavingForDetail();
return snapshot;
}
export function clearAdvancedSearchLeaveSnapshots(): void {
clearPersistedAdvancedSearchLeaveSnapshot();
useAdvancedSearchSessionStore.getState().clearLeaveScrollSnapshot();
}
/** Merge zustand leave snapshot, sessionStorage, and session stash scroll fields. */
export function resolveAdvancedSearchLeaveSnapshot(
stash: AdvancedSearchSessionStash | null,
): AdvancedSearchLeaveSnapshot | null {
const leave = useAdvancedSearchSessionStore.getState().peekLeaveScrollSnapshot();
const persisted = peekPersistedAdvancedSearchLeaveSnapshot();
const scrollTop = Math.max(
leave?.scrollTop ?? 0,
persisted?.scrollTop ?? 0,
stash?.scrollTop ?? 0,
);
const albumRowScrollLeft = Math.max(
leave?.albumRowScrollLeft ?? 0,
persisted?.albumRowScrollLeft ?? 0,
stash?.albumRowScrollLeft ?? 0,
);
const artistRowScrollLeft = Math.max(
leave?.artistRowScrollLeft ?? 0,
persisted?.artistRowScrollLeft ?? 0,
stash?.artistRowScrollLeft ?? 0,
);
if (scrollTop <= 0 && albumRowScrollLeft <= 0 && artistRowScrollLeft <= 0) return null;
return { scrollTop, albumRowScrollLeft, artistRowScrollLeft };
}
@@ -0,0 +1,171 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import type { NavigationType } from 'react-router-dom';
import {
buildReturnToFromLocation,
navigateAlbumDetailBack,
navigatePathWithAlbumReturnTo,
navigateToAlbumDetail,
navigateToArtistDetail,
readAlbumDetailReturnTo,
shouldRestoreAlbumBrowseSession,
shouldRestoreArtistBrowseSession,
shouldSkipMainScrollResetOnRouteChange,
} from './albumDetailNavigation';
import { useAdvancedSearchSessionStore } from '../../store/advancedSearchSessionStore';
describe('albumDetailNavigation', () => {
afterEach(() => {
useAdvancedSearchSessionStore.getState().clearLeaveScrollSnapshot();
});
it('reads returnTo from location state', () => {
expect(readAlbumDetailReturnTo({ returnTo: '/artist/abc' })).toBe('/artist/abc');
expect(readAlbumDetailReturnTo({ returnTo: 'bad' })).toBeNull();
expect(readAlbumDetailReturnTo(null)).toBeNull();
});
it('detects album browse restore navigation', () => {
expect(shouldRestoreAlbumBrowseSession('POP' as NavigationType, null)).toBe(true);
expect(shouldRestoreAlbumBrowseSession('PUSH' as NavigationType, { albumBrowseRestore: true })).toBe(true);
expect(shouldRestoreAlbumBrowseSession('PUSH' as NavigationType, null)).toBe(false);
});
it('navigates to album with returnTo snapshot', () => {
const navigate = vi.fn();
navigateToAlbumDetail(navigate, { pathname: '/artist/a', search: '', hash: '', state: null }, 'alb-1');
expect(navigate).toHaveBeenCalledWith('/album/alb-1', { state: { returnTo: '/artist/a' } });
});
it('preserves returnTo when opening a related album', () => {
const navigate = vi.fn();
navigateToAlbumDetail(
navigate,
{
pathname: '/album/parent',
search: '',
hash: '',
state: { returnTo: '/albums' },
},
'child',
);
expect(navigate).toHaveBeenCalledWith('/album/child', { state: { returnTo: '/albums' } });
});
it('routes album paths through returnTo helper', () => {
const navigate = vi.fn();
navigatePathWithAlbumReturnTo(
navigate,
{ pathname: '/', search: '', hash: '', state: null },
'/album/x?lossless=1',
);
expect(navigate).toHaveBeenCalledWith('/album/x?lossless=1', { state: { returnTo: '/' } });
});
it('navigates back to saved returnTo', () => {
const navigate = vi.fn();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/genres/Rock' } });
expect(navigate).toHaveBeenCalledWith('/genres/Rock', undefined);
});
it('flags All Albums return for browse restore', () => {
const navigate = vi.fn();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/albums' } });
expect(navigate).toHaveBeenCalledWith('/albums', { state: { albumBrowseRestore: true } });
});
it('flags New Releases and Random Albums return for browse restore', () => {
const navigate = vi.fn();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/new-releases' } });
expect(navigate).toHaveBeenCalledWith('/new-releases', { state: { albumBrowseRestore: true } });
navigate.mockClear();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/random/albums' } });
expect(navigate).toHaveBeenCalledWith('/random/albums', { state: { albumBrowseRestore: true } });
});
it('flags Artists browse return for session restore', () => {
const navigate = vi.fn();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/artists' } });
expect(navigate).toHaveBeenCalledWith('/artists', { state: { artistBrowseRestore: true } });
});
it('detects artist browse restore navigation', () => {
expect(shouldRestoreArtistBrowseSession('POP' as NavigationType, null)).toBe(true);
expect(shouldRestoreArtistBrowseSession('PUSH' as NavigationType, { artistBrowseRestore: true })).toBe(true);
expect(shouldRestoreArtistBrowseSession('PUSH' as NavigationType, null)).toBe(false);
});
it('flags Advanced Search return for session restore', () => {
const navigate = vi.fn();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/search/advanced?q=rock' } });
expect(navigate).toHaveBeenCalledWith('/search/advanced?q=rock', {
state: { advancedSearchRestore: true },
});
});
it('flags Search return for session restore (basic, advanced, and tracks paths)', () => {
const navigate = vi.fn();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/search?q=rock' } });
expect(navigate).toHaveBeenCalledWith('/search?q=rock', {
state: { advancedSearchRestore: true },
});
navigate.mockClear();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/search/advanced?q=rock' } });
expect(navigate).toHaveBeenCalledWith('/search/advanced?q=rock', {
state: { advancedSearchRestore: true },
});
navigate.mockClear();
navigateAlbumDetailBack(navigate, { state: { returnTo: '/tracks' } });
expect(navigate).toHaveBeenCalledWith('/tracks', {
state: { advancedSearchRestore: true },
});
});
it('navigates to artist with returnTo snapshot from Advanced Search', () => {
const navigate = vi.fn();
navigateToArtistDetail(
navigate,
{ pathname: '/search/advanced', search: '?q=rock', hash: '', state: null },
'art-1',
);
expect(navigate).toHaveBeenCalledWith('/artist/art-1', {
state: { returnTo: '/search/advanced?q=rock' },
});
});
it('skips main scroll reset when All Albums browse restore is pending', () => {
expect(shouldSkipMainScrollResetOnRouteChange('/albums', { albumBrowseRestore: true })).toBe(true);
expect(shouldSkipMainScrollResetOnRouteChange('/new-releases', { albumBrowseRestore: true })).toBe(true);
expect(shouldSkipMainScrollResetOnRouteChange('/random/albums', { albumBrowseRestore: true })).toBe(true);
expect(shouldSkipMainScrollResetOnRouteChange('/tracks', null)).toBe(false);
});
it('skips main scroll reset when Artists browse restore is pending', () => {
expect(shouldSkipMainScrollResetOnRouteChange('/artists', { artistBrowseRestore: true })).toBe(true);
});
it('skips main scroll reset when Advanced Search session restore is pending', () => {
expect(shouldSkipMainScrollResetOnRouteChange('/search/advanced', { advancedSearchRestore: true })).toBe(true);
});
it('skips main scroll reset when Search session restore is pending', () => {
expect(shouldSkipMainScrollResetOnRouteChange('/search', { advancedSearchRestore: true })).toBe(true);
expect(shouldSkipMainScrollResetOnRouteChange('/tracks', { advancedSearchRestore: true })).toBe(true);
});
it('skips main scroll reset when Advanced Search vertical scroll restore is pending', () => {
useAdvancedSearchSessionStore.getState().setLeaveScrollSnapshot({
scrollTop: 420,
albumRowScrollLeft: 0,
artistRowScrollLeft: 0,
});
expect(shouldSkipMainScrollResetOnRouteChange('/search/advanced', null)).toBe(true);
});
it('builds return path with search and hash', () => {
expect(buildReturnToFromLocation({
pathname: '/tracks',
search: '?q=test',
hash: '#top',
})).toBe('/tracks?q=test#top');
});
});
@@ -0,0 +1,193 @@
import type { Location, NavigateFunction, NavigationType } from 'react-router-dom';
import {
isAdvancedSearchPath,
useAdvancedSearchSessionStore,
} from '../../store/advancedSearchSessionStore';
import {
isAlbumDetailPath,
isArtistDetailPath,
} from '../../store/albumBrowseSessionStore';
import {
peekPersistedAdvancedSearchLeaveSnapshot,
saveAdvancedSearchLeaveSnapshot,
} from './advancedSearchScrollSnapshot';
export type AlbumDetailLocationState = {
returnTo?: string;
};
export type AlbumsBrowseRestoreLocationState = {
albumBrowseRestore?: boolean;
artistBrowseRestore?: boolean;
advancedSearchRestore?: boolean;
};
export function readAlbumDetailReturnTo(state: unknown): string | null {
const returnTo = (state as AlbumDetailLocationState | null)?.returnTo;
if (typeof returnTo !== 'string' || returnTo.length === 0) return null;
if (!returnTo.startsWith('/')) return null;
return returnTo;
}
export function readAlbumBrowseRestore(state: unknown): boolean {
return (state as AlbumsBrowseRestoreLocationState | null)?.albumBrowseRestore === true;
}
export function readArtistBrowseRestore(state: unknown): boolean {
return (state as AlbumsBrowseRestoreLocationState | null)?.artistBrowseRestore === true;
}
export function readAdvancedSearchRestore(state: unknown): boolean {
return (state as AlbumsBrowseRestoreLocationState | null)?.advancedSearchRestore === true;
}
export function buildReturnToFromLocation(
location: Pick<Location, 'pathname' | 'search' | 'hash'>,
): string {
return `${location.pathname}${location.search}${location.hash}`;
}
export function albumBrowseRestoreNavigationState(): AlbumsBrowseRestoreLocationState {
return { albumBrowseRestore: true };
}
export function artistBrowseRestoreNavigationState(): AlbumsBrowseRestoreLocationState {
return { artistBrowseRestore: true };
}
export function advancedSearchRestoreNavigationState(): AlbumsBrowseRestoreLocationState {
return { advancedSearchRestore: true };
}
export function shouldRestoreAdvancedSearchSession(
navigationType: NavigationType,
locationState: unknown,
): boolean {
return navigationType === 'POP' || readAdvancedSearchRestore(locationState);
}
export function shouldRestoreAlbumBrowseSession(
navigationType: NavigationType,
locationState: unknown,
): boolean {
return navigationType === 'POP' || readAlbumBrowseRestore(locationState);
}
export function shouldRestoreArtistBrowseSession(
navigationType: NavigationType,
locationState: unknown,
): boolean {
return navigationType === 'POP' || readArtistBrowseRestore(locationState);
}
/** Skip AppShell main scroll reset when a child route will restore scroll itself. */
export function shouldSkipMainScrollResetOnRouteChange(
pathname: string,
locationState: unknown,
): boolean {
if (readAlbumBrowseRestore(locationState)) return true;
if (readArtistBrowseRestore(locationState)) return true;
if (readAdvancedSearchRestore(locationState)) return true;
const leave = useAdvancedSearchSessionStore.getState().peekLeaveScrollSnapshot();
if ((leave?.scrollTop ?? 0) > 0) return true;
if (isAdvancedSearchPath(pathname)) {
const persisted = peekPersistedAdvancedSearchLeaveSnapshot();
if ((persisted?.scrollTop ?? 0) > 0) return true;
}
return false;
}
function isAlbumGridBrowseReturnPath(path: string): boolean {
return path === '/albums' || path.startsWith('/albums?')
|| path === '/new-releases' || path.startsWith('/new-releases?')
|| path === '/random/albums' || path.startsWith('/random/albums?');
}
function isSearchReturnPath(path: string): boolean {
return path === '/search' || path.startsWith('/search?')
|| path === '/search/advanced' || path.startsWith('/search/advanced?')
|| path === '/tracks' || path.startsWith('/tracks?');
}
function isArtistsBrowseReturnPath(path: string): boolean {
return path === '/artists' || path.startsWith('/artists?');
}
function browseReturnRestoreState(returnTo: string): AlbumsBrowseRestoreLocationState | undefined {
if (isAlbumGridBrowseReturnPath(returnTo)) return albumBrowseRestoreNavigationState();
if (isArtistsBrowseReturnPath(returnTo)) return artistBrowseRestoreNavigationState();
if (isSearchReturnPath(returnTo)) return advancedSearchRestoreNavigationState();
return undefined;
}
function buildReturnTo(
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
): string {
const existing = readAlbumDetailReturnTo(location.state);
const onDetail = isAlbumDetailPath(location.pathname) || isArtistDetailPath(location.pathname);
return onDetail && existing ? existing : buildReturnToFromLocation(location);
}
function saveSearchLeaveIfNeeded(
location: Pick<Location, 'pathname' | 'search' | 'hash'>,
): void {
if (isAdvancedSearchPath(location.pathname)) {
saveAdvancedSearchLeaveSnapshot();
}
}
export function navigateToAlbumDetail(
navigate: NavigateFunction,
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
albumId: string,
opts?: { search?: string },
): void {
saveSearchLeaveIfNeeded(location);
const returnTo = buildReturnTo(location);
const raw = opts?.search ?? '';
const qs = raw ? (raw.startsWith('?') ? raw : `?${raw}`) : '';
navigate(`/album/${albumId}${qs}`, { state: { returnTo } satisfies AlbumDetailLocationState });
}
export function navigateToArtistDetail(
navigate: NavigateFunction,
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
artistId: string,
opts?: { search?: string },
): void {
saveSearchLeaveIfNeeded(location);
const returnTo = buildReturnTo(location);
const raw = opts?.search ?? '';
const qs = raw ? (raw.startsWith('?') ? raw : `?${raw}`) : '';
navigate(`/artist/${artistId}${qs}`, { state: { returnTo } satisfies AlbumDetailLocationState });
}
/** Route any path; album detail links get a `returnTo` snapshot in location state. */
export function navigatePathWithAlbumReturnTo(
navigate: NavigateFunction,
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
path: string,
): void {
const albumMatch = path.match(/^\/album\/([^/?#]+)(\?[^#]*)?/);
if (!albumMatch) {
navigate(path);
return;
}
const [, albumId, search = ''] = albumMatch;
navigateToAlbumDetail(navigate, location, albumId, { search });
}
export function navigateAlbumDetailBack(
navigate: NavigateFunction,
location: Pick<Location, 'state'>,
fallback = '/',
): void {
const returnTo = readAlbumDetailReturnTo(location.state);
if (returnTo) {
const restoreState = browseReturnRestoreState(returnTo);
navigate(returnTo, restoreState ? { state: restoreState } : undefined);
return;
}
if (window.history.length > 1) navigate(-1);
else navigate(fallback);
}
@@ -0,0 +1,62 @@
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
const SAFETY_TIMEOUT_MS = 3000;
function clampScrollTop(el: HTMLElement, scrollTop: number): number {
const maxScroll = Math.max(0, el.scrollHeight - el.clientHeight);
return Math.min(Math.max(0, scrollTop), maxScroll);
}
function scrollRestoreMatches(el: HTMLElement, targetScrollTop: number): boolean {
const maxScroll = Math.max(0, el.scrollHeight - el.clientHeight);
if (targetScrollTop > maxScroll + 1) return false;
const desired = clampScrollTop(el, targetScrollTop);
return Math.abs(el.scrollTop - desired) <= 1;
}
/** Apply main viewport scroll after route content is ready; retry until layout can reach target. */
export function restoreMainViewportScroll(
targetScrollTop: number,
onComplete: () => void,
): () => void {
let cancelled = false;
let ro: ResizeObserver | null = null;
let safetyTimeoutId = 0;
const finish = () => {
if (cancelled) return;
cancelled = true;
ro?.disconnect();
ro = null;
if (safetyTimeoutId) window.clearTimeout(safetyTimeoutId);
onComplete();
};
const apply = () => {
if (cancelled) return;
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (!el) return;
const desired = clampScrollTop(el, targetScrollTop);
el.scrollTop = desired;
el.dispatchEvent(new Event('scroll', { bubbles: false }));
if (scrollRestoreMatches(el, targetScrollTop)) finish();
};
const scheduleApply = () => {
requestAnimationFrame(apply);
};
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (el && typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(scheduleApply);
ro.observe(el);
}
apply();
scheduleApply();
safetyTimeoutId = window.setTimeout(finish, SAFETY_TIMEOUT_MS);
return finish;
}
+8 -2
View File
@@ -2,10 +2,11 @@ import { getArtist } from '../../api/subsonicArtists';
import { getAlbum, getSong } from '../../api/subsonicLibrary';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { songToTrack } from '../playback/songToTrack';
import type { NavigateFunction } from 'react-router-dom';
import type { Location, NavigateFunction } from 'react-router-dom';
import type { TFunction } from 'i18next';
import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore';
import { navigateToAlbumDetail } from '../navigation/albumDetailNavigation';
import { findServerIdForShareUrl, type EntitySharePayloadV1 } from './shareLink';
import { showToast } from '../ui/toast';
@@ -96,6 +97,7 @@ export async function applySharePastePayload(
payload: EntitySharePayloadV1,
navigate: NavigateFunction,
t: TFunction,
location?: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
): Promise<void> {
const { servers, isLoggedIn, setActiveServer } = useAuthStore.getState();
if (!isLoggedIn) {
@@ -134,7 +136,11 @@ export async function applySharePastePayload(
showToast(t('sharePaste.albumUnavailable'), 5000, 'error');
return;
}
navigate(`/album/${payload.id}`);
if (location) {
navigateToAlbumDetail(navigate, location, payload.id);
} else {
navigate(`/album/${payload.id}`);
}
showToast(t('sharePaste.openedAlbum'), 3000, 'info');
return;
}