mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
4ac373a65b
* 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)
214 lines
6.9 KiB
TypeScript
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,
|
|
};
|
|
}
|