mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +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:
@@ -213,6 +213,8 @@ export interface LibraryAdvancedSearchRequest {
|
||||
offset?: number;
|
||||
/** Skip expensive COUNT queries (Live Search). */
|
||||
skipTotals?: boolean;
|
||||
/** Album text query matches title/name only (All Albums scoped browse). */
|
||||
queryAlbumTitleOnly?: boolean | null;
|
||||
}
|
||||
|
||||
export interface LibraryAlbumDto {
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelect
|
||||
import { useAppActivityTracking } from '../hooks/useAppActivityTracking';
|
||||
import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator';
|
||||
import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority';
|
||||
import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope';
|
||||
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
|
||||
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
|
||||
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
|
||||
@@ -101,6 +102,7 @@ export function AppShell() {
|
||||
const location = useLocation();
|
||||
const prevPathnameRef = useRef(location.pathname);
|
||||
useCoverNavigationPriority();
|
||||
useLiveSearchRouteScope();
|
||||
useNowPlayingPrewarm();
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
logLibrarySearch,
|
||||
} from '../utils/library/libraryDevLog';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -38,6 +38,19 @@ import { albumCoverRefForSong } from '../cover/ref';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useShareSearch } from '../hooks/useShareSearch';
|
||||
import ShareSearchResults from './search/ShareSearchResults';
|
||||
import {
|
||||
LiveSearchScopeBadge,
|
||||
LiveSearchScopeGhostBadge,
|
||||
createLiveSearchScopeBackspaceState,
|
||||
handleLiveSearchScopeBackspace,
|
||||
handleLiveSearchScopeUndo,
|
||||
isLiveSearchDropdownBlocked,
|
||||
liveSearchScopePlaceholderKey,
|
||||
noteLiveSearchScopeQueryInput,
|
||||
resetLiveSearchScopeBackspaceState,
|
||||
resolveLiveSearchScopeGhost,
|
||||
} from './search/liveSearchScopeUi';
|
||||
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||
import { resolveIndexKey } from '../utils/server/serverIndexKey';
|
||||
|
||||
type LiveSearchSource = 'local' | 'network';
|
||||
@@ -98,7 +111,15 @@ function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' |
|
||||
|
||||
export default function LiveSearch() {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const query = useLiveSearchScopeStore(s => s.query);
|
||||
const setQuery = useLiveSearchScopeStore(s => s.setQuery);
|
||||
const scope = useLiveSearchScopeStore(s => s.scope);
|
||||
const setScope = useLiveSearchScopeStore(s => s.setScope);
|
||||
const clearScope = useLiveSearchScopeStore(s => s.clearScope);
|
||||
const undoLiveSearch = useLiveSearchScopeStore(s => s.undo);
|
||||
const scopeBackspaceRef = useRef(createLiveSearchScopeBackspaceState());
|
||||
const location = useLocation();
|
||||
const ghostScope = resolveLiveSearchScopeGhost(location.pathname, scope);
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -136,6 +157,14 @@ export default function LiveSearch() {
|
||||
void refreshLocalReady();
|
||||
}, [refreshLocalReady, musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
resetLiveSearchScopeBackspaceState(scopeBackspaceRef.current);
|
||||
}, [scope]);
|
||||
|
||||
useEffect(() => {
|
||||
noteLiveSearchScopeQueryInput(scopeBackspaceRef.current, query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!indexEnabled || !serverId) return;
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
@@ -161,7 +190,16 @@ export default function LiveSearch() {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
setSearchSource(null);
|
||||
}, []);
|
||||
}, [setQuery]);
|
||||
|
||||
const handleQueryChange = useCallback((value: string) => {
|
||||
setQuery(value, { recordUndo: true });
|
||||
if (!value) {
|
||||
setResults(null);
|
||||
setOpen(false);
|
||||
setSearchSource(null);
|
||||
}
|
||||
}, [setQuery]);
|
||||
|
||||
/** Leave live search for a full-page route — cancel in-flight queries and reset overlay state. */
|
||||
const leaveLiveSearchFor = useCallback((path: string) => {
|
||||
@@ -175,11 +213,19 @@ export default function LiveSearch() {
|
||||
setIsFocused(false);
|
||||
inputRef.current?.blur();
|
||||
navigate(path);
|
||||
}, [navigate]);
|
||||
}, [navigate, setQuery]);
|
||||
|
||||
const share = useShareSearch(query, closeSearch);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLiveSearchDropdownBlocked(scope)) {
|
||||
setResults(null);
|
||||
setOpen(false);
|
||||
setSearchSource(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (share.shareMatch) {
|
||||
setResults(null);
|
||||
setLoading(false);
|
||||
@@ -338,9 +384,9 @@ export default function LiveSearch() {
|
||||
abort.abort();
|
||||
liveSearchGenRef.current += 1;
|
||||
};
|
||||
}, [query, share.shareMatch, serverId, indexEnabled, musicLibraryFilterVersion, t]);
|
||||
}, [query, scope, share.shareMatch, serverId, indexEnabled, musicLibraryFilterVersion, t]);
|
||||
|
||||
const isSearchActive = isFocused || open || query.trim().length > 0;
|
||||
const isSearchActive = isFocused || open || query.trim().length > 0 || scope != null;
|
||||
|
||||
useEffect(() => {
|
||||
const root = ref.current;
|
||||
@@ -485,6 +531,9 @@ export default function LiveSearch() {
|
||||
] : [];
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (handleLiveSearchScopeUndo(e, undoLiveSearch)) return;
|
||||
if (handleLiveSearchScopeBackspace(e, query, scope, clearScope, scopeBackspaceRef.current)) return;
|
||||
if (isLiveSearchDropdownBlocked(scope)) return;
|
||||
if (share.shareMatch) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -547,46 +596,50 @@ export default function LiveSearch() {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="live-search-icon animate-spin" style={{ opacity: 0.6 }}>
|
||||
<div style={{ width: 16, height: 16, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%' }} />
|
||||
</span>
|
||||
) : (
|
||||
<Search size={16} className="live-search-icon" />
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="live-search-input"
|
||||
className="input live-search-field"
|
||||
type="search"
|
||||
placeholder={t('search.placeholder')}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
if (results) setOpen(true);
|
||||
}}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="search-results"
|
||||
aria-expanded={open}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
className="live-search-clear"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
setResults(null);
|
||||
setOpen(false);
|
||||
setSearchSource(null);
|
||||
<div className="live-search-field-cluster">
|
||||
{loading ? (
|
||||
<span className="live-search-leading-icon animate-spin" style={{ opacity: 0.6 }}>
|
||||
<div style={{ width: 16, height: 16, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%' }} />
|
||||
</span>
|
||||
) : (
|
||||
<Search size={16} className="live-search-leading-icon" aria-hidden />
|
||||
)}
|
||||
{scope && (
|
||||
<LiveSearchScopeBadge
|
||||
scope={scope}
|
||||
className="live-search-scope-badge"
|
||||
clearScope={clearScope}
|
||||
/>
|
||||
)}
|
||||
{ghostScope && (
|
||||
<LiveSearchScopeGhostBadge
|
||||
scope={ghostScope}
|
||||
className="live-search-scope-badge live-search-scope-badge--ghost"
|
||||
setScope={setScope}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="live-search-input"
|
||||
className="input live-search-field"
|
||||
type="search"
|
||||
placeholder={t(liveSearchScopePlaceholderKey(scope))}
|
||||
data-tooltip={scope ? t(liveSearchScopePlaceholderKey(scope)) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
value={query}
|
||||
onChange={e => handleQueryChange(e.target.value)}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
if (!isLiveSearchDropdownBlocked(scope) && results) setOpen(true);
|
||||
}}
|
||||
aria-label={t('search.clearLabel')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="search-results"
|
||||
aria-expanded={open && !isLiveSearchDropdownBlocked(scope)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="live-search-adv-btn"
|
||||
type="button"
|
||||
@@ -607,7 +660,7 @@ export default function LiveSearch() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
{open && !isLiveSearchDropdownBlocked(scope) && (
|
||||
<div className="live-search-dropdown" id="search-results" role="listbox" ref={dropdownRef}>
|
||||
{searchSource && !share.shareMatch && (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { search } from '../api/subsonicSearch';
|
||||
import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
@@ -17,6 +18,18 @@ import { albumCoverRefForSong } from '../cover/ref';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useShareSearch } from '../hooks/useShareSearch';
|
||||
import ShareSearchResults from './search/ShareSearchResults';
|
||||
import {
|
||||
LiveSearchScopeBadge,
|
||||
LiveSearchScopeGhostBadge,
|
||||
createLiveSearchScopeBackspaceState,
|
||||
handleLiveSearchScopeBackspace,
|
||||
handleLiveSearchScopeUndo,
|
||||
isLiveSearchDropdownBlocked,
|
||||
liveSearchScopePlaceholderKey,
|
||||
noteLiveSearchScopeQueryInput,
|
||||
resetLiveSearchScopeBackspaceState,
|
||||
resolveLiveSearchScopeGhost,
|
||||
} from './search/liveSearchScopeUi';
|
||||
|
||||
const STORAGE_KEY = 'psysonic_recent_searches';
|
||||
const MAX_RECENT = 6;
|
||||
@@ -31,9 +44,12 @@ function saveRecent(q: string, prev: string[]): string[] {
|
||||
return updated;
|
||||
}
|
||||
|
||||
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
function debounce<A extends unknown[]>(
|
||||
fn: (...args: A) => void,
|
||||
ms: number,
|
||||
): (...args: A) => void {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
return (q: string) => { clearTimeout(timer); timer = setTimeout(() => fn(q), ms); };
|
||||
return (...args: A) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
|
||||
/** Mobile search row thumb — larger than desktop live search (32px). */
|
||||
@@ -93,7 +109,14 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
const location = useLocation();
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const query = useLiveSearchScopeStore(s => s.query);
|
||||
const setQuery = useLiveSearchScopeStore(s => s.setQuery);
|
||||
const scope = useLiveSearchScopeStore(s => s.scope);
|
||||
const setScope = useLiveSearchScopeStore(s => s.setScope);
|
||||
const clearScope = useLiveSearchScopeStore(s => s.clearScope);
|
||||
const undoLiveSearch = useLiveSearchScopeStore(s => s.undo);
|
||||
const scopeBackspaceRef = useRef(createLiveSearchScopeBackspaceState());
|
||||
const ghostScope = resolveLiveSearchScopeGhost(location.pathname, scope);
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
|
||||
@@ -103,6 +126,14 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
resetLiveSearchScopeBackspaceState(scopeBackspaceRef.current);
|
||||
}, [scope]);
|
||||
|
||||
useEffect(() => {
|
||||
noteLiveSearchScopeQueryInput(scopeBackspaceRef.current, query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
@@ -113,20 +144,26 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
debounce(async (q: string) => {
|
||||
if (!q.trim()) { setResults(null); setLoading(false); return; }
|
||||
setLoading(true);
|
||||
try { setResults(await search(q)); }
|
||||
finally { setLoading(false); }
|
||||
try {
|
||||
setResults(await search(q));
|
||||
} finally { setLoading(false); }
|
||||
}, 300),
|
||||
[musicLibraryFilterVersion]
|
||||
[musicLibraryFilterVersion],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLiveSearchDropdownBlocked(scope)) {
|
||||
setResults(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (share.shareMatch) {
|
||||
setResults(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
doSearch(query);
|
||||
}, [query, doSearch, share.shareMatch]);
|
||||
}, [query, scope, doSearch, share.shareMatch]);
|
||||
|
||||
const commit = (q: string) => {
|
||||
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
|
||||
@@ -146,7 +183,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
onClose();
|
||||
};
|
||||
const useRecent = (term: string) => {
|
||||
setQuery(term);
|
||||
setQuery(term, { recordUndo: true });
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
const removeRecent = (term: string, e: React.MouseEvent) => {
|
||||
@@ -159,27 +196,50 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
};
|
||||
|
||||
const hasResults =
|
||||
!!share.shareMatch ||
|
||||
(results && (results.artists.length || results.albums.length || results.songs.length));
|
||||
const showEmpty = !query;
|
||||
!isLiveSearchDropdownBlocked(scope)
|
||||
&& (
|
||||
!!share.shareMatch
|
||||
|| (results && (results.artists.length || results.albums.length || results.songs.length))
|
||||
);
|
||||
const showEmpty = !query && !scope;
|
||||
|
||||
return createPortal(
|
||||
<div className="mobile-search-overlay">
|
||||
{/* ── Search bar ── */}
|
||||
<div className="mobile-search-bar">
|
||||
<div className="mobile-search-field">
|
||||
<div className={`mobile-search-field${scope ? ' mobile-search-field--scoped' : ''}`}>
|
||||
{loading ? (
|
||||
<div className="mobile-search-spinner" />
|
||||
) : (
|
||||
<Search size={16} className="mobile-search-icon" />
|
||||
)}
|
||||
{scope && (
|
||||
<LiveSearchScopeBadge
|
||||
scope={scope}
|
||||
className="mobile-search-scope-badge"
|
||||
clearScope={clearScope}
|
||||
/>
|
||||
)}
|
||||
{ghostScope && (
|
||||
<LiveSearchScopeGhostBadge
|
||||
scope={ghostScope}
|
||||
className="mobile-search-scope-badge mobile-search-scope-badge--ghost"
|
||||
setScope={setScope}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="mobile-search-input"
|
||||
type="search"
|
||||
placeholder={t('search.placeholder')}
|
||||
placeholder={t(liveSearchScopePlaceholderKey(scope))}
|
||||
data-tooltip={scope ? t(liveSearchScopePlaceholderKey(scope)) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onChange={e => setQuery(e.target.value, { recordUndo: true })}
|
||||
onKeyDown={(e) => {
|
||||
if (handleLiveSearchScopeUndo(e, undoLiveSearch)) return;
|
||||
if (handleLiveSearchScopeBackspace(e, query, scope, clearScope, scopeBackspaceRef.current)) return;
|
||||
}}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
@@ -187,7 +247,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
{query && (
|
||||
<button
|
||||
className="mobile-search-clear"
|
||||
onClick={() => { setQuery(''); setResults(null); inputRef.current?.focus(); }}
|
||||
onClick={() => { setQuery('', { recordUndo: true }); setResults(null); inputRef.current?.focus(); }}
|
||||
aria-label={t('search.clearLabel')}
|
||||
>
|
||||
<X size={15} />
|
||||
@@ -249,7 +309,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
)}
|
||||
|
||||
{/* ── No results ── */}
|
||||
{!loading && query && !hasResults && (
|
||||
{!loading && query && !hasResults && !isLiveSearchDropdownBlocked(scope) && (
|
||||
<div className="mobile-search-noresults">
|
||||
{t('search.noResults', { query })}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useSongBrowseList } from '../hooks/useSongBrowseList';
|
||||
import SongBrowseSection from './tracks/SongBrowseSection';
|
||||
|
||||
@@ -9,14 +9,13 @@ interface Props {
|
||||
|
||||
/** @deprecated Use SongBrowseSection via SearchBrowsePage (`/tracks`). */
|
||||
export default function VirtualSongList({ title, emptyBrowseText }: Props) {
|
||||
const browse = useSongBrowseList({ enabled: true });
|
||||
const [searchQuery] = useState('');
|
||||
const browse = useSongBrowseList({ enabled: true, searchQuery });
|
||||
|
||||
return (
|
||||
<SongBrowseSection
|
||||
title={title}
|
||||
emptyBrowseText={emptyBrowseText}
|
||||
query={browse.query}
|
||||
onQueryChange={browse.setQuery}
|
||||
songs={browse.songs}
|
||||
hasMore={browse.hasMore}
|
||||
loading={browse.loading}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import React from 'react';
|
||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||
import { Check } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import type { PlayerState } from '../../store/playerStoreTypes';
|
||||
import { ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
|
||||
import { VirtualCardGrid } from '../VirtualCardGrid';
|
||||
import { ArtistCardAvatar } from './ArtistAvatars';
|
||||
|
||||
export type ArtistsGridVirtualization = {
|
||||
virtualizer: Virtualizer<HTMLElement, Element>;
|
||||
scrollMargin: number;
|
||||
};
|
||||
|
||||
interface TileProps {
|
||||
artist: SubsonicArtist;
|
||||
selectionMode: boolean;
|
||||
@@ -68,11 +64,10 @@ function ArtistGridTile({ artist, ...rest }: TileProps) {
|
||||
|
||||
interface Props {
|
||||
visible: SubsonicArtist[];
|
||||
/** Column count from layout (capped at six in parent); drives `repeat(n, minmax(0,1fr))`. */
|
||||
gridCols: number;
|
||||
/** ResizeObserver target — same node for plain and virtual grid. */
|
||||
measureRef: React.RefObject<HTMLDivElement | null>;
|
||||
virtualization?: ArtistsGridVirtualization | null;
|
||||
/** Plain CSS grid (canonical card layout) vs row virtualization for large catalogs. */
|
||||
disableVirtualization: boolean;
|
||||
/** Remount grid when browse filters change so virtualizer state cannot go stale. */
|
||||
layoutKey: string;
|
||||
selectionMode: boolean;
|
||||
selectedIds: Set<string>;
|
||||
selectedArtists: SubsonicArtist[];
|
||||
@@ -84,14 +79,12 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* Card grid for the artists page. Optional row virtualization (TanStack) for
|
||||
* large libraries; column count and `measureRef` always come from the parent.
|
||||
* Card grid for the artists page — same VirtualCardGrid path as Albums/Composers.
|
||||
*/
|
||||
export function ArtistsGridView({
|
||||
visible,
|
||||
gridCols,
|
||||
measureRef,
|
||||
virtualization,
|
||||
disableVirtualization,
|
||||
layoutKey,
|
||||
selectionMode,
|
||||
selectedIds,
|
||||
selectedArtists,
|
||||
@@ -112,67 +105,19 @@ export function ArtistsGridView({
|
||||
t,
|
||||
};
|
||||
|
||||
const cols = Math.max(1, gridCols);
|
||||
|
||||
if (virtualization) {
|
||||
const { virtualizer, scrollMargin } = virtualization;
|
||||
const rowCount = Math.ceil(visible.length / cols);
|
||||
return (
|
||||
<div
|
||||
ref={measureRef}
|
||||
className="album-grid-wrap"
|
||||
style={{ display: 'block', position: 'relative', width: '100%' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: rowCount === 0 ? 0 : virtualizer.getTotalSize(),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map(vRow => {
|
||||
const start = vRow.index * cols;
|
||||
const rowArtists = visible.slice(start, start + cols);
|
||||
return (
|
||||
<div
|
||||
key={vRow.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vRow.start - scrollMargin}px)`,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
gap: 'var(--space-4)',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{rowArtists.map(artist => (
|
||||
<ArtistGridTile key={artist.id} artist={artist} {...tilePropsShared} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={measureRef}
|
||||
className="album-grid-wrap"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
gap: 'var(--space-4)',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{visible.map(artist => (
|
||||
<VirtualCardGrid
|
||||
key={layoutKey}
|
||||
items={visible}
|
||||
itemKey={(artist) => artist.id}
|
||||
rowVariant="artist"
|
||||
disableVirtualization={disableVirtualization}
|
||||
layoutSignal={visible.length}
|
||||
scrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
wrapClassName={disableVirtualization ? 'album-grid-wrap album-grid-wrap--plain' : 'album-grid-wrap'}
|
||||
renderItem={artist => (
|
||||
<ArtistGridTile key={artist.id} artist={artist} {...tilePropsShared} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
|
||||
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { queueSongStar } from '../../store/pendingStarSync';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
|
||||
@@ -30,8 +30,8 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const navigateToAlbum = useNavigateToAlbum();
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -108,7 +108,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
)}
|
||||
{song.artistId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${song.artistId}`))}>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigateToArtist(song.artistId!))}>
|
||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||
</div>
|
||||
)}
|
||||
@@ -251,7 +251,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
)}
|
||||
{song.artistId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${song.artistId}`))}>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigateToArtist(song.artistId!))}>
|
||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { KeyboardEvent, MouseEvent } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createLiveSearchScopeBackspaceState,
|
||||
handleLiveSearchScopeBackspace,
|
||||
handleLiveSearchScopeBadgeClick,
|
||||
handleLiveSearchScopeGhostClick,
|
||||
handleLiveSearchScopeUndo,
|
||||
isLiveSearchDropdownBlocked,
|
||||
liveSearchScopePlaceholderKey,
|
||||
noteLiveSearchScopeQueryInput,
|
||||
resetLiveSearchScopeBackspaceState,
|
||||
resolveLiveSearchScopeGhost,
|
||||
} from './liveSearchScopeUi';
|
||||
|
||||
function keyEvent(
|
||||
key: string,
|
||||
mods: Partial<KeyboardEvent<HTMLInputElement>> & { code?: string } = {},
|
||||
) {
|
||||
const { code, ...rest } = mods;
|
||||
return {
|
||||
key,
|
||||
code: code ?? key,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
...rest,
|
||||
} as unknown as KeyboardEvent<HTMLInputElement>;
|
||||
}
|
||||
|
||||
describe('resolveLiveSearchScopeGhost', () => {
|
||||
it('offers artists ghost on artists browse when scope was cleared', () => {
|
||||
expect(resolveLiveSearchScopeGhost('/artists', null)).toBe('artists');
|
||||
expect(resolveLiveSearchScopeGhost('/artists', 'artists')).toBeNull();
|
||||
expect(resolveLiveSearchScopeGhost('/albums', null)).toBe('albums');
|
||||
expect(resolveLiveSearchScopeGhost('/albums', 'albums')).toBeNull();
|
||||
expect(resolveLiveSearchScopeGhost('/new-releases', null)).toBe('newReleases');
|
||||
expect(resolveLiveSearchScopeGhost('/new-releases', 'newReleases')).toBeNull();
|
||||
expect(resolveLiveSearchScopeGhost('/tracks', null)).toBe('tracks');
|
||||
expect(resolveLiveSearchScopeGhost('/tracks', 'tracks')).toBeNull();
|
||||
expect(resolveLiveSearchScopeGhost('/composers', null)).toBe('composers');
|
||||
expect(resolveLiveSearchScopeGhost('/composers', 'composers')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLiveSearchScopeBadgeClick', () => {
|
||||
it('clears scope with undo', () => {
|
||||
const clearScope = vi.fn();
|
||||
const e = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as MouseEvent<HTMLElement>;
|
||||
handleLiveSearchScopeBadgeClick(e, clearScope);
|
||||
expect(clearScope).toHaveBeenCalledWith({ recordUndo: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLiveSearchScopeGhostClick', () => {
|
||||
it('restores scope with undo', () => {
|
||||
const setScope = vi.fn();
|
||||
const e = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as MouseEvent<HTMLElement>;
|
||||
handleLiveSearchScopeGhostClick(e, 'artists', setScope);
|
||||
expect(setScope).toHaveBeenCalledWith('artists', { recordUndo: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLiveSearchScopeBackspace', () => {
|
||||
it('clears scope on first Backspace when the field was never filled', () => {
|
||||
const clearScope = vi.fn();
|
||||
const state = createLiveSearchScopeBackspaceState();
|
||||
const e = keyEvent('Backspace');
|
||||
expect(handleLiveSearchScopeBackspace(e, '', 'artists', clearScope, state)).toBe(true);
|
||||
expect(clearScope).toHaveBeenCalledWith({ recordUndo: true });
|
||||
});
|
||||
|
||||
it('requires two Backspaces on empty after prior input', () => {
|
||||
const clearScope = vi.fn();
|
||||
const state = createLiveSearchScopeBackspaceState();
|
||||
noteLiveSearchScopeQueryInput(state, 'ab');
|
||||
|
||||
const first = keyEvent('Backspace');
|
||||
expect(handleLiveSearchScopeBackspace(first, '', 'artists', clearScope, state)).toBe(true);
|
||||
expect(clearScope).not.toHaveBeenCalled();
|
||||
|
||||
const second = keyEvent('Backspace');
|
||||
expect(handleLiveSearchScopeBackspace(second, '', 'artists', clearScope, state)).toBe(true);
|
||||
expect(clearScope).toHaveBeenCalledWith({ recordUndo: true });
|
||||
});
|
||||
|
||||
it('does not clear scope while text remains', () => {
|
||||
const clearScope = vi.fn();
|
||||
const state = createLiveSearchScopeBackspaceState();
|
||||
expect(handleLiveSearchScopeBackspace(keyEvent('Backspace'), 'a', 'artists', clearScope, state)).toBe(false);
|
||||
expect(clearScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets empty streak when deleting characters', () => {
|
||||
const clearScope = vi.fn();
|
||||
const state = createLiveSearchScopeBackspaceState();
|
||||
noteLiveSearchScopeQueryInput(state, 'a');
|
||||
handleLiveSearchScopeBackspace(keyEvent('Backspace'), '', 'artists', clearScope, state);
|
||||
expect(state.emptyBackspaceStreak).toBe(1);
|
||||
handleLiveSearchScopeBackspace(keyEvent('Backspace'), 'x', 'artists', clearScope, state);
|
||||
expect(state.emptyBackspaceStreak).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetLiveSearchScopeBackspaceState', () => {
|
||||
it('clears hadQueryInput and streak', () => {
|
||||
const state = createLiveSearchScopeBackspaceState();
|
||||
noteLiveSearchScopeQueryInput(state, 'x');
|
||||
state.emptyBackspaceStreak = 1;
|
||||
resetLiveSearchScopeBackspaceState(state);
|
||||
expect(state.hadQueryInput).toBe(false);
|
||||
expect(state.emptyBackspaceStreak).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLiveSearchDropdownBlocked', () => {
|
||||
it('blocks dropdown when a browse scope badge is active', () => {
|
||||
expect(isLiveSearchDropdownBlocked('artists')).toBe(true);
|
||||
expect(isLiveSearchDropdownBlocked(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('liveSearchScopePlaceholderKey', () => {
|
||||
it('uses scoped placeholders when a browse scope badge is active', () => {
|
||||
expect(liveSearchScopePlaceholderKey('artists')).toBe('search.scopeArtistsPlaceholder');
|
||||
expect(liveSearchScopePlaceholderKey('albums')).toBe('search.scopeAlbumsPlaceholder');
|
||||
expect(liveSearchScopePlaceholderKey('newReleases')).toBe('search.scopeNewReleasesPlaceholder');
|
||||
expect(liveSearchScopePlaceholderKey('tracks')).toBe('search.scopeTracksPlaceholder');
|
||||
expect(liveSearchScopePlaceholderKey('composers')).toBe('search.scopeComposersPlaceholder');
|
||||
expect(liveSearchScopePlaceholderKey(null)).toBe('search.placeholder');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLiveSearchScopeUndo', () => {
|
||||
it('calls undo on Ctrl+Z (English layout)', () => {
|
||||
const undo = vi.fn(() => true);
|
||||
const e = keyEvent('z', { ctrlKey: true, code: 'KeyZ' });
|
||||
expect(handleLiveSearchScopeUndo(e, undo)).toBe(true);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(undo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls undo on Ctrl+Z with non-Latin key label (e.g. Russian Я)', () => {
|
||||
const undo = vi.fn(() => true);
|
||||
const e = keyEvent('я', { ctrlKey: true, code: 'KeyZ' });
|
||||
expect(handleLiveSearchScopeUndo(e, undo)).toBe(true);
|
||||
expect(undo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores plain z', () => {
|
||||
const undo = vi.fn();
|
||||
expect(handleLiveSearchScopeUndo(keyEvent('z'), undo)).toBe(false);
|
||||
expect(undo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { KeyboardEvent, MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ALL_NAV_ITEMS } from '../../config/navItems';
|
||||
import type { LiveSearchScope } from '../../store/liveSearchScopeStore';
|
||||
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '../../store/albumBrowseSessionStore';
|
||||
import { isTracksBrowsePath } from '../../store/advancedSearchSessionStore';
|
||||
import { isArtistsBrowsePath } from '../../store/artistBrowseSessionStore';
|
||||
import { isComposersBrowsePath } from '../../store/composerBrowseSessionStore';
|
||||
|
||||
const SCOPE_NAV_ITEM: Record<LiveSearchScope, keyof typeof ALL_NAV_ITEMS> = {
|
||||
artists: 'artists',
|
||||
albums: 'allAlbums',
|
||||
newReleases: 'newReleases',
|
||||
tracks: 'tracks',
|
||||
composers: 'composers',
|
||||
};
|
||||
|
||||
/** Scope to restore when on a browse route but the badge was cleared (global search mode). */
|
||||
export function resolveLiveSearchScopeGhost(
|
||||
pathname: string,
|
||||
activeScope: LiveSearchScope | null,
|
||||
): LiveSearchScope | null {
|
||||
if (activeScope != null) return null;
|
||||
if (isArtistsBrowsePath(pathname)) return 'artists';
|
||||
if (isAlbumsBrowsePath(pathname)) return 'albums';
|
||||
if (isNewReleasesBrowsePath(pathname)) return 'newReleases';
|
||||
if (isTracksBrowsePath(pathname)) return 'tracks';
|
||||
if (isComposersBrowsePath(pathname)) return 'composers';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function liveSearchScopePlaceholderKey(scope: LiveSearchScope | null): string {
|
||||
switch (scope) {
|
||||
case 'artists':
|
||||
return 'search.scopeArtistsPlaceholder';
|
||||
case 'albums':
|
||||
return 'search.scopeAlbumsPlaceholder';
|
||||
case 'newReleases':
|
||||
return 'search.scopeNewReleasesPlaceholder';
|
||||
case 'tracks':
|
||||
return 'search.scopeTracksPlaceholder';
|
||||
case 'composers':
|
||||
return 'search.scopeComposersPlaceholder';
|
||||
default:
|
||||
return 'search.placeholder';
|
||||
}
|
||||
}
|
||||
|
||||
/** Scoped browse mode filters the page only — no live-search dropdown. */
|
||||
export function isLiveSearchDropdownBlocked(scope: LiveSearchScope | null): boolean {
|
||||
return scope != null;
|
||||
}
|
||||
|
||||
export function liveSearchScopeBadgeTooltipKey(scope: LiveSearchScope): string {
|
||||
switch (scope) {
|
||||
case 'artists':
|
||||
return 'search.scopeArtistsBadgeTooltip';
|
||||
case 'albums':
|
||||
return 'search.scopeAlbumsBadgeTooltip';
|
||||
case 'newReleases':
|
||||
return 'search.scopeNewReleasesBadgeTooltip';
|
||||
case 'tracks':
|
||||
return 'search.scopeTracksBadgeTooltip';
|
||||
case 'composers':
|
||||
return 'search.scopeComposersBadgeTooltip';
|
||||
default:
|
||||
return 'search.scopeArtistsBadgeTooltip';
|
||||
}
|
||||
}
|
||||
|
||||
export function liveSearchScopeGhostTooltipKey(scope: LiveSearchScope): string {
|
||||
switch (scope) {
|
||||
case 'artists':
|
||||
return 'search.scopeArtistsGhostTooltip';
|
||||
case 'albums':
|
||||
return 'search.scopeAlbumsGhostTooltip';
|
||||
case 'newReleases':
|
||||
return 'search.scopeNewReleasesGhostTooltip';
|
||||
case 'tracks':
|
||||
return 'search.scopeTracksGhostTooltip';
|
||||
case 'composers':
|
||||
return 'search.scopeComposersGhostTooltip';
|
||||
default:
|
||||
return 'search.scopeArtistsGhostTooltip';
|
||||
}
|
||||
}
|
||||
|
||||
type LiveSearchScopeIconProps = {
|
||||
scope: LiveSearchScope;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/** Sidebar nav icon for the scoped browse page (e.g. Users for Artists). */
|
||||
export function LiveSearchScopeIcon({ scope, size = 14 }: LiveSearchScopeIconProps) {
|
||||
const Icon = ALL_NAV_ITEMS[SCOPE_NAV_ITEM[scope]].icon;
|
||||
return <Icon size={size} aria-hidden />;
|
||||
}
|
||||
|
||||
/** Tracks Backspace-on-empty badge removal (double after prior text input). */
|
||||
export type LiveSearchScopeBackspaceState = {
|
||||
hadQueryInput: boolean;
|
||||
emptyBackspaceStreak: number;
|
||||
};
|
||||
|
||||
export function createLiveSearchScopeBackspaceState(): LiveSearchScopeBackspaceState {
|
||||
return { hadQueryInput: false, emptyBackspaceStreak: 0 };
|
||||
}
|
||||
|
||||
export function resetLiveSearchScopeBackspaceState(state: LiveSearchScopeBackspaceState): void {
|
||||
state.hadQueryInput = false;
|
||||
state.emptyBackspaceStreak = 0;
|
||||
}
|
||||
|
||||
/** Call when the scoped field query changes (typing, paste, clear button, undo). */
|
||||
export function noteLiveSearchScopeQueryInput(
|
||||
state: LiveSearchScopeBackspaceState,
|
||||
query: string,
|
||||
): void {
|
||||
if (query !== '') state.hadQueryInput = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backspace on an empty scoped field removes the badge.
|
||||
* After the user typed text (even if cleared), two consecutive Backspaces on empty are required.
|
||||
*/
|
||||
export function handleLiveSearchScopeBackspace(
|
||||
e: KeyboardEvent<HTMLInputElement>,
|
||||
query: string,
|
||||
scope: LiveSearchScope | null,
|
||||
clearScope: (options?: { recordUndo?: boolean }) => void,
|
||||
state: LiveSearchScopeBackspaceState,
|
||||
): boolean {
|
||||
if (e.key !== 'Backspace' || !scope) return false;
|
||||
|
||||
if (query !== '') {
|
||||
state.emptyBackspaceStreak = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (!state.hadQueryInput) {
|
||||
clearScope({ recordUndo: true });
|
||||
resetLiveSearchScopeBackspaceState(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
state.emptyBackspaceStreak += 1;
|
||||
if (state.emptyBackspaceStreak >= 2) {
|
||||
clearScope({ recordUndo: true });
|
||||
resetLiveSearchScopeBackspaceState(state);
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Single click removes the scope badge. */
|
||||
export function handleLiveSearchScopeBadgeClick(
|
||||
e: MouseEvent<HTMLElement>,
|
||||
clearScope: (options?: { recordUndo?: boolean }) => void,
|
||||
): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearScope({ recordUndo: true });
|
||||
}
|
||||
|
||||
/** Single click restores scope after the user cleared the badge on this browse route. */
|
||||
export function handleLiveSearchScopeGhostClick(
|
||||
e: MouseEvent<HTMLElement>,
|
||||
scope: LiveSearchScope,
|
||||
setScope: (scope: LiveSearchScope, options?: { recordUndo?: boolean }) => void,
|
||||
): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setScope(scope, { recordUndo: true });
|
||||
}
|
||||
|
||||
type LiveSearchScopeBadgeProps = {
|
||||
scope: LiveSearchScope;
|
||||
className: string;
|
||||
clearScope: (options?: { recordUndo?: boolean }) => void;
|
||||
};
|
||||
|
||||
export function LiveSearchScopeBadge({ scope, className, clearScope }: LiveSearchScopeBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = t(liveSearchScopeBadgeTooltipKey(scope));
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label={tooltip}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => handleLiveSearchScopeBadgeClick(e, clearScope)}
|
||||
>
|
||||
<LiveSearchScopeIcon scope={scope} size={14} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type LiveSearchScopeGhostBadgeProps = {
|
||||
scope: LiveSearchScope;
|
||||
className: string;
|
||||
setScope: (scope: LiveSearchScope, options?: { recordUndo?: boolean }) => void;
|
||||
};
|
||||
|
||||
export function LiveSearchScopeGhostBadge({ scope, className, setScope }: LiveSearchScopeGhostBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = t(liveSearchScopeGhostTooltipKey(scope));
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label={tooltip}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => handleLiveSearchScopeGhostClick(e, scope, setScope)}
|
||||
>
|
||||
<LiveSearchScopeIcon scope={scope} size={14} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Field-local undo (Ctrl/Cmd+Z) for live search query and scope badge. */
|
||||
export function handleLiveSearchScopeUndo(
|
||||
e: KeyboardEvent<HTMLInputElement>,
|
||||
undo: () => boolean,
|
||||
): boolean {
|
||||
const isUndoKey = e.code === 'KeyZ' || e.key.toLowerCase() === 'z';
|
||||
if (!isUndoKey || !(e.ctrlKey || e.metaKey) || e.shiftKey) return false;
|
||||
e.preventDefault();
|
||||
return undo();
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import React from 'react';
|
||||
import { Search as SearchIcon, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PagedSongList from '../PagedSongList';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
emptyBrowseText?: string;
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
searchActive?: boolean;
|
||||
songs: SubsonicSong[];
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
@@ -16,12 +14,11 @@ interface Props {
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
/** Tracks hub toolbar + paginated song list (shared chrome with Search song results). */
|
||||
/** Tracks hub toolbar + paginated song list (search lives in the header live search field). */
|
||||
export default function SongBrowseSection({
|
||||
title,
|
||||
emptyBrowseText,
|
||||
query,
|
||||
onQueryChange,
|
||||
searchActive = false,
|
||||
songs,
|
||||
hasMore,
|
||||
loading,
|
||||
@@ -29,31 +26,15 @@ export default function SongBrowseSection({
|
||||
onLoadMore,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const showEmptyBrowse = !loading && songs.length === 0 && query.trim() === '' && (browseUnsupported || !hasMore);
|
||||
const showEmptyBrowse =
|
||||
!loading && songs.length === 0 && !searchActive && (browseUnsupported || !hasMore);
|
||||
|
||||
return (
|
||||
<section className="virtual-song-list-section">
|
||||
{title && <h2 className="section-title virtual-song-list-title">{title}</h2>}
|
||||
{title && !searchActive && (
|
||||
<h2 className="section-title virtual-song-list-title">{title}</h2>
|
||||
)}
|
||||
<div className="virtual-song-list-toolbar">
|
||||
<div className="virtual-song-list-search">
|
||||
<SearchIcon size={16} className="virtual-song-list-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="input virtual-song-list-search-input"
|
||||
placeholder={t('tracks.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={e => onQueryChange(e.target.value)}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
className="virtual-song-list-search-clear"
|
||||
onClick={() => onQueryChange('')}
|
||||
aria-label={t('search.clearLabel')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="virtual-song-list-meta">
|
||||
{songs.length > 0 && (
|
||||
<span>{t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''}</span>
|
||||
|
||||
@@ -24,9 +24,12 @@ const TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 14;
|
||||
/** Tracks hub hero + song rails (above the browse-all list). */
|
||||
export default function TracksPageChrome({
|
||||
onLayoutReady,
|
||||
hideDiscoveryChrome = false,
|
||||
}: {
|
||||
/** Fires once when hero + rails finish their initial load (or fail). */
|
||||
onLayoutReady?: () => void;
|
||||
/** When true, skip hero and song rails (active scoped search). */
|
||||
hideDiscoveryChrome?: boolean;
|
||||
}) {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { t } = useTranslation();
|
||||
@@ -79,15 +82,15 @@ export default function TracksPageChrome({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeServerId) return;
|
||||
if (!activeServerId || hideDiscoveryChrome) return;
|
||||
rerollHero();
|
||||
rerollRandom();
|
||||
reloadRated();
|
||||
}, [activeServerId, rerollHero, rerollRandom, reloadRated]);
|
||||
}, [activeServerId, hideDiscoveryChrome, rerollHero, rerollRandom, reloadRated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onLayoutReady || layoutReadyNotifiedRef.current) return;
|
||||
if (!activeServerId) {
|
||||
if (hideDiscoveryChrome || !activeServerId) {
|
||||
layoutReadyNotifiedRef.current = true;
|
||||
onLayoutReady();
|
||||
return;
|
||||
@@ -95,7 +98,7 @@ export default function TracksPageChrome({
|
||||
if (heroLoading || randomLoading || ratedLoading) return;
|
||||
layoutReadyNotifiedRef.current = true;
|
||||
onLayoutReady();
|
||||
}, [activeServerId, onLayoutReady, heroLoading, randomLoading, ratedLoading]);
|
||||
}, [activeServerId, hideDiscoveryChrome, onLayoutReady, heroLoading, randomLoading, ratedLoading]);
|
||||
|
||||
const railSongs = useMemo(
|
||||
() => (hero ? random.filter(s => s.id !== hero.id) : random),
|
||||
@@ -108,12 +111,14 @@ export default function TracksPageChrome({
|
||||
<header className="tracks-header">
|
||||
<div className="tracks-header-text">
|
||||
<h1 className="page-title">{t('tracks.title')}</h1>
|
||||
<p className="tracks-subtitle">{t('tracks.subtitle')}</p>
|
||||
{!hideDiscoveryChrome && (
|
||||
<p className="tracks-subtitle">{t('tracks.subtitle')}</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{!perfFlags.disableMainstageHero && hero && (
|
||||
{!perfFlags.disableMainstageHero && !hideDiscoveryChrome && hero && (
|
||||
<section className="tracks-hero">
|
||||
<div className="tracks-hero-cover">
|
||||
{hero.albumId && hero.coverArt ? (
|
||||
@@ -173,7 +178,7 @@ export default function TracksPageChrome({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!perfFlags.disableMainstageRails && ratedSupported && (ratedLoading || rated.length > 0) && (
|
||||
{!perfFlags.disableMainstageRails && !hideDiscoveryChrome && ratedSupported && (ratedLoading || rated.length > 0) && (
|
||||
<SongRail
|
||||
title={t('tracks.railHighlyRated')}
|
||||
songs={rated}
|
||||
@@ -184,7 +189,7 @@ export default function TracksPageChrome({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!perfFlags.disableMainstageRails && (
|
||||
{!perfFlags.disableMainstageRails && !hideDiscoveryChrome && (
|
||||
<SongRail
|
||||
title={t('tracks.railRandom')}
|
||||
songs={railSongs}
|
||||
|
||||
@@ -142,6 +142,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Artist page: top-track thumbnails use the same album cover path and warm batch as the albums grid (PR #886)',
|
||||
'Library browse navigation: session restore on back for Artists, Search/Tracks, New Releases, Random Albums; unified SearchBrowsePage (PR #936)',
|
||||
'Genres: local index genre browse with Subsonic fallback, aligned counts cache, scroll restore, hold-to-shuffle play (PR #937)',
|
||||
'Live Search: scoped browse on Artists, Albums, New Releases, Tracks, and Composers — header badge, ghost restore, album title FTS, session stash (PR #938)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,6 +39,21 @@ export const search = {
|
||||
recentSearches: 'Zuletzt gesucht',
|
||||
browse: 'Stöbern',
|
||||
emptyHint: 'Was möchtest du hören?',
|
||||
scopeArtistsPlaceholder: 'Künstler suchen…',
|
||||
scopeArtistsBadgeTooltip: 'Klicken zum Entfernen',
|
||||
scopeArtistsGhostTooltip: 'Klicken — nur auf dieser Seite suchen',
|
||||
scopeAlbumsPlaceholder: 'Album suchen…',
|
||||
scopeAlbumsBadgeTooltip: 'Klicken zum Entfernen',
|
||||
scopeAlbumsGhostTooltip: 'Klicken — nur auf dieser Seite suchen',
|
||||
scopeNewReleasesPlaceholder: 'Neuerscheinungen suchen…',
|
||||
scopeNewReleasesBadgeTooltip: 'Klicken zum Entfernen',
|
||||
scopeNewReleasesGhostTooltip: 'Klicken — nur auf dieser Seite suchen',
|
||||
scopeTracksPlaceholder: 'Titel, Künstler oder Album suchen…',
|
||||
scopeTracksBadgeTooltip: 'Klicken zum Entfernen',
|
||||
scopeTracksGhostTooltip: 'Klicken — nur auf dieser Seite suchen',
|
||||
scopeComposersPlaceholder: 'Komponist suchen…',
|
||||
scopeComposersBadgeTooltip: 'Klicken — entfernen',
|
||||
scopeComposersGhostTooltip: 'Klicken — nur auf dieser Seite suchen',
|
||||
genres: 'Genres',
|
||||
shareLink: 'Share link',
|
||||
shareTrackTitle: 'Shared track',
|
||||
|
||||
@@ -39,6 +39,21 @@ export const search = {
|
||||
recentSearches: 'Recent Searches',
|
||||
browse: 'Browse',
|
||||
emptyHint: 'What do you want to hear?',
|
||||
scopeArtistsPlaceholder: 'Search for artist…',
|
||||
scopeArtistsBadgeTooltip: 'Click to remove',
|
||||
scopeArtistsGhostTooltip: 'Click to search on this page only',
|
||||
scopeAlbumsPlaceholder: 'Search for album…',
|
||||
scopeAlbumsBadgeTooltip: 'Click to remove',
|
||||
scopeAlbumsGhostTooltip: 'Click to search on this page only',
|
||||
scopeNewReleasesPlaceholder: 'Search new releases…',
|
||||
scopeNewReleasesBadgeTooltip: 'Click to remove',
|
||||
scopeNewReleasesGhostTooltip: 'Click to search on this page only',
|
||||
scopeTracksPlaceholder: 'Find a track by title, artist or album…',
|
||||
scopeTracksBadgeTooltip: 'Click to remove',
|
||||
scopeTracksGhostTooltip: 'Click to search on this page only',
|
||||
scopeComposersPlaceholder: 'Search for composer…',
|
||||
scopeComposersBadgeTooltip: 'Click to remove',
|
||||
scopeComposersGhostTooltip: 'Click to search on this page only',
|
||||
genres: 'Genres',
|
||||
shareLink: 'Share link',
|
||||
shareTrackTitle: 'Shared track',
|
||||
|
||||
@@ -39,6 +39,21 @@ export const search = {
|
||||
recentSearches: 'Búsquedas Recientes',
|
||||
browse: 'Explorar',
|
||||
emptyHint: '¿Qué quieres escuchar?',
|
||||
scopeArtistsPlaceholder: 'Buscar artista…',
|
||||
scopeArtistsBadgeTooltip: 'Clic para quitar',
|
||||
scopeArtistsGhostTooltip: 'Clic — buscar solo en esta página',
|
||||
scopeAlbumsPlaceholder: 'Buscar álbum…',
|
||||
scopeAlbumsBadgeTooltip: 'Clic para quitar',
|
||||
scopeAlbumsGhostTooltip: 'Clic — buscar solo en esta página',
|
||||
scopeNewReleasesPlaceholder: 'Buscar novedades…',
|
||||
scopeNewReleasesBadgeTooltip: 'Clic para quitar',
|
||||
scopeNewReleasesGhostTooltip: 'Clic — buscar solo en esta página',
|
||||
scopeTracksPlaceholder: 'Busca una canción por título, artista o álbum…',
|
||||
scopeTracksBadgeTooltip: 'Clic para quitar',
|
||||
scopeTracksGhostTooltip: 'Clic — buscar solo en esta página',
|
||||
scopeComposersPlaceholder: 'Buscar compositor…',
|
||||
scopeComposersBadgeTooltip: 'Clic — quitar',
|
||||
scopeComposersGhostTooltip: 'Clic — buscar solo en esta página',
|
||||
genres: 'Géneros',
|
||||
shareLink: 'Share link',
|
||||
shareTrackTitle: 'Shared track',
|
||||
|
||||
@@ -39,6 +39,21 @@ export const search = {
|
||||
recentSearches: 'Recherches récentes',
|
||||
browse: 'Parcourir',
|
||||
emptyHint: 'Que veux-tu écouter ?',
|
||||
scopeArtistsPlaceholder: 'Rechercher un artiste…',
|
||||
scopeArtistsBadgeTooltip: 'Clic pour retirer',
|
||||
scopeArtistsGhostTooltip: 'Clic — rechercher sur cette page seulement',
|
||||
scopeAlbumsPlaceholder: 'Rechercher un album…',
|
||||
scopeAlbumsBadgeTooltip: 'Clic pour retirer',
|
||||
scopeAlbumsGhostTooltip: 'Clic — rechercher sur cette page seulement',
|
||||
scopeNewReleasesPlaceholder: 'Rechercher dans les nouveautés…',
|
||||
scopeNewReleasesBadgeTooltip: 'Clic pour retirer',
|
||||
scopeNewReleasesGhostTooltip: 'Clic — rechercher sur cette page seulement',
|
||||
scopeTracksPlaceholder: 'Chercher un titre par titre, artiste ou album…',
|
||||
scopeTracksBadgeTooltip: 'Clic pour retirer',
|
||||
scopeTracksGhostTooltip: 'Clic — rechercher sur cette page seulement',
|
||||
scopeComposersPlaceholder: 'Rechercher un compositeur…',
|
||||
scopeComposersBadgeTooltip: 'Clic — retirer',
|
||||
scopeComposersGhostTooltip: 'Clic — rechercher sur cette page seulement',
|
||||
genres: 'Genres',
|
||||
shareLink: 'Share link',
|
||||
shareTrackTitle: 'Shared track',
|
||||
|
||||
@@ -39,6 +39,21 @@ export const search = {
|
||||
recentSearches: 'Siste søk',
|
||||
browse: 'Utforsk',
|
||||
emptyHint: 'Hva vil du høre?',
|
||||
scopeArtistsPlaceholder: 'Søk etter artist…',
|
||||
scopeArtistsBadgeTooltip: 'Klikk for å fjerne',
|
||||
scopeArtistsGhostTooltip: 'Klikk — søk bare på denne siden',
|
||||
scopeAlbumsPlaceholder: 'Søk etter album…',
|
||||
scopeAlbumsBadgeTooltip: 'Klikk for å fjerne',
|
||||
scopeAlbumsGhostTooltip: 'Klikk — søk bare på denne siden',
|
||||
scopeNewReleasesPlaceholder: 'Søk i nye utgivelser…',
|
||||
scopeNewReleasesBadgeTooltip: 'Klikk for å fjerne',
|
||||
scopeNewReleasesGhostTooltip: 'Klikk — søk bare på denne siden',
|
||||
scopeTracksPlaceholder: 'Finn et spor etter tittel, artist eller album…',
|
||||
scopeTracksBadgeTooltip: 'Klikk for å fjerne',
|
||||
scopeTracksGhostTooltip: 'Klikk — søk bare på denne siden',
|
||||
scopeComposersPlaceholder: 'Søk komponist…',
|
||||
scopeComposersBadgeTooltip: 'Klikk — fjern',
|
||||
scopeComposersGhostTooltip: 'Klikk — søk bare på denne siden',
|
||||
genres: 'Sjangre',
|
||||
shareLink: 'Share link',
|
||||
shareTrackTitle: 'Shared track',
|
||||
|
||||
@@ -39,6 +39,21 @@ export const search = {
|
||||
recentSearches: 'Recente zoekopdrachten',
|
||||
browse: 'Bladeren',
|
||||
emptyHint: 'Wat wil je horen?',
|
||||
scopeArtistsPlaceholder: 'Artiest zoeken…',
|
||||
scopeArtistsBadgeTooltip: 'Klik om te verwijderen',
|
||||
scopeArtistsGhostTooltip: 'Klik — alleen op deze pagina zoeken',
|
||||
scopeAlbumsPlaceholder: 'Album zoeken…',
|
||||
scopeAlbumsBadgeTooltip: 'Klik om te verwijderen',
|
||||
scopeAlbumsGhostTooltip: 'Klik — alleen op deze pagina zoeken',
|
||||
scopeNewReleasesPlaceholder: 'Zoek in nieuwe releases…',
|
||||
scopeNewReleasesBadgeTooltip: 'Klik om te verwijderen',
|
||||
scopeNewReleasesGhostTooltip: 'Klik — alleen op deze pagina zoeken',
|
||||
scopeTracksPlaceholder: 'Zoek op titel, artiest of album…',
|
||||
scopeTracksBadgeTooltip: 'Klik om te verwijderen',
|
||||
scopeTracksGhostTooltip: 'Klik — alleen op deze pagina zoeken',
|
||||
scopeComposersPlaceholder: 'Componist zoeken…',
|
||||
scopeComposersBadgeTooltip: 'Klik — verwijderen',
|
||||
scopeComposersGhostTooltip: 'Klik — alleen op deze pagina zoeken',
|
||||
genres: 'Genres',
|
||||
shareLink: 'Share link',
|
||||
shareTrackTitle: 'Shared track',
|
||||
|
||||
@@ -39,6 +39,21 @@ export const search = {
|
||||
recentSearches: 'Căutări Recente',
|
||||
browse: 'Răsfoiește',
|
||||
emptyHint: 'Ce vrei să auzi?',
|
||||
scopeArtistsPlaceholder: 'Caută artist…',
|
||||
scopeArtistsBadgeTooltip: 'Clic pentru a elimina',
|
||||
scopeArtistsGhostTooltip: 'Clic — caută doar pe această pagină',
|
||||
scopeAlbumsPlaceholder: 'Caută album…',
|
||||
scopeAlbumsBadgeTooltip: 'Clic pentru a elimina',
|
||||
scopeAlbumsGhostTooltip: 'Clic — caută doar pe această pagină',
|
||||
scopeNewReleasesPlaceholder: 'Caută în lansări noi…',
|
||||
scopeNewReleasesBadgeTooltip: 'Clic pentru a elimina',
|
||||
scopeNewReleasesGhostTooltip: 'Clic — caută doar pe această pagină',
|
||||
scopeTracksPlaceholder: 'Găsește o piesă după titlu, artist sau album…',
|
||||
scopeTracksBadgeTooltip: 'Clic pentru a elimina',
|
||||
scopeTracksGhostTooltip: 'Clic — caută doar pe această pagină',
|
||||
scopeComposersPlaceholder: 'Caută compozitor…',
|
||||
scopeComposersBadgeTooltip: 'Clic — elimină',
|
||||
scopeComposersGhostTooltip: 'Clic — caută doar pe această pagină',
|
||||
genres: 'Genuri',
|
||||
shareLink: 'Share link',
|
||||
shareTrackTitle: 'Shared track',
|
||||
|
||||
@@ -39,6 +39,21 @@ export const search = {
|
||||
recentSearches: 'Недавние запросы',
|
||||
browse: 'Обзор',
|
||||
emptyHint: 'Что хочешь послушать?',
|
||||
scopeArtistsPlaceholder: 'Поиск исполнителя…',
|
||||
scopeArtistsBadgeTooltip: 'Щелчок — удалить',
|
||||
scopeArtistsGhostTooltip: 'Щелчок — искать только на этой странице',
|
||||
scopeAlbumsPlaceholder: 'Поиск альбома…',
|
||||
scopeAlbumsBadgeTooltip: 'Щелчок — удалить',
|
||||
scopeAlbumsGhostTooltip: 'Щелчок — искать только на этой странице',
|
||||
scopeNewReleasesPlaceholder: 'Поиск в новинках…',
|
||||
scopeNewReleasesBadgeTooltip: 'Щелчок — удалить',
|
||||
scopeNewReleasesGhostTooltip: 'Щелчок — искать только на этой странице',
|
||||
scopeTracksPlaceholder: 'Найти трек по названию, исполнителю или альбому…',
|
||||
scopeTracksBadgeTooltip: 'Щелчок — удалить',
|
||||
scopeTracksGhostTooltip: 'Щелчок — искать только на этой странице',
|
||||
scopeComposersPlaceholder: 'Поиск композитора…',
|
||||
scopeComposersBadgeTooltip: 'Щелчок — удалить',
|
||||
scopeComposersGhostTooltip: 'Щелчок — искать только на этой странице',
|
||||
genres: 'Жанры',
|
||||
shareLink: 'Ссылка для обмена',
|
||||
shareTrackTitle: 'Общий трек',
|
||||
|
||||
@@ -39,6 +39,21 @@ export const search = {
|
||||
recentSearches: '最近搜索',
|
||||
browse: '浏览',
|
||||
emptyHint: '你想听什么?',
|
||||
scopeArtistsPlaceholder: '搜索艺术家…',
|
||||
scopeArtistsBadgeTooltip: '单击移除',
|
||||
scopeArtistsGhostTooltip: '单击 — 仅在此页面搜索',
|
||||
scopeAlbumsPlaceholder: '搜索专辑…',
|
||||
scopeAlbumsBadgeTooltip: '单击移除',
|
||||
scopeAlbumsGhostTooltip: '单击 — 仅在此页面搜索',
|
||||
scopeNewReleasesPlaceholder: '搜索新发布…',
|
||||
scopeNewReleasesBadgeTooltip: '单击移除',
|
||||
scopeNewReleasesGhostTooltip: '单击 — 仅在此页面搜索',
|
||||
scopeTracksPlaceholder: '按标题、艺人或专辑搜索…',
|
||||
scopeTracksBadgeTooltip: '单击移除',
|
||||
scopeTracksGhostTooltip: '单击 — 仅在此页面搜索',
|
||||
scopeComposersPlaceholder: '搜索作曲家…',
|
||||
scopeComposersBadgeTooltip: '单击 — 移除',
|
||||
scopeComposersGhostTooltip: '单击 — 仅在此页面搜索',
|
||||
genres: '流派',
|
||||
shareLink: 'Share link',
|
||||
shareTrackTitle: 'Shared track',
|
||||
|
||||
+92
-20
@@ -36,11 +36,21 @@ import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { useAlbumBrowseFilters, useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
|
||||
import { useAlbumBrowseData } from '../hooks/useAlbumBrowseData';
|
||||
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
|
||||
import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset';
|
||||
import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch';
|
||||
import { peekAlbumBrowseScrollRestore } from '../store/albumBrowseSessionStore';
|
||||
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||
import { useAlbumCatalogYearBounds } from '../hooks/useAlbumCatalogYearBounds';
|
||||
import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort';
|
||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||
import { resolveAlbumYearBounds } from '../utils/library/albumYearFilter';
|
||||
import {
|
||||
filterAlbumsByCompilation,
|
||||
filterAlbumsByGenres,
|
||||
filterAlbumsByStarred,
|
||||
filterAlbumsByYearBounds,
|
||||
} from '../utils/library/albumBrowseFilters';
|
||||
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
|
||||
|
||||
type SortType = AlbumBrowseSort;
|
||||
|
||||
@@ -81,6 +91,14 @@ export default function Albums() {
|
||||
setLosslessOnly,
|
||||
} = useAlbumBrowseFilters(serverId, scrollSnapshotRef);
|
||||
|
||||
const albumsSearchQuery = useScopedBrowseSearchQuery('albums');
|
||||
const { textSearchAlbums, textSearchLoading } = useBrowseAlbumTextSearch(
|
||||
albumsSearchQuery,
|
||||
indexEnabled,
|
||||
serverId,
|
||||
losslessOnly,
|
||||
);
|
||||
|
||||
const {
|
||||
scrollBodyEl,
|
||||
bindScrollBody: bindAlbumsScrollBody,
|
||||
@@ -88,24 +106,7 @@ export default function Albums() {
|
||||
} = useInpageScrollViewport();
|
||||
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const {
|
||||
albums,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
displayAlbums,
|
||||
visibleAlbums,
|
||||
genreFiltered,
|
||||
serverFilterActive,
|
||||
narrowGenreList,
|
||||
genreCatalogOptions,
|
||||
yearFilterActive,
|
||||
debouncedYearFields,
|
||||
compFilterActive,
|
||||
pendingClientFilterMatch,
|
||||
bindLoadMoreSentinel,
|
||||
loadMore,
|
||||
} = useAlbumBrowseData({
|
||||
const browseData = useAlbumBrowseData({
|
||||
serverId,
|
||||
indexEnabled,
|
||||
musicLibraryFilterVersion,
|
||||
@@ -122,6 +123,55 @@ export default function Albums() {
|
||||
restoreDisplayCount: restoreDisplayCountRef.current,
|
||||
});
|
||||
|
||||
const textSearchActive = textSearchAlbums != null;
|
||||
const albumBrowsePlainLayout =
|
||||
perfFlags.disableMainstageVirtualLists
|
||||
|| textSearchActive
|
||||
|| albumsSearchQuery.trim().length > 0;
|
||||
|
||||
const textSearchYearBounds = useMemo(
|
||||
() => resolveAlbumYearBounds(browseData.debouncedYearFields.from, browseData.debouncedYearFields.to),
|
||||
[browseData.debouncedYearFields.from, browseData.debouncedYearFields.to],
|
||||
);
|
||||
|
||||
const textSearchVisibleAlbums = useMemo(() => {
|
||||
if (!textSearchActive || !textSearchAlbums) return null;
|
||||
let out = textSearchAlbums;
|
||||
if (selectedGenres.length > 0) out = filterAlbumsByGenres(out, selectedGenres);
|
||||
if (textSearchYearBounds.active) out = filterAlbumsByYearBounds(out, textSearchYearBounds.bounds);
|
||||
if (compFilter !== 'all') out = filterAlbumsByCompilation(out, compFilter);
|
||||
if (starredOnly) out = filterAlbumsByStarred(out, starredOverrides);
|
||||
return out;
|
||||
}, [
|
||||
textSearchActive,
|
||||
textSearchAlbums,
|
||||
selectedGenres,
|
||||
textSearchYearBounds.active,
|
||||
textSearchYearBounds.bounds,
|
||||
compFilter,
|
||||
starredOnly,
|
||||
starredOverrides,
|
||||
]);
|
||||
|
||||
const albums = textSearchActive ? (textSearchAlbums ?? []) : browseData.albums;
|
||||
const loading = textSearchActive ? textSearchLoading : browseData.loading;
|
||||
const loadingMore = textSearchActive ? false : browseData.loadingMore;
|
||||
const hasMore = textSearchActive ? false : browseData.hasMore;
|
||||
const displayAlbums = textSearchActive ? (textSearchVisibleAlbums ?? []) : browseData.displayAlbums;
|
||||
const visibleAlbums = textSearchActive ? (textSearchVisibleAlbums ?? []) : browseData.visibleAlbums;
|
||||
const genreFiltered = textSearchActive ? selectedGenres.length > 0 : browseData.genreFiltered;
|
||||
const serverFilterActive = textSearchActive
|
||||
? selectedGenres.length > 0 || textSearchYearBounds.active || losslessOnly || starredOnly
|
||||
: browseData.serverFilterActive;
|
||||
const narrowGenreList = browseData.narrowGenreList;
|
||||
const genreCatalogOptions = browseData.genreCatalogOptions;
|
||||
const yearFilterActive = browseData.yearFilterActive;
|
||||
const debouncedYearFields = browseData.debouncedYearFields;
|
||||
const compFilterActive = browseData.compFilterActive;
|
||||
const pendingClientFilterMatch = textSearchActive ? false : browseData.pendingClientFilterMatch;
|
||||
const bindLoadMoreSentinel = browseData.bindLoadMoreSentinel;
|
||||
const loadMore = browseData.loadMore;
|
||||
|
||||
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length);
|
||||
|
||||
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
|
||||
@@ -135,6 +185,22 @@ export default function Albums() {
|
||||
loadMore,
|
||||
});
|
||||
|
||||
useAlbumBrowseScrollReset({
|
||||
scrollSnapshotRef,
|
||||
getScrollRoot,
|
||||
isScrollRestorePending,
|
||||
resetKey: [
|
||||
albumsSearchQuery,
|
||||
sort,
|
||||
selectedGenres.join('\u0001'),
|
||||
yearFilterActive ? `${debouncedYearFields.from}:${debouncedYearFields.to}` : '',
|
||||
compFilter,
|
||||
starredOnly,
|
||||
losslessOnly,
|
||||
serverId,
|
||||
].join('|'),
|
||||
});
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
@@ -266,6 +332,7 @@ export default function Albums() {
|
||||
);
|
||||
|
||||
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
|
||||
albumsSearchQuery,
|
||||
sort,
|
||||
genreFiltered,
|
||||
yearFilterActive,
|
||||
@@ -394,8 +461,9 @@ export default function Albums() {
|
||||
hasMore,
|
||||
selectionMode,
|
||||
sort,
|
||||
albumsSearchQuery,
|
||||
perfFlags.disableMainstageGridCards,
|
||||
perfFlags.disableMainstageVirtualLists,
|
||||
albumBrowsePlainLayout,
|
||||
]}
|
||||
>
|
||||
{loading && albums.length === 0 ? (
|
||||
@@ -418,6 +486,10 @@ export default function Albums() {
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{visibleEmptyMessage}
|
||||
</div>
|
||||
) : !loading && textSearchActive && visibleAlbums.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('albums.noMatchingFilters')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
|
||||
@@ -427,7 +499,7 @@ export default function Albums() {
|
||||
items={displayAlbums}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
disableVirtualization={albumBrowsePlainLayout}
|
||||
layoutSignal={displayAlbums.length}
|
||||
scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
warmGridCovers={albumGridWarmCovers(
|
||||
|
||||
+38
-67
@@ -9,8 +9,6 @@ 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 { useCardGridMetrics } from '../hooks/useCardGridMetrics';
|
||||
import { useRemeasureGridVirtualizer } from '../hooks/useRemeasureGridVirtualizer';
|
||||
import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import {
|
||||
@@ -32,10 +30,12 @@ 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() {
|
||||
@@ -51,8 +51,6 @@ export default function Artists() {
|
||||
);
|
||||
|
||||
const {
|
||||
filter,
|
||||
setFilter,
|
||||
letterFilter,
|
||||
setLetterFilter,
|
||||
starredOnly,
|
||||
@@ -61,6 +59,8 @@ export default function Artists() {
|
||||
setViewMode,
|
||||
} = useArtistsBrowseFilters(serverId, scrollSnapshotRef);
|
||||
|
||||
const artistsSearchQuery = useScopedBrowseSearchQuery('artists');
|
||||
|
||||
const {
|
||||
scrollBodyEl: artistsScrollBodyEl,
|
||||
bindScrollBody: bindArtistsScrollBody,
|
||||
@@ -91,13 +91,18 @@ export default function Artists() {
|
||||
});
|
||||
|
||||
const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch(
|
||||
filter,
|
||||
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,
|
||||
@@ -105,7 +110,7 @@ export default function Artists() {
|
||||
loadMore: sliceLoadMore,
|
||||
} = useClientSliceInfiniteScroll({
|
||||
pageSize: PAGE_SIZE,
|
||||
resetDeps: [filter, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId],
|
||||
resetDeps: [artistsSearchQuery, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId],
|
||||
getScrollRoot: getArtistsScrollRoot,
|
||||
scrollRootEl: artistsScrollBodyEl,
|
||||
restoreDisplayCount: restoreVisibleCountRef.current,
|
||||
@@ -224,7 +229,7 @@ export default function Artists() {
|
||||
});
|
||||
|
||||
const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [
|
||||
filter,
|
||||
artistsSearchQuery,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
@@ -243,48 +248,6 @@ export default function Artists() {
|
||||
[getArtistsScrollRoot],
|
||||
);
|
||||
|
||||
const artistGridMeasureRef = useRef<HTMLDivElement>(null);
|
||||
const { gridCols: artistGridCols, rowHeightEst: artistGridRowHeightEst } = useCardGridMetrics(
|
||||
artistGridMeasureRef,
|
||||
viewMode === 'grid',
|
||||
'artist',
|
||||
visible.length,
|
||||
);
|
||||
|
||||
const artistVirtualRowCount = Math.max(0, Math.ceil(visible.length / Math.max(1, artistGridCols)));
|
||||
|
||||
const artistGridOverscan = Math.max(
|
||||
2,
|
||||
Math.ceil(artistsInpageScrollHeight / Math.max(1, artistGridRowHeightEst)),
|
||||
);
|
||||
|
||||
const artistGridScrollMargin = useVirtualizerScrollMargin(
|
||||
artistGridMeasureRef,
|
||||
getInpageScrollElement,
|
||||
{
|
||||
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'grid',
|
||||
deps: [artistVirtualRowCount, artistGridCols],
|
||||
},
|
||||
);
|
||||
|
||||
const artistGridVirtualizer = useVirtualizer({
|
||||
count:
|
||||
perfFlags.disableMainstageVirtualLists || viewMode !== 'grid'
|
||||
? 0
|
||||
: artistVirtualRowCount,
|
||||
getScrollElement: getInpageScrollElement,
|
||||
estimateSize: () => artistGridRowHeightEst,
|
||||
overscan: artistGridOverscan,
|
||||
scrollMargin: artistGridScrollMargin,
|
||||
});
|
||||
|
||||
useRemeasureGridVirtualizer(artistGridVirtualizer, {
|
||||
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'grid' && artistVirtualRowCount > 0,
|
||||
gridCols: artistGridCols,
|
||||
rowHeightEst: artistGridRowHeightEst,
|
||||
virtualRowCount: artistVirtualRowCount,
|
||||
});
|
||||
|
||||
const artistListOverscan = Math.max(
|
||||
12,
|
||||
Math.ceil(artistsInpageScrollHeight / ARTIST_LIST_ROW_EST),
|
||||
@@ -295,14 +258,14 @@ export default function Artists() {
|
||||
artistListWrapRef,
|
||||
getInpageScrollElement,
|
||||
{
|
||||
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'list',
|
||||
active: !artistBrowsePlainLayout && viewMode === 'list',
|
||||
deps: [artistListFlatRows.length],
|
||||
},
|
||||
);
|
||||
|
||||
const artistListVirtualizer = useVirtualizer({
|
||||
count:
|
||||
perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : artistListFlatRows.length,
|
||||
artistBrowsePlainLayout || viewMode !== 'list' ? 0 : artistListFlatRows.length,
|
||||
getScrollElement: getInpageScrollElement,
|
||||
estimateSize: index => {
|
||||
const row = artistListFlatRows[index];
|
||||
@@ -320,6 +283,27 @@ export default function Artists() {
|
||||
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' : ''}`}
|
||||
@@ -333,14 +317,6 @@ export default function Artists() {
|
||||
? t('artists.selectionCount', { count: selectedIds.size })
|
||||
: t('artists.title')}
|
||||
</h1>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 220 }}
|
||||
placeholder={t('artists.search')}
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
id="artist-filter-input"
|
||||
/>
|
||||
{textSearchLoading && (
|
||||
<div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
)}
|
||||
@@ -432,13 +408,8 @@ export default function Artists() {
|
||||
{!loading && !pendingLetterMatch && viewMode === 'grid' && (
|
||||
<ArtistsGridView
|
||||
visible={visible}
|
||||
gridCols={artistGridCols}
|
||||
measureRef={artistGridMeasureRef}
|
||||
virtualization={
|
||||
perfFlags.disableMainstageVirtualLists
|
||||
? null
|
||||
: { virtualizer: artistGridVirtualizer, scrollMargin: artistGridScrollMargin }
|
||||
}
|
||||
disableVirtualization={artistBrowsePlainLayout}
|
||||
layoutKey={browseScrollResetKey}
|
||||
selectionMode={selectionMode}
|
||||
selectedIds={selectedIds}
|
||||
selectedArtists={selectedArtists}
|
||||
@@ -452,7 +423,7 @@ export default function Artists() {
|
||||
|
||||
{!loading && !pendingLetterMatch && viewMode === 'list' && (
|
||||
<ArtistsListView
|
||||
virtualized={!perfFlags.disableMainstageVirtualLists}
|
||||
virtualized={!artistBrowsePlainLayout}
|
||||
groups={groups}
|
||||
letters={letters}
|
||||
artistListFlatRows={artistListFlatRows}
|
||||
|
||||
+86
-25
@@ -1,6 +1,6 @@
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ndListArtistsByRole } from '../api/navidromeBrowse';
|
||||
import { LayoutGrid, List } from 'lucide-react';
|
||||
import StarFilterButton from '../components/StarFilterButton';
|
||||
@@ -12,7 +12,14 @@ import { APP_MAIN_SCROLL_VIEWPORT_ID, COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID } from
|
||||
import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight';
|
||||
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
|
||||
import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch';
|
||||
import { useComposersBrowseFilters, type ComposerBrowseScrollSnapshot } from '../hooks/useComposersBrowseFilters';
|
||||
import { useComposersBrowseScrollRestore } from '../hooks/useComposersBrowseScrollRestore';
|
||||
import { useArtistsBrowseScrollReset } from '../hooks/useArtistsBrowseScrollReset';
|
||||
import { useNavigateToComposer } from '../hooks/useNavigateToComposer';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { peekComposerBrowseScrollRestore } from '../store/composerBrowseSessionStore';
|
||||
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
|
||||
import { readComposerBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import OverlayScrollArea from '../components/OverlayScrollArea';
|
||||
@@ -76,10 +83,24 @@ export default function Composers() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<'unsupported' | 'transient' | null>(null);
|
||||
const [reloadTick, setReloadTick] = useState(0);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
const scrollSnapshotRef = useRef<ComposerBrowseScrollSnapshot>({ scrollTop: 0, visibleCount: 0 });
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const restoreVisibleCountRef = useRef<number | undefined>(
|
||||
peekComposerBrowseScrollRestore(serverId)?.visibleCount,
|
||||
);
|
||||
|
||||
const {
|
||||
letterFilter,
|
||||
setLetterFilter,
|
||||
starredOnly,
|
||||
setStarredOnly,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
} = useComposersBrowseFilters(serverId, scrollSnapshotRef);
|
||||
|
||||
const composersSearchQuery = useScopedBrowseSearchQuery('composers');
|
||||
|
||||
// Compact tiles + initial-letter only → 200 per page is comfortable.
|
||||
const PAGE_SIZE = 200;
|
||||
@@ -89,28 +110,35 @@ export default function Composers() {
|
||||
bindScrollBody: bindComposersScrollBody,
|
||||
getScrollRoot,
|
||||
} = useInpageScrollViewport();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const navigateToComposer = useNavigateToComposer();
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const serverId = useAuthStore(s => s.activeServerId);
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch(
|
||||
filter,
|
||||
composersSearchQuery,
|
||||
indexEnabled,
|
||||
serverId,
|
||||
'composers_browse',
|
||||
);
|
||||
const composerSource = textSearchArtists ?? composers;
|
||||
const textSearchActive = textSearchArtists != null;
|
||||
const composerBrowsePlainLayout =
|
||||
perfFlags.disableMainstageVirtualLists
|
||||
|| textSearchActive
|
||||
|| composersSearchQuery.trim().length > 0;
|
||||
|
||||
const {
|
||||
visibleCount,
|
||||
loadingMore,
|
||||
bindSentinel,
|
||||
loadMore: sliceLoadMore,
|
||||
} = useClientSliceInfiniteScroll({
|
||||
pageSize: PAGE_SIZE,
|
||||
resetDeps: [letterFilter, effectiveFilter, starredOnly, viewMode, composerSource],
|
||||
resetDeps: [composersSearchQuery, letterFilter, starredOnly, viewMode, composerSource, serverId],
|
||||
getScrollRoot,
|
||||
scrollRootEl: scrollBodyEl,
|
||||
restoreDisplayCount: restoreVisibleCountRef.current,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -165,6 +193,26 @@ export default function Composers() {
|
||||
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
|
||||
scrollSnapshotRef.current = {
|
||||
scrollTop: scrollBodyEl?.scrollTop ?? 0,
|
||||
visibleCount,
|
||||
};
|
||||
|
||||
const { isScrollRestorePending } = useComposersBrowseScrollRestore({
|
||||
serverId,
|
||||
scrollBodyEl,
|
||||
visibleCount,
|
||||
loading: loading || textSearchLoading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore: sliceLoadMore,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrollRestorePending || !readComposerBrowseRestore(location.state)) return;
|
||||
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
||||
}, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
|
||||
|
||||
const { groups, letters } = useMemo(() => {
|
||||
if (viewMode !== 'list') return { groups: {} as Record<string, SubsonicArtist[]>, letters: [] as string[] };
|
||||
const g: Record<string, SubsonicArtist[]> = {};
|
||||
@@ -213,14 +261,14 @@ export default function Composers() {
|
||||
composerListWrapRef,
|
||||
getInpageScrollElement,
|
||||
{
|
||||
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'list',
|
||||
active: !composerBrowsePlainLayout && viewMode === 'list',
|
||||
deps: [composerListFlatRows.length],
|
||||
},
|
||||
);
|
||||
|
||||
const composerListVirtualizer = useVirtualizer({
|
||||
count:
|
||||
perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : composerListFlatRows.length,
|
||||
composerBrowsePlainLayout || viewMode !== 'list' ? 0 : composerListFlatRows.length,
|
||||
getScrollElement: getInpageScrollElement,
|
||||
estimateSize: index => {
|
||||
const row = composerListFlatRows[index];
|
||||
@@ -239,12 +287,33 @@ export default function Composers() {
|
||||
});
|
||||
|
||||
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
|
||||
filter,
|
||||
composersSearchQuery,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const browseScrollResetKey = [
|
||||
composersSearchQuery,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
serverId,
|
||||
musicLibraryFilterVersion,
|
||||
textSearchArtists?.length ?? '',
|
||||
textSearchArtists?.[0]?.id ?? '',
|
||||
].join('\0');
|
||||
|
||||
useArtistsBrowseScrollReset({
|
||||
scrollSnapshotRef,
|
||||
getScrollRoot,
|
||||
isScrollRestorePending,
|
||||
resetKey: browseScrollResetKey,
|
||||
viewMode,
|
||||
listVirtualize: !composerBrowsePlainLayout,
|
||||
listVirtualizer: composerListVirtualizer,
|
||||
});
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
@@ -272,14 +341,6 @@ export default function Composers() {
|
||||
<div className="mainstage-inpage-toolbar-row">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('composers.title')}</h1>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 220 }}
|
||||
placeholder={t('composers.search')}
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
id="composer-filter-input"
|
||||
/>
|
||||
{textSearchLoading && (
|
||||
<div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
)}
|
||||
@@ -342,7 +403,7 @@ export default function Composers() {
|
||||
items={visible}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="composer"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
disableVirtualization={composerBrowsePlainLayout}
|
||||
layoutSignal={visible.length}
|
||||
wrapClassName="composer-grid-wrap"
|
||||
gridGap="var(--space-2)"
|
||||
@@ -350,7 +411,7 @@ export default function Composers() {
|
||||
renderItem={artist => (
|
||||
<div
|
||||
className="composer-card"
|
||||
onClick={() => navigate(`/composer/${artist.id}`)}
|
||||
onClick={() => navigateToComposer(artist.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
|
||||
@@ -368,7 +429,7 @@ export default function Composers() {
|
||||
)}
|
||||
|
||||
{!loading && viewMode === 'list' && (
|
||||
perfFlags.disableMainstageVirtualLists ? (
|
||||
composerBrowsePlainLayout ? (
|
||||
<>
|
||||
{letters.map(letter => (
|
||||
<div key={letter} style={{ marginBottom: '1.5rem' }}>
|
||||
@@ -378,7 +439,7 @@ export default function Composers() {
|
||||
<button
|
||||
key={artist.id}
|
||||
className="artist-row"
|
||||
onClick={() => navigate(`/composer/${artist.id}`)}
|
||||
onClick={() => navigateToComposer(artist.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
|
||||
@@ -442,7 +503,7 @@ export default function Composers() {
|
||||
<button
|
||||
type="button"
|
||||
className="artist-row"
|
||||
onClick={() => navigate(`/composer/${artist.id}`)}
|
||||
onClick={() => navigateToComposer(artist.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
|
||||
|
||||
+73
-29
@@ -3,7 +3,7 @@ import { getAlbumsByGenre } from '../api/subsonicGenres';
|
||||
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { dedupeById } from '../utils/dedupeById';
|
||||
import { useEffect, useLayoutEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
@@ -29,8 +29,13 @@ import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
|
||||
import InpageScrollSentinel from '../components/InpageScrollSentinel';
|
||||
import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '../hooks/useAlbumGridBrowseFilters';
|
||||
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
|
||||
import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset';
|
||||
import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch';
|
||||
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
|
||||
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { filterAlbumsByGenres } from '../utils/library/albumBrowseFilters';
|
||||
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
@@ -49,6 +54,7 @@ export default function NewReleases() {
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const auth = useAuthStore();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
@@ -64,6 +70,19 @@ export default function NewReleases() {
|
||||
} = useAlbumGridBrowseFilters(serverId, 'new-releases', scrollSnapshotRef, gridSnapshotRef);
|
||||
const restoringSessionRef = useRef(initialAlbums != null);
|
||||
|
||||
const newReleasesSearchQuery = useScopedBrowseSearchQuery('newReleases');
|
||||
const { textSearchAlbums, textSearchLoading } = useBrowseAlbumTextSearch(
|
||||
newReleasesSearchQuery,
|
||||
indexEnabled,
|
||||
serverId,
|
||||
);
|
||||
const textSearchActive = textSearchAlbums != null;
|
||||
const scopedSearchQuery = newReleasesSearchQuery.trim();
|
||||
const albumBrowsePlainLayout =
|
||||
perfFlags.disableMainstageVirtualLists
|
||||
|| textSearchActive
|
||||
|| scopedSearchQuery.length > 0;
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() => initialAlbums ?? []);
|
||||
const [hasMore, setHasMore] = useState(() => initialHasMore ?? true);
|
||||
const {
|
||||
@@ -80,22 +99,35 @@ export default function NewReleases() {
|
||||
isBlocked,
|
||||
} = useAsyncInpagePagination(PAGE_SIZE, { initialLoading: initialAlbums == null });
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const filtered = selectedGenres.length > 0;
|
||||
const genreFiltered = selectedGenres.length > 0;
|
||||
|
||||
gridSnapshotRef.current = { albums, hasMore };
|
||||
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, albums.length);
|
||||
const displayAlbums = useMemo(() => {
|
||||
if (textSearchActive && textSearchAlbums) {
|
||||
return genreFiltered
|
||||
? filterAlbumsByGenres(textSearchAlbums, selectedGenres)
|
||||
: textSearchAlbums;
|
||||
}
|
||||
return albums;
|
||||
}, [textSearchActive, textSearchAlbums, albums, genreFiltered, selectedGenres]);
|
||||
|
||||
const loadingGrid = textSearchActive ? textSearchLoading : loading;
|
||||
const gridHasMore = textSearchActive ? false : (!genreFiltered && hasMore);
|
||||
|
||||
gridSnapshotRef.current = { albums: displayAlbums, hasMore: gridHasMore };
|
||||
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length);
|
||||
|
||||
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
|
||||
filtered,
|
||||
newReleasesSearchQuery,
|
||||
genreFiltered,
|
||||
selectionMode,
|
||||
selectedGenres,
|
||||
]);
|
||||
|
||||
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
|
||||
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(displayAlbums);
|
||||
|
||||
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
|
||||
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
|
||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(a.id));
|
||||
|
||||
const handleDownloadZips = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
@@ -156,21 +188,21 @@ export default function NewReleases() {
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restoringSessionRef.current) return;
|
||||
if (filtered) loadFiltered(selectedGenres);
|
||||
if (restoringSessionRef.current || scopedSearchQuery) return;
|
||||
if (genreFiltered) loadFiltered(selectedGenres);
|
||||
else {
|
||||
resetPage();
|
||||
void load(0);
|
||||
}
|
||||
}, [filtered, selectedGenres, load, loadFiltered, resetPage]);
|
||||
}, [genreFiltered, selectedGenres, load, loadFiltered, resetPage, scopedSearchQuery]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!hasMore || filtered || isBlocked()) return;
|
||||
if (!gridHasMore || genreFiltered || textSearchActive || isBlocked()) return;
|
||||
requestNextPage(offset => load(offset, true));
|
||||
}, [hasMore, filtered, isBlocked, requestNextPage, load]);
|
||||
}, [gridHasMore, genreFiltered, textSearchActive, isBlocked, requestNextPage, load]);
|
||||
|
||||
const bindLoadMoreSentinel = useInpageScrollSentinel({
|
||||
active: !filtered && hasMore,
|
||||
active: gridHasMore,
|
||||
getScrollRoot,
|
||||
scrollRootEl: scrollBodyEl,
|
||||
onIntersect: loadMore,
|
||||
@@ -180,13 +212,20 @@ export default function NewReleases() {
|
||||
serverId,
|
||||
surface: 'new-releases',
|
||||
scrollBodyEl,
|
||||
displayAlbumsLength: albums.length,
|
||||
loading,
|
||||
loadingMore: loading,
|
||||
hasMore,
|
||||
displayAlbumsLength: displayAlbums.length,
|
||||
loading: loadingGrid,
|
||||
loadingMore: loadingGrid,
|
||||
hasMore: gridHasMore,
|
||||
loadMore,
|
||||
});
|
||||
|
||||
useAlbumBrowseScrollReset({
|
||||
scrollSnapshotRef,
|
||||
getScrollRoot,
|
||||
isScrollRestorePending,
|
||||
resetKey: [newReleasesSearchQuery, selectedGenres.join('\u0001'), serverId].join('|'),
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isScrollRestorePending && restoringSessionRef.current) {
|
||||
restoringSessionRef.current = false;
|
||||
@@ -243,31 +282,36 @@ export default function NewReleases() {
|
||||
viewportRef={bindNewReleasesScrollBody}
|
||||
railInset="panel"
|
||||
measureDeps={[
|
||||
loading,
|
||||
albums.length,
|
||||
filtered,
|
||||
hasMore,
|
||||
loadingGrid,
|
||||
displayAlbums.length,
|
||||
genreFiltered,
|
||||
gridHasMore,
|
||||
selectionMode,
|
||||
perfFlags.disableMainstageVirtualLists,
|
||||
newReleasesSearchQuery,
|
||||
albumBrowsePlainLayout,
|
||||
]}
|
||||
>
|
||||
{loading && albums.length === 0 ? (
|
||||
{loadingGrid && displayAlbums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : !loading && albums.length === 0 && !filtered ? (
|
||||
) : !loadingGrid && displayAlbums.length === 0 && !genreFiltered && !scopedSearchQuery ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('common.libraryEmpty')}
|
||||
</div>
|
||||
) : !loadingGrid && textSearchActive && displayAlbums.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('albums.noMatchingFilters')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
|
||||
<VirtualCardGrid
|
||||
items={albums}
|
||||
items={displayAlbums}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
disableVirtualization={albumBrowsePlainLayout}
|
||||
layoutSignal={displayAlbums.length}
|
||||
scrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => (
|
||||
@@ -281,8 +325,8 @@ export default function NewReleases() {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!filtered && hasMore && (
|
||||
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loading} />
|
||||
{gridHasMore && (
|
||||
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loadingGrid} />
|
||||
)}
|
||||
</div>
|
||||
{isScrollRestorePending && (
|
||||
|
||||
@@ -60,6 +60,10 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { useSongBrowseList, type SongBrowseListRestore } from '../hooks/useSongBrowseList';
|
||||
import TracksPageChrome from '../components/tracks/TracksPageChrome';
|
||||
import SongBrowseSection from '../components/tracks/SongBrowseSection';
|
||||
import {
|
||||
useLiveSearchScopeStore,
|
||||
useScopedBrowseSearchQuery,
|
||||
} from '../store/liveSearchScopeStore';
|
||||
|
||||
const MOOD_UI_ENABLED = OXIMEDIA_MOOD_SEARCH_ENABLED;
|
||||
|
||||
@@ -177,8 +181,22 @@ export default function SearchBrowsePage() {
|
||||
}
|
||||
: null;
|
||||
|
||||
const tracksLiveSearchInitRef = useRef(false);
|
||||
if (!tracksLiveSearchInitRef.current && restoreStash && showTracksChrome) {
|
||||
tracksLiveSearchInitRef.current = true;
|
||||
const store = useLiveSearchScopeStore.getState();
|
||||
store.setScope('tracks');
|
||||
if (restoreStash.query) store.setQuery(restoreStash.query);
|
||||
}
|
||||
|
||||
const tracksSearchQuery = useScopedBrowseSearchQuery('tracks');
|
||||
const liveSearchQuery = useLiveSearchScopeStore(s => s.query);
|
||||
const tracksSearchActive =
|
||||
tracksSearchQuery.trim().length > 0 || liveSearchQuery.trim().length > 0;
|
||||
|
||||
const songBrowse = useSongBrowseList({
|
||||
enabled: showTracksChrome,
|
||||
searchQuery: tracksSearchQuery,
|
||||
initialRestore: songBrowseInitialRestore,
|
||||
});
|
||||
|
||||
@@ -188,6 +206,9 @@ export default function SearchBrowsePage() {
|
||||
restoringSession ? resolveAdvancedSearchLeaveSnapshot(restoreStash) : null,
|
||||
);
|
||||
const scrollTopRestoreTargetRef = useRef(leaveSnapshotRef.current?.scrollTop ?? 0);
|
||||
const tracksSearchRestorePendingRef = useRef(
|
||||
!!(songBrowseInitialRestore?.query.trim()),
|
||||
);
|
||||
const albumRowScrollLeftRestoreRef = useRef(leaveSnapshotRef.current?.albumRowScrollLeft ?? 0);
|
||||
const artistRowScrollLeftRestoreRef = useRef(leaveSnapshotRef.current?.artistRowScrollLeft ?? 0);
|
||||
const mainScrollTopRef = useRef(0);
|
||||
@@ -196,12 +217,16 @@ export default function SearchBrowsePage() {
|
||||
const skipSearchAutoFocusRef = useRef(restoreStash != null);
|
||||
const skipEnterAnimationRef = useRef(restoreStash != null || leaveSnapshotRef.current != null);
|
||||
const leaveRestoreUiFinishedRef = useRef(leaveSnapshotRef.current == null);
|
||||
const restoringTracksSearch = !!(restoreStash?.query.trim() && showTracksChrome);
|
||||
const [tracksChromeLayoutReady, setTracksChromeLayoutReady] = useState(
|
||||
() => !showTracksChrome || leaveSnapshotRef.current == null,
|
||||
() => !showTracksChrome || leaveSnapshotRef.current == null || restoringTracksSearch,
|
||||
);
|
||||
const [isLeaveRestorePending, setIsLeaveRestorePending] = useState(
|
||||
() => leaveSnapshotRef.current != null,
|
||||
);
|
||||
const tracksDiscoveryHidden =
|
||||
tracksSearchActive
|
||||
|| (isLeaveRestorePending && !!(restoreStash?.query.trim() || songBrowseInitialRestore?.query.trim()));
|
||||
|
||||
const handleTracksChromeLayoutReady = useCallback(() => {
|
||||
setTracksChromeLayoutReady(true);
|
||||
@@ -210,12 +235,15 @@ export default function SearchBrowsePage() {
|
||||
const finishLeaveRestoreUi = useCallback(() => {
|
||||
if (leaveRestoreUiFinishedRef.current) return;
|
||||
leaveRestoreUiFinishedRef.current = true;
|
||||
clearAdvancedSearchLeaveSnapshots();
|
||||
leaveSnapshotRef.current = null;
|
||||
setIsLeaveRestorePending(false);
|
||||
if (hadRestoreOnMountRef.current) {
|
||||
useAdvancedSearchSessionStore.getState().clearReturnStash();
|
||||
}
|
||||
// Defer stash teardown until after AppShell's route-change scroll reset effect.
|
||||
window.setTimeout(() => {
|
||||
clearAdvancedSearchLeaveSnapshots();
|
||||
if (hadRestoreOnMountRef.current) {
|
||||
useAdvancedSearchSessionStore.getState().clearReturnStash();
|
||||
}
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
const sessionRef = useRef<AdvancedSearchSessionStash>({
|
||||
@@ -241,7 +269,7 @@ export default function SearchBrowsePage() {
|
||||
tracksBrowseUnsupported: false,
|
||||
});
|
||||
sessionRef.current = {
|
||||
query: showTracksChrome ? songBrowse.query : query,
|
||||
query: showTracksChrome ? liveSearchQuery : query,
|
||||
genre,
|
||||
yearFrom,
|
||||
yearTo,
|
||||
@@ -585,6 +613,11 @@ export default function SearchBrowsePage() {
|
||||
const stash = useAdvancedSearchSessionStore.getState().peekReturnStash();
|
||||
if (stash) {
|
||||
setQuery(stash.query);
|
||||
if (showTracksChrome) {
|
||||
const store = useLiveSearchScopeStore.getState();
|
||||
store.setScope('tracks');
|
||||
store.setQuery(stash.query);
|
||||
}
|
||||
setGenre(stash.genre);
|
||||
setYearFrom(stash.yearFrom);
|
||||
setYearTo(stash.yearTo);
|
||||
@@ -612,20 +645,47 @@ export default function SearchBrowsePage() {
|
||||
useAdvancedSearchSessionStore.getState().clearReturnStash();
|
||||
}, [navigationType, location.state]);
|
||||
|
||||
const tracksSearchRestoreSynced =
|
||||
!tracksSearchRestorePendingRef.current
|
||||
|| tracksSearchQuery.trim() === (songBrowseInitialRestore?.query.trim() ?? '');
|
||||
|
||||
const leaveRestoreContentReady = showTracksChrome
|
||||
? tracksChromeLayoutReady
|
||||
&& ((hadRestoreOnMountRef.current && songBrowse.hasSearched) || (songBrowse.hasSearched && !songBrowse.loading))
|
||||
&& tracksSearchRestoreSynced
|
||||
&& (
|
||||
(hadRestoreOnMountRef.current && songBrowseInitialRestore != null)
|
||||
|| (songBrowse.hasSearched && !songBrowse.loading)
|
||||
)
|
||||
: ((hadRestoreOnMountRef.current && results !== null) || (hasSearched && !loading));
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!leaveRestoreContentReady || leaveRestoreUiFinishedRef.current) return;
|
||||
if (showTracksChrome) return;
|
||||
const target = scrollTopRestoreTargetRef.current;
|
||||
if (target <= 0) {
|
||||
finishLeaveRestoreUi();
|
||||
return;
|
||||
}
|
||||
return restoreMainViewportScroll(target, finishLeaveRestoreUi);
|
||||
}, [leaveRestoreContentReady, finishLeaveRestoreUi]);
|
||||
}, [leaveRestoreContentReady, finishLeaveRestoreUi, showTracksChrome]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showTracksChrome || leaveRestoreUiFinishedRef.current) return;
|
||||
if (!leaveRestoreContentReady) return;
|
||||
const target = scrollTopRestoreTargetRef.current;
|
||||
if (target <= 0) {
|
||||
finishLeaveRestoreUi();
|
||||
return;
|
||||
}
|
||||
if (songBrowse.songs.length === 0) return;
|
||||
return restoreMainViewportScroll(target, finishLeaveRestoreUi);
|
||||
}, [
|
||||
showTracksChrome,
|
||||
leaveRestoreContentReady,
|
||||
finishLeaveRestoreUi,
|
||||
songBrowse.songs.length,
|
||||
tracksSearchRestoreSynced,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLeaveRestorePending || !readAdvancedSearchRestore(location.state)) return;
|
||||
@@ -795,6 +855,7 @@ export default function SearchBrowsePage() {
|
||||
{showTracksChrome ? (
|
||||
<>
|
||||
<TracksPageChrome
|
||||
hideDiscoveryChrome={tracksDiscoveryHidden}
|
||||
onLayoutReady={
|
||||
isLeaveRestorePending && showTracksChrome ? handleTracksChromeLayoutReady : undefined
|
||||
}
|
||||
@@ -803,8 +864,7 @@ export default function SearchBrowsePage() {
|
||||
<SongBrowseSection
|
||||
title={t('tracks.browseTitle')}
|
||||
emptyBrowseText={t('tracks.browseUnsupported')}
|
||||
query={songBrowse.query}
|
||||
onQueryChange={songBrowse.setQuery}
|
||||
searchActive={tracksSearchActive}
|
||||
songs={songBrowse.songs}
|
||||
hasMore={songBrowse.hasMore}
|
||||
loading={songBrowse.loading}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
albumBrowseSurfaceForPath,
|
||||
clearGenreDetailReturnStash,
|
||||
isAlbumDetailPath,
|
||||
isAlbumsBrowsePath,
|
||||
isNewReleasesBrowsePath,
|
||||
isGenreDetailPath,
|
||||
genreDetailGenreFromPath,
|
||||
peekAlbumBrowseScrollRestore,
|
||||
@@ -144,6 +146,11 @@ describe('isGenreDetailPath', () => {
|
||||
describe('albumBrowseSurfaceForPath', () => {
|
||||
it('maps album grid browse routes', () => {
|
||||
expect(albumBrowseSurfaceForPath('/albums')).toBe('albums');
|
||||
expect(isAlbumsBrowsePath('/albums')).toBe(true);
|
||||
expect(isAlbumsBrowsePath('/albums/')).toBe(true);
|
||||
expect(isAlbumsBrowsePath('/new-releases')).toBe(false);
|
||||
expect(isNewReleasesBrowsePath('/new-releases')).toBe(true);
|
||||
expect(isNewReleasesBrowsePath('/new-releases/')).toBe(true);
|
||||
expect(albumBrowseSurfaceForPath('/new-releases')).toBe('new-releases');
|
||||
expect(albumBrowseSurfaceForPath('/random/albums')).toBe('random-albums');
|
||||
expect(albumBrowseSurfaceForPath('/artists')).toBeNull();
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface AlbumBrowseReturnFilters {
|
||||
compFilter: AlbumBrowseCompFilter;
|
||||
starredOnly: boolean;
|
||||
losslessOnly: boolean;
|
||||
/** Header live search query when leaving for album detail (All Albums scope). */
|
||||
searchQuery?: string;
|
||||
/** In-page grid scroll position when leaving the browse surface. */
|
||||
scrollTop?: number;
|
||||
/** Row count at leave time — preload at least this many rows before scroll. */
|
||||
@@ -77,6 +79,7 @@ function cloneReturnFilters(filters: AlbumBrowseReturnFilters): AlbumBrowseRetur
|
||||
compFilter: filters.compFilter,
|
||||
starredOnly: filters.starredOnly,
|
||||
losslessOnly: filters.losslessOnly,
|
||||
...(typeof filters.searchQuery === 'string' ? { searchQuery: filters.searchQuery } : {}),
|
||||
...(typeof filters.scrollTop === 'number' ? { scrollTop: filters.scrollTop } : {}),
|
||||
...(typeof filters.displayCount === 'number' ? { displayCount: filters.displayCount } : {}),
|
||||
...(filters.albums ? { albums: [...filters.albums] } : {}),
|
||||
@@ -194,6 +197,16 @@ export function albumBrowseSortForServer(
|
||||
}
|
||||
|
||||
/** Map pathname to album grid browse surface, if any. */
|
||||
/** All Albums browse route (`/albums`) — scoped live search target. */
|
||||
export function isAlbumsBrowsePath(pathname: string): boolean {
|
||||
return albumBrowseSurfaceForPath(pathname) === 'albums';
|
||||
}
|
||||
|
||||
/** New Releases browse route (`/new-releases`) — scoped live search target. */
|
||||
export function isNewReleasesBrowsePath(pathname: string): boolean {
|
||||
return albumBrowseSurfaceForPath(pathname) === 'new-releases';
|
||||
}
|
||||
|
||||
export function albumBrowseSurfaceForPath(pathname: string): AlbumBrowseSurface | null {
|
||||
const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname;
|
||||
if (path === '/albums') return 'albums';
|
||||
@@ -224,6 +237,11 @@ export function isArtistDetailPath(pathname: string): boolean {
|
||||
return /^\/artist\/[^/]+\/?$/.test(pathname);
|
||||
}
|
||||
|
||||
export function isAdvancedSearchLeaveTargetPath(pathname: string): boolean {
|
||||
return isAlbumDetailPath(pathname) || isArtistDetailPath(pathname);
|
||||
/** True when pathname is a single composer detail route (`/composer/:id`). */
|
||||
export function isComposerDetailPath(pathname: string): boolean {
|
||||
return /^\/composer\/[^/]+\/?$/.test(pathname);
|
||||
}
|
||||
|
||||
export function isAdvancedSearchLeaveTargetPath(pathname: string): boolean {
|
||||
return isAlbumDetailPath(pathname) || isArtistDetailPath(pathname) || isComposerDetailPath(pathname);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { create } from 'zustand';
|
||||
import { ALL_SENTINEL } from '../utils/componentHelpers/artistsHelpers';
|
||||
|
||||
export type ComposerBrowseViewMode = 'grid' | 'list';
|
||||
|
||||
/** Browse state restored when returning to Composers via back from composer detail. */
|
||||
export interface ComposerBrowseReturnState {
|
||||
filter: string;
|
||||
letterFilter: string;
|
||||
starredOnly: boolean;
|
||||
viewMode: ComposerBrowseViewMode;
|
||||
scrollTop?: number;
|
||||
visibleCount?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPOSER_BROWSE_RETURN_STATE: ComposerBrowseReturnState = {
|
||||
filter: '',
|
||||
letterFilter: ALL_SENTINEL,
|
||||
starredOnly: false,
|
||||
viewMode: 'grid',
|
||||
};
|
||||
|
||||
interface ComposerBrowseSessionStore {
|
||||
returnStashByServer: Record<string, ComposerBrowseReturnState>;
|
||||
stashReturnState: (serverId: string, state: ComposerBrowseReturnState) => void;
|
||||
clearReturnStash: (serverId: string) => void;
|
||||
peekReturnStash: (serverId: string) => ComposerBrowseReturnState | null;
|
||||
}
|
||||
|
||||
export const useComposerBrowseSessionStore = create<ComposerBrowseSessionStore>((set, get) => ({
|
||||
returnStashByServer: {},
|
||||
|
||||
stashReturnState: (serverId, state) => {
|
||||
if (!serverId) return;
|
||||
set((s) => ({
|
||||
returnStashByServer: {
|
||||
...s.returnStashByServer,
|
||||
[serverId]: {
|
||||
filter: state.filter,
|
||||
letterFilter: state.letterFilter,
|
||||
starredOnly: state.starredOnly,
|
||||
viewMode: state.viewMode,
|
||||
...(typeof state.scrollTop === 'number' ? { scrollTop: state.scrollTop } : {}),
|
||||
...(typeof state.visibleCount === 'number' ? { visibleCount: state.visibleCount } : {}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
clearReturnStash: (serverId) => {
|
||||
if (!serverId) return;
|
||||
const next = { ...get().returnStashByServer };
|
||||
delete next[serverId];
|
||||
set({ returnStashByServer: next });
|
||||
},
|
||||
|
||||
peekReturnStash: (serverId) => {
|
||||
if (!serverId) return null;
|
||||
const stash = get().returnStashByServer[serverId];
|
||||
if (!stash) return null;
|
||||
return {
|
||||
filter: stash.filter,
|
||||
letterFilter: stash.letterFilter,
|
||||
starredOnly: stash.starredOnly,
|
||||
viewMode: stash.viewMode,
|
||||
...(typeof stash.scrollTop === 'number' ? { scrollTop: stash.scrollTop } : {}),
|
||||
...(typeof stash.visibleCount === 'number' ? { visibleCount: stash.visibleCount } : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
export function peekComposerBrowseScrollRestore(
|
||||
serverId: string,
|
||||
): { scrollTop: number; visibleCount: number } | null {
|
||||
const stash = useComposerBrowseSessionStore.getState().peekReturnStash(serverId);
|
||||
if (!stash) return null;
|
||||
if (typeof stash.scrollTop !== 'number' || typeof stash.visibleCount !== 'number') return null;
|
||||
return {
|
||||
scrollTop: Math.max(0, stash.scrollTop),
|
||||
visibleCount: Math.max(0, stash.visibleCount),
|
||||
};
|
||||
}
|
||||
|
||||
/** True when pathname is the Composers browse route (`/composers`). */
|
||||
export function isComposersBrowsePath(pathname: string): boolean {
|
||||
return pathname === '/composers';
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import {
|
||||
scopedBrowseSearchQuery,
|
||||
useLiveSearchScopeStore,
|
||||
} from './liveSearchScopeStore';
|
||||
|
||||
describe('liveSearchScopeStore', () => {
|
||||
beforeEach(() => {
|
||||
useLiveSearchScopeStore.setState({ query: '', scope: null, undoStack: [] });
|
||||
});
|
||||
|
||||
it('returns browse query only when the expected scope is active', () => {
|
||||
useLiveSearchScopeStore.setState({ query: 'beatles', scope: 'artists' });
|
||||
expect(scopedBrowseSearchQuery('beatles', 'artists', 'artists')).toBe('beatles');
|
||||
expect(scopedBrowseSearchQuery('beatles', null, 'artists')).toBe('');
|
||||
useLiveSearchScopeStore.setState({ query: 'abbey', scope: 'albums' });
|
||||
expect(scopedBrowseSearchQuery('abbey', 'albums', 'albums')).toBe('abbey');
|
||||
expect(scopedBrowseSearchQuery('abbey', 'artists', 'albums')).toBe('');
|
||||
useLiveSearchScopeStore.setState({ query: 'jazz', scope: 'newReleases' });
|
||||
expect(scopedBrowseSearchQuery('jazz', 'newReleases', 'newReleases')).toBe('jazz');
|
||||
useLiveSearchScopeStore.setState({ query: 'track', scope: 'tracks' });
|
||||
expect(scopedBrowseSearchQuery('track', 'tracks', 'tracks')).toBe('track');
|
||||
expect(scopedBrowseSearchQuery('track', 'albums', 'tracks')).toBe('');
|
||||
useLiveSearchScopeStore.setState({ query: 'bach', scope: 'composers' });
|
||||
expect(scopedBrowseSearchQuery('bach', 'composers', 'composers')).toBe('bach');
|
||||
expect(scopedBrowseSearchQuery('bach', 'artists', 'composers')).toBe('');
|
||||
});
|
||||
|
||||
it('undoes query and scope badge changes', () => {
|
||||
useLiveSearchScopeStore.getState().setScope('artists');
|
||||
useLiveSearchScopeStore.getState().setQuery('ab', { recordUndo: true });
|
||||
useLiveSearchScopeStore.getState().setQuery('a', { recordUndo: true });
|
||||
useLiveSearchScopeStore.getState().clearScope({ recordUndo: true });
|
||||
|
||||
expect(useLiveSearchScopeStore.getState().scope).toBeNull();
|
||||
expect(useLiveSearchScopeStore.getState().undo()).toBe(true);
|
||||
expect(useLiveSearchScopeStore.getState().scope).toBe('artists');
|
||||
expect(useLiveSearchScopeStore.getState().query).toBe('a');
|
||||
expect(useLiveSearchScopeStore.getState().undo()).toBe(true);
|
||||
expect(useLiveSearchScopeStore.getState().query).toBe('ab');
|
||||
});
|
||||
|
||||
it('does not record undo for programmatic setQuery by default', () => {
|
||||
useLiveSearchScopeStore.getState().setQuery('test');
|
||||
expect(useLiveSearchScopeStore.getState().undo()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/** Page-scoped live search mode — badge in the header search field. */
|
||||
export type LiveSearchScope = 'artists' | 'albums' | 'newReleases' | 'tracks' | 'composers';
|
||||
|
||||
export type LiveSearchSnapshot = {
|
||||
query: string;
|
||||
scope: LiveSearchScope | null;
|
||||
};
|
||||
|
||||
type LiveSearchMutationOpts = {
|
||||
/** Push the current field state onto the search-local undo stack. */
|
||||
recordUndo?: boolean;
|
||||
};
|
||||
|
||||
interface LiveSearchScopeStore {
|
||||
query: string;
|
||||
scope: LiveSearchScope | null;
|
||||
undoStack: LiveSearchSnapshot[];
|
||||
setQuery: (query: string, options?: LiveSearchMutationOpts) => void;
|
||||
setScope: (scope: LiveSearchScope | null, options?: LiveSearchMutationOpts) => void;
|
||||
clearScope: (options?: LiveSearchMutationOpts) => void;
|
||||
recordUndoSnapshot: () => void;
|
||||
undo: () => boolean;
|
||||
}
|
||||
|
||||
const MAX_UNDO = 50;
|
||||
|
||||
function snapshotsEqual(a: LiveSearchSnapshot, b: LiveSearchSnapshot): boolean {
|
||||
return a.query === b.query && a.scope === b.scope;
|
||||
}
|
||||
|
||||
export const useLiveSearchScopeStore = create<LiveSearchScopeStore>((set, get) => ({
|
||||
query: '',
|
||||
scope: null,
|
||||
undoStack: [],
|
||||
|
||||
recordUndoSnapshot: () => {
|
||||
const snap: LiveSearchSnapshot = { query: get().query, scope: get().scope };
|
||||
set((s) => {
|
||||
const last = s.undoStack[s.undoStack.length - 1];
|
||||
if (last && snapshotsEqual(last, snap)) return s;
|
||||
return { undoStack: [...s.undoStack, snap].slice(-MAX_UNDO) };
|
||||
});
|
||||
},
|
||||
|
||||
setQuery: (query, options) => {
|
||||
if (get().query === query) return;
|
||||
if (options?.recordUndo) get().recordUndoSnapshot();
|
||||
set({ query });
|
||||
},
|
||||
|
||||
setScope: (scope, options) => {
|
||||
if (get().scope === scope) return;
|
||||
if (options?.recordUndo) get().recordUndoSnapshot();
|
||||
set({ scope });
|
||||
},
|
||||
|
||||
clearScope: (options) => {
|
||||
if (get().scope == null) return;
|
||||
if (options?.recordUndo) get().recordUndoSnapshot();
|
||||
set({ scope: null });
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const stack = get().undoStack;
|
||||
if (stack.length === 0) return false;
|
||||
const prev = stack[stack.length - 1]!;
|
||||
set({ query: prev.query, scope: prev.scope, undoStack: stack.slice(0, -1) });
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
/** Browse filter text when the header scope badge matches the page. */
|
||||
export function scopedBrowseSearchQuery(
|
||||
query: string,
|
||||
activeScope: LiveSearchScope | null,
|
||||
expectedScope: LiveSearchScope,
|
||||
): string {
|
||||
return activeScope === expectedScope ? query : '';
|
||||
}
|
||||
|
||||
export function useScopedBrowseSearchQuery(expectedScope: LiveSearchScope): string {
|
||||
const query = useLiveSearchScopeStore(s => s.query);
|
||||
const scope = useLiveSearchScopeStore(s => s.scope);
|
||||
return scopedBrowseSearchQuery(query, scope, expectedScope);
|
||||
}
|
||||
@@ -85,6 +85,12 @@
|
||||
contain-intrinsic-size: 0 220px;
|
||||
}
|
||||
|
||||
/* Text search / plain grid: no deferred paint — stale scroll + content-visibility blanks tiles. */
|
||||
.album-grid-wrap--plain > .artist-card {
|
||||
content-visibility: visible;
|
||||
contain-intrinsic-size: auto;
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.album-grid-wrap {
|
||||
|
||||
@@ -22,18 +22,26 @@
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.live-search[data-collapsed]:not([data-active]) .live-search-icon {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
.live-search[data-collapsed]:not([data-active]) .live-search-field-cluster {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.live-search[data-collapsed]:not([data-active]) .live-search-field {
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
.live-search[data-collapsed]:not([data-active]) .live-search-field,
|
||||
.live-search[data-collapsed]:not([data-active]) .live-search-scope-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.live-search[data-collapsed]:not([data-active]) .live-search-leading-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.live-search[data-collapsed][data-active] {
|
||||
@@ -51,13 +59,18 @@
|
||||
z-index: 25;
|
||||
}
|
||||
|
||||
.live-search[data-collapsed][data-active] .live-search-field-cluster {
|
||||
width: 100%;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.live-search[data-collapsed][data-active] .live-search-field {
|
||||
width: 100%;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.live-search[data-collapsed]:not([data-active]) .live-search-clear,
|
||||
.live-search[data-collapsed]:not([data-active]) .live-search-adv-btn {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -75,32 +88,75 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.live-search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
.live-search-field-cluster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-overlay0);
|
||||
border-radius: var(--radius-full);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.live-search-field-cluster:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
.live-search-leading-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 12px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.live-search-scope-badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-left: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.live-search-scope-badge--ghost {
|
||||
opacity: 0.42;
|
||||
border-style: dashed;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.live-search-scope-badge--ghost:hover {
|
||||
opacity: 0.9;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.live-search-field {
|
||||
padding-left: 36px !important;
|
||||
padding: var(--space-3) var(--space-4) !important;
|
||||
padding-left: 8px !important;
|
||||
padding-right: 58px !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: var(--radius-full) !important;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.live-search-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
font-size: 18px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.live-search-clear:hover {
|
||||
color: var(--text-primary);
|
||||
.live-search-field:focus {
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.live-search-adv-btn {
|
||||
@@ -249,4 +305,3 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,30 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-search-scope-badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mobile-search-scope-badge--ghost {
|
||||
opacity: 0.42;
|
||||
border-style: dashed;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-search-field--scoped .mobile-search-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.mobile-search-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
|
||||
@@ -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