mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(search): scoped live search on browse pages (#938)
* feat(artists): scoped live search badge replaces page filter Move Artists browse text search into the header Live Search with a page scope badge (Users icon), field-local undo, and double-click/backspace to clear scope. Block the live-search dropdown while scoped so results only filter the Artists grid; mobile overlay follows the same rules. * fix(artists): plain grid for scoped search fixes broken card layout Route the browse grid through VirtualCardGrid, switch to non-virtual CSS grid when live search filters the catalog, reset scroll on filter changes, and skip content-visibility on plain tiles to avoid blank/black cards. * fix(search): scope badge double-Backspace and single clear control Require two Backspaces on an empty scoped field after prior text input; one Backspace still clears the badge when the field was never filled. Move live-search clear/advanced controls inside the field pill, drop the extra outer clear button, and use type=text to avoid native search clears. * fix(search): drop duplicate outer live-search clear button Keep the native in-field clear on type=search and the original pill layout; remove only the extra × control outside the search border. Reset dropdown state when the query is cleared via the native control. * refactor(search): generic scoped browse query helper, drop dead code Rename artistsBrowseSearchQuery to scopedBrowseSearchQuery with an expectedScope argument; wire Artists via useScopedBrowseSearchQuery. Remove unused liveSearchScoped dropdown helper (scoped mode blocks it). * feat(search): ghost scope badge and single-click badge remove After clearing the artists scope on /artists, show a faded ghost chip to restore page-only search while keeping the global search placeholder. Active badge removes on one click; tooltips and styles updated. * feat(search): scoped live search for All Albums and New Releases Wire albums and newReleases scope badges with debounced album title search (local index title-only FTS + filtered search3). Plain grid, scroll reset, and session query restore on album grid browse pages. * fix(browse): preserve scroll restore after album/artist detail back Only reset in-page scroll when filter resetKey changes, not when isScrollRestorePending clears after session restore. * feat(search): scoped live search for Tracks browse Wire /tracks to header live search with wide title/artist/album FTS, hide hero and discovery rails while search is active, and remove the inline search field from the browse list. * fix(search): clear header query when leaving scoped browse pages Prevent global live search from firing on album/detail routes after a scoped browse query; browse session stashes still restore on back. * fix(tracks): restore scroll after back from detail during scoped search Hold stashed song results across fetchSongPage churn, defer leave-stash teardown past AppShell scroll reset, restore tracks scroll after the list is ready, and save scroll snapshot when opening artist from song context menu. * fix(tracks): hide discovery headings during scoped search Hide the page subtitle and "Browse all tracks" section title when tracks search is active, matching hero/rails chrome behavior. * feat(search): scoped live search for Composers browse Wire /composers to header live search with composers scope badge, session stash, scroll restore, and plain grid/list during text filter. Remove the in-page filter input; add i18n and navigation helpers. * docs: CHANGELOG and credits for scoped browse live search (PR #938)
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
} from '../store/albumBrowseSessionStore';
|
||||
import type { AlbumBrowseSort } from '../utils/library/browseTextSearch';
|
||||
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
|
||||
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||
|
||||
const ALBUMS_SURFACE: AlbumBrowseSurface = 'albums';
|
||||
|
||||
@@ -106,6 +107,7 @@ export function useAlbumBrowseFilters(
|
||||
compFilter,
|
||||
starredOnly,
|
||||
losslessOnly,
|
||||
searchQuery: useLiveSearchScopeStore.getState().query,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -119,6 +121,7 @@ export function useAlbumBrowseFilters(
|
||||
restoredFromStashRef.current = true;
|
||||
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, ALBUMS_SURFACE);
|
||||
if (restored) {
|
||||
useLiveSearchScopeStore.getState().setQuery(restored.searchQuery ?? '');
|
||||
setSelectedGenres(restored.selectedGenres);
|
||||
setYearFrom(restored.yearFrom);
|
||||
setYearTo(restored.yearTo);
|
||||
@@ -132,6 +135,7 @@ export function useAlbumBrowseFilters(
|
||||
if (restoredFromStashRef.current) return;
|
||||
|
||||
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, ALBUMS_SURFACE);
|
||||
useLiveSearchScopeStore.getState().setQuery('');
|
||||
setSelectedGenres([]);
|
||||
setYearFrom('');
|
||||
setYearTo('');
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useLayoutEffect, useRef, type RefObject } from 'react';
|
||||
import type { AlbumBrowseScrollSnapshot } from './useAlbumBrowseFilters';
|
||||
|
||||
type Args = {
|
||||
scrollSnapshotRef: RefObject<AlbumBrowseScrollSnapshot>;
|
||||
getScrollRoot: () => HTMLElement | null;
|
||||
isScrollRestorePending: boolean;
|
||||
resetKey: string;
|
||||
};
|
||||
|
||||
/** Scroll to top when browse filters shrink the album grid (e.g. scoped text search). */
|
||||
export function useAlbumBrowseScrollReset({
|
||||
scrollSnapshotRef,
|
||||
getScrollRoot,
|
||||
isScrollRestorePending,
|
||||
resetKey,
|
||||
}: Args): void {
|
||||
const prevResetKeyRef = useRef(resetKey);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isScrollRestorePending) return;
|
||||
if (prevResetKeyRef.current === resetKey) return;
|
||||
prevResetKeyRef.current = resetKey;
|
||||
|
||||
const el = getScrollRoot();
|
||||
if (!el) return;
|
||||
|
||||
if (el.scrollTop !== 0) {
|
||||
el.scrollTop = 0;
|
||||
el.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
}
|
||||
scrollSnapshotRef.current.scrollTop = 0;
|
||||
}, [resetKey, isScrollRestorePending, getScrollRoot, scrollSnapshotRef]);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useAlbumBrowseSessionStore,
|
||||
} from '../store/albumBrowseSessionStore';
|
||||
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
|
||||
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||
import {
|
||||
inpageScrollViewportIdForSurface,
|
||||
readInpageScrollTop,
|
||||
@@ -53,8 +54,11 @@ export function useAlbumGridBrowseFilters(
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>(() => initialState.selectedGenres);
|
||||
const restoredFromStashRef = useRef(false);
|
||||
const filtersRef = useRef({ selectedGenres });
|
||||
filtersRef.current = { selectedGenres };
|
||||
const filtersRef = useRef({ selectedGenres, searchQuery: '' });
|
||||
filtersRef.current = {
|
||||
selectedGenres,
|
||||
searchQuery: useLiveSearchScopeStore.getState().query,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
restoredFromStashRef.current = false;
|
||||
@@ -66,8 +70,11 @@ export function useAlbumGridBrowseFilters(
|
||||
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);
|
||||
if (restored) {
|
||||
useLiveSearchScopeStore.getState().setQuery(restored.searchQuery ?? '');
|
||||
if (!sameGenreSelection(restored.selectedGenres, filtersRef.current.selectedGenres)) {
|
||||
setSelectedGenres(restored.selectedGenres);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -75,6 +82,7 @@ export function useAlbumGridBrowseFilters(
|
||||
if (restoredFromStashRef.current) return;
|
||||
|
||||
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
|
||||
useLiveSearchScopeStore.getState().setQuery('');
|
||||
setSelectedGenres([]);
|
||||
}, [serverId, surface, navigationType, location.state]);
|
||||
|
||||
@@ -93,6 +101,7 @@ export function useAlbumGridBrowseFilters(
|
||||
useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, surface, {
|
||||
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
|
||||
selectedGenres: filtersRef.current.selectedGenres,
|
||||
searchQuery: filtersRef.current.searchQuery,
|
||||
scrollTop,
|
||||
displayCount: gridSnapshot?.albums.length ?? scrollSnapshot?.displayCount,
|
||||
albums: gridSnapshot?.albums,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../store/artistBrowseSessionStore';
|
||||
import { isArtistDetailPath } from '../store/albumBrowseSessionStore';
|
||||
import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation';
|
||||
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||
|
||||
export type ArtistBrowseScrollSnapshot = {
|
||||
scrollTop: number;
|
||||
@@ -38,9 +39,6 @@ export function useArtistsBrowseFilters(
|
||||
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,
|
||||
);
|
||||
@@ -56,7 +54,7 @@ export function useArtistsBrowseFilters(
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
|
||||
browseStateRef.current = {
|
||||
filter,
|
||||
filter: useLiveSearchScopeStore.getState().query,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
@@ -74,7 +72,7 @@ export function useArtistsBrowseFilters(
|
||||
restoredFromStashRef.current = true;
|
||||
const restored = useArtistBrowseSessionStore.getState().peekReturnStash(serverId);
|
||||
if (restored) {
|
||||
setFilter(restored.filter);
|
||||
useLiveSearchScopeStore.getState().setQuery(restored.filter);
|
||||
setLetterFilter(restored.letterFilter);
|
||||
setStarredOnly(restored.starredOnly);
|
||||
setViewMode(restored.viewMode);
|
||||
@@ -86,7 +84,7 @@ export function useArtistsBrowseFilters(
|
||||
if (restoredFromStashRef.current) return;
|
||||
|
||||
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
setFilter('');
|
||||
useLiveSearchScopeStore.getState().setQuery('');
|
||||
setLetterFilter(DEFAULT_ARTIST_BROWSE_RETURN_STATE.letterFilter);
|
||||
setStarredOnly(false);
|
||||
setViewMode('grid');
|
||||
@@ -110,8 +108,6 @@ export function useArtistsBrowseFilters(
|
||||
}, [serverId, scrollSnapshotRef]);
|
||||
|
||||
return {
|
||||
filter,
|
||||
setFilter,
|
||||
letterFilter,
|
||||
setLetterFilter,
|
||||
starredOnly,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useLayoutEffect, useRef, type RefObject } from 'react';
|
||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||
type BrowseScrollSnapshot = {
|
||||
scrollTop: number;
|
||||
visibleCount: number;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
scrollSnapshotRef: RefObject<BrowseScrollSnapshot>;
|
||||
getScrollRoot: () => HTMLElement | null;
|
||||
isScrollRestorePending: boolean;
|
||||
resetKey: string;
|
||||
viewMode: 'grid' | 'list';
|
||||
listVirtualize: boolean;
|
||||
listVirtualizer: Virtualizer<HTMLElement, Element>;
|
||||
};
|
||||
|
||||
/** Scroll to top when browse filters shrink the list (e.g. scoped text search). */
|
||||
export function useArtistsBrowseScrollReset({
|
||||
scrollSnapshotRef,
|
||||
getScrollRoot,
|
||||
isScrollRestorePending,
|
||||
resetKey,
|
||||
viewMode,
|
||||
listVirtualize,
|
||||
listVirtualizer,
|
||||
}: Args): void {
|
||||
const prevResetKeyRef = useRef(resetKey);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isScrollRestorePending) return;
|
||||
if (prevResetKeyRef.current === resetKey) return;
|
||||
prevResetKeyRef.current = resetKey;
|
||||
|
||||
const el = getScrollRoot();
|
||||
if (!el) return;
|
||||
|
||||
if (el.scrollTop !== 0) {
|
||||
el.scrollTop = 0;
|
||||
el.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
}
|
||||
scrollSnapshotRef.current.scrollTop = 0;
|
||||
|
||||
if (listVirtualize && viewMode === 'list') listVirtualizer.scrollToOffset(0);
|
||||
}, [
|
||||
resetKey,
|
||||
isScrollRestorePending,
|
||||
getScrollRoot,
|
||||
scrollSnapshotRef,
|
||||
viewMode,
|
||||
listVirtualize,
|
||||
listVirtualizer,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
|
||||
BROWSE_TEXT_DEBOUNCE_RACE_MS,
|
||||
browseRaceCountsAlbums,
|
||||
raceBrowseWithLocalFallback,
|
||||
runLocalBrowseAlbums,
|
||||
runNetworkBrowseAlbums,
|
||||
} from '../utils/library/browseTextSearch';
|
||||
|
||||
/**
|
||||
* Debounced album title search with local-vs-network race when the
|
||||
* library index is enabled; network-only when it is not.
|
||||
*/
|
||||
export function useBrowseAlbumTextSearch(
|
||||
filter: string,
|
||||
indexEnabled: boolean,
|
||||
serverId: string | null | undefined,
|
||||
losslessOnly = false,
|
||||
) {
|
||||
const [debouncedFilter, setDebouncedFilter] = useState('');
|
||||
const [textSearchAlbums, setTextSearchAlbums] = useState<SubsonicAlbum[] | null>(null);
|
||||
const [textSearchLoading, setTextSearchLoading] = useState(false);
|
||||
const searchGenRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const ms = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS;
|
||||
const timer = window.setTimeout(() => setDebouncedFilter(filter.trim()), ms);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [filter, indexEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = debouncedFilter;
|
||||
if (!q || !serverId) {
|
||||
setTextSearchAlbums(null);
|
||||
setTextSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const gen = ++searchGenRef.current;
|
||||
const isStale = () => gen !== searchGenRef.current;
|
||||
setTextSearchLoading(true);
|
||||
|
||||
void (async () => {
|
||||
if (!indexEnabled) {
|
||||
const albums = await runNetworkBrowseAlbums(q);
|
||||
if (isStale()) return;
|
||||
setTextSearchAlbums(albums);
|
||||
setTextSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = await raceBrowseWithLocalFallback(
|
||||
isStale,
|
||||
() => runLocalBrowseAlbums(serverId, q, undefined, losslessOnly),
|
||||
() => runNetworkBrowseAlbums(q),
|
||||
{
|
||||
surface: 'albums_browse',
|
||||
query: q,
|
||||
indexEnabled,
|
||||
counts: browseRaceCountsAlbums,
|
||||
},
|
||||
);
|
||||
if (isStale()) return;
|
||||
setTextSearchAlbums(outcome?.result ?? null);
|
||||
setTextSearchLoading(false);
|
||||
})();
|
||||
}, [debouncedFilter, indexEnabled, serverId, losslessOnly]);
|
||||
|
||||
const effectiveFilter = textSearchAlbums != null ? '' : filter;
|
||||
return { textSearchAlbums, textSearchLoading, effectiveFilter };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
|
||||
import { isComposerDetailPath } from '../store/albumBrowseSessionStore';
|
||||
import {
|
||||
DEFAULT_COMPOSER_BROWSE_RETURN_STATE,
|
||||
type ComposerBrowseReturnState,
|
||||
type ComposerBrowseViewMode,
|
||||
isComposersBrowsePath,
|
||||
useComposerBrowseSessionStore,
|
||||
} from '../store/composerBrowseSessionStore';
|
||||
import { shouldRestoreComposerBrowseSession } from '../utils/navigation/albumDetailNavigation';
|
||||
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||
|
||||
export type ComposerBrowseScrollSnapshot = {
|
||||
scrollTop: number;
|
||||
visibleCount: number;
|
||||
};
|
||||
|
||||
function returnStateForNavigation(
|
||||
serverId: string,
|
||||
navigationType: NavigationType,
|
||||
locationState: unknown,
|
||||
): ComposerBrowseReturnState {
|
||||
if (!shouldRestoreComposerBrowseSession(navigationType, locationState) || !serverId) {
|
||||
return DEFAULT_COMPOSER_BROWSE_RETURN_STATE;
|
||||
}
|
||||
return (
|
||||
useComposerBrowseSessionStore.getState().peekReturnStash(serverId)
|
||||
?? DEFAULT_COMPOSER_BROWSE_RETURN_STATE
|
||||
);
|
||||
}
|
||||
|
||||
export function useComposersBrowseFilters(
|
||||
serverId: string,
|
||||
scrollSnapshotRef?: RefObject<ComposerBrowseScrollSnapshot>,
|
||||
) {
|
||||
const navigationType = useNavigationType();
|
||||
const location = useLocation();
|
||||
|
||||
const [letterFilter, setLetterFilter] = useState(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).letterFilter,
|
||||
);
|
||||
const [starredOnly, setStarredOnly] = useState(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).starredOnly,
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<ComposerBrowseViewMode>(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).viewMode,
|
||||
);
|
||||
|
||||
const browseStateRef = useRef<ComposerBrowseReturnState>(DEFAULT_COMPOSER_BROWSE_RETURN_STATE);
|
||||
const restoredFromStashRef = useRef(false);
|
||||
|
||||
browseStateRef.current = {
|
||||
filter: useLiveSearchScopeStore.getState().query,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
restoredFromStashRef.current = false;
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) return;
|
||||
|
||||
if (shouldRestoreComposerBrowseSession(navigationType, location.state)) {
|
||||
restoredFromStashRef.current = true;
|
||||
const restored = useComposerBrowseSessionStore.getState().peekReturnStash(serverId);
|
||||
if (restored) {
|
||||
useLiveSearchScopeStore.getState().setQuery(restored.filter);
|
||||
setLetterFilter(restored.letterFilter);
|
||||
setStarredOnly(restored.starredOnly);
|
||||
setViewMode(restored.viewMode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoredFromStashRef.current) return;
|
||||
|
||||
useComposerBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
useLiveSearchScopeStore.getState().setQuery('');
|
||||
setLetterFilter(DEFAULT_COMPOSER_BROWSE_RETURN_STATE.letterFilter);
|
||||
setStarredOnly(false);
|
||||
setViewMode('grid');
|
||||
}, [serverId, navigationType, location.state]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!serverId) return;
|
||||
const path = window.location.pathname;
|
||||
if (isComposerDetailPath(path)) {
|
||||
const snapshot = scrollSnapshotRef?.current;
|
||||
useComposerBrowseSessionStore.getState().stashReturnState(serverId, {
|
||||
...browseStateRef.current,
|
||||
scrollTop: snapshot?.scrollTop,
|
||||
visibleCount: snapshot?.visibleCount,
|
||||
});
|
||||
} else if (!isComposersBrowsePath(path)) {
|
||||
useComposerBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
}
|
||||
};
|
||||
}, [serverId, scrollSnapshotRef]);
|
||||
|
||||
return {
|
||||
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 {
|
||||
peekComposerBrowseScrollRestore,
|
||||
useComposerBrowseSessionStore,
|
||||
} from '../store/composerBrowseSessionStore';
|
||||
import { shouldRestoreComposerBrowseSession } from '../utils/navigation/albumDetailNavigation';
|
||||
|
||||
type PendingScroll = {
|
||||
scrollTop: number;
|
||||
visibleCount: number;
|
||||
};
|
||||
|
||||
export type UseComposersBrowseScrollRestoreArgs = {
|
||||
serverId: string;
|
||||
scrollBodyEl: HTMLElement | null;
|
||||
visibleCount: number;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
};
|
||||
|
||||
export type UseComposersBrowseScrollRestoreResult = {
|
||||
isScrollRestorePending: boolean;
|
||||
};
|
||||
|
||||
function readPendingScrollRestore(
|
||||
serverId: string,
|
||||
navigationType: NavigationType,
|
||||
locationState: unknown,
|
||||
): PendingScroll | null {
|
||||
if (!shouldRestoreComposerBrowseSession(navigationType, locationState) || !serverId) return null;
|
||||
return peekComposerBrowseScrollRestore(serverId);
|
||||
}
|
||||
|
||||
/** Restore Composers in-page scroll after returning from composer detail. */
|
||||
export function useComposersBrowseScrollRestore({
|
||||
serverId,
|
||||
scrollBodyEl,
|
||||
visibleCount,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
}: UseComposersBrowseScrollRestoreArgs): UseComposersBrowseScrollRestoreResult {
|
||||
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);
|
||||
useComposerBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
}, [
|
||||
scrollBodyEl,
|
||||
visibleCount,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
return { isScrollRestorePending };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||
import { syncLiveSearchRouteScope } from './useLiveSearchRouteScope';
|
||||
|
||||
describe('syncLiveSearchRouteScope', () => {
|
||||
beforeEach(() => {
|
||||
useLiveSearchScopeStore.setState({ query: '', scope: null, undoStack: [] });
|
||||
});
|
||||
|
||||
it('activates scope on supported browse routes', () => {
|
||||
syncLiveSearchRouteScope('/albums');
|
||||
expect(useLiveSearchScopeStore.getState().scope).toBe('albums');
|
||||
|
||||
syncLiveSearchRouteScope('/tracks');
|
||||
expect(useLiveSearchScopeStore.getState().scope).toBe('tracks');
|
||||
|
||||
syncLiveSearchRouteScope('/composers');
|
||||
expect(useLiveSearchScopeStore.getState().scope).toBe('composers');
|
||||
});
|
||||
|
||||
it('clears scope and query when leaving browse routes', () => {
|
||||
useLiveSearchScopeStore.setState({ query: 'beatles', scope: 'albums' });
|
||||
|
||||
syncLiveSearchRouteScope('/album/abc123');
|
||||
|
||||
expect(useLiveSearchScopeStore.getState().scope).toBeNull();
|
||||
expect(useLiveSearchScopeStore.getState().query).toBe('');
|
||||
});
|
||||
|
||||
it('clears query when leaving browse with scope already cleared (ghost mode)', () => {
|
||||
useLiveSearchScopeStore.setState({ query: 'beatles', scope: null });
|
||||
|
||||
syncLiveSearchRouteScope('/album/abc123');
|
||||
|
||||
expect(useLiveSearchScopeStore.getState().scope).toBeNull();
|
||||
expect(useLiveSearchScopeStore.getState().query).toBe('');
|
||||
});
|
||||
|
||||
it('preserves query when switching between browse routes', () => {
|
||||
useLiveSearchScopeStore.setState({ query: 'jazz', scope: 'albums' });
|
||||
|
||||
syncLiveSearchRouteScope('/artists');
|
||||
|
||||
expect(useLiveSearchScopeStore.getState().scope).toBe('artists');
|
||||
expect(useLiveSearchScopeStore.getState().query).toBe('jazz');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '../store/albumBrowseSessionStore';
|
||||
import { isArtistsBrowsePath } from '../store/artistBrowseSessionStore';
|
||||
import { isTracksBrowsePath } from '../store/advancedSearchSessionStore';
|
||||
import { isComposersBrowsePath } from '../store/composerBrowseSessionStore';
|
||||
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||
|
||||
/** Keep scope badge in sync with browse routes; clear field text when leaving browse. */
|
||||
export function syncLiveSearchRouteScope(pathname: string): void {
|
||||
const store = useLiveSearchScopeStore.getState();
|
||||
|
||||
if (isArtistsBrowsePath(pathname)) {
|
||||
store.setScope('artists');
|
||||
} else if (isAlbumsBrowsePath(pathname)) {
|
||||
store.setScope('albums');
|
||||
} else if (isNewReleasesBrowsePath(pathname)) {
|
||||
store.setScope('newReleases');
|
||||
} else if (isTracksBrowsePath(pathname)) {
|
||||
store.setScope('tracks');
|
||||
} else if (isComposersBrowsePath(pathname)) {
|
||||
store.setScope('composers');
|
||||
} else {
|
||||
if (store.scope != null) store.clearScope();
|
||||
if (store.query !== '') store.setQuery('');
|
||||
}
|
||||
}
|
||||
|
||||
/** Activate the browse scope badge when a supported route is open; clear on leave. */
|
||||
export function useLiveSearchRouteScope() {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
syncLiveSearchRouteScope(location.pathname);
|
||||
}, [location.pathname]);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { navigateToComposerDetail } from '../utils/navigation/albumDetailNavigation';
|
||||
|
||||
/** Navigate to composer detail, remembering the current page for the back button. */
|
||||
export function useNavigateToComposer() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return useCallback(
|
||||
(composerId: string, opts?: { search?: string }) => {
|
||||
navigateToComposerDetail(navigate, location, composerId, opts);
|
||||
},
|
||||
[navigate, location],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { useSongBrowseList } from './useSongBrowseList';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
|
||||
vi.mock('../api/subsonicSearch', () => ({
|
||||
searchSongsPaged: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('../api/navidromeBrowse', () => ({
|
||||
ndListSongs: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/library/advancedSearchLocal', () => ({
|
||||
runLocalSongBrowse: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/library/browseTextSearch', () => ({
|
||||
BROWSE_TEXT_DEBOUNCE_NETWORK_MS: 10,
|
||||
BROWSE_TEXT_DEBOUNCE_RACE_MS: 10,
|
||||
browseRaceCountsSongs: vi.fn(),
|
||||
loadMoreLocalBrowseSongs: vi.fn(async () => []),
|
||||
raceBrowseWithLocalFallback: vi.fn(async () => null),
|
||||
runLocalBrowseSongPage: vi.fn(async () => []),
|
||||
runNetworkBrowseSongPage: vi.fn(async () => [{ id: 'fresh' } as SubsonicSong]),
|
||||
}));
|
||||
|
||||
const stashedSong = { id: 'stashed', title: 'Stashed', artist: 'A', duration: 180 } as SubsonicSong;
|
||||
|
||||
describe('useSongBrowseList restore hold', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ activeServerId: 'srv-1' });
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
});
|
||||
|
||||
it('keeps stashed songs after fetchSongPage identity changes until query edits', async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ searchQuery }) => useSongBrowseList({
|
||||
enabled: true,
|
||||
searchQuery,
|
||||
initialRestore: {
|
||||
query: 'jazz',
|
||||
songs: [stashedSong],
|
||||
offset: 1,
|
||||
hasMore: false,
|
||||
localSearchMode: true,
|
||||
browseUnsupported: false,
|
||||
hasSearched: true,
|
||||
},
|
||||
}),
|
||||
{ initialProps: { searchQuery: 'jazz' } },
|
||||
);
|
||||
|
||||
expect(result.current.songs).toEqual([stashedSong]);
|
||||
|
||||
rerender({ searchQuery: 'jazz' });
|
||||
await waitFor(() => {
|
||||
expect(result.current.songs).toEqual([stashedSong]);
|
||||
}, { timeout: 500 });
|
||||
|
||||
rerender({ searchQuery: 'jazzx' });
|
||||
await waitFor(() => {
|
||||
expect(result.current.songs[0]?.id).toBe('fresh');
|
||||
}, { timeout: 500 });
|
||||
});
|
||||
});
|
||||
@@ -42,16 +42,19 @@ export type SongBrowseListRestore = {
|
||||
|
||||
type UseSongBrowseListArgs = {
|
||||
enabled: boolean;
|
||||
/** Header scoped browse query (wide title/artist/album search). */
|
||||
searchQuery: string;
|
||||
initialRestore?: SongBrowseListRestore | null;
|
||||
};
|
||||
|
||||
/** Tracks hub song browse — all-library paging or filtered text search. */
|
||||
export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseListArgs) {
|
||||
export function useSongBrowseList({ enabled, searchQuery, 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 [debouncedQuery, setDebouncedQuery] = useState(
|
||||
() => initialRestore?.query.trim() ?? searchQuery.trim(),
|
||||
);
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>(() => initialRestore?.songs ?? []);
|
||||
const [offset, setOffset] = useState(() => initialRestore?.offset ?? 0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -63,14 +66,25 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList
|
||||
|
||||
const requestSeqRef = useRef(0);
|
||||
const localSearchModeRef = useRef(initialRestore?.localSearchMode ?? false);
|
||||
const skipInitialFetchRef = useRef(initialRestore != null);
|
||||
/** Keep stashed songs until the user edits the scoped query (survives fetchSongPage identity changes). */
|
||||
const holdRestoredListRef = useRef(initialRestore != null);
|
||||
const heldRestoredQueryRef = useRef(initialRestore?.query.trim() ?? '');
|
||||
|
||||
const restoreQueryHoldRef = useRef(
|
||||
initialRestore?.query.trim() ? initialRestore.query.trim() : null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const incoming = searchQuery.trim();
|
||||
if (incoming !== '') {
|
||||
restoreQueryHoldRef.current = null;
|
||||
}
|
||||
const effectiveQuery = incoming || restoreQueryHoldRef.current || '';
|
||||
const debounceMs = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS;
|
||||
const timer = window.setTimeout(() => setDebouncedQuery(query.trim()), debounceMs);
|
||||
const timer = window.setTimeout(() => setDebouncedQuery(effectiveQuery), debounceMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [query, indexEnabled, enabled]);
|
||||
}, [searchQuery, indexEnabled, enabled]);
|
||||
|
||||
const fetchSongPage = useCallback(
|
||||
async (q: string, pageOffset: number, isStale: () => boolean): Promise<SubsonicSong[]> => {
|
||||
@@ -114,9 +128,14 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (skipInitialFetchRef.current) {
|
||||
skipInitialFetchRef.current = false;
|
||||
return;
|
||||
|
||||
if (holdRestoredListRef.current) {
|
||||
const expected = heldRestoredQueryRef.current;
|
||||
if (searchQuery.trim() !== expected || debouncedQuery !== expected) {
|
||||
holdRestoredListRef.current = false;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
@@ -152,7 +171,7 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedQuery, fetchSongPage, enabled]);
|
||||
}, [debouncedQuery, searchQuery, fetchSongPage, enabled]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!enabled || loading || !hasMore) return;
|
||||
@@ -182,8 +201,6 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList
|
||||
}, [enabled, loading, hasMore, debouncedQuery, offset, fetchSongPage]);
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
songs,
|
||||
offset,
|
||||
loading,
|
||||
|
||||
Reference in New Issue
Block a user