mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -47,6 +47,8 @@ export interface LocalSearchOpts {
|
||||
moodGroup: string;
|
||||
losslessOnly?: boolean;
|
||||
resultType: AdvancedResultType;
|
||||
/** When searching albums, match album title only (not album artist). */
|
||||
albumTitleOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface LocalAdvancedSearchPage {
|
||||
@@ -141,6 +143,9 @@ function buildRequest(
|
||||
limit,
|
||||
offset,
|
||||
skipTotals,
|
||||
...(opts.resultType === 'albums' && opts.albumTitleOnly
|
||||
? { queryAlbumTitleOnly: true }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,28 @@ export function filterAlbumsByCompilation(
|
||||
return albums;
|
||||
}
|
||||
|
||||
export function filterAlbumsByGenres(
|
||||
albums: SubsonicAlbum[],
|
||||
genres: string[],
|
||||
): SubsonicAlbum[] {
|
||||
if (genres.length === 0) return albums;
|
||||
const wanted = new Set(genres.map(g => g.toLowerCase()));
|
||||
return albums.filter(a => {
|
||||
const g = (a.genre ?? '').trim().toLowerCase();
|
||||
return g !== '' && wanted.has(g);
|
||||
});
|
||||
}
|
||||
|
||||
/** Scoped All Albums text search — album title/name only (not performer). */
|
||||
export function filterAlbumsByNameTextQuery(
|
||||
albums: SubsonicAlbum[],
|
||||
query: string,
|
||||
): SubsonicAlbum[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return albums;
|
||||
return albums.filter(a => a.name.toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
export function countGenresFromAlbums(albums: SubsonicAlbum[]): GenreFilterOption[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const a of albums) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
albumBrowseStarredNeedsLocalIntersect,
|
||||
compilationFilterClauses,
|
||||
countGenresFromAlbums,
|
||||
filterAlbumsByNameTextQuery,
|
||||
filterAlbumsByStarred,
|
||||
filterAlbumsByYearBounds,
|
||||
} from './albumBrowseFilters';
|
||||
@@ -118,6 +119,19 @@ describe('countGenresFromAlbums', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAlbumsByNameTextQuery', () => {
|
||||
const albums: SubsonicAlbum[] = [
|
||||
{ id: '1', name: 'Abbey Road', artist: 'The Beatles', artistId: 'a', songCount: 1, duration: 1 },
|
||||
{ id: '2', name: 'Beatles for Sale', artist: 'The Beatles', artistId: 'a', songCount: 1, duration: 1 },
|
||||
{ id: '3', name: 'Random Title', artist: 'Abbey Road Band', artistId: 'b', songCount: 1, duration: 1 },
|
||||
];
|
||||
|
||||
it('matches album title only, not artist name', () => {
|
||||
expect(filterAlbumsByNameTextQuery(albums, 'abbey').map(a => a.id)).toEqual(['1']);
|
||||
expect(filterAlbumsByNameTextQuery(albums, 'beatles').map(a => a.id)).toEqual(['2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAlbumsByYearBounds', () => {
|
||||
const albums: SubsonicAlbum[] = [
|
||||
{ id: '1', name: 'A', artist: 'X', artistId: 'a', songCount: 1, duration: 1, year: 1985 },
|
||||
|
||||
@@ -157,6 +157,11 @@ export function browseRaceCountsArtists(result: unknown): LibrarySearchDebugEntr
|
||||
return { artists: n, albums: 0, songs: 0 };
|
||||
}
|
||||
|
||||
export function browseRaceCountsAlbums(result: unknown): LibrarySearchDebugEntry['counts'] {
|
||||
const n = Array.isArray(result) ? result.length : 0;
|
||||
return { artists: 0, albums: n, songs: 0 };
|
||||
}
|
||||
|
||||
export function browseRaceCountsSongs(result: unknown): LibrarySearchDebugEntry['counts'] {
|
||||
const n = Array.isArray(result) ? result.length : 0;
|
||||
return { artists: 0, albums: 0, songs: n };
|
||||
@@ -172,6 +177,7 @@ export function browseRaceCountsFullSearch(result: unknown): LibrarySearchDebugE
|
||||
}
|
||||
|
||||
const ARTIST_BROWSE_LIMIT = 500;
|
||||
const ALBUM_BROWSE_LIMIT = 500;
|
||||
|
||||
const emptyBrowseOpts = (query: string): LocalSearchOpts => ({
|
||||
query,
|
||||
@@ -184,6 +190,19 @@ const emptyBrowseOpts = (query: string): LocalSearchOpts => ({
|
||||
resultType: 'artists',
|
||||
});
|
||||
|
||||
const albumBrowseOpts = (query: string, losslessOnly = false): LocalSearchOpts => ({
|
||||
query,
|
||||
genre: '',
|
||||
yearFrom: '',
|
||||
yearTo: '',
|
||||
bpmFrom: '',
|
||||
bpmTo: '',
|
||||
moodGroup: '',
|
||||
losslessOnly,
|
||||
albumTitleOnly: true,
|
||||
resultType: 'albums',
|
||||
});
|
||||
|
||||
const songBrowseOpts = (query: string): LocalSearchOpts => ({
|
||||
query,
|
||||
genre: '',
|
||||
@@ -239,6 +258,40 @@ export async function runNetworkBrowseArtists(
|
||||
}
|
||||
}
|
||||
|
||||
/** Local album title/artist search for All Albums browse. */
|
||||
export async function runLocalBrowseAlbums(
|
||||
serverId: string | null | undefined,
|
||||
query: string,
|
||||
limit = ALBUM_BROWSE_LIMIT,
|
||||
losslessOnly = false,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
const page = await runLocalAdvancedSearch(
|
||||
serverId,
|
||||
albumBrowseOpts(query, losslessOnly),
|
||||
limit,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!page) return null;
|
||||
return page.albums;
|
||||
}
|
||||
|
||||
/** Network search3 album slice for All Albums browse (title match only). */
|
||||
export async function runNetworkBrowseAlbums(
|
||||
query: string,
|
||||
limit = ALBUM_BROWSE_LIMIT,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
try {
|
||||
const r = await search(q, { artistCount: 0, albumCount: limit, songCount: 0 });
|
||||
return filterAlbumsByNameTextQuery(r.albums, q);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginated local track text search (Tracks browse / VirtualSongList). */
|
||||
export async function runLocalBrowseSongPage(
|
||||
serverId: string | null | undefined,
|
||||
@@ -334,6 +387,7 @@ export async function loadMoreLocalBrowseSongs(
|
||||
export type { AlbumBrowseSort } from './albumBrowseSort';
|
||||
export { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
|
||||
import { albumSortClauses, type AlbumBrowseSort } from './albumBrowseSort';
|
||||
import { filterAlbumsByNameTextQuery } from './albumBrowseFilters';
|
||||
import { runLocalAlbumBrowse, type AlbumBrowseQuery } from './albumBrowseLoad';
|
||||
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export type LibrarySearchSurface =
|
||||
| 'live_search'
|
||||
| 'advanced_search'
|
||||
| 'artists_browse'
|
||||
| 'albums_browse'
|
||||
| 'composers_browse'
|
||||
| 'tracks_browse'
|
||||
| 'search_results';
|
||||
|
||||
@@ -61,10 +61,17 @@ function readMainScrollTopFromDom(): number {
|
||||
return document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTop ?? 0;
|
||||
}
|
||||
|
||||
function readMainScrollTopForLeave(providerSnap?: AdvancedSearchLeaveSnapshot): number {
|
||||
const fromDom = readMainScrollTopFromDom();
|
||||
const fromProvider = providerSnap?.scrollTop ?? 0;
|
||||
// After route commit the DOM viewport may already be the destination page (scrollTop 0).
|
||||
return Math.max(fromDom, fromProvider);
|
||||
}
|
||||
|
||||
export function readAdvancedSearchLeaveSnapshot(): AdvancedSearchLeaveSnapshot {
|
||||
const providerSnap = leaveScrollProvider?.();
|
||||
return {
|
||||
scrollTop: Math.max(readMainScrollTopFromDom(), providerSnap?.scrollTop ?? 0),
|
||||
scrollTop: readMainScrollTopForLeave(providerSnap),
|
||||
albumRowScrollLeft: Math.max(
|
||||
readAlbumRowScrollLeftFromDom(),
|
||||
providerSnap?.albumRowScrollLeft ?? 0,
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
navigatePathWithAlbumReturnTo,
|
||||
navigateToAlbumDetail,
|
||||
navigateToArtistDetail,
|
||||
navigateToComposerDetail,
|
||||
readAlbumDetailReturnTo,
|
||||
shouldRestoreAlbumBrowseSession,
|
||||
shouldRestoreArtistBrowseSession,
|
||||
shouldRestoreComposerBrowseSession,
|
||||
shouldSkipMainScrollResetOnRouteChange,
|
||||
} from './albumDetailNavigation';
|
||||
import { useAdvancedSearchSessionStore } from '../../store/advancedSearchSessionStore';
|
||||
@@ -88,6 +90,18 @@ describe('albumDetailNavigation', () => {
|
||||
expect(navigate).toHaveBeenCalledWith('/artists', { state: { artistBrowseRestore: true } });
|
||||
});
|
||||
|
||||
it('flags Composers browse return for session restore', () => {
|
||||
const navigate = vi.fn();
|
||||
navigateAlbumDetailBack(navigate, { state: { returnTo: '/composers' } });
|
||||
expect(navigate).toHaveBeenCalledWith('/composers', { state: { composerBrowseRestore: true } });
|
||||
});
|
||||
|
||||
it('detects composer browse restore navigation', () => {
|
||||
expect(shouldRestoreComposerBrowseSession('POP' as NavigationType, null)).toBe(true);
|
||||
expect(shouldRestoreComposerBrowseSession('PUSH' as NavigationType, { composerBrowseRestore: true })).toBe(true);
|
||||
expect(shouldRestoreComposerBrowseSession('PUSH' as NavigationType, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects artist browse restore navigation', () => {
|
||||
expect(shouldRestoreArtistBrowseSession('POP' as NavigationType, null)).toBe(true);
|
||||
expect(shouldRestoreArtistBrowseSession('PUSH' as NavigationType, { artistBrowseRestore: true })).toBe(true);
|
||||
@@ -132,6 +146,18 @@ describe('albumDetailNavigation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to composer with returnTo snapshot from Composers browse', () => {
|
||||
const navigate = vi.fn();
|
||||
navigateToComposerDetail(
|
||||
navigate,
|
||||
{ pathname: '/composers', search: '', hash: '', state: null },
|
||||
'comp-1',
|
||||
);
|
||||
expect(navigate).toHaveBeenCalledWith('/composer/comp-1', {
|
||||
state: { returnTo: '/composers' },
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -143,6 +169,10 @@ describe('albumDetailNavigation', () => {
|
||||
expect(shouldSkipMainScrollResetOnRouteChange('/artists', { artistBrowseRestore: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('skips main scroll reset when Composers browse restore is pending', () => {
|
||||
expect(shouldSkipMainScrollResetOnRouteChange('/composers', { composerBrowseRestore: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('skips main scroll reset when Advanced Search session restore is pending', () => {
|
||||
expect(shouldSkipMainScrollResetOnRouteChange('/search/advanced', { advancedSearchRestore: true })).toBe(true);
|
||||
});
|
||||
@@ -159,6 +189,33 @@ describe('albumDetailNavigation', () => {
|
||||
artistRowScrollLeft: 0,
|
||||
});
|
||||
expect(shouldSkipMainScrollResetOnRouteChange('/search/advanced', null)).toBe(true);
|
||||
expect(shouldSkipMainScrollResetOnRouteChange('/tracks', null)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips main scroll reset when Advanced Search return stash carries scrollTop', () => {
|
||||
useAdvancedSearchSessionStore.getState().stashReturnSession({
|
||||
query: 'jazz',
|
||||
genre: '',
|
||||
yearFrom: '',
|
||||
yearTo: '',
|
||||
bpmFrom: '',
|
||||
bpmTo: '',
|
||||
moodGroup: '',
|
||||
losslessOnly: false,
|
||||
resultType: 'all',
|
||||
starredOnly: false,
|
||||
results: { artists: [], albums: [], songs: [] },
|
||||
hasSearched: true,
|
||||
activeSearch: null,
|
||||
localMode: false,
|
||||
songsServerOffset: 0,
|
||||
songsHasMore: false,
|
||||
genreNote: false,
|
||||
basicSearchMode: false,
|
||||
tracksBrowseMode: true,
|
||||
scrollTop: 880,
|
||||
});
|
||||
expect(shouldSkipMainScrollResetOnRouteChange('/tracks', null)).toBe(true);
|
||||
});
|
||||
|
||||
it('builds return path with search and hash', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import {
|
||||
isAlbumDetailPath,
|
||||
isArtistDetailPath,
|
||||
isComposerDetailPath,
|
||||
} from '../../store/albumBrowseSessionStore';
|
||||
import {
|
||||
peekPersistedAdvancedSearchLeaveSnapshot,
|
||||
@@ -19,6 +20,7 @@ export type AlbumDetailLocationState = {
|
||||
export type AlbumsBrowseRestoreLocationState = {
|
||||
albumBrowseRestore?: boolean;
|
||||
artistBrowseRestore?: boolean;
|
||||
composerBrowseRestore?: boolean;
|
||||
advancedSearchRestore?: boolean;
|
||||
};
|
||||
|
||||
@@ -37,6 +39,10 @@ export function readArtistBrowseRestore(state: unknown): boolean {
|
||||
return (state as AlbumsBrowseRestoreLocationState | null)?.artistBrowseRestore === true;
|
||||
}
|
||||
|
||||
export function readComposerBrowseRestore(state: unknown): boolean {
|
||||
return (state as AlbumsBrowseRestoreLocationState | null)?.composerBrowseRestore === true;
|
||||
}
|
||||
|
||||
export function readAdvancedSearchRestore(state: unknown): boolean {
|
||||
return (state as AlbumsBrowseRestoreLocationState | null)?.advancedSearchRestore === true;
|
||||
}
|
||||
@@ -55,6 +61,10 @@ export function artistBrowseRestoreNavigationState(): AlbumsBrowseRestoreLocatio
|
||||
return { artistBrowseRestore: true };
|
||||
}
|
||||
|
||||
export function composerBrowseRestoreNavigationState(): AlbumsBrowseRestoreLocationState {
|
||||
return { composerBrowseRestore: true };
|
||||
}
|
||||
|
||||
export function advancedSearchRestoreNavigationState(): AlbumsBrowseRestoreLocationState {
|
||||
return { advancedSearchRestore: true };
|
||||
}
|
||||
@@ -80,6 +90,13 @@ export function shouldRestoreArtistBrowseSession(
|
||||
return navigationType === 'POP' || readArtistBrowseRestore(locationState);
|
||||
}
|
||||
|
||||
export function shouldRestoreComposerBrowseSession(
|
||||
navigationType: NavigationType,
|
||||
locationState: unknown,
|
||||
): boolean {
|
||||
return navigationType === 'POP' || readComposerBrowseRestore(locationState);
|
||||
}
|
||||
|
||||
/** Skip AppShell main scroll reset when a child route will restore scroll itself. */
|
||||
export function shouldSkipMainScrollResetOnRouteChange(
|
||||
pathname: string,
|
||||
@@ -87,9 +104,12 @@ export function shouldSkipMainScrollResetOnRouteChange(
|
||||
): boolean {
|
||||
if (readAlbumBrowseRestore(locationState)) return true;
|
||||
if (readArtistBrowseRestore(locationState)) return true;
|
||||
if (readComposerBrowseRestore(locationState)) return true;
|
||||
if (readAdvancedSearchRestore(locationState)) return true;
|
||||
const leave = useAdvancedSearchSessionStore.getState().peekLeaveScrollSnapshot();
|
||||
if ((leave?.scrollTop ?? 0) > 0) return true;
|
||||
const stash = useAdvancedSearchSessionStore.getState().peekReturnStash();
|
||||
if (isAdvancedSearchPath(pathname) && (stash?.scrollTop ?? 0) > 0) return true;
|
||||
if (isAdvancedSearchPath(pathname)) {
|
||||
const persisted = peekPersistedAdvancedSearchLeaveSnapshot();
|
||||
if ((persisted?.scrollTop ?? 0) > 0) return true;
|
||||
@@ -113,6 +133,10 @@ function isArtistsBrowseReturnPath(path: string): boolean {
|
||||
return path === '/artists' || path.startsWith('/artists?');
|
||||
}
|
||||
|
||||
function isComposersBrowseReturnPath(path: string): boolean {
|
||||
return path === '/composers' || path.startsWith('/composers?');
|
||||
}
|
||||
|
||||
function isGenreDetailReturnPath(path: string): boolean {
|
||||
const bare = path.split('?')[0]?.replace(/\/$/, '') || path;
|
||||
return /^\/genres\/[^/]+$/.test(bare);
|
||||
@@ -122,6 +146,7 @@ function browseReturnRestoreState(returnTo: string): AlbumsBrowseRestoreLocation
|
||||
if (isAlbumGridBrowseReturnPath(returnTo)) return albumBrowseRestoreNavigationState();
|
||||
if (isGenreDetailReturnPath(returnTo)) return albumBrowseRestoreNavigationState();
|
||||
if (isArtistsBrowseReturnPath(returnTo)) return artistBrowseRestoreNavigationState();
|
||||
if (isComposersBrowseReturnPath(returnTo)) return composerBrowseRestoreNavigationState();
|
||||
if (isSearchReturnPath(returnTo)) return advancedSearchRestoreNavigationState();
|
||||
return undefined;
|
||||
}
|
||||
@@ -130,7 +155,9 @@ function buildReturnTo(
|
||||
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
|
||||
): string {
|
||||
const existing = readAlbumDetailReturnTo(location.state);
|
||||
const onDetail = isAlbumDetailPath(location.pathname) || isArtistDetailPath(location.pathname);
|
||||
const onDetail = isAlbumDetailPath(location.pathname)
|
||||
|| isArtistDetailPath(location.pathname)
|
||||
|| isComposerDetailPath(location.pathname);
|
||||
return onDetail && existing ? existing : buildReturnToFromLocation(location);
|
||||
}
|
||||
|
||||
@@ -168,6 +195,19 @@ export function navigateToArtistDetail(
|
||||
navigate(`/artist/${artistId}${qs}`, { state: { returnTo } satisfies AlbumDetailLocationState });
|
||||
}
|
||||
|
||||
export function navigateToComposerDetail(
|
||||
navigate: NavigateFunction,
|
||||
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
|
||||
composerId: string,
|
||||
opts?: { search?: string },
|
||||
): void {
|
||||
saveSearchLeaveIfNeeded(location);
|
||||
const returnTo = buildReturnTo(location);
|
||||
const raw = opts?.search ?? '';
|
||||
const qs = raw ? (raw.startsWith('?') ? raw : `?${raw}`) : '';
|
||||
navigate(`/composer/${composerId}${qs}`, { state: { returnTo } satisfies AlbumDetailLocationState });
|
||||
}
|
||||
|
||||
/** Route any path; album detail links get a `returnTo` snapshot in location state. */
|
||||
export function navigatePathWithAlbumReturnTo(
|
||||
navigate: NavigateFunction,
|
||||
|
||||
Reference in New Issue
Block a user