Files
psysonic/src/hooks/useSongBrowseList.ts
T
cucadmuh 4ac373a65b 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)
2026-06-01 13:04:36 +03:00

214 lines
6.9 KiB
TypeScript

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;
/** 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, searchQuery, initialRestore }: UseSongBrowseListArgs) {
const serverId = useAuthStore(s => s.activeServerId);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
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);
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);
/** 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(effectiveQuery), debounceMs);
return () => window.clearTimeout(timer);
}, [searchQuery, 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 (holdRestoredListRef.current) {
const expected = heldRestoredQueryRef.current;
if (searchQuery.trim() !== expected || debouncedQuery !== expected) {
holdRestoredListRef.current = false;
} else {
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, searchQuery, 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 {
songs,
offset,
loading,
hasMore,
browseUnsupported,
hasSearched,
localSearchMode: localSearchModeRef.current,
loadMore,
};
}