mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +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)
473 lines
17 KiB
TypeScript
473 lines
17 KiB
TypeScript
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import { LayoutGrid, List, Images, CheckSquare2 } from 'lucide-react';
|
|
import StarFilterButton from '../components/StarFilterButton';
|
|
import OverlayScrollArea from '../components/OverlayScrollArea';
|
|
import { usePlayerStore } from '../store/playerStore';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { APP_MAIN_SCROLL_VIEWPORT_ID, ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
|
import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight';
|
|
import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin';
|
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
|
import {
|
|
ALL_SENTINEL,
|
|
ALPHABET,
|
|
ARTIST_LIST_LAST_IN_LETTER_EST,
|
|
ARTIST_LIST_LETTER_ROW_EST,
|
|
ARTIST_LIST_ROW_EST,
|
|
} from '../utils/componentHelpers/artistsHelpers';
|
|
import { useArtistsFiltering } from '../hooks/useArtistsFiltering';
|
|
import { useArtistsBrowseCatalog } from '../hooks/useArtistsBrowseCatalog';
|
|
import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch';
|
|
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
|
|
import { useClientSliceInfiniteScroll } from '../hooks/useClientSliceInfiniteScroll';
|
|
import { useInpageScrollSentinel } from '../hooks/useInpageScrollSentinel';
|
|
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
|
|
import { ArtistsGridView } from '../components/artists/ArtistsGridView';
|
|
import { ArtistsListView } from '../components/artists/ArtistsListView';
|
|
import InpageScrollSentinel from '../components/InpageScrollSentinel';
|
|
import { useArtistsBrowseFilters, type ArtistBrowseScrollSnapshot } from '../hooks/useArtistsBrowseFilters';
|
|
import { useArtistsBrowseScrollRestore } from '../hooks/useArtistsBrowseScrollRestore';
|
|
import { useArtistsBrowseScrollReset } from '../hooks/useArtistsBrowseScrollReset';
|
|
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
|
import { peekArtistBrowseScrollRestore } from '../store/artistBrowseSessionStore';
|
|
import { readArtistBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
|
|
|
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
|
|
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
|
|
|
export default function Artists() {
|
|
const perfFlags = usePerfProbeFlags();
|
|
const { t } = useTranslation();
|
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
|
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
|
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
|
|
|
const scrollSnapshotRef = useRef<ArtistBrowseScrollSnapshot>({ scrollTop: 0, visibleCount: 0 });
|
|
const restoreVisibleCountRef = useRef<number | undefined>(
|
|
peekArtistBrowseScrollRestore(serverId)?.visibleCount,
|
|
);
|
|
|
|
const {
|
|
letterFilter,
|
|
setLetterFilter,
|
|
starredOnly,
|
|
setStarredOnly,
|
|
viewMode,
|
|
setViewMode,
|
|
} = useArtistsBrowseFilters(serverId, scrollSnapshotRef);
|
|
|
|
const artistsSearchQuery = useScopedBrowseSearchQuery('artists');
|
|
|
|
const {
|
|
scrollBodyEl: artistsScrollBodyEl,
|
|
bindScrollBody: bindArtistsScrollBody,
|
|
getScrollRoot: getArtistsScrollRoot,
|
|
} = useInpageScrollViewport();
|
|
|
|
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
|
const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O
|
|
const navigateToArtist = useNavigateToArtist();
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
|
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
|
|
|
const {
|
|
catalogArtists,
|
|
loading: catalogLoading,
|
|
catalogHasMore,
|
|
catalogLoadingMore,
|
|
browseMode,
|
|
loadCatalogChunk,
|
|
catalogLoadingRef,
|
|
} = useArtistsBrowseCatalog({
|
|
serverId,
|
|
indexEnabled,
|
|
starredOnly,
|
|
musicLibraryFilterVersion,
|
|
});
|
|
|
|
const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch(
|
|
artistsSearchQuery,
|
|
indexEnabled,
|
|
serverId,
|
|
);
|
|
const artists = textSearchArtists ?? catalogArtists;
|
|
const loading = catalogLoading || textSearchLoading;
|
|
const textSearchActive = textSearchArtists != null;
|
|
/** Scoped/plain text filter — canonical CSS grid, not row virtualization (small result sets). */
|
|
const artistBrowsePlainLayout =
|
|
perfFlags.disableMainstageVirtualLists
|
|
|| textSearchActive
|
|
|| artistsSearchQuery.trim().length > 0;
|
|
|
|
const {
|
|
visibleCount,
|
|
loadingMore: sliceLoadingMore,
|
|
loadMore: sliceLoadMore,
|
|
} = useClientSliceInfiniteScroll({
|
|
pageSize: PAGE_SIZE,
|
|
resetDeps: [artistsSearchQuery, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId],
|
|
getScrollRoot: getArtistsScrollRoot,
|
|
scrollRootEl: artistsScrollBodyEl,
|
|
restoreDisplayCount: restoreVisibleCountRef.current,
|
|
});
|
|
|
|
// ── Multi-selection ──────────────────────────────────────────────────────
|
|
const [selectionMode, setSelectionMode] = useState(false);
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
|
|
const toggleSelectionMode = () => {
|
|
setSelectionMode(v => !v);
|
|
setSelectedIds(new Set());
|
|
};
|
|
|
|
const toggleSelect = useCallback((id: string) => {
|
|
setSelectedIds(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id); else next.add(id);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const selectedArtists = artists.filter(a => selectedIds.has(a.id));
|
|
|
|
const {
|
|
filtered, visible, hasMore, groups, letters, artistListFlatRows,
|
|
} = useArtistsFiltering({ artists, filter: effectiveFilter, letterFilter, starredOnly, visibleCount, viewMode });
|
|
|
|
const pendingLetterMatch =
|
|
browseMode === 'slice'
|
|
&& !textSearchActive
|
|
&& !starredOnly
|
|
&& letterFilter !== ALL_SENTINEL
|
|
&& filtered.length === 0
|
|
&& catalogHasMore;
|
|
|
|
const gridHasMore =
|
|
hasMore
|
|
|| (browseMode === 'slice' && !textSearchActive && !starredOnly && catalogHasMore);
|
|
const gridLoadingMore = sliceLoadingMore || catalogLoadingMore;
|
|
|
|
const loadMoreRef = useRef<() => void>(() => {});
|
|
const sentinelIntersectingRef = useRef(false);
|
|
|
|
const loadMoreGrid = useCallback(() => {
|
|
if (hasMore) {
|
|
sliceLoadMore();
|
|
return;
|
|
}
|
|
if (browseMode === 'slice' && !textSearchActive && !starredOnly && catalogHasMore && !catalogLoadingRef.current) {
|
|
void loadCatalogChunk(true);
|
|
}
|
|
}, [
|
|
hasMore,
|
|
sliceLoadMore,
|
|
browseMode,
|
|
textSearchActive,
|
|
starredOnly,
|
|
catalogHasMore,
|
|
loadCatalogChunk,
|
|
catalogLoadingRef,
|
|
]);
|
|
|
|
loadMoreRef.current = loadMoreGrid;
|
|
|
|
scrollSnapshotRef.current = {
|
|
scrollTop: artistsScrollBodyEl?.scrollTop ?? 0,
|
|
visibleCount,
|
|
};
|
|
|
|
const { isScrollRestorePending } = useArtistsBrowseScrollRestore({
|
|
serverId,
|
|
scrollBodyEl: artistsScrollBodyEl,
|
|
visibleCount,
|
|
loading: loading || pendingLetterMatch,
|
|
loadingMore: gridLoadingMore,
|
|
hasMore: gridHasMore,
|
|
loadMore: loadMoreGrid,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (isScrollRestorePending || !readArtistBrowseRestore(location.state)) return;
|
|
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
|
}, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (!pendingLetterMatch || catalogLoadingRef.current) return;
|
|
void loadCatalogChunk(true);
|
|
}, [pendingLetterMatch, loadCatalogChunk, catalogLoadingRef]);
|
|
|
|
useEffect(() => {
|
|
if (browseMode !== 'slice' || textSearchActive || starredOnly) return;
|
|
if (!sentinelIntersectingRef.current) return;
|
|
if (visibleCount < filtered.length - PAGE_SIZE) return;
|
|
if (!catalogHasMore || catalogLoadingRef.current) return;
|
|
void loadCatalogChunk(true);
|
|
}, [
|
|
browseMode,
|
|
textSearchActive,
|
|
starredOnly,
|
|
visibleCount,
|
|
filtered.length,
|
|
catalogHasMore,
|
|
loadCatalogChunk,
|
|
catalogLoadingRef,
|
|
PAGE_SIZE,
|
|
]);
|
|
|
|
const bindLoadMoreSentinel = useInpageScrollSentinel({
|
|
active: gridHasMore,
|
|
getScrollRoot: getArtistsScrollRoot,
|
|
scrollRootEl: artistsScrollBodyEl,
|
|
onIntersect: () => loadMoreRef.current(),
|
|
drainSignal: gridLoadingMore,
|
|
intersectingRef: sentinelIntersectingRef,
|
|
});
|
|
|
|
const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [
|
|
artistsSearchQuery,
|
|
letterFilter,
|
|
starredOnly,
|
|
viewMode,
|
|
]);
|
|
|
|
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
|
const artistsInpageScrollHeight = useElementClientHeightForElement(
|
|
artistsScrollBodyEl,
|
|
mainScrollViewportHeight,
|
|
);
|
|
|
|
const getInpageScrollElement = useCallback(
|
|
() =>
|
|
getArtistsScrollRoot()
|
|
?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null),
|
|
[getArtistsScrollRoot],
|
|
);
|
|
|
|
const artistListOverscan = Math.max(
|
|
12,
|
|
Math.ceil(artistsInpageScrollHeight / ARTIST_LIST_ROW_EST),
|
|
);
|
|
|
|
const artistListWrapRef = useRef<HTMLDivElement>(null);
|
|
const artistListScrollMargin = useVirtualizerScrollMargin(
|
|
artistListWrapRef,
|
|
getInpageScrollElement,
|
|
{
|
|
active: !artistBrowsePlainLayout && viewMode === 'list',
|
|
deps: [artistListFlatRows.length],
|
|
},
|
|
);
|
|
|
|
const artistListVirtualizer = useVirtualizer({
|
|
count:
|
|
artistBrowsePlainLayout || viewMode !== 'list' ? 0 : artistListFlatRows.length,
|
|
getScrollElement: getInpageScrollElement,
|
|
estimateSize: index => {
|
|
const row = artistListFlatRows[index];
|
|
if (!row) return ARTIST_LIST_ROW_EST;
|
|
if (row.kind === 'letter') return ARTIST_LIST_LETTER_ROW_EST;
|
|
return row.isLastInLetter ? ARTIST_LIST_LAST_IN_LETTER_EST : ARTIST_LIST_ROW_EST;
|
|
},
|
|
getItemKey: index => {
|
|
const row = artistListFlatRows[index];
|
|
if (!row) return index;
|
|
if (row.kind === 'letter') return `letter:${row.letter}`;
|
|
return `artist:${row.artist.id}`;
|
|
},
|
|
overscan: artistListOverscan,
|
|
scrollMargin: artistListScrollMargin,
|
|
});
|
|
|
|
const browseScrollResetKey = [
|
|
artistsSearchQuery,
|
|
letterFilter,
|
|
starredOnly,
|
|
viewMode,
|
|
serverId,
|
|
musicLibraryFilterVersion,
|
|
textSearchArtists?.length ?? '',
|
|
textSearchArtists?.[0]?.id ?? '',
|
|
].join('\0');
|
|
|
|
useArtistsBrowseScrollReset({
|
|
scrollSnapshotRef,
|
|
getScrollRoot: getArtistsScrollRoot,
|
|
isScrollRestorePending,
|
|
resetKey: browseScrollResetKey,
|
|
viewMode,
|
|
listVirtualize: !artistBrowsePlainLayout,
|
|
listVirtualizer: artistListVirtualizer,
|
|
});
|
|
|
|
return (
|
|
<div
|
|
className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}
|
|
>
|
|
<div className="mainstage-inpage-toolbar">
|
|
<div className="page-sticky-header">
|
|
<div className="mainstage-inpage-toolbar-row">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
|
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
|
{selectionMode && selectedIds.size > 0
|
|
? t('artists.selectionCount', { count: selectedIds.size })
|
|
: t('artists.title')}
|
|
</h1>
|
|
{textSearchLoading && (
|
|
<div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} />
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
{!(selectionMode && selectedIds.size > 0) && (<>
|
|
<StarFilterButton size="compact" active={starredOnly} onChange={setStarredOnly} />
|
|
<button
|
|
className={`btn btn-surface`}
|
|
onClick={() => setShowArtistImages(!showArtistImages)}
|
|
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
|
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
|
|
data-tooltip-wrap
|
|
>
|
|
<Images size={20} />
|
|
</button>
|
|
<button
|
|
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
|
onClick={() => setViewMode('grid')}
|
|
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
|
data-tooltip={t('artists.gridView')}
|
|
>
|
|
<LayoutGrid size={20} />
|
|
</button>
|
|
<button
|
|
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
|
|
onClick={() => setViewMode('list')}
|
|
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
|
data-tooltip={t('artists.listView')}
|
|
>
|
|
<List size={20} />
|
|
</button>
|
|
</>
|
|
)}
|
|
<button
|
|
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
|
onClick={toggleSelectionMode}
|
|
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
|
|
data-tooltip-pos="bottom"
|
|
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
|
>
|
|
<CheckSquare2 size={15} />
|
|
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mainstage-inpage-toolbar-alpha-row">
|
|
{ALPHABET.map(l => (
|
|
<button
|
|
key={l}
|
|
onClick={() => setLetterFilter(l)}
|
|
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
|
|
>
|
|
{l === ALL_SENTINEL ? t('artists.all') : l}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<OverlayScrollArea
|
|
className="mainstage-inpage-scroll"
|
|
viewportClassName="mainstage-inpage-scroll__viewport"
|
|
viewportId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
|
|
viewportRef={bindArtistsScrollBody}
|
|
railInset="panel"
|
|
measureDeps={[
|
|
loading,
|
|
viewMode,
|
|
visible.length,
|
|
artistListFlatRows.length,
|
|
filtered.length,
|
|
gridHasMore,
|
|
selectionMode,
|
|
]}
|
|
>
|
|
<div style={{ position: 'relative' }}>
|
|
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
|
|
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
|
|
|
|
{!loading && pendingLetterMatch && (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
|
<div className="spinner" />
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !pendingLetterMatch && viewMode === 'grid' && (
|
|
<ArtistsGridView
|
|
visible={visible}
|
|
disableVirtualization={artistBrowsePlainLayout}
|
|
layoutKey={browseScrollResetKey}
|
|
selectionMode={selectionMode}
|
|
selectedIds={selectedIds}
|
|
selectedArtists={selectedArtists}
|
|
showArtistImages={showArtistImages}
|
|
toggleSelect={toggleSelect}
|
|
onOpenArtist={navigateToArtist}
|
|
openContextMenu={openContextMenu}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{!loading && !pendingLetterMatch && viewMode === 'list' && (
|
|
<ArtistsListView
|
|
virtualized={!artistBrowsePlainLayout}
|
|
groups={groups}
|
|
letters={letters}
|
|
artistListFlatRows={artistListFlatRows}
|
|
artistListVirtualizer={artistListVirtualizer}
|
|
artistListWrapRef={artistListWrapRef}
|
|
artistListScrollMargin={artistListScrollMargin}
|
|
selectionMode={selectionMode}
|
|
selectedIds={selectedIds}
|
|
selectedArtists={selectedArtists}
|
|
showArtistImages={showArtistImages}
|
|
toggleSelect={toggleSelect}
|
|
onOpenArtist={navigateToArtist}
|
|
openContextMenu={openContextMenu}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{!loading && gridHasMore && (
|
|
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={gridLoadingMore} />
|
|
)}
|
|
|
|
{!loading && !pendingLetterMatch && filtered.length === 0 && (
|
|
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
|
|
{t('artists.notFound')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isScrollRestorePending && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
<div className="spinner" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</OverlayScrollArea>
|
|
</div>
|
|
);
|
|
}
|