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:
cucadmuh
2026-06-01 13:04:36 +03:00
committed by GitHub
parent ddf10ee01d
commit 4ac373a65b
59 changed files with 2301 additions and 392 deletions
+2
View File
@@ -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 {
+2
View File
@@ -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);
+99 -46
View File
@@ -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
+76 -16
View File
@@ -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>
+3 -4
View File
@@ -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}
+21 -76
View File
@@ -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();
});
});
+237
View File
@@ -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();
}
+8 -27
View File
@@ -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>
+13 -8
View File
@@ -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}
+1
View File
@@ -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)',
],
},
{
+4
View File
@@ -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('');
+34
View File
@@ -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]);
}
+13 -4
View File
@@ -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,
+4 -8
View File
@@ -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,
+54
View File
@@ -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,
]);
}
+73
View File
@@ -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 };
}
+113
View File
@@ -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 };
}
+47
View File
@@ -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');
});
});
+36
View File
@@ -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]);
}
+15
View File
@@ -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],
);
}
+69
View File
@@ -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 });
});
});
+29 -12
View File
@@ -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,
+15
View File
@@ -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',
+15
View File
@@ -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',
+15
View File
@@ -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',
+15
View File
@@ -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',
+15
View File
@@ -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',
+15
View File
@@ -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',
+15
View File
@@ -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',
+15
View File
@@ -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: 'Общий трек',
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 && (
+70 -10
View File
@@ -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();
+20 -2
View File
@@ -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);
}
+87
View File
@@ -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';
}
+47
View File
@@ -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);
});
});
+87
View File
@@ -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 {
+84 -29
View File
@@ -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;
}
+24
View File
@@ -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;
+5
View File
@@ -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 }
: {}),
};
}
+22
View File
@@ -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) {
+14
View File
@@ -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 },
+54
View File
@@ -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';
+1
View File
@@ -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', () => {
+41 -1
View File
@@ -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,