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
+11
View File
@@ -208,6 +208,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Live Search — scoped browse on library pages
**By [@cucadmuh](https://github.com/cucadmuh), PR [#938](https://github.com/Psychotoxical/psysonic/pull/938)**
* **Artists, All Albums, New Releases, Tracks, and Composers** use the header Live Search field with a scope badge (sidebar icon) instead of a separate in-page filter input.
* While scoped, typing filters **that page only** via the same local-vs-network browse search; the global Live Search dropdown stays closed.
* **Ghost badge** on browse routes when scope is cleared — one click restores page-only mode; query text is preserved.
* Album browse text search uses title-only FTS in the local index; Tracks hides discovery chrome while searching; session stash restores query and scroll after back from detail.
## Changed ## Changed
### CI — hot-path coverage gates block merges ### CI — hot-path coverage gates block merges
@@ -22,7 +22,7 @@ use crate::filter::{self, EntityKind, FilterOp, SqlFragment};
use crate::repos; use crate::repos;
use crate::search::{ use crate::search::{
aliased_track_columns, aliased_track_columns_resolved_bpm, bpm_resolved_expr, aliased_track_columns, aliased_track_columns_resolved_bpm, bpm_resolved_expr,
fts_album_prefix_match_query, fts_column_prefix_query, fts_query_meets_min_len, fts_album_prefix_match_query, fts_album_title_prefix_match_query, fts_column_prefix_query, fts_query_meets_min_len,
fts_track_prefix_match_query, library_scope_equals_sql, like_contains, PAGE_LIMIT_MAX, fts_track_prefix_match_query, library_scope_equals_sql, like_contains, PAGE_LIMIT_MAX,
}; };
use crate::store::LibraryStore; use crate::store::LibraryStore;
@@ -305,6 +305,14 @@ fn server_has_indexed_tracks(store: &LibraryStore, server_id: &str) -> Result<bo
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
fn fts_album_text_match_query(req: &LibraryAdvancedSearchRequest, text: &str) -> Option<String> {
if req.query_album_title_only == Some(true) {
fts_album_title_prefix_match_query(text)
} else {
fts_album_prefix_match_query(text)
}
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn build_album( fn build_album(
store: &LibraryStore, store: &LibraryStore,
@@ -327,7 +335,7 @@ fn build_album(
return build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied); return build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied);
} }
if server_has_indexed_tracks(store, &req.server_id)? { if server_has_indexed_tracks(store, &req.server_id)? {
if let Some(q) = text.and_then(fts_album_prefix_match_query) { if let Some(q) = text.and_then(|t| fts_album_text_match_query(req, t)) {
return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied); return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
} }
return build_album_from_tracks( return build_album_from_tracks(
@@ -340,7 +348,7 @@ fn build_album(
return Ok(table); return Ok(table);
} }
} }
if let Some(q) = text.and_then(fts_album_prefix_match_query) { if let Some(q) = text.and_then(|t| fts_album_text_match_query(req, t)) {
return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied); return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
} }
build_album_from_tracks( build_album_from_tracks(
@@ -1408,6 +1416,7 @@ mod tests {
filters: Vec::new(), filters: Vec::new(),
starred_only: None, starred_only: None,
restrict_album_ids: None, restrict_album_ids: None,
query_album_title_only: None,
sort: Vec::new(), sort: Vec::new(),
limit: 50, limit: 50,
offset: 0, offset: 0,
@@ -532,6 +532,9 @@ pub struct LibraryAdvancedSearchRequest {
/// `starred_only` — use one or the other. /// `starred_only` — use one or the other.
#[serde(default)] #[serde(default)]
pub restrict_album_ids: Option<Vec<String>>, pub restrict_album_ids: Option<Vec<String>>,
/// When true, album text search matches title/name only (not album artist).
#[serde(default)]
pub query_album_title_only: Option<bool>,
#[serde(default)] #[serde(default)]
pub sort: Vec<LibrarySortClause>, pub sort: Vec<LibrarySortClause>,
pub limit: u32, pub limit: u32,
@@ -146,6 +146,11 @@ pub(crate) fn fts_album_prefix_match_query(raw: &str) -> Option<String> {
}) })
} }
/// Album title column only (All Albums scoped browse — not album artist).
pub(crate) fn fts_album_title_prefix_match_query(raw: &str) -> Option<String> {
fts_prefix_token_expr(raw).map(|tokens| format!("album : {tokens}"))
}
/// Live Search album match — any query word may hit album or album_artist (Navidrome parity). /// Live Search album match — any query word may hit album or album_artist (Navidrome parity).
pub(crate) fn fts_album_prefix_any_token_match_query(raw: &str) -> Option<String> { pub(crate) fn fts_album_prefix_any_token_match_query(raw: &str) -> Option<String> {
fts_prefix_token_or_expr(raw).map(|tokens| { fts_prefix_token_or_expr(raw).map(|tokens| {
@@ -429,6 +434,14 @@ mod tests {
); );
} }
#[test]
fn fts_album_title_prefix_match_query_is_album_column_only() {
assert_eq!(
fts_album_title_prefix_match_query("metal").as_deref(),
Some("album : \"metal\"*")
);
}
#[test] #[test]
fn fts_track_match_query_or_across_display_columns() { fn fts_track_match_query_or_across_display_columns() {
let q = fts_track_match_query("manowar").unwrap(); let q = fts_track_match_query("manowar").unwrap();
+2
View File
@@ -213,6 +213,8 @@ export interface LibraryAdvancedSearchRequest {
offset?: number; offset?: number;
/** Skip expensive COUNT queries (Live Search). */ /** Skip expensive COUNT queries (Live Search). */
skipTotals?: boolean; skipTotals?: boolean;
/** Album text query matches title/name only (All Albums scoped browse). */
queryAlbumTitleOnly?: boolean | null;
} }
export interface LibraryAlbumDto { export interface LibraryAlbumDto {
+2
View File
@@ -48,6 +48,7 @@ import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelect
import { useAppActivityTracking } from '../hooks/useAppActivityTracking'; import { useAppActivityTracking } from '../hooks/useAppActivityTracking';
import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator'; import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator';
import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority'; import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority';
import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope';
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm'; import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav'; import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam'; import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
@@ -101,6 +102,7 @@ export function AppShell() {
const location = useLocation(); const location = useLocation();
const prevPathnameRef = useRef(location.pathname); const prevPathnameRef = useRef(location.pathname);
useCoverNavigationPriority(); useCoverNavigationPriority();
useLiveSearchRouteScope();
useNowPlayingPrewarm(); useNowPlayingPrewarm();
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const offlineAlbums = useOfflineStore(s => s.albums); const offlineAlbums = useOfflineStore(s => s.albums);
+99 -46
View File
@@ -21,7 +21,7 @@ import {
logLibrarySearch, logLibrarySearch,
} from '../utils/library/libraryDevLog'; } from '../utils/library/libraryDevLog';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; 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 { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react'; import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
@@ -38,6 +38,19 @@ import { albumCoverRefForSong } from '../cover/ref';
import { showToast } from '../utils/ui/toast'; import { showToast } from '../utils/ui/toast';
import { useShareSearch } from '../hooks/useShareSearch'; import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults'; 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'; import { resolveIndexKey } from '../utils/server/serverIndexKey';
type LiveSearchSource = 'local' | 'network'; type LiveSearchSource = 'local' | 'network';
@@ -98,7 +111,15 @@ function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' |
export default function LiveSearch() { export default function LiveSearch() {
const { t } = useTranslation(); 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 [results, setResults] = useState<SearchResults | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -136,6 +157,14 @@ export default function LiveSearch() {
void refreshLocalReady(); void refreshLocalReady();
}, [refreshLocalReady, musicLibraryFilterVersion]); }, [refreshLocalReady, musicLibraryFilterVersion]);
useEffect(() => {
resetLiveSearchScopeBackspaceState(scopeBackspaceRef.current);
}, [scope]);
useEffect(() => {
noteLiveSearchScopeQueryInput(scopeBackspaceRef.current, query);
}, [query]);
useEffect(() => { useEffect(() => {
if (!indexEnabled || !serverId) return; if (!indexEnabled || !serverId) return;
let unlistenProgress: (() => void) | undefined; let unlistenProgress: (() => void) | undefined;
@@ -161,7 +190,16 @@ export default function LiveSearch() {
setOpen(false); setOpen(false);
setQuery(''); setQuery('');
setSearchSource(null); 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. */ /** Leave live search for a full-page route — cancel in-flight queries and reset overlay state. */
const leaveLiveSearchFor = useCallback((path: string) => { const leaveLiveSearchFor = useCallback((path: string) => {
@@ -175,11 +213,19 @@ export default function LiveSearch() {
setIsFocused(false); setIsFocused(false);
inputRef.current?.blur(); inputRef.current?.blur();
navigate(path); navigate(path);
}, [navigate]); }, [navigate, setQuery]);
const share = useShareSearch(query, closeSearch); const share = useShareSearch(query, closeSearch);
useEffect(() => { useEffect(() => {
if (isLiveSearchDropdownBlocked(scope)) {
setResults(null);
setOpen(false);
setSearchSource(null);
setLoading(false);
return;
}
if (share.shareMatch) { if (share.shareMatch) {
setResults(null); setResults(null);
setLoading(false); setLoading(false);
@@ -338,9 +384,9 @@ export default function LiveSearch() {
abort.abort(); abort.abort();
liveSearchGenRef.current += 1; 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(() => { useEffect(() => {
const root = ref.current; const root = ref.current;
@@ -485,6 +531,9 @@ export default function LiveSearch() {
] : []; ] : [];
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { 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 (share.shareMatch) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
@@ -547,46 +596,50 @@ export default function LiveSearch() {
requestAnimationFrame(() => inputRef.current?.focus()); requestAnimationFrame(() => inputRef.current?.focus());
}} }}
> >
{loading ? ( <div className="live-search-field-cluster">
<span className="live-search-icon animate-spin" style={{ opacity: 0.6 }}> {loading ? (
<div style={{ width: 16, height: 16, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%' }} /> <span className="live-search-leading-icon animate-spin" style={{ opacity: 0.6 }}>
</span> <div style={{ width: 16, height: 16, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%' }} />
) : ( </span>
<Search size={16} className="live-search-icon" /> ) : (
)} <Search size={16} className="live-search-leading-icon" aria-hidden />
<input )}
ref={inputRef} {scope && (
id="live-search-input" <LiveSearchScopeBadge
className="input live-search-field" scope={scope}
type="search" className="live-search-scope-badge"
placeholder={t('search.placeholder')} clearScope={clearScope}
value={query} />
onChange={e => setQuery(e.target.value)} )}
onFocus={() => { {ghostScope && (
setIsFocused(true); <LiveSearchScopeGhostBadge
if (results) setOpen(true); scope={ghostScope}
}} className="live-search-scope-badge live-search-scope-badge--ghost"
onBlur={() => setIsFocused(false)} setScope={setScope}
onKeyDown={handleKeyDown} />
aria-autocomplete="list" )}
aria-controls="search-results" <input
aria-expanded={open} ref={inputRef}
autoComplete="off" id="live-search-input"
/> className="input live-search-field"
{query && ( type="search"
<button placeholder={t(liveSearchScopePlaceholderKey(scope))}
className="live-search-clear" data-tooltip={scope ? t(liveSearchScopePlaceholderKey(scope)) : undefined}
onClick={() => { data-tooltip-pos="bottom"
setQuery(''); value={query}
setResults(null); onChange={e => handleQueryChange(e.target.value)}
setOpen(false); onFocus={() => {
setSearchSource(null); setIsFocused(true);
if (!isLiveSearchDropdownBlocked(scope) && results) setOpen(true);
}} }}
aria-label={t('search.clearLabel')} onBlur={() => setIsFocused(false)}
> onKeyDown={handleKeyDown}
× aria-autocomplete="list"
</button> aria-controls="search-results"
)} aria-expanded={open && !isLiveSearchDropdownBlocked(scope)}
autoComplete="off"
/>
</div>
<button <button
className="live-search-adv-btn" className="live-search-adv-btn"
type="button" type="button"
@@ -607,7 +660,7 @@ export default function LiveSearch() {
</button> </button>
</div> </div>
{open && ( {open && !isLiveSearchDropdownBlocked(scope) && (
<div className="live-search-dropdown" id="search-results" role="listbox" ref={dropdownRef}> <div className="live-search-dropdown" id="search-results" role="listbox" ref={dropdownRef}>
{searchSource && !share.shareMatch && ( {searchSource && !share.shareMatch && (
<div <div
+76 -16
View File
@@ -1,6 +1,7 @@
import { search } from '../api/subsonicSearch'; import { search } from '../api/subsonicSearch';
import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes'; import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack'; import { songToTrack } from '../utils/playback/songToTrack';
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
@@ -17,6 +18,18 @@ import { albumCoverRefForSong } from '../cover/ref';
import { showToast } from '../utils/ui/toast'; import { showToast } from '../utils/ui/toast';
import { useShareSearch } from '../hooks/useShareSearch'; import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults'; 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 STORAGE_KEY = 'psysonic_recent_searches';
const MAX_RECENT = 6; const MAX_RECENT = 6;
@@ -31,9 +44,12 @@ function saveRecent(q: string, prev: string[]): string[] {
return updated; 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>; 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). */ /** 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 location = useLocation();
const enqueue = usePlayerStore(s => s.enqueue); 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 [results, setResults] = useState<SearchResults | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent); const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
@@ -103,6 +126,14 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
useEffect(() => { inputRef.current?.focus(); }, []); useEffect(() => { inputRef.current?.focus(); }, []);
useEffect(() => {
resetLiveSearchScopeBackspaceState(scopeBackspaceRef.current);
}, [scope]);
useEffect(() => {
noteLiveSearchScopeQueryInput(scopeBackspaceRef.current, query);
}, [query]);
useEffect(() => { useEffect(() => {
const prev = document.body.style.overflow; const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
@@ -113,20 +144,26 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
debounce(async (q: string) => { debounce(async (q: string) => {
if (!q.trim()) { setResults(null); setLoading(false); return; } if (!q.trim()) { setResults(null); setLoading(false); return; }
setLoading(true); setLoading(true);
try { setResults(await search(q)); } try {
finally { setLoading(false); } setResults(await search(q));
} finally { setLoading(false); }
}, 300), }, 300),
[musicLibraryFilterVersion] [musicLibraryFilterVersion],
); );
useEffect(() => { useEffect(() => {
if (isLiveSearchDropdownBlocked(scope)) {
setResults(null);
setLoading(false);
return;
}
if (share.shareMatch) { if (share.shareMatch) {
setResults(null); setResults(null);
setLoading(false); setLoading(false);
return; return;
} }
doSearch(query); doSearch(query);
}, [query, doSearch, share.shareMatch]); }, [query, scope, doSearch, share.shareMatch]);
const commit = (q: string) => { const commit = (q: string) => {
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev)); if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
@@ -146,7 +183,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
onClose(); onClose();
}; };
const useRecent = (term: string) => { const useRecent = (term: string) => {
setQuery(term); setQuery(term, { recordUndo: true });
inputRef.current?.focus(); inputRef.current?.focus();
}; };
const removeRecent = (term: string, e: React.MouseEvent) => { const removeRecent = (term: string, e: React.MouseEvent) => {
@@ -159,27 +196,50 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
}; };
const hasResults = const hasResults =
!!share.shareMatch || !isLiveSearchDropdownBlocked(scope)
(results && (results.artists.length || results.albums.length || results.songs.length)); && (
const showEmpty = !query; !!share.shareMatch
|| (results && (results.artists.length || results.albums.length || results.songs.length))
);
const showEmpty = !query && !scope;
return createPortal( return createPortal(
<div className="mobile-search-overlay"> <div className="mobile-search-overlay">
{/* ── Search bar ── */} {/* ── Search bar ── */}
<div className="mobile-search-bar"> <div className="mobile-search-bar">
<div className="mobile-search-field"> <div className={`mobile-search-field${scope ? ' mobile-search-field--scoped' : ''}`}>
{loading ? ( {loading ? (
<div className="mobile-search-spinner" /> <div className="mobile-search-spinner" />
) : ( ) : (
<Search size={16} className="mobile-search-icon" /> <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 <input
ref={inputRef} ref={inputRef}
className="mobile-search-input" className="mobile-search-input"
type="search" type="search"
placeholder={t('search.placeholder')} placeholder={t(liveSearchScopePlaceholderKey(scope))}
data-tooltip={scope ? t(liveSearchScopePlaceholderKey(scope)) : undefined}
data-tooltip-pos="bottom"
value={query} 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" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
@@ -187,7 +247,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
{query && ( {query && (
<button <button
className="mobile-search-clear" 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')} aria-label={t('search.clearLabel')}
> >
<X size={15} /> <X size={15} />
@@ -249,7 +309,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
)} )}
{/* ── No results ── */} {/* ── No results ── */}
{!loading && query && !hasResults && ( {!loading && query && !hasResults && !isLiveSearchDropdownBlocked(scope) && (
<div className="mobile-search-noresults"> <div className="mobile-search-noresults">
{t('search.noResults', { query })} {t('search.noResults', { query })}
</div> </div>
+3 -4
View File
@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { useSongBrowseList } from '../hooks/useSongBrowseList'; import { useSongBrowseList } from '../hooks/useSongBrowseList';
import SongBrowseSection from './tracks/SongBrowseSection'; import SongBrowseSection from './tracks/SongBrowseSection';
@@ -9,14 +9,13 @@ interface Props {
/** @deprecated Use SongBrowseSection via SearchBrowsePage (`/tracks`). */ /** @deprecated Use SongBrowseSection via SearchBrowsePage (`/tracks`). */
export default function VirtualSongList({ title, emptyBrowseText }: Props) { export default function VirtualSongList({ title, emptyBrowseText }: Props) {
const browse = useSongBrowseList({ enabled: true }); const [searchQuery] = useState('');
const browse = useSongBrowseList({ enabled: true, searchQuery });
return ( return (
<SongBrowseSection <SongBrowseSection
title={title} title={title}
emptyBrowseText={emptyBrowseText} emptyBrowseText={emptyBrowseText}
query={browse.query}
onQueryChange={browse.setQuery}
songs={browse.songs} songs={browse.songs}
hasMore={browse.hasMore} hasMore={browse.hasMore}
loading={browse.loading} loading={browse.loading}
+21 -76
View File
@@ -1,16 +1,12 @@
import React from 'react'; import React from 'react';
import type { Virtualizer } from '@tanstack/react-virtual';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { SubsonicArtist } from '../../api/subsonicTypes'; import type { SubsonicArtist } from '../../api/subsonicTypes';
import type { PlayerState } from '../../store/playerStoreTypes'; import type { PlayerState } from '../../store/playerStoreTypes';
import { ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
import { VirtualCardGrid } from '../VirtualCardGrid';
import { ArtistCardAvatar } from './ArtistAvatars'; import { ArtistCardAvatar } from './ArtistAvatars';
export type ArtistsGridVirtualization = {
virtualizer: Virtualizer<HTMLElement, Element>;
scrollMargin: number;
};
interface TileProps { interface TileProps {
artist: SubsonicArtist; artist: SubsonicArtist;
selectionMode: boolean; selectionMode: boolean;
@@ -68,11 +64,10 @@ function ArtistGridTile({ artist, ...rest }: TileProps) {
interface Props { interface Props {
visible: SubsonicArtist[]; visible: SubsonicArtist[];
/** Column count from layout (capped at six in parent); drives `repeat(n, minmax(0,1fr))`. */ /** Plain CSS grid (canonical card layout) vs row virtualization for large catalogs. */
gridCols: number; disableVirtualization: boolean;
/** ResizeObserver target — same node for plain and virtual grid. */ /** Remount grid when browse filters change so virtualizer state cannot go stale. */
measureRef: React.RefObject<HTMLDivElement | null>; layoutKey: string;
virtualization?: ArtistsGridVirtualization | null;
selectionMode: boolean; selectionMode: boolean;
selectedIds: Set<string>; selectedIds: Set<string>;
selectedArtists: SubsonicArtist[]; selectedArtists: SubsonicArtist[];
@@ -84,14 +79,12 @@ interface Props {
} }
/** /**
* Card grid for the artists page. Optional row virtualization (TanStack) for * Card grid for the artists page same VirtualCardGrid path as Albums/Composers.
* large libraries; column count and `measureRef` always come from the parent.
*/ */
export function ArtistsGridView({ export function ArtistsGridView({
visible, visible,
gridCols, disableVirtualization,
measureRef, layoutKey,
virtualization,
selectionMode, selectionMode,
selectedIds, selectedIds,
selectedArtists, selectedArtists,
@@ -112,67 +105,19 @@ export function ArtistsGridView({
t, 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 ( return (
<div <VirtualCardGrid
ref={measureRef} key={layoutKey}
className="album-grid-wrap" items={visible}
style={{ itemKey={(artist) => artist.id}
display: 'grid', rowVariant="artist"
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, disableVirtualization={disableVirtualization}
gap: 'var(--space-4)', layoutSignal={visible.length}
alignItems: 'start', scrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
}} wrapClassName={disableVirtualization ? 'album-grid-wrap album-grid-wrap--plain' : 'album-grid-wrap'}
> renderItem={artist => (
{visible.map(artist => (
<ArtistGridTile key={artist.id} artist={artist} {...tilePropsShared} /> <ArtistGridTile key={artist.id} artist={artist} {...tilePropsShared} />
))} )}
</div> />
); );
} }
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next'; 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 { 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 { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
import { getAlbum } from '../../api/subsonicLibrary'; import { getAlbum } from '../../api/subsonicLibrary';
import { queueSongStar } from '../../store/pendingStarSync'; import { queueSongStar } from '../../store/pendingStarSync';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
@@ -30,8 +30,8 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
} = props; } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const auth = useAuthStore(); const auth = useAuthStore();
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum(); const navigateToAlbum = useNavigateToAlbum();
const navigateToArtist = useNavigateToArtist();
return ( return (
<> <>
@@ -108,7 +108,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div> </div>
)} )}
{song.artistId && ( {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')} <User size={14} /> {t('contextMenu.goToArtist')}
</div> </div>
)} )}
@@ -251,7 +251,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div> </div>
)} )}
{song.artistId && ( {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')} <User size={14} /> {t('contextMenu.goToArtist')}
</div> </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 type { SubsonicSong } from '../../api/subsonicTypes';
import React from 'react'; import React from 'react';
import { Search as SearchIcon, X } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import PagedSongList from '../PagedSongList'; import PagedSongList from '../PagedSongList';
interface Props { interface Props {
title?: string; title?: string;
emptyBrowseText?: string; emptyBrowseText?: string;
query: string; searchActive?: boolean;
onQueryChange: (value: string) => void;
songs: SubsonicSong[]; songs: SubsonicSong[];
hasMore: boolean; hasMore: boolean;
loading: boolean; loading: boolean;
@@ -16,12 +14,11 @@ interface Props {
onLoadMore: () => void; 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({ export default function SongBrowseSection({
title, title,
emptyBrowseText, emptyBrowseText,
query, searchActive = false,
onQueryChange,
songs, songs,
hasMore, hasMore,
loading, loading,
@@ -29,31 +26,15 @@ export default function SongBrowseSection({
onLoadMore, onLoadMore,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const showEmptyBrowse = !loading && songs.length === 0 && query.trim() === '' && (browseUnsupported || !hasMore); const showEmptyBrowse =
!loading && songs.length === 0 && !searchActive && (browseUnsupported || !hasMore);
return ( return (
<section className="virtual-song-list-section"> <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-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"> <div className="virtual-song-list-meta">
{songs.length > 0 && ( {songs.length > 0 && (
<span>{t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''}</span> <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). */ /** Tracks hub hero + song rails (above the browse-all list). */
export default function TracksPageChrome({ export default function TracksPageChrome({
onLayoutReady, onLayoutReady,
hideDiscoveryChrome = false,
}: { }: {
/** Fires once when hero + rails finish their initial load (or fail). */ /** Fires once when hero + rails finish their initial load (or fail). */
onLayoutReady?: () => void; onLayoutReady?: () => void;
/** When true, skip hero and song rails (active scoped search). */
hideDiscoveryChrome?: boolean;
}) { }) {
const perfFlags = usePerfProbeFlags(); const perfFlags = usePerfProbeFlags();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -79,15 +82,15 @@ export default function TracksPageChrome({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!activeServerId) return; if (!activeServerId || hideDiscoveryChrome) return;
rerollHero(); rerollHero();
rerollRandom(); rerollRandom();
reloadRated(); reloadRated();
}, [activeServerId, rerollHero, rerollRandom, reloadRated]); }, [activeServerId, hideDiscoveryChrome, rerollHero, rerollRandom, reloadRated]);
useEffect(() => { useEffect(() => {
if (!onLayoutReady || layoutReadyNotifiedRef.current) return; if (!onLayoutReady || layoutReadyNotifiedRef.current) return;
if (!activeServerId) { if (hideDiscoveryChrome || !activeServerId) {
layoutReadyNotifiedRef.current = true; layoutReadyNotifiedRef.current = true;
onLayoutReady(); onLayoutReady();
return; return;
@@ -95,7 +98,7 @@ export default function TracksPageChrome({
if (heroLoading || randomLoading || ratedLoading) return; if (heroLoading || randomLoading || ratedLoading) return;
layoutReadyNotifiedRef.current = true; layoutReadyNotifiedRef.current = true;
onLayoutReady(); onLayoutReady();
}, [activeServerId, onLayoutReady, heroLoading, randomLoading, ratedLoading]); }, [activeServerId, hideDiscoveryChrome, onLayoutReady, heroLoading, randomLoading, ratedLoading]);
const railSongs = useMemo( const railSongs = useMemo(
() => (hero ? random.filter(s => s.id !== hero.id) : random), () => (hero ? random.filter(s => s.id !== hero.id) : random),
@@ -108,12 +111,14 @@ export default function TracksPageChrome({
<header className="tracks-header"> <header className="tracks-header">
<div className="tracks-header-text"> <div className="tracks-header-text">
<h1 className="page-title">{t('tracks.title')}</h1> <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> </div>
</header> </header>
)} )}
{!perfFlags.disableMainstageHero && hero && ( {!perfFlags.disableMainstageHero && !hideDiscoveryChrome && hero && (
<section className="tracks-hero"> <section className="tracks-hero">
<div className="tracks-hero-cover"> <div className="tracks-hero-cover">
{hero.albumId && hero.coverArt ? ( {hero.albumId && hero.coverArt ? (
@@ -173,7 +178,7 @@ export default function TracksPageChrome({
</section> </section>
)} )}
{!perfFlags.disableMainstageRails && ratedSupported && (ratedLoading || rated.length > 0) && ( {!perfFlags.disableMainstageRails && !hideDiscoveryChrome && ratedSupported && (ratedLoading || rated.length > 0) && (
<SongRail <SongRail
title={t('tracks.railHighlyRated')} title={t('tracks.railHighlyRated')}
songs={rated} songs={rated}
@@ -184,7 +189,7 @@ export default function TracksPageChrome({
/> />
)} )}
{!perfFlags.disableMainstageRails && ( {!perfFlags.disableMainstageRails && !hideDiscoveryChrome && (
<SongRail <SongRail
title={t('tracks.railRandom')} title={t('tracks.railRandom')}
songs={railSongs} 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)', '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)', '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)', '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'; } from '../store/albumBrowseSessionStore';
import type { AlbumBrowseSort } from '../utils/library/browseTextSearch'; import type { AlbumBrowseSort } from '../utils/library/browseTextSearch';
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation'; import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
const ALBUMS_SURFACE: AlbumBrowseSurface = 'albums'; const ALBUMS_SURFACE: AlbumBrowseSurface = 'albums';
@@ -106,6 +107,7 @@ export function useAlbumBrowseFilters(
compFilter, compFilter,
starredOnly, starredOnly,
losslessOnly, losslessOnly,
searchQuery: useLiveSearchScopeStore.getState().query,
}; };
useEffect(() => { useEffect(() => {
@@ -119,6 +121,7 @@ export function useAlbumBrowseFilters(
restoredFromStashRef.current = true; restoredFromStashRef.current = true;
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, ALBUMS_SURFACE); const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, ALBUMS_SURFACE);
if (restored) { if (restored) {
useLiveSearchScopeStore.getState().setQuery(restored.searchQuery ?? '');
setSelectedGenres(restored.selectedGenres); setSelectedGenres(restored.selectedGenres);
setYearFrom(restored.yearFrom); setYearFrom(restored.yearFrom);
setYearTo(restored.yearTo); setYearTo(restored.yearTo);
@@ -132,6 +135,7 @@ export function useAlbumBrowseFilters(
if (restoredFromStashRef.current) return; if (restoredFromStashRef.current) return;
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, ALBUMS_SURFACE); useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, ALBUMS_SURFACE);
useLiveSearchScopeStore.getState().setQuery('');
setSelectedGenres([]); setSelectedGenres([]);
setYearFrom(''); setYearFrom('');
setYearTo(''); 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, useAlbumBrowseSessionStore,
} from '../store/albumBrowseSessionStore'; } from '../store/albumBrowseSessionStore';
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation'; import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
import { import {
inpageScrollViewportIdForSurface, inpageScrollViewportIdForSurface,
readInpageScrollTop, readInpageScrollTop,
@@ -53,8 +54,11 @@ export function useAlbumGridBrowseFilters(
const [selectedGenres, setSelectedGenres] = useState<string[]>(() => initialState.selectedGenres); const [selectedGenres, setSelectedGenres] = useState<string[]>(() => initialState.selectedGenres);
const restoredFromStashRef = useRef(false); const restoredFromStashRef = useRef(false);
const filtersRef = useRef({ selectedGenres }); const filtersRef = useRef({ selectedGenres, searchQuery: '' });
filtersRef.current = { selectedGenres }; filtersRef.current = {
selectedGenres,
searchQuery: useLiveSearchScopeStore.getState().query,
};
useEffect(() => { useEffect(() => {
restoredFromStashRef.current = false; restoredFromStashRef.current = false;
@@ -66,8 +70,11 @@ export function useAlbumGridBrowseFilters(
if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) { if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) {
restoredFromStashRef.current = true; restoredFromStashRef.current = true;
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, surface); const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, surface);
if (restored && !sameGenreSelection(restored.selectedGenres, filtersRef.current.selectedGenres)) { if (restored) {
setSelectedGenres(restored.selectedGenres); useLiveSearchScopeStore.getState().setQuery(restored.searchQuery ?? '');
if (!sameGenreSelection(restored.selectedGenres, filtersRef.current.selectedGenres)) {
setSelectedGenres(restored.selectedGenres);
}
} }
return; return;
} }
@@ -75,6 +82,7 @@ export function useAlbumGridBrowseFilters(
if (restoredFromStashRef.current) return; if (restoredFromStashRef.current) return;
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface); useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
useLiveSearchScopeStore.getState().setQuery('');
setSelectedGenres([]); setSelectedGenres([]);
}, [serverId, surface, navigationType, location.state]); }, [serverId, surface, navigationType, location.state]);
@@ -93,6 +101,7 @@ export function useAlbumGridBrowseFilters(
useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, surface, { useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, surface, {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS, ...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: filtersRef.current.selectedGenres, selectedGenres: filtersRef.current.selectedGenres,
searchQuery: filtersRef.current.searchQuery,
scrollTop, scrollTop,
displayCount: gridSnapshot?.albums.length ?? scrollSnapshot?.displayCount, displayCount: gridSnapshot?.albums.length ?? scrollSnapshot?.displayCount,
albums: gridSnapshot?.albums, albums: gridSnapshot?.albums,
+4 -8
View File
@@ -10,6 +10,7 @@ import {
} from '../store/artistBrowseSessionStore'; } from '../store/artistBrowseSessionStore';
import { isArtistDetailPath } from '../store/albumBrowseSessionStore'; import { isArtistDetailPath } from '../store/albumBrowseSessionStore';
import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation'; import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation';
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
export type ArtistBrowseScrollSnapshot = { export type ArtistBrowseScrollSnapshot = {
scrollTop: number; scrollTop: number;
@@ -38,9 +39,6 @@ export function useArtistsBrowseFilters(
const location = useLocation(); const location = useLocation();
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages); const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const [filter, setFilter] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).filter,
);
const [letterFilter, setLetterFilter] = useState( const [letterFilter, setLetterFilter] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).letterFilter, () => returnStateForNavigation(serverId, navigationType, location.state).letterFilter,
); );
@@ -56,7 +54,7 @@ export function useArtistsBrowseFilters(
const showArtistImages = useAuthStore(s => s.showArtistImages); const showArtistImages = useAuthStore(s => s.showArtistImages);
browseStateRef.current = { browseStateRef.current = {
filter, filter: useLiveSearchScopeStore.getState().query,
letterFilter, letterFilter,
starredOnly, starredOnly,
viewMode, viewMode,
@@ -74,7 +72,7 @@ export function useArtistsBrowseFilters(
restoredFromStashRef.current = true; restoredFromStashRef.current = true;
const restored = useArtistBrowseSessionStore.getState().peekReturnStash(serverId); const restored = useArtistBrowseSessionStore.getState().peekReturnStash(serverId);
if (restored) { if (restored) {
setFilter(restored.filter); useLiveSearchScopeStore.getState().setQuery(restored.filter);
setLetterFilter(restored.letterFilter); setLetterFilter(restored.letterFilter);
setStarredOnly(restored.starredOnly); setStarredOnly(restored.starredOnly);
setViewMode(restored.viewMode); setViewMode(restored.viewMode);
@@ -86,7 +84,7 @@ export function useArtistsBrowseFilters(
if (restoredFromStashRef.current) return; if (restoredFromStashRef.current) return;
useArtistBrowseSessionStore.getState().clearReturnStash(serverId); useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
setFilter(''); useLiveSearchScopeStore.getState().setQuery('');
setLetterFilter(DEFAULT_ARTIST_BROWSE_RETURN_STATE.letterFilter); setLetterFilter(DEFAULT_ARTIST_BROWSE_RETURN_STATE.letterFilter);
setStarredOnly(false); setStarredOnly(false);
setViewMode('grid'); setViewMode('grid');
@@ -110,8 +108,6 @@ export function useArtistsBrowseFilters(
}, [serverId, scrollSnapshotRef]); }, [serverId, scrollSnapshotRef]);
return { return {
filter,
setFilter,
letterFilter, letterFilter,
setLetterFilter, setLetterFilter,
starredOnly, 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 = { type UseSongBrowseListArgs = {
enabled: boolean; enabled: boolean;
/** Header scoped browse query (wide title/artist/album search). */
searchQuery: string;
initialRestore?: SongBrowseListRestore | null; initialRestore?: SongBrowseListRestore | null;
}; };
/** Tracks hub song browse — all-library paging or filtered text search. */ /** 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 serverId = useAuthStore(s => s.activeServerId);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const [query, setQuery] = useState(() => initialRestore?.query ?? ''); const [debouncedQuery, setDebouncedQuery] = useState(
const [debouncedQuery, setDebouncedQuery] = useState(() => initialRestore?.query.trim() ?? ''); () => initialRestore?.query.trim() ?? searchQuery.trim(),
);
const [songs, setSongs] = useState<SubsonicSong[]>(() => initialRestore?.songs ?? []); const [songs, setSongs] = useState<SubsonicSong[]>(() => initialRestore?.songs ?? []);
const [offset, setOffset] = useState(() => initialRestore?.offset ?? 0); const [offset, setOffset] = useState(() => initialRestore?.offset ?? 0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -63,14 +66,25 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList
const requestSeqRef = useRef(0); const requestSeqRef = useRef(0);
const localSearchModeRef = useRef(initialRestore?.localSearchMode ?? false); 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(() => { useEffect(() => {
if (!enabled) return; 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 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); return () => window.clearTimeout(timer);
}, [query, indexEnabled, enabled]); }, [searchQuery, indexEnabled, enabled]);
const fetchSongPage = useCallback( const fetchSongPage = useCallback(
async (q: string, pageOffset: number, isStale: () => boolean): Promise<SubsonicSong[]> => { async (q: string, pageOffset: number, isStale: () => boolean): Promise<SubsonicSong[]> => {
@@ -114,9 +128,14 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
if (skipInitialFetchRef.current) {
skipInitialFetchRef.current = false; if (holdRestoredListRef.current) {
return; const expected = heldRestoredQueryRef.current;
if (searchQuery.trim() !== expected || debouncedQuery !== expected) {
holdRestoredListRef.current = false;
} else {
return;
}
} }
let cancelled = false; let cancelled = false;
@@ -152,7 +171,7 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [debouncedQuery, fetchSongPage, enabled]); }, [debouncedQuery, searchQuery, fetchSongPage, enabled]);
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
if (!enabled || loading || !hasMore) return; if (!enabled || loading || !hasMore) return;
@@ -182,8 +201,6 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList
}, [enabled, loading, hasMore, debouncedQuery, offset, fetchSongPage]); }, [enabled, loading, hasMore, debouncedQuery, offset, fetchSongPage]);
return { return {
query,
setQuery,
songs, songs,
offset, offset,
loading, loading,
+15
View File
@@ -39,6 +39,21 @@ export const search = {
recentSearches: 'Zuletzt gesucht', recentSearches: 'Zuletzt gesucht',
browse: 'Stöbern', browse: 'Stöbern',
emptyHint: 'Was möchtest du hören?', 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', genres: 'Genres',
shareLink: 'Share link', shareLink: 'Share link',
shareTrackTitle: 'Shared track', shareTrackTitle: 'Shared track',
+15
View File
@@ -39,6 +39,21 @@ export const search = {
recentSearches: 'Recent Searches', recentSearches: 'Recent Searches',
browse: 'Browse', browse: 'Browse',
emptyHint: 'What do you want to hear?', 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', genres: 'Genres',
shareLink: 'Share link', shareLink: 'Share link',
shareTrackTitle: 'Shared track', shareTrackTitle: 'Shared track',
+15
View File
@@ -39,6 +39,21 @@ export const search = {
recentSearches: 'Búsquedas Recientes', recentSearches: 'Búsquedas Recientes',
browse: 'Explorar', browse: 'Explorar',
emptyHint: '¿Qué quieres escuchar?', 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', genres: 'Géneros',
shareLink: 'Share link', shareLink: 'Share link',
shareTrackTitle: 'Shared track', shareTrackTitle: 'Shared track',
+15
View File
@@ -39,6 +39,21 @@ export const search = {
recentSearches: 'Recherches récentes', recentSearches: 'Recherches récentes',
browse: 'Parcourir', browse: 'Parcourir',
emptyHint: 'Que veux-tu écouter ?', 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', genres: 'Genres',
shareLink: 'Share link', shareLink: 'Share link',
shareTrackTitle: 'Shared track', shareTrackTitle: 'Shared track',
+15
View File
@@ -39,6 +39,21 @@ export const search = {
recentSearches: 'Siste søk', recentSearches: 'Siste søk',
browse: 'Utforsk', browse: 'Utforsk',
emptyHint: 'Hva vil du høre?', 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', genres: 'Sjangre',
shareLink: 'Share link', shareLink: 'Share link',
shareTrackTitle: 'Shared track', shareTrackTitle: 'Shared track',
+15
View File
@@ -39,6 +39,21 @@ export const search = {
recentSearches: 'Recente zoekopdrachten', recentSearches: 'Recente zoekopdrachten',
browse: 'Bladeren', browse: 'Bladeren',
emptyHint: 'Wat wil je horen?', 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', genres: 'Genres',
shareLink: 'Share link', shareLink: 'Share link',
shareTrackTitle: 'Shared track', shareTrackTitle: 'Shared track',
+15
View File
@@ -39,6 +39,21 @@ export const search = {
recentSearches: 'Căutări Recente', recentSearches: 'Căutări Recente',
browse: 'Răsfoiește', browse: 'Răsfoiește',
emptyHint: 'Ce vrei să auzi?', 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', genres: 'Genuri',
shareLink: 'Share link', shareLink: 'Share link',
shareTrackTitle: 'Shared track', shareTrackTitle: 'Shared track',
+15
View File
@@ -39,6 +39,21 @@ export const search = {
recentSearches: 'Недавние запросы', recentSearches: 'Недавние запросы',
browse: 'Обзор', browse: 'Обзор',
emptyHint: 'Что хочешь послушать?', emptyHint: 'Что хочешь послушать?',
scopeArtistsPlaceholder: 'Поиск исполнителя…',
scopeArtistsBadgeTooltip: 'Щелчок — удалить',
scopeArtistsGhostTooltip: 'Щелчок — искать только на этой странице',
scopeAlbumsPlaceholder: 'Поиск альбома…',
scopeAlbumsBadgeTooltip: 'Щелчок — удалить',
scopeAlbumsGhostTooltip: 'Щелчок — искать только на этой странице',
scopeNewReleasesPlaceholder: 'Поиск в новинках…',
scopeNewReleasesBadgeTooltip: 'Щелчок — удалить',
scopeNewReleasesGhostTooltip: 'Щелчок — искать только на этой странице',
scopeTracksPlaceholder: 'Найти трек по названию, исполнителю или альбому…',
scopeTracksBadgeTooltip: 'Щелчок — удалить',
scopeTracksGhostTooltip: 'Щелчок — искать только на этой странице',
scopeComposersPlaceholder: 'Поиск композитора…',
scopeComposersBadgeTooltip: 'Щелчок — удалить',
scopeComposersGhostTooltip: 'Щелчок — искать только на этой странице',
genres: 'Жанры', genres: 'Жанры',
shareLink: 'Ссылка для обмена', shareLink: 'Ссылка для обмена',
shareTrackTitle: 'Общий трек', shareTrackTitle: 'Общий трек',
+15
View File
@@ -39,6 +39,21 @@ export const search = {
recentSearches: '最近搜索', recentSearches: '最近搜索',
browse: '浏览', browse: '浏览',
emptyHint: '你想听什么?', emptyHint: '你想听什么?',
scopeArtistsPlaceholder: '搜索艺术家…',
scopeArtistsBadgeTooltip: '单击移除',
scopeArtistsGhostTooltip: '单击 — 仅在此页面搜索',
scopeAlbumsPlaceholder: '搜索专辑…',
scopeAlbumsBadgeTooltip: '单击移除',
scopeAlbumsGhostTooltip: '单击 — 仅在此页面搜索',
scopeNewReleasesPlaceholder: '搜索新发布…',
scopeNewReleasesBadgeTooltip: '单击移除',
scopeNewReleasesGhostTooltip: '单击 — 仅在此页面搜索',
scopeTracksPlaceholder: '按标题、艺人或专辑搜索…',
scopeTracksBadgeTooltip: '单击移除',
scopeTracksGhostTooltip: '单击 — 仅在此页面搜索',
scopeComposersPlaceholder: '搜索作曲家…',
scopeComposersBadgeTooltip: '单击 — 移除',
scopeComposersGhostTooltip: '单击 — 仅在此页面搜索',
genres: '流派', genres: '流派',
shareLink: 'Share link', shareLink: 'Share link',
shareTrackTitle: 'Shared track', 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 { useAlbumBrowseFilters, useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
import { useAlbumBrowseData } from '../hooks/useAlbumBrowseData'; import { useAlbumBrowseData } from '../hooks/useAlbumBrowseData';
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore'; import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset';
import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch';
import { peekAlbumBrowseScrollRestore } from '../store/albumBrowseSessionStore'; import { peekAlbumBrowseScrollRestore } from '../store/albumBrowseSessionStore';
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation'; import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
import { useAlbumCatalogYearBounds } from '../hooks/useAlbumCatalogYearBounds'; import { useAlbumCatalogYearBounds } from '../hooks/useAlbumCatalogYearBounds';
import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort'; import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort';
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode'; 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; type SortType = AlbumBrowseSort;
@@ -81,6 +91,14 @@ export default function Albums() {
setLosslessOnly, setLosslessOnly,
} = useAlbumBrowseFilters(serverId, scrollSnapshotRef); } = useAlbumBrowseFilters(serverId, scrollSnapshotRef);
const albumsSearchQuery = useScopedBrowseSearchQuery('albums');
const { textSearchAlbums, textSearchLoading } = useBrowseAlbumTextSearch(
albumsSearchQuery,
indexEnabled,
serverId,
losslessOnly,
);
const { const {
scrollBodyEl, scrollBodyEl,
bindScrollBody: bindAlbumsScrollBody, bindScrollBody: bindAlbumsScrollBody,
@@ -88,24 +106,7 @@ export default function Albums() {
} = useInpageScrollViewport(); } = useInpageScrollViewport();
const starredOverrides = usePlayerStore(s => s.starredOverrides); const starredOverrides = usePlayerStore(s => s.starredOverrides);
const { const browseData = useAlbumBrowseData({
albums,
loading,
loadingMore,
hasMore,
displayAlbums,
visibleAlbums,
genreFiltered,
serverFilterActive,
narrowGenreList,
genreCatalogOptions,
yearFilterActive,
debouncedYearFields,
compFilterActive,
pendingClientFilterMatch,
bindLoadMoreSentinel,
loadMore,
} = useAlbumBrowseData({
serverId, serverId,
indexEnabled, indexEnabled,
musicLibraryFilterVersion, musicLibraryFilterVersion,
@@ -122,6 +123,55 @@ export default function Albums() {
restoreDisplayCount: restoreDisplayCountRef.current, 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); useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length);
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({ const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
@@ -135,6 +185,22 @@ export default function Albums() {
loadMore, 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 location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@@ -266,6 +332,7 @@ export default function Albums() {
); );
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
albumsSearchQuery,
sort, sort,
genreFiltered, genreFiltered,
yearFilterActive, yearFilterActive,
@@ -394,8 +461,9 @@ export default function Albums() {
hasMore, hasMore,
selectionMode, selectionMode,
sort, sort,
albumsSearchQuery,
perfFlags.disableMainstageGridCards, perfFlags.disableMainstageGridCards,
perfFlags.disableMainstageVirtualLists, albumBrowsePlainLayout,
]} ]}
> >
{loading && albums.length === 0 ? ( {loading && albums.length === 0 ? (
@@ -418,6 +486,10 @@ export default function Albums() {
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}> <div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{visibleEmptyMessage} {visibleEmptyMessage}
</div> </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={{ position: 'relative' }}>
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}> <div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
@@ -427,7 +499,7 @@ export default function Albums() {
items={displayAlbums} items={displayAlbums}
itemKey={(a, _i) => a.id} itemKey={(a, _i) => a.id}
rowVariant="album" rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists} disableVirtualization={albumBrowsePlainLayout}
layoutSignal={displayAlbums.length} layoutSignal={displayAlbums.length}
scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID} scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
warmGridCovers={albumGridWarmCovers( warmGridCovers={albumGridWarmCovers(
+38 -67
View File
@@ -9,8 +9,6 @@ import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID, ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { APP_MAIN_SCROLL_VIEWPORT_ID, ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight'; import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight';
import { useCardGridMetrics } from '../hooks/useCardGridMetrics';
import { useRemeasureGridVirtualizer } from '../hooks/useRemeasureGridVirtualizer';
import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin'; import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin';
import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { import {
@@ -32,10 +30,12 @@ import { ArtistsListView } from '../components/artists/ArtistsListView';
import InpageScrollSentinel from '../components/InpageScrollSentinel'; import InpageScrollSentinel from '../components/InpageScrollSentinel';
import { useArtistsBrowseFilters, type ArtistBrowseScrollSnapshot } from '../hooks/useArtistsBrowseFilters'; import { useArtistsBrowseFilters, type ArtistBrowseScrollSnapshot } from '../hooks/useArtistsBrowseFilters';
import { useArtistsBrowseScrollRestore } from '../hooks/useArtistsBrowseScrollRestore'; import { useArtistsBrowseScrollRestore } from '../hooks/useArtistsBrowseScrollRestore';
import { useArtistsBrowseScrollReset } from '../hooks/useArtistsBrowseScrollReset';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist'; import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { peekArtistBrowseScrollRestore } from '../store/artistBrowseSessionStore'; import { peekArtistBrowseScrollRestore } from '../store/artistBrowseSessionStore';
import { readArtistBrowseRestore } from '../utils/navigation/albumDetailNavigation'; import { readArtistBrowseRestore } from '../utils/navigation/albumDetailNavigation';
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore';
export default function Artists() { export default function Artists() {
@@ -51,8 +51,6 @@ export default function Artists() {
); );
const { const {
filter,
setFilter,
letterFilter, letterFilter,
setLetterFilter, setLetterFilter,
starredOnly, starredOnly,
@@ -61,6 +59,8 @@ export default function Artists() {
setViewMode, setViewMode,
} = useArtistsBrowseFilters(serverId, scrollSnapshotRef); } = useArtistsBrowseFilters(serverId, scrollSnapshotRef);
const artistsSearchQuery = useScopedBrowseSearchQuery('artists');
const { const {
scrollBodyEl: artistsScrollBodyEl, scrollBodyEl: artistsScrollBodyEl,
bindScrollBody: bindArtistsScrollBody, bindScrollBody: bindArtistsScrollBody,
@@ -91,13 +91,18 @@ export default function Artists() {
}); });
const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch( const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch(
filter, artistsSearchQuery,
indexEnabled, indexEnabled,
serverId, serverId,
); );
const artists = textSearchArtists ?? catalogArtists; const artists = textSearchArtists ?? catalogArtists;
const loading = catalogLoading || textSearchLoading; const loading = catalogLoading || textSearchLoading;
const textSearchActive = textSearchArtists != null; 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 { const {
visibleCount, visibleCount,
@@ -105,7 +110,7 @@ export default function Artists() {
loadMore: sliceLoadMore, loadMore: sliceLoadMore,
} = useClientSliceInfiniteScroll({ } = useClientSliceInfiniteScroll({
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
resetDeps: [filter, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId], resetDeps: [artistsSearchQuery, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId],
getScrollRoot: getArtistsScrollRoot, getScrollRoot: getArtistsScrollRoot,
scrollRootEl: artistsScrollBodyEl, scrollRootEl: artistsScrollBodyEl,
restoreDisplayCount: restoreVisibleCountRef.current, restoreDisplayCount: restoreVisibleCountRef.current,
@@ -224,7 +229,7 @@ export default function Artists() {
}); });
const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [ const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [
filter, artistsSearchQuery,
letterFilter, letterFilter,
starredOnly, starredOnly,
viewMode, viewMode,
@@ -243,48 +248,6 @@ export default function Artists() {
[getArtistsScrollRoot], [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( const artistListOverscan = Math.max(
12, 12,
Math.ceil(artistsInpageScrollHeight / ARTIST_LIST_ROW_EST), Math.ceil(artistsInpageScrollHeight / ARTIST_LIST_ROW_EST),
@@ -295,14 +258,14 @@ export default function Artists() {
artistListWrapRef, artistListWrapRef,
getInpageScrollElement, getInpageScrollElement,
{ {
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'list', active: !artistBrowsePlainLayout && viewMode === 'list',
deps: [artistListFlatRows.length], deps: [artistListFlatRows.length],
}, },
); );
const artistListVirtualizer = useVirtualizer({ const artistListVirtualizer = useVirtualizer({
count: count:
perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : artistListFlatRows.length, artistBrowsePlainLayout || viewMode !== 'list' ? 0 : artistListFlatRows.length,
getScrollElement: getInpageScrollElement, getScrollElement: getInpageScrollElement,
estimateSize: index => { estimateSize: index => {
const row = artistListFlatRows[index]; const row = artistListFlatRows[index];
@@ -320,6 +283,27 @@ export default function Artists() {
scrollMargin: artistListScrollMargin, 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 ( return (
<div <div
className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`} 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.selectionCount', { count: selectedIds.size })
: t('artists.title')} : t('artists.title')}
</h1> </h1>
<input
className="input"
style={{ maxWidth: 220 }}
placeholder={t('artists.search')}
value={filter}
onChange={e => setFilter(e.target.value)}
id="artist-filter-input"
/>
{textSearchLoading && ( {textSearchLoading && (
<div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} /> <div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} />
)} )}
@@ -432,13 +408,8 @@ export default function Artists() {
{!loading && !pendingLetterMatch && viewMode === 'grid' && ( {!loading && !pendingLetterMatch && viewMode === 'grid' && (
<ArtistsGridView <ArtistsGridView
visible={visible} visible={visible}
gridCols={artistGridCols} disableVirtualization={artistBrowsePlainLayout}
measureRef={artistGridMeasureRef} layoutKey={browseScrollResetKey}
virtualization={
perfFlags.disableMainstageVirtualLists
? null
: { virtualizer: artistGridVirtualizer, scrollMargin: artistGridScrollMargin }
}
selectionMode={selectionMode} selectionMode={selectionMode}
selectedIds={selectedIds} selectedIds={selectedIds}
selectedArtists={selectedArtists} selectedArtists={selectedArtists}
@@ -452,7 +423,7 @@ export default function Artists() {
{!loading && !pendingLetterMatch && viewMode === 'list' && ( {!loading && !pendingLetterMatch && viewMode === 'list' && (
<ArtistsListView <ArtistsListView
virtualized={!perfFlags.disableMainstageVirtualLists} virtualized={!artistBrowsePlainLayout}
groups={groups} groups={groups}
letters={letters} letters={letters}
artistListFlatRows={artistListFlatRows} artistListFlatRows={artistListFlatRows}
+86 -25
View File
@@ -1,6 +1,6 @@
import type { SubsonicArtist } from '../api/subsonicTypes'; import type { SubsonicArtist } from '../api/subsonicTypes';
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; 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 { ndListArtistsByRole } from '../api/navidromeBrowse';
import { LayoutGrid, List } from 'lucide-react'; import { LayoutGrid, List } from 'lucide-react';
import StarFilterButton from '../components/StarFilterButton'; 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 { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch'; 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 { 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 { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid'; import { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea'; import OverlayScrollArea from '../components/OverlayScrollArea';
@@ -76,10 +83,24 @@ export default function Composers() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<'unsupported' | 'transient' | null>(null); const [loadError, setLoadError] = useState<'unsupported' | 'transient' | null>(null);
const [reloadTick, setReloadTick] = useState(0); const [reloadTick, setReloadTick] = useState(0);
const [filter, setFilter] = useState('');
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL); const scrollSnapshotRef = useRef<ComposerBrowseScrollSnapshot>({ scrollTop: 0, visibleCount: 0 });
const [starredOnly, setStarredOnly] = useState(false); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); 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. // Compact tiles + initial-letter only → 200 per page is comfortable.
const PAGE_SIZE = 200; const PAGE_SIZE = 200;
@@ -89,28 +110,35 @@ export default function Composers() {
bindScrollBody: bindComposersScrollBody, bindScrollBody: bindComposersScrollBody,
getScrollRoot, getScrollRoot,
} = useInpageScrollViewport(); } = useInpageScrollViewport();
const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const navigateToComposer = useNavigateToComposer();
const openContextMenu = usePlayerStore(state => state.openContextMenu); 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 indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch( const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch(
filter, composersSearchQuery,
indexEnabled, indexEnabled,
serverId, serverId,
'composers_browse', 'composers_browse',
); );
const composerSource = textSearchArtists ?? composers; const composerSource = textSearchArtists ?? composers;
const textSearchActive = textSearchArtists != null;
const composerBrowsePlainLayout =
perfFlags.disableMainstageVirtualLists
|| textSearchActive
|| composersSearchQuery.trim().length > 0;
const { const {
visibleCount, visibleCount,
loadingMore, loadingMore,
bindSentinel, bindSentinel,
loadMore: sliceLoadMore,
} = useClientSliceInfiniteScroll({ } = useClientSliceInfiniteScroll({
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
resetDeps: [letterFilter, effectiveFilter, starredOnly, viewMode, composerSource], resetDeps: [composersSearchQuery, letterFilter, starredOnly, viewMode, composerSource, serverId],
getScrollRoot, getScrollRoot,
scrollRootEl: scrollBodyEl, scrollRootEl: scrollBodyEl,
restoreDisplayCount: restoreVisibleCountRef.current,
}); });
useEffect(() => { useEffect(() => {
@@ -165,6 +193,26 @@ export default function Composers() {
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]); const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
const hasMore = visibleCount < filtered.length; 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(() => { const { groups, letters } = useMemo(() => {
if (viewMode !== 'list') return { groups: {} as Record<string, SubsonicArtist[]>, letters: [] as string[] }; if (viewMode !== 'list') return { groups: {} as Record<string, SubsonicArtist[]>, letters: [] as string[] };
const g: Record<string, SubsonicArtist[]> = {}; const g: Record<string, SubsonicArtist[]> = {};
@@ -213,14 +261,14 @@ export default function Composers() {
composerListWrapRef, composerListWrapRef,
getInpageScrollElement, getInpageScrollElement,
{ {
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'list', active: !composerBrowsePlainLayout && viewMode === 'list',
deps: [composerListFlatRows.length], deps: [composerListFlatRows.length],
}, },
); );
const composerListVirtualizer = useVirtualizer({ const composerListVirtualizer = useVirtualizer({
count: count:
perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : composerListFlatRows.length, composerBrowsePlainLayout || viewMode !== 'list' ? 0 : composerListFlatRows.length,
getScrollElement: getInpageScrollElement, getScrollElement: getInpageScrollElement,
estimateSize: index => { estimateSize: index => {
const row = composerListFlatRows[index]; const row = composerListFlatRows[index];
@@ -239,12 +287,33 @@ export default function Composers() {
}); });
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
filter, composersSearchQuery,
letterFilter, letterFilter,
starredOnly, starredOnly,
viewMode, 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) { if (loadError) {
return ( return (
<div className="content-body animate-fade-in"> <div className="content-body animate-fade-in">
@@ -272,14 +341,6 @@ export default function Composers() {
<div className="mainstage-inpage-toolbar-row"> <div className="mainstage-inpage-toolbar-row">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('composers.title')}</h1> <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 && ( {textSearchLoading && (
<div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} /> <div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} />
)} )}
@@ -342,7 +403,7 @@ export default function Composers() {
items={visible} items={visible}
itemKey={(a, _i) => a.id} itemKey={(a, _i) => a.id}
rowVariant="composer" rowVariant="composer"
disableVirtualization={perfFlags.disableMainstageVirtualLists} disableVirtualization={composerBrowsePlainLayout}
layoutSignal={visible.length} layoutSignal={visible.length}
wrapClassName="composer-grid-wrap" wrapClassName="composer-grid-wrap"
gridGap="var(--space-2)" gridGap="var(--space-2)"
@@ -350,7 +411,7 @@ export default function Composers() {
renderItem={artist => ( renderItem={artist => (
<div <div
className="composer-card" className="composer-card"
onClick={() => navigate(`/composer/${artist.id}`)} onClick={() => navigateToComposer(artist.id)}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer'); openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
@@ -368,7 +429,7 @@ export default function Composers() {
)} )}
{!loading && viewMode === 'list' && ( {!loading && viewMode === 'list' && (
perfFlags.disableMainstageVirtualLists ? ( composerBrowsePlainLayout ? (
<> <>
{letters.map(letter => ( {letters.map(letter => (
<div key={letter} style={{ marginBottom: '1.5rem' }}> <div key={letter} style={{ marginBottom: '1.5rem' }}>
@@ -378,7 +439,7 @@ export default function Composers() {
<button <button
key={artist.id} key={artist.id}
className="artist-row" className="artist-row"
onClick={() => navigate(`/composer/${artist.id}`)} onClick={() => navigateToComposer(artist.id)}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer'); openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
@@ -442,7 +503,7 @@ export default function Composers() {
<button <button
type="button" type="button"
className="artist-row" className="artist-row"
onClick={() => navigate(`/composer/${artist.id}`)} onClick={() => navigateToComposer(artist.id)}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer'); 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 { getAlbumList, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes'; import type { SubsonicAlbum } from '../api/subsonicTypes';
import { dedupeById } from '../utils/dedupeById'; 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 { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import AlbumCard from '../components/AlbumCard'; import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar'; import GenreFilterBar from '../components/GenreFilterBar';
@@ -29,8 +29,13 @@ import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
import InpageScrollSentinel from '../components/InpageScrollSentinel'; import InpageScrollSentinel from '../components/InpageScrollSentinel';
import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '../hooks/useAlbumGridBrowseFilters'; import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '../hooks/useAlbumGridBrowseFilters';
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore'; import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset';
import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch';
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters'; import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation'; 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; const PAGE_SIZE = 30;
@@ -49,6 +54,7 @@ export default function NewReleases() {
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore(); const auth = useAuthStore();
const serverId = useAuthStore(s => s.activeServerId ?? ''); const serverId = useAuthStore(s => s.activeServerId ?? '');
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const downloadAlbum = useOfflineStore(s => s.downloadAlbum); const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -64,6 +70,19 @@ export default function NewReleases() {
} = useAlbumGridBrowseFilters(serverId, 'new-releases', scrollSnapshotRef, gridSnapshotRef); } = useAlbumGridBrowseFilters(serverId, 'new-releases', scrollSnapshotRef, gridSnapshotRef);
const restoringSessionRef = useRef(initialAlbums != null); 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 [albums, setAlbums] = useState<SubsonicAlbum[]>(() => initialAlbums ?? []);
const [hasMore, setHasMore] = useState(() => initialHasMore ?? true); const [hasMore, setHasMore] = useState(() => initialHasMore ?? true);
const { const {
@@ -80,22 +99,35 @@ export default function NewReleases() {
isBlocked, isBlocked,
} = useAsyncInpagePagination(PAGE_SIZE, { initialLoading: initialAlbums == null }); } = useAsyncInpagePagination(PAGE_SIZE, { initialLoading: initialAlbums == null });
const [selectionMode, setSelectionMode] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
const filtered = selectedGenres.length > 0; const genreFiltered = selectedGenres.length > 0;
gridSnapshotRef.current = { albums, hasMore }; const displayAlbums = useMemo(() => {
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, albums.length); 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, [ const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
filtered, newReleasesSearchQuery,
genreFiltered,
selectionMode, selectionMode,
selectedGenres, selectedGenres,
]); ]);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums); const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(displayAlbums);
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); }; const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
const clearSelection = () => { setSelectionMode(false); 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 () => { const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return; if (selectedAlbums.length === 0) return;
@@ -156,21 +188,21 @@ export default function NewReleases() {
}, [musicLibraryFilterVersion]); }, [musicLibraryFilterVersion]);
useEffect(() => { useEffect(() => {
if (restoringSessionRef.current) return; if (restoringSessionRef.current || scopedSearchQuery) return;
if (filtered) loadFiltered(selectedGenres); if (genreFiltered) loadFiltered(selectedGenres);
else { else {
resetPage(); resetPage();
void load(0); void load(0);
} }
}, [filtered, selectedGenres, load, loadFiltered, resetPage]); }, [genreFiltered, selectedGenres, load, loadFiltered, resetPage, scopedSearchQuery]);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (!hasMore || filtered || isBlocked()) return; if (!gridHasMore || genreFiltered || textSearchActive || isBlocked()) return;
requestNextPage(offset => load(offset, true)); requestNextPage(offset => load(offset, true));
}, [hasMore, filtered, isBlocked, requestNextPage, load]); }, [gridHasMore, genreFiltered, textSearchActive, isBlocked, requestNextPage, load]);
const bindLoadMoreSentinel = useInpageScrollSentinel({ const bindLoadMoreSentinel = useInpageScrollSentinel({
active: !filtered && hasMore, active: gridHasMore,
getScrollRoot, getScrollRoot,
scrollRootEl: scrollBodyEl, scrollRootEl: scrollBodyEl,
onIntersect: loadMore, onIntersect: loadMore,
@@ -180,13 +212,20 @@ export default function NewReleases() {
serverId, serverId,
surface: 'new-releases', surface: 'new-releases',
scrollBodyEl, scrollBodyEl,
displayAlbumsLength: albums.length, displayAlbumsLength: displayAlbums.length,
loading, loading: loadingGrid,
loadingMore: loading, loadingMore: loadingGrid,
hasMore, hasMore: gridHasMore,
loadMore, loadMore,
}); });
useAlbumBrowseScrollReset({
scrollSnapshotRef,
getScrollRoot,
isScrollRestorePending,
resetKey: [newReleasesSearchQuery, selectedGenres.join('\u0001'), serverId].join('|'),
});
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isScrollRestorePending && restoringSessionRef.current) { if (!isScrollRestorePending && restoringSessionRef.current) {
restoringSessionRef.current = false; restoringSessionRef.current = false;
@@ -243,31 +282,36 @@ export default function NewReleases() {
viewportRef={bindNewReleasesScrollBody} viewportRef={bindNewReleasesScrollBody}
railInset="panel" railInset="panel"
measureDeps={[ measureDeps={[
loading, loadingGrid,
albums.length, displayAlbums.length,
filtered, genreFiltered,
hasMore, gridHasMore,
selectionMode, selectionMode,
perfFlags.disableMainstageVirtualLists, newReleasesSearchQuery,
albumBrowsePlainLayout,
]} ]}
> >
{loading && albums.length === 0 ? ( {loadingGrid && displayAlbums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" /> <div className="spinner" />
</div> </div>
) : !loading && albums.length === 0 && !filtered ? ( ) : !loadingGrid && displayAlbums.length === 0 && !genreFiltered && !scopedSearchQuery ? (
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}> <div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('common.libraryEmpty')} {t('common.libraryEmpty')}
</div> </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={{ position: 'relative' }}>
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}> <div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
<VirtualCardGrid <VirtualCardGrid
items={albums} items={displayAlbums}
itemKey={(a, _i) => a.id} itemKey={(a, _i) => a.id}
rowVariant="album" rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists} disableVirtualization={albumBrowsePlainLayout}
layoutSignal={albums.length} layoutSignal={displayAlbums.length}
scrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID} scrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
warmGridCovers={albumGridWarmCovers()} warmGridCovers={albumGridWarmCovers()}
renderItem={a => ( renderItem={a => (
@@ -281,8 +325,8 @@ export default function NewReleases() {
/> />
)} )}
/> />
{!filtered && hasMore && ( {gridHasMore && (
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loading} /> <InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loadingGrid} />
)} )}
</div> </div>
{isScrollRestorePending && ( {isScrollRestorePending && (
+70 -10
View File
@@ -60,6 +60,10 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { useSongBrowseList, type SongBrowseListRestore } from '../hooks/useSongBrowseList'; import { useSongBrowseList, type SongBrowseListRestore } from '../hooks/useSongBrowseList';
import TracksPageChrome from '../components/tracks/TracksPageChrome'; import TracksPageChrome from '../components/tracks/TracksPageChrome';
import SongBrowseSection from '../components/tracks/SongBrowseSection'; import SongBrowseSection from '../components/tracks/SongBrowseSection';
import {
useLiveSearchScopeStore,
useScopedBrowseSearchQuery,
} from '../store/liveSearchScopeStore';
const MOOD_UI_ENABLED = OXIMEDIA_MOOD_SEARCH_ENABLED; const MOOD_UI_ENABLED = OXIMEDIA_MOOD_SEARCH_ENABLED;
@@ -177,8 +181,22 @@ export default function SearchBrowsePage() {
} }
: null; : 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({ const songBrowse = useSongBrowseList({
enabled: showTracksChrome, enabled: showTracksChrome,
searchQuery: tracksSearchQuery,
initialRestore: songBrowseInitialRestore, initialRestore: songBrowseInitialRestore,
}); });
@@ -188,6 +206,9 @@ export default function SearchBrowsePage() {
restoringSession ? resolveAdvancedSearchLeaveSnapshot(restoreStash) : null, restoringSession ? resolveAdvancedSearchLeaveSnapshot(restoreStash) : null,
); );
const scrollTopRestoreTargetRef = useRef(leaveSnapshotRef.current?.scrollTop ?? 0); const scrollTopRestoreTargetRef = useRef(leaveSnapshotRef.current?.scrollTop ?? 0);
const tracksSearchRestorePendingRef = useRef(
!!(songBrowseInitialRestore?.query.trim()),
);
const albumRowScrollLeftRestoreRef = useRef(leaveSnapshotRef.current?.albumRowScrollLeft ?? 0); const albumRowScrollLeftRestoreRef = useRef(leaveSnapshotRef.current?.albumRowScrollLeft ?? 0);
const artistRowScrollLeftRestoreRef = useRef(leaveSnapshotRef.current?.artistRowScrollLeft ?? 0); const artistRowScrollLeftRestoreRef = useRef(leaveSnapshotRef.current?.artistRowScrollLeft ?? 0);
const mainScrollTopRef = useRef(0); const mainScrollTopRef = useRef(0);
@@ -196,12 +217,16 @@ export default function SearchBrowsePage() {
const skipSearchAutoFocusRef = useRef(restoreStash != null); const skipSearchAutoFocusRef = useRef(restoreStash != null);
const skipEnterAnimationRef = useRef(restoreStash != null || leaveSnapshotRef.current != null); const skipEnterAnimationRef = useRef(restoreStash != null || leaveSnapshotRef.current != null);
const leaveRestoreUiFinishedRef = useRef(leaveSnapshotRef.current == null); const leaveRestoreUiFinishedRef = useRef(leaveSnapshotRef.current == null);
const restoringTracksSearch = !!(restoreStash?.query.trim() && showTracksChrome);
const [tracksChromeLayoutReady, setTracksChromeLayoutReady] = useState( const [tracksChromeLayoutReady, setTracksChromeLayoutReady] = useState(
() => !showTracksChrome || leaveSnapshotRef.current == null, () => !showTracksChrome || leaveSnapshotRef.current == null || restoringTracksSearch,
); );
const [isLeaveRestorePending, setIsLeaveRestorePending] = useState( const [isLeaveRestorePending, setIsLeaveRestorePending] = useState(
() => leaveSnapshotRef.current != null, () => leaveSnapshotRef.current != null,
); );
const tracksDiscoveryHidden =
tracksSearchActive
|| (isLeaveRestorePending && !!(restoreStash?.query.trim() || songBrowseInitialRestore?.query.trim()));
const handleTracksChromeLayoutReady = useCallback(() => { const handleTracksChromeLayoutReady = useCallback(() => {
setTracksChromeLayoutReady(true); setTracksChromeLayoutReady(true);
@@ -210,12 +235,15 @@ export default function SearchBrowsePage() {
const finishLeaveRestoreUi = useCallback(() => { const finishLeaveRestoreUi = useCallback(() => {
if (leaveRestoreUiFinishedRef.current) return; if (leaveRestoreUiFinishedRef.current) return;
leaveRestoreUiFinishedRef.current = true; leaveRestoreUiFinishedRef.current = true;
clearAdvancedSearchLeaveSnapshots();
leaveSnapshotRef.current = null; leaveSnapshotRef.current = null;
setIsLeaveRestorePending(false); setIsLeaveRestorePending(false);
if (hadRestoreOnMountRef.current) { // Defer stash teardown until after AppShell's route-change scroll reset effect.
useAdvancedSearchSessionStore.getState().clearReturnStash(); window.setTimeout(() => {
} clearAdvancedSearchLeaveSnapshots();
if (hadRestoreOnMountRef.current) {
useAdvancedSearchSessionStore.getState().clearReturnStash();
}
}, 0);
}, []); }, []);
const sessionRef = useRef<AdvancedSearchSessionStash>({ const sessionRef = useRef<AdvancedSearchSessionStash>({
@@ -241,7 +269,7 @@ export default function SearchBrowsePage() {
tracksBrowseUnsupported: false, tracksBrowseUnsupported: false,
}); });
sessionRef.current = { sessionRef.current = {
query: showTracksChrome ? songBrowse.query : query, query: showTracksChrome ? liveSearchQuery : query,
genre, genre,
yearFrom, yearFrom,
yearTo, yearTo,
@@ -585,6 +613,11 @@ export default function SearchBrowsePage() {
const stash = useAdvancedSearchSessionStore.getState().peekReturnStash(); const stash = useAdvancedSearchSessionStore.getState().peekReturnStash();
if (stash) { if (stash) {
setQuery(stash.query); setQuery(stash.query);
if (showTracksChrome) {
const store = useLiveSearchScopeStore.getState();
store.setScope('tracks');
store.setQuery(stash.query);
}
setGenre(stash.genre); setGenre(stash.genre);
setYearFrom(stash.yearFrom); setYearFrom(stash.yearFrom);
setYearTo(stash.yearTo); setYearTo(stash.yearTo);
@@ -612,20 +645,47 @@ export default function SearchBrowsePage() {
useAdvancedSearchSessionStore.getState().clearReturnStash(); useAdvancedSearchSessionStore.getState().clearReturnStash();
}, [navigationType, location.state]); }, [navigationType, location.state]);
const tracksSearchRestoreSynced =
!tracksSearchRestorePendingRef.current
|| tracksSearchQuery.trim() === (songBrowseInitialRestore?.query.trim() ?? '');
const leaveRestoreContentReady = showTracksChrome const leaveRestoreContentReady = showTracksChrome
? tracksChromeLayoutReady ? tracksChromeLayoutReady
&& ((hadRestoreOnMountRef.current && songBrowse.hasSearched) || (songBrowse.hasSearched && !songBrowse.loading)) && tracksSearchRestoreSynced
&& (
(hadRestoreOnMountRef.current && songBrowseInitialRestore != null)
|| (songBrowse.hasSearched && !songBrowse.loading)
)
: ((hadRestoreOnMountRef.current && results !== null) || (hasSearched && !loading)); : ((hadRestoreOnMountRef.current && results !== null) || (hasSearched && !loading));
useLayoutEffect(() => { useLayoutEffect(() => {
if (!leaveRestoreContentReady || leaveRestoreUiFinishedRef.current) return; if (!leaveRestoreContentReady || leaveRestoreUiFinishedRef.current) return;
if (showTracksChrome) return;
const target = scrollTopRestoreTargetRef.current; const target = scrollTopRestoreTargetRef.current;
if (target <= 0) { if (target <= 0) {
finishLeaveRestoreUi(); finishLeaveRestoreUi();
return; return;
} }
return restoreMainViewportScroll(target, finishLeaveRestoreUi); 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(() => { useEffect(() => {
if (isLeaveRestorePending || !readAdvancedSearchRestore(location.state)) return; if (isLeaveRestorePending || !readAdvancedSearchRestore(location.state)) return;
@@ -795,6 +855,7 @@ export default function SearchBrowsePage() {
{showTracksChrome ? ( {showTracksChrome ? (
<> <>
<TracksPageChrome <TracksPageChrome
hideDiscoveryChrome={tracksDiscoveryHidden}
onLayoutReady={ onLayoutReady={
isLeaveRestorePending && showTracksChrome ? handleTracksChromeLayoutReady : undefined isLeaveRestorePending && showTracksChrome ? handleTracksChromeLayoutReady : undefined
} }
@@ -803,8 +864,7 @@ export default function SearchBrowsePage() {
<SongBrowseSection <SongBrowseSection
title={t('tracks.browseTitle')} title={t('tracks.browseTitle')}
emptyBrowseText={t('tracks.browseUnsupported')} emptyBrowseText={t('tracks.browseUnsupported')}
query={songBrowse.query} searchActive={tracksSearchActive}
onQueryChange={songBrowse.setQuery}
songs={songBrowse.songs} songs={songBrowse.songs}
hasMore={songBrowse.hasMore} hasMore={songBrowse.hasMore}
loading={songBrowse.loading} loading={songBrowse.loading}
@@ -7,6 +7,8 @@ import {
albumBrowseSurfaceForPath, albumBrowseSurfaceForPath,
clearGenreDetailReturnStash, clearGenreDetailReturnStash,
isAlbumDetailPath, isAlbumDetailPath,
isAlbumsBrowsePath,
isNewReleasesBrowsePath,
isGenreDetailPath, isGenreDetailPath,
genreDetailGenreFromPath, genreDetailGenreFromPath,
peekAlbumBrowseScrollRestore, peekAlbumBrowseScrollRestore,
@@ -144,6 +146,11 @@ describe('isGenreDetailPath', () => {
describe('albumBrowseSurfaceForPath', () => { describe('albumBrowseSurfaceForPath', () => {
it('maps album grid browse routes', () => { it('maps album grid browse routes', () => {
expect(albumBrowseSurfaceForPath('/albums')).toBe('albums'); 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('/new-releases')).toBe('new-releases');
expect(albumBrowseSurfaceForPath('/random/albums')).toBe('random-albums'); expect(albumBrowseSurfaceForPath('/random/albums')).toBe('random-albums');
expect(albumBrowseSurfaceForPath('/artists')).toBeNull(); expect(albumBrowseSurfaceForPath('/artists')).toBeNull();
+20 -2
View File
@@ -17,6 +17,8 @@ export interface AlbumBrowseReturnFilters {
compFilter: AlbumBrowseCompFilter; compFilter: AlbumBrowseCompFilter;
starredOnly: boolean; starredOnly: boolean;
losslessOnly: 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. */ /** In-page grid scroll position when leaving the browse surface. */
scrollTop?: number; scrollTop?: number;
/** Row count at leave time — preload at least this many rows before scroll. */ /** 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, compFilter: filters.compFilter,
starredOnly: filters.starredOnly, starredOnly: filters.starredOnly,
losslessOnly: filters.losslessOnly, losslessOnly: filters.losslessOnly,
...(typeof filters.searchQuery === 'string' ? { searchQuery: filters.searchQuery } : {}),
...(typeof filters.scrollTop === 'number' ? { scrollTop: filters.scrollTop } : {}), ...(typeof filters.scrollTop === 'number' ? { scrollTop: filters.scrollTop } : {}),
...(typeof filters.displayCount === 'number' ? { displayCount: filters.displayCount } : {}), ...(typeof filters.displayCount === 'number' ? { displayCount: filters.displayCount } : {}),
...(filters.albums ? { albums: [...filters.albums] } : {}), ...(filters.albums ? { albums: [...filters.albums] } : {}),
@@ -194,6 +197,16 @@ export function albumBrowseSortForServer(
} }
/** Map pathname to album grid browse surface, if any. */ /** 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 { export function albumBrowseSurfaceForPath(pathname: string): AlbumBrowseSurface | null {
const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname; const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname;
if (path === '/albums') return 'albums'; if (path === '/albums') return 'albums';
@@ -224,6 +237,11 @@ export function isArtistDetailPath(pathname: string): boolean {
return /^\/artist\/[^/]+\/?$/.test(pathname); return /^\/artist\/[^/]+\/?$/.test(pathname);
} }
export function isAdvancedSearchLeaveTargetPath(pathname: string): boolean { /** True when pathname is a single composer detail route (`/composer/:id`). */
return isAlbumDetailPath(pathname) || isArtistDetailPath(pathname); 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; 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) { @media (min-width: 1024px) {
.album-grid-wrap { .album-grid-wrap {
+84 -29
View File
@@ -22,18 +22,26 @@
cursor: text; cursor: text;
} }
.live-search[data-collapsed]:not([data-active]) .live-search-icon { .live-search[data-collapsed]:not([data-active]) .live-search-field-cluster {
left: 50%; width: 34px;
transform: translateX(-50%); 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 { .live-search[data-collapsed]:not([data-active]) .live-search-field,
width: 0 !important; .live-search[data-collapsed]:not([data-active]) .live-search-scope-badge {
min-width: 0 !important; display: none;
padding: 0 !important; }
border: none !important;
opacity: 0; .live-search[data-collapsed]:not([data-active]) .live-search-leading-icon {
pointer-events: none; margin: 0;
} }
.live-search[data-collapsed][data-active] { .live-search[data-collapsed][data-active] {
@@ -51,13 +59,18 @@
z-index: 25; 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 { .live-search[data-collapsed][data-active] .live-search-field {
width: 100%; width: 100%;
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
.live-search[data-collapsed]:not([data-active]) .live-search-clear,
.live-search[data-collapsed]:not([data-active]) .live-search-adv-btn { .live-search[data-collapsed]:not([data-active]) .live-search-adv-btn {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
@@ -75,32 +88,75 @@
align-items: center; align-items: center;
} }
.live-search-icon { .live-search-field-cluster {
position: absolute;
left: 12px;
color: var(--text-muted);
pointer-events: none;
display: flex; display: flex;
align-items: center; 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 { .live-search-field {
padding-left: 36px !important; padding: var(--space-3) var(--space-4) !important;
padding-left: 8px !important;
padding-right: 58px !important; padding-right: 58px !important;
border: none !important;
background: transparent !important;
box-shadow: none !important;
border-radius: var(--radius-full) !important; border-radius: var(--radius-full) !important;
flex: 1;
min-width: 0;
} }
.live-search-clear { .live-search-field:focus {
position: absolute; border-color: transparent !important;
right: 8px; box-shadow: none !important;
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-adv-btn { .live-search-adv-btn {
@@ -249,4 +305,3 @@
color: var(--text-muted); color: var(--text-muted);
font-size: 13px; font-size: 13px;
} }
+24
View File
@@ -34,6 +34,30 @@
flex-shrink: 0; 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 { .mobile-search-input {
flex: 1; flex: 1;
background: none; background: none;
+5
View File
@@ -47,6 +47,8 @@ export interface LocalSearchOpts {
moodGroup: string; moodGroup: string;
losslessOnly?: boolean; losslessOnly?: boolean;
resultType: AdvancedResultType; resultType: AdvancedResultType;
/** When searching albums, match album title only (not album artist). */
albumTitleOnly?: boolean;
} }
export interface LocalAdvancedSearchPage { export interface LocalAdvancedSearchPage {
@@ -141,6 +143,9 @@ function buildRequest(
limit, limit,
offset, offset,
skipTotals, skipTotals,
...(opts.resultType === 'albums' && opts.albumTitleOnly
? { queryAlbumTitleOnly: true }
: {}),
}; };
} }
+22
View File
@@ -82,6 +82,28 @@ export function filterAlbumsByCompilation(
return albums; 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[] { export function countGenresFromAlbums(albums: SubsonicAlbum[]): GenreFilterOption[] {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const a of albums) { for (const a of albums) {
+14
View File
@@ -6,6 +6,7 @@ import {
albumBrowseStarredNeedsLocalIntersect, albumBrowseStarredNeedsLocalIntersect,
compilationFilterClauses, compilationFilterClauses,
countGenresFromAlbums, countGenresFromAlbums,
filterAlbumsByNameTextQuery,
filterAlbumsByStarred, filterAlbumsByStarred,
filterAlbumsByYearBounds, filterAlbumsByYearBounds,
} from './albumBrowseFilters'; } 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', () => { describe('filterAlbumsByYearBounds', () => {
const albums: SubsonicAlbum[] = [ const albums: SubsonicAlbum[] = [
{ id: '1', name: 'A', artist: 'X', artistId: 'a', songCount: 1, duration: 1, year: 1985 }, { 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 }; 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'] { export function browseRaceCountsSongs(result: unknown): LibrarySearchDebugEntry['counts'] {
const n = Array.isArray(result) ? result.length : 0; const n = Array.isArray(result) ? result.length : 0;
return { artists: 0, albums: 0, songs: n }; return { artists: 0, albums: 0, songs: n };
@@ -172,6 +177,7 @@ export function browseRaceCountsFullSearch(result: unknown): LibrarySearchDebugE
} }
const ARTIST_BROWSE_LIMIT = 500; const ARTIST_BROWSE_LIMIT = 500;
const ALBUM_BROWSE_LIMIT = 500;
const emptyBrowseOpts = (query: string): LocalSearchOpts => ({ const emptyBrowseOpts = (query: string): LocalSearchOpts => ({
query, query,
@@ -184,6 +190,19 @@ const emptyBrowseOpts = (query: string): LocalSearchOpts => ({
resultType: 'artists', 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 => ({ const songBrowseOpts = (query: string): LocalSearchOpts => ({
query, query,
genre: '', 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). */ /** Paginated local track text search (Tracks browse / VirtualSongList). */
export async function runLocalBrowseSongPage( export async function runLocalBrowseSongPage(
serverId: string | null | undefined, serverId: string | null | undefined,
@@ -334,6 +387,7 @@ export async function loadMoreLocalBrowseSongs(
export type { AlbumBrowseSort } from './albumBrowseSort'; export type { AlbumBrowseSort } from './albumBrowseSort';
export { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort'; export { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
import { albumSortClauses, type AlbumBrowseSort } from './albumBrowseSort'; import { albumSortClauses, type AlbumBrowseSort } from './albumBrowseSort';
import { filterAlbumsByNameTextQuery } from './albumBrowseFilters';
import { runLocalAlbumBrowse, type AlbumBrowseQuery } from './albumBrowseLoad'; import { runLocalAlbumBrowse, type AlbumBrowseQuery } from './albumBrowseLoad';
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes'; import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
+1
View File
@@ -26,6 +26,7 @@ export type LibrarySearchSurface =
| 'live_search' | 'live_search'
| 'advanced_search' | 'advanced_search'
| 'artists_browse' | 'artists_browse'
| 'albums_browse'
| 'composers_browse' | 'composers_browse'
| 'tracks_browse' | 'tracks_browse'
| 'search_results'; | 'search_results';
@@ -61,10 +61,17 @@ function readMainScrollTopFromDom(): number {
return document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTop ?? 0; 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 { export function readAdvancedSearchLeaveSnapshot(): AdvancedSearchLeaveSnapshot {
const providerSnap = leaveScrollProvider?.(); const providerSnap = leaveScrollProvider?.();
return { return {
scrollTop: Math.max(readMainScrollTopFromDom(), providerSnap?.scrollTop ?? 0), scrollTop: readMainScrollTopForLeave(providerSnap),
albumRowScrollLeft: Math.max( albumRowScrollLeft: Math.max(
readAlbumRowScrollLeftFromDom(), readAlbumRowScrollLeftFromDom(),
providerSnap?.albumRowScrollLeft ?? 0, providerSnap?.albumRowScrollLeft ?? 0,
@@ -6,9 +6,11 @@ import {
navigatePathWithAlbumReturnTo, navigatePathWithAlbumReturnTo,
navigateToAlbumDetail, navigateToAlbumDetail,
navigateToArtistDetail, navigateToArtistDetail,
navigateToComposerDetail,
readAlbumDetailReturnTo, readAlbumDetailReturnTo,
shouldRestoreAlbumBrowseSession, shouldRestoreAlbumBrowseSession,
shouldRestoreArtistBrowseSession, shouldRestoreArtistBrowseSession,
shouldRestoreComposerBrowseSession,
shouldSkipMainScrollResetOnRouteChange, shouldSkipMainScrollResetOnRouteChange,
} from './albumDetailNavigation'; } from './albumDetailNavigation';
import { useAdvancedSearchSessionStore } from '../../store/advancedSearchSessionStore'; import { useAdvancedSearchSessionStore } from '../../store/advancedSearchSessionStore';
@@ -88,6 +90,18 @@ describe('albumDetailNavigation', () => {
expect(navigate).toHaveBeenCalledWith('/artists', { state: { artistBrowseRestore: true } }); 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', () => { it('detects artist browse restore navigation', () => {
expect(shouldRestoreArtistBrowseSession('POP' as NavigationType, null)).toBe(true); expect(shouldRestoreArtistBrowseSession('POP' as NavigationType, null)).toBe(true);
expect(shouldRestoreArtistBrowseSession('PUSH' as NavigationType, { artistBrowseRestore: true })).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', () => { it('skips main scroll reset when All Albums browse restore is pending', () => {
expect(shouldSkipMainScrollResetOnRouteChange('/albums', { albumBrowseRestore: true })).toBe(true); expect(shouldSkipMainScrollResetOnRouteChange('/albums', { albumBrowseRestore: true })).toBe(true);
expect(shouldSkipMainScrollResetOnRouteChange('/new-releases', { 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); 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', () => { it('skips main scroll reset when Advanced Search session restore is pending', () => {
expect(shouldSkipMainScrollResetOnRouteChange('/search/advanced', { advancedSearchRestore: true })).toBe(true); expect(shouldSkipMainScrollResetOnRouteChange('/search/advanced', { advancedSearchRestore: true })).toBe(true);
}); });
@@ -159,6 +189,33 @@ describe('albumDetailNavigation', () => {
artistRowScrollLeft: 0, artistRowScrollLeft: 0,
}); });
expect(shouldSkipMainScrollResetOnRouteChange('/search/advanced', null)).toBe(true); 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', () => { it('builds return path with search and hash', () => {
+41 -1
View File
@@ -6,6 +6,7 @@ import {
import { import {
isAlbumDetailPath, isAlbumDetailPath,
isArtistDetailPath, isArtistDetailPath,
isComposerDetailPath,
} from '../../store/albumBrowseSessionStore'; } from '../../store/albumBrowseSessionStore';
import { import {
peekPersistedAdvancedSearchLeaveSnapshot, peekPersistedAdvancedSearchLeaveSnapshot,
@@ -19,6 +20,7 @@ export type AlbumDetailLocationState = {
export type AlbumsBrowseRestoreLocationState = { export type AlbumsBrowseRestoreLocationState = {
albumBrowseRestore?: boolean; albumBrowseRestore?: boolean;
artistBrowseRestore?: boolean; artistBrowseRestore?: boolean;
composerBrowseRestore?: boolean;
advancedSearchRestore?: boolean; advancedSearchRestore?: boolean;
}; };
@@ -37,6 +39,10 @@ export function readArtistBrowseRestore(state: unknown): boolean {
return (state as AlbumsBrowseRestoreLocationState | null)?.artistBrowseRestore === true; 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 { export function readAdvancedSearchRestore(state: unknown): boolean {
return (state as AlbumsBrowseRestoreLocationState | null)?.advancedSearchRestore === true; return (state as AlbumsBrowseRestoreLocationState | null)?.advancedSearchRestore === true;
} }
@@ -55,6 +61,10 @@ export function artistBrowseRestoreNavigationState(): AlbumsBrowseRestoreLocatio
return { artistBrowseRestore: true }; return { artistBrowseRestore: true };
} }
export function composerBrowseRestoreNavigationState(): AlbumsBrowseRestoreLocationState {
return { composerBrowseRestore: true };
}
export function advancedSearchRestoreNavigationState(): AlbumsBrowseRestoreLocationState { export function advancedSearchRestoreNavigationState(): AlbumsBrowseRestoreLocationState {
return { advancedSearchRestore: true }; return { advancedSearchRestore: true };
} }
@@ -80,6 +90,13 @@ export function shouldRestoreArtistBrowseSession(
return navigationType === 'POP' || readArtistBrowseRestore(locationState); 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. */ /** Skip AppShell main scroll reset when a child route will restore scroll itself. */
export function shouldSkipMainScrollResetOnRouteChange( export function shouldSkipMainScrollResetOnRouteChange(
pathname: string, pathname: string,
@@ -87,9 +104,12 @@ export function shouldSkipMainScrollResetOnRouteChange(
): boolean { ): boolean {
if (readAlbumBrowseRestore(locationState)) return true; if (readAlbumBrowseRestore(locationState)) return true;
if (readArtistBrowseRestore(locationState)) return true; if (readArtistBrowseRestore(locationState)) return true;
if (readComposerBrowseRestore(locationState)) return true;
if (readAdvancedSearchRestore(locationState)) return true; if (readAdvancedSearchRestore(locationState)) return true;
const leave = useAdvancedSearchSessionStore.getState().peekLeaveScrollSnapshot(); const leave = useAdvancedSearchSessionStore.getState().peekLeaveScrollSnapshot();
if ((leave?.scrollTop ?? 0) > 0) return true; if ((leave?.scrollTop ?? 0) > 0) return true;
const stash = useAdvancedSearchSessionStore.getState().peekReturnStash();
if (isAdvancedSearchPath(pathname) && (stash?.scrollTop ?? 0) > 0) return true;
if (isAdvancedSearchPath(pathname)) { if (isAdvancedSearchPath(pathname)) {
const persisted = peekPersistedAdvancedSearchLeaveSnapshot(); const persisted = peekPersistedAdvancedSearchLeaveSnapshot();
if ((persisted?.scrollTop ?? 0) > 0) return true; if ((persisted?.scrollTop ?? 0) > 0) return true;
@@ -113,6 +133,10 @@ function isArtistsBrowseReturnPath(path: string): boolean {
return path === '/artists' || path.startsWith('/artists?'); return path === '/artists' || path.startsWith('/artists?');
} }
function isComposersBrowseReturnPath(path: string): boolean {
return path === '/composers' || path.startsWith('/composers?');
}
function isGenreDetailReturnPath(path: string): boolean { function isGenreDetailReturnPath(path: string): boolean {
const bare = path.split('?')[0]?.replace(/\/$/, '') || path; const bare = path.split('?')[0]?.replace(/\/$/, '') || path;
return /^\/genres\/[^/]+$/.test(bare); return /^\/genres\/[^/]+$/.test(bare);
@@ -122,6 +146,7 @@ function browseReturnRestoreState(returnTo: string): AlbumsBrowseRestoreLocation
if (isAlbumGridBrowseReturnPath(returnTo)) return albumBrowseRestoreNavigationState(); if (isAlbumGridBrowseReturnPath(returnTo)) return albumBrowseRestoreNavigationState();
if (isGenreDetailReturnPath(returnTo)) return albumBrowseRestoreNavigationState(); if (isGenreDetailReturnPath(returnTo)) return albumBrowseRestoreNavigationState();
if (isArtistsBrowseReturnPath(returnTo)) return artistBrowseRestoreNavigationState(); if (isArtistsBrowseReturnPath(returnTo)) return artistBrowseRestoreNavigationState();
if (isComposersBrowseReturnPath(returnTo)) return composerBrowseRestoreNavigationState();
if (isSearchReturnPath(returnTo)) return advancedSearchRestoreNavigationState(); if (isSearchReturnPath(returnTo)) return advancedSearchRestoreNavigationState();
return undefined; return undefined;
} }
@@ -130,7 +155,9 @@ function buildReturnTo(
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>, location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
): string { ): string {
const existing = readAlbumDetailReturnTo(location.state); 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); return onDetail && existing ? existing : buildReturnToFromLocation(location);
} }
@@ -168,6 +195,19 @@ export function navigateToArtistDetail(
navigate(`/artist/${artistId}${qs}`, { state: { returnTo } satisfies AlbumDetailLocationState }); 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. */ /** Route any path; album detail links get a `returnTo` snapshot in location state. */
export function navigatePathWithAlbumReturnTo( export function navigatePathWithAlbumReturnTo(
navigate: NavigateFunction, navigate: NavigateFunction,