mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(search): scoped live search on browse pages (#938)
* feat(artists): scoped live search badge replaces page filter Move Artists browse text search into the header Live Search with a page scope badge (Users icon), field-local undo, and double-click/backspace to clear scope. Block the live-search dropdown while scoped so results only filter the Artists grid; mobile overlay follows the same rules. * fix(artists): plain grid for scoped search fixes broken card layout Route the browse grid through VirtualCardGrid, switch to non-virtual CSS grid when live search filters the catalog, reset scroll on filter changes, and skip content-visibility on plain tiles to avoid blank/black cards. * fix(search): scope badge double-Backspace and single clear control Require two Backspaces on an empty scoped field after prior text input; one Backspace still clears the badge when the field was never filled. Move live-search clear/advanced controls inside the field pill, drop the extra outer clear button, and use type=text to avoid native search clears. * fix(search): drop duplicate outer live-search clear button Keep the native in-field clear on type=search and the original pill layout; remove only the extra × control outside the search border. Reset dropdown state when the query is cleared via the native control. * refactor(search): generic scoped browse query helper, drop dead code Rename artistsBrowseSearchQuery to scopedBrowseSearchQuery with an expectedScope argument; wire Artists via useScopedBrowseSearchQuery. Remove unused liveSearchScoped dropdown helper (scoped mode blocks it). * feat(search): ghost scope badge and single-click badge remove After clearing the artists scope on /artists, show a faded ghost chip to restore page-only search while keeping the global search placeholder. Active badge removes on one click; tooltips and styles updated. * feat(search): scoped live search for All Albums and New Releases Wire albums and newReleases scope badges with debounced album title search (local index title-only FTS + filtered search3). Plain grid, scroll reset, and session query restore on album grid browse pages. * fix(browse): preserve scroll restore after album/artist detail back Only reset in-page scroll when filter resetKey changes, not when isScrollRestorePending clears after session restore. * feat(search): scoped live search for Tracks browse Wire /tracks to header live search with wide title/artist/album FTS, hide hero and discovery rails while search is active, and remove the inline search field from the browse list. * fix(search): clear header query when leaving scoped browse pages Prevent global live search from firing on album/detail routes after a scoped browse query; browse session stashes still restore on back. * fix(tracks): restore scroll after back from detail during scoped search Hold stashed song results across fetchSongPage churn, defer leave-stash teardown past AppShell scroll reset, restore tracks scroll after the list is ready, and save scroll snapshot when opening artist from song context menu. * fix(tracks): hide discovery headings during scoped search Hide the page subtitle and "Browse all tracks" section title when tracks search is active, matching hero/rails chrome behavior. * feat(search): scoped live search for Composers browse Wire /composers to header live search with composers scope badge, session stash, scroll restore, and plain grid/list during text filter. Remove the in-page filter input; add i18n and navigation helpers. * docs: CHANGELOG and credits for scoped browse live search (PR #938)
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div className="live-search-field-cluster">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="live-search-icon animate-spin" style={{ opacity: 0.6 }}>
|
<span className="live-search-leading-icon animate-spin" style={{ opacity: 0.6 }}>
|
||||||
<div style={{ width: 16, height: 16, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%' }} />
|
<div style={{ width: 16, height: 16, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%' }} />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Search size={16} className="live-search-icon" />
|
<Search size={16} className="live-search-leading-icon" aria-hidden />
|
||||||
|
)}
|
||||||
|
{scope && (
|
||||||
|
<LiveSearchScopeBadge
|
||||||
|
scope={scope}
|
||||||
|
className="live-search-scope-badge"
|
||||||
|
clearScope={clearScope}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{ghostScope && (
|
||||||
|
<LiveSearchScopeGhostBadge
|
||||||
|
scope={ghostScope}
|
||||||
|
className="live-search-scope-badge live-search-scope-badge--ghost"
|
||||||
|
setScope={setScope}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
id="live-search-input"
|
id="live-search-input"
|
||||||
className="input live-search-field"
|
className="input live-search-field"
|
||||||
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 => handleQueryChange(e.target.value)}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setIsFocused(true);
|
setIsFocused(true);
|
||||||
if (results) setOpen(true);
|
if (!isLiveSearchDropdownBlocked(scope) && results) setOpen(true);
|
||||||
}}
|
}}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-controls="search-results"
|
aria-controls="search-results"
|
||||||
aria-expanded={open}
|
aria-expanded={open && !isLiveSearchDropdownBlocked(scope)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
{query && (
|
</div>
|
||||||
<button
|
|
||||||
className="live-search-clear"
|
|
||||||
onClick={() => {
|
|
||||||
setQuery('');
|
|
||||||
setResults(null);
|
|
||||||
setOpen(false);
|
|
||||||
setSearchSource(null);
|
|
||||||
}}
|
|
||||||
aria-label={t('search.clearLabel')}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<div
|
<VirtualCardGrid
|
||||||
ref={measureRef}
|
key={layoutKey}
|
||||||
className="album-grid-wrap"
|
items={visible}
|
||||||
style={{ display: 'block', position: 'relative', width: '100%' }}
|
itemKey={(artist) => artist.id}
|
||||||
>
|
rowVariant="artist"
|
||||||
<div
|
disableVirtualization={disableVirtualization}
|
||||||
style={{
|
layoutSignal={visible.length}
|
||||||
height: rowCount === 0 ? 0 : virtualizer.getTotalSize(),
|
scrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||||
width: '100%',
|
wrapClassName={disableVirtualization ? 'album-grid-wrap album-grid-wrap--plain' : 'album-grid-wrap'}
|
||||||
position: 'relative',
|
renderItem={artist => (
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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} />
|
<ArtistGridTile key={artist.id} artist={artist} {...tilePropsShared} />
|
||||||
))}
|
)}
|
||||||
</div>
|
/>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={measureRef}
|
|
||||||
className="album-grid-wrap"
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
|
||||||
gap: 'var(--space-4)',
|
|
||||||
alignItems: 'start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{visible.map(artist => (
|
|
||||||
<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import type { KeyboardEvent, MouseEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ALL_NAV_ITEMS } from '../../config/navItems';
|
||||||
|
import type { LiveSearchScope } from '../../store/liveSearchScopeStore';
|
||||||
|
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '../../store/albumBrowseSessionStore';
|
||||||
|
import { isTracksBrowsePath } from '../../store/advancedSearchSessionStore';
|
||||||
|
import { isArtistsBrowsePath } from '../../store/artistBrowseSessionStore';
|
||||||
|
import { isComposersBrowsePath } from '../../store/composerBrowseSessionStore';
|
||||||
|
|
||||||
|
const SCOPE_NAV_ITEM: Record<LiveSearchScope, keyof typeof ALL_NAV_ITEMS> = {
|
||||||
|
artists: 'artists',
|
||||||
|
albums: 'allAlbums',
|
||||||
|
newReleases: 'newReleases',
|
||||||
|
tracks: 'tracks',
|
||||||
|
composers: 'composers',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Scope to restore when on a browse route but the badge was cleared (global search mode). */
|
||||||
|
export function resolveLiveSearchScopeGhost(
|
||||||
|
pathname: string,
|
||||||
|
activeScope: LiveSearchScope | null,
|
||||||
|
): LiveSearchScope | null {
|
||||||
|
if (activeScope != null) return null;
|
||||||
|
if (isArtistsBrowsePath(pathname)) return 'artists';
|
||||||
|
if (isAlbumsBrowsePath(pathname)) return 'albums';
|
||||||
|
if (isNewReleasesBrowsePath(pathname)) return 'newReleases';
|
||||||
|
if (isTracksBrowsePath(pathname)) return 'tracks';
|
||||||
|
if (isComposersBrowsePath(pathname)) return 'composers';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liveSearchScopePlaceholderKey(scope: LiveSearchScope | null): string {
|
||||||
|
switch (scope) {
|
||||||
|
case 'artists':
|
||||||
|
return 'search.scopeArtistsPlaceholder';
|
||||||
|
case 'albums':
|
||||||
|
return 'search.scopeAlbumsPlaceholder';
|
||||||
|
case 'newReleases':
|
||||||
|
return 'search.scopeNewReleasesPlaceholder';
|
||||||
|
case 'tracks':
|
||||||
|
return 'search.scopeTracksPlaceholder';
|
||||||
|
case 'composers':
|
||||||
|
return 'search.scopeComposersPlaceholder';
|
||||||
|
default:
|
||||||
|
return 'search.placeholder';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scoped browse mode filters the page only — no live-search dropdown. */
|
||||||
|
export function isLiveSearchDropdownBlocked(scope: LiveSearchScope | null): boolean {
|
||||||
|
return scope != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liveSearchScopeBadgeTooltipKey(scope: LiveSearchScope): string {
|
||||||
|
switch (scope) {
|
||||||
|
case 'artists':
|
||||||
|
return 'search.scopeArtistsBadgeTooltip';
|
||||||
|
case 'albums':
|
||||||
|
return 'search.scopeAlbumsBadgeTooltip';
|
||||||
|
case 'newReleases':
|
||||||
|
return 'search.scopeNewReleasesBadgeTooltip';
|
||||||
|
case 'tracks':
|
||||||
|
return 'search.scopeTracksBadgeTooltip';
|
||||||
|
case 'composers':
|
||||||
|
return 'search.scopeComposersBadgeTooltip';
|
||||||
|
default:
|
||||||
|
return 'search.scopeArtistsBadgeTooltip';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liveSearchScopeGhostTooltipKey(scope: LiveSearchScope): string {
|
||||||
|
switch (scope) {
|
||||||
|
case 'artists':
|
||||||
|
return 'search.scopeArtistsGhostTooltip';
|
||||||
|
case 'albums':
|
||||||
|
return 'search.scopeAlbumsGhostTooltip';
|
||||||
|
case 'newReleases':
|
||||||
|
return 'search.scopeNewReleasesGhostTooltip';
|
||||||
|
case 'tracks':
|
||||||
|
return 'search.scopeTracksGhostTooltip';
|
||||||
|
case 'composers':
|
||||||
|
return 'search.scopeComposersGhostTooltip';
|
||||||
|
default:
|
||||||
|
return 'search.scopeArtistsGhostTooltip';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveSearchScopeIconProps = {
|
||||||
|
scope: LiveSearchScope;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sidebar nav icon for the scoped browse page (e.g. Users for Artists). */
|
||||||
|
export function LiveSearchScopeIcon({ scope, size = 14 }: LiveSearchScopeIconProps) {
|
||||||
|
const Icon = ALL_NAV_ITEMS[SCOPE_NAV_ITEM[scope]].icon;
|
||||||
|
return <Icon size={size} aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tracks Backspace-on-empty badge removal (double after prior text input). */
|
||||||
|
export type LiveSearchScopeBackspaceState = {
|
||||||
|
hadQueryInput: boolean;
|
||||||
|
emptyBackspaceStreak: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createLiveSearchScopeBackspaceState(): LiveSearchScopeBackspaceState {
|
||||||
|
return { hadQueryInput: false, emptyBackspaceStreak: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetLiveSearchScopeBackspaceState(state: LiveSearchScopeBackspaceState): void {
|
||||||
|
state.hadQueryInput = false;
|
||||||
|
state.emptyBackspaceStreak = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call when the scoped field query changes (typing, paste, clear button, undo). */
|
||||||
|
export function noteLiveSearchScopeQueryInput(
|
||||||
|
state: LiveSearchScopeBackspaceState,
|
||||||
|
query: string,
|
||||||
|
): void {
|
||||||
|
if (query !== '') state.hadQueryInput = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backspace on an empty scoped field removes the badge.
|
||||||
|
* After the user typed text (even if cleared), two consecutive Backspaces on empty are required.
|
||||||
|
*/
|
||||||
|
export function handleLiveSearchScopeBackspace(
|
||||||
|
e: KeyboardEvent<HTMLInputElement>,
|
||||||
|
query: string,
|
||||||
|
scope: LiveSearchScope | null,
|
||||||
|
clearScope: (options?: { recordUndo?: boolean }) => void,
|
||||||
|
state: LiveSearchScopeBackspaceState,
|
||||||
|
): boolean {
|
||||||
|
if (e.key !== 'Backspace' || !scope) return false;
|
||||||
|
|
||||||
|
if (query !== '') {
|
||||||
|
state.emptyBackspaceStreak = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!state.hadQueryInput) {
|
||||||
|
clearScope({ recordUndo: true });
|
||||||
|
resetLiveSearchScopeBackspaceState(state);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.emptyBackspaceStreak += 1;
|
||||||
|
if (state.emptyBackspaceStreak >= 2) {
|
||||||
|
clearScope({ recordUndo: true });
|
||||||
|
resetLiveSearchScopeBackspaceState(state);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single click removes the scope badge. */
|
||||||
|
export function handleLiveSearchScopeBadgeClick(
|
||||||
|
e: MouseEvent<HTMLElement>,
|
||||||
|
clearScope: (options?: { recordUndo?: boolean }) => void,
|
||||||
|
): void {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
clearScope({ recordUndo: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single click restores scope after the user cleared the badge on this browse route. */
|
||||||
|
export function handleLiveSearchScopeGhostClick(
|
||||||
|
e: MouseEvent<HTMLElement>,
|
||||||
|
scope: LiveSearchScope,
|
||||||
|
setScope: (scope: LiveSearchScope, options?: { recordUndo?: boolean }) => void,
|
||||||
|
): void {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setScope(scope, { recordUndo: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveSearchScopeBadgeProps = {
|
||||||
|
scope: LiveSearchScope;
|
||||||
|
className: string;
|
||||||
|
clearScope: (options?: { recordUndo?: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LiveSearchScopeBadge({ scope, className, clearScope }: LiveSearchScopeBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tooltip = t(liveSearchScopeBadgeTooltipKey(scope));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={className}
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
data-tooltip={tooltip}
|
||||||
|
data-tooltip-pos="bottom"
|
||||||
|
aria-label={tooltip}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => handleLiveSearchScopeBadgeClick(e, clearScope)}
|
||||||
|
>
|
||||||
|
<LiveSearchScopeIcon scope={scope} size={14} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveSearchScopeGhostBadgeProps = {
|
||||||
|
scope: LiveSearchScope;
|
||||||
|
className: string;
|
||||||
|
setScope: (scope: LiveSearchScope, options?: { recordUndo?: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LiveSearchScopeGhostBadge({ scope, className, setScope }: LiveSearchScopeGhostBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tooltip = t(liveSearchScopeGhostTooltipKey(scope));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={className}
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
data-tooltip={tooltip}
|
||||||
|
data-tooltip-pos="bottom"
|
||||||
|
aria-label={tooltip}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => handleLiveSearchScopeGhostClick(e, scope, setScope)}
|
||||||
|
>
|
||||||
|
<LiveSearchScopeIcon scope={scope} size={14} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field-local undo (Ctrl/Cmd+Z) for live search query and scope badge. */
|
||||||
|
export function handleLiveSearchScopeUndo(
|
||||||
|
e: KeyboardEvent<HTMLInputElement>,
|
||||||
|
undo: () => boolean,
|
||||||
|
): boolean {
|
||||||
|
const isUndoKey = e.code === 'KeyZ' || e.key.toLowerCase() === 'z';
|
||||||
|
if (!isUndoKey || !(e.ctrlKey || e.metaKey) || e.shiftKey) return false;
|
||||||
|
e.preventDefault();
|
||||||
|
return undo();
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
import 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 && (
|
||||||
<div className="virtual-song-list-toolbar">
|
<h2 className="section-title virtual-song-list-title">{title}</h2>
|
||||||
<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-toolbar">
|
||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{!hideDiscoveryChrome && (
|
||||||
<p className="tracks-subtitle">{t('tracks.subtitle')}</p>
|
<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}
|
||||||
|
|||||||
@@ -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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useLayoutEffect, useRef, type RefObject } from 'react';
|
||||||
|
import type { AlbumBrowseScrollSnapshot } from './useAlbumBrowseFilters';
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
scrollSnapshotRef: RefObject<AlbumBrowseScrollSnapshot>;
|
||||||
|
getScrollRoot: () => HTMLElement | null;
|
||||||
|
isScrollRestorePending: boolean;
|
||||||
|
resetKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Scroll to top when browse filters shrink the album grid (e.g. scoped text search). */
|
||||||
|
export function useAlbumBrowseScrollReset({
|
||||||
|
scrollSnapshotRef,
|
||||||
|
getScrollRoot,
|
||||||
|
isScrollRestorePending,
|
||||||
|
resetKey,
|
||||||
|
}: Args): void {
|
||||||
|
const prevResetKeyRef = useRef(resetKey);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (isScrollRestorePending) return;
|
||||||
|
if (prevResetKeyRef.current === resetKey) return;
|
||||||
|
prevResetKeyRef.current = resetKey;
|
||||||
|
|
||||||
|
const el = getScrollRoot();
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (el.scrollTop !== 0) {
|
||||||
|
el.scrollTop = 0;
|
||||||
|
el.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||||
|
}
|
||||||
|
scrollSnapshotRef.current.scrollTop = 0;
|
||||||
|
}, [resetKey, isScrollRestorePending, getScrollRoot, scrollSnapshotRef]);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
useAlbumBrowseSessionStore,
|
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,15 +70,19 @@ 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) {
|
||||||
|
useLiveSearchScopeStore.getState().setQuery(restored.searchQuery ?? '');
|
||||||
|
if (!sameGenreSelection(restored.selectedGenres, filtersRef.current.selectedGenres)) {
|
||||||
setSelectedGenres(restored.selectedGenres);
|
setSelectedGenres(restored.selectedGenres);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useLayoutEffect, useRef, type RefObject } from 'react';
|
||||||
|
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||||
|
type BrowseScrollSnapshot = {
|
||||||
|
scrollTop: number;
|
||||||
|
visibleCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
scrollSnapshotRef: RefObject<BrowseScrollSnapshot>;
|
||||||
|
getScrollRoot: () => HTMLElement | null;
|
||||||
|
isScrollRestorePending: boolean;
|
||||||
|
resetKey: string;
|
||||||
|
viewMode: 'grid' | 'list';
|
||||||
|
listVirtualize: boolean;
|
||||||
|
listVirtualizer: Virtualizer<HTMLElement, Element>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Scroll to top when browse filters shrink the list (e.g. scoped text search). */
|
||||||
|
export function useArtistsBrowseScrollReset({
|
||||||
|
scrollSnapshotRef,
|
||||||
|
getScrollRoot,
|
||||||
|
isScrollRestorePending,
|
||||||
|
resetKey,
|
||||||
|
viewMode,
|
||||||
|
listVirtualize,
|
||||||
|
listVirtualizer,
|
||||||
|
}: Args): void {
|
||||||
|
const prevResetKeyRef = useRef(resetKey);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (isScrollRestorePending) return;
|
||||||
|
if (prevResetKeyRef.current === resetKey) return;
|
||||||
|
prevResetKeyRef.current = resetKey;
|
||||||
|
|
||||||
|
const el = getScrollRoot();
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (el.scrollTop !== 0) {
|
||||||
|
el.scrollTop = 0;
|
||||||
|
el.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||||
|
}
|
||||||
|
scrollSnapshotRef.current.scrollTop = 0;
|
||||||
|
|
||||||
|
if (listVirtualize && viewMode === 'list') listVirtualizer.scrollToOffset(0);
|
||||||
|
}, [
|
||||||
|
resetKey,
|
||||||
|
isScrollRestorePending,
|
||||||
|
getScrollRoot,
|
||||||
|
scrollSnapshotRef,
|
||||||
|
viewMode,
|
||||||
|
listVirtualize,
|
||||||
|
listVirtualizer,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
|
||||||
|
BROWSE_TEXT_DEBOUNCE_RACE_MS,
|
||||||
|
browseRaceCountsAlbums,
|
||||||
|
raceBrowseWithLocalFallback,
|
||||||
|
runLocalBrowseAlbums,
|
||||||
|
runNetworkBrowseAlbums,
|
||||||
|
} from '../utils/library/browseTextSearch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced album title search with local-vs-network race when the
|
||||||
|
* library index is enabled; network-only when it is not.
|
||||||
|
*/
|
||||||
|
export function useBrowseAlbumTextSearch(
|
||||||
|
filter: string,
|
||||||
|
indexEnabled: boolean,
|
||||||
|
serverId: string | null | undefined,
|
||||||
|
losslessOnly = false,
|
||||||
|
) {
|
||||||
|
const [debouncedFilter, setDebouncedFilter] = useState('');
|
||||||
|
const [textSearchAlbums, setTextSearchAlbums] = useState<SubsonicAlbum[] | null>(null);
|
||||||
|
const [textSearchLoading, setTextSearchLoading] = useState(false);
|
||||||
|
const searchGenRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ms = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS;
|
||||||
|
const timer = window.setTimeout(() => setDebouncedFilter(filter.trim()), ms);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [filter, indexEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = debouncedFilter;
|
||||||
|
if (!q || !serverId) {
|
||||||
|
setTextSearchAlbums(null);
|
||||||
|
setTextSearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gen = ++searchGenRef.current;
|
||||||
|
const isStale = () => gen !== searchGenRef.current;
|
||||||
|
setTextSearchLoading(true);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
if (!indexEnabled) {
|
||||||
|
const albums = await runNetworkBrowseAlbums(q);
|
||||||
|
if (isStale()) return;
|
||||||
|
setTextSearchAlbums(albums);
|
||||||
|
setTextSearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await raceBrowseWithLocalFallback(
|
||||||
|
isStale,
|
||||||
|
() => runLocalBrowseAlbums(serverId, q, undefined, losslessOnly),
|
||||||
|
() => runNetworkBrowseAlbums(q),
|
||||||
|
{
|
||||||
|
surface: 'albums_browse',
|
||||||
|
query: q,
|
||||||
|
indexEnabled,
|
||||||
|
counts: browseRaceCountsAlbums,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (isStale()) return;
|
||||||
|
setTextSearchAlbums(outcome?.result ?? null);
|
||||||
|
setTextSearchLoading(false);
|
||||||
|
})();
|
||||||
|
}, [debouncedFilter, indexEnabled, serverId, losslessOnly]);
|
||||||
|
|
||||||
|
const effectiveFilter = textSearchAlbums != null ? '' : filter;
|
||||||
|
return { textSearchAlbums, textSearchLoading, effectiveFilter };
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||||
|
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
|
||||||
|
import { isComposerDetailPath } from '../store/albumBrowseSessionStore';
|
||||||
|
import {
|
||||||
|
DEFAULT_COMPOSER_BROWSE_RETURN_STATE,
|
||||||
|
type ComposerBrowseReturnState,
|
||||||
|
type ComposerBrowseViewMode,
|
||||||
|
isComposersBrowsePath,
|
||||||
|
useComposerBrowseSessionStore,
|
||||||
|
} from '../store/composerBrowseSessionStore';
|
||||||
|
import { shouldRestoreComposerBrowseSession } from '../utils/navigation/albumDetailNavigation';
|
||||||
|
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||||
|
|
||||||
|
export type ComposerBrowseScrollSnapshot = {
|
||||||
|
scrollTop: number;
|
||||||
|
visibleCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function returnStateForNavigation(
|
||||||
|
serverId: string,
|
||||||
|
navigationType: NavigationType,
|
||||||
|
locationState: unknown,
|
||||||
|
): ComposerBrowseReturnState {
|
||||||
|
if (!shouldRestoreComposerBrowseSession(navigationType, locationState) || !serverId) {
|
||||||
|
return DEFAULT_COMPOSER_BROWSE_RETURN_STATE;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
useComposerBrowseSessionStore.getState().peekReturnStash(serverId)
|
||||||
|
?? DEFAULT_COMPOSER_BROWSE_RETURN_STATE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComposersBrowseFilters(
|
||||||
|
serverId: string,
|
||||||
|
scrollSnapshotRef?: RefObject<ComposerBrowseScrollSnapshot>,
|
||||||
|
) {
|
||||||
|
const navigationType = useNavigationType();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const [letterFilter, setLetterFilter] = useState(
|
||||||
|
() => returnStateForNavigation(serverId, navigationType, location.state).letterFilter,
|
||||||
|
);
|
||||||
|
const [starredOnly, setStarredOnly] = useState(
|
||||||
|
() => returnStateForNavigation(serverId, navigationType, location.state).starredOnly,
|
||||||
|
);
|
||||||
|
const [viewMode, setViewMode] = useState<ComposerBrowseViewMode>(
|
||||||
|
() => returnStateForNavigation(serverId, navigationType, location.state).viewMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
const browseStateRef = useRef<ComposerBrowseReturnState>(DEFAULT_COMPOSER_BROWSE_RETURN_STATE);
|
||||||
|
const restoredFromStashRef = useRef(false);
|
||||||
|
|
||||||
|
browseStateRef.current = {
|
||||||
|
filter: useLiveSearchScopeStore.getState().query,
|
||||||
|
letterFilter,
|
||||||
|
starredOnly,
|
||||||
|
viewMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restoredFromStashRef.current = false;
|
||||||
|
}, [serverId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!serverId) return;
|
||||||
|
|
||||||
|
if (shouldRestoreComposerBrowseSession(navigationType, location.state)) {
|
||||||
|
restoredFromStashRef.current = true;
|
||||||
|
const restored = useComposerBrowseSessionStore.getState().peekReturnStash(serverId);
|
||||||
|
if (restored) {
|
||||||
|
useLiveSearchScopeStore.getState().setQuery(restored.filter);
|
||||||
|
setLetterFilter(restored.letterFilter);
|
||||||
|
setStarredOnly(restored.starredOnly);
|
||||||
|
setViewMode(restored.viewMode);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restoredFromStashRef.current) return;
|
||||||
|
|
||||||
|
useComposerBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||||
|
useLiveSearchScopeStore.getState().setQuery('');
|
||||||
|
setLetterFilter(DEFAULT_COMPOSER_BROWSE_RETURN_STATE.letterFilter);
|
||||||
|
setStarredOnly(false);
|
||||||
|
setViewMode('grid');
|
||||||
|
}, [serverId, navigationType, location.state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (!serverId) return;
|
||||||
|
const path = window.location.pathname;
|
||||||
|
if (isComposerDetailPath(path)) {
|
||||||
|
const snapshot = scrollSnapshotRef?.current;
|
||||||
|
useComposerBrowseSessionStore.getState().stashReturnState(serverId, {
|
||||||
|
...browseStateRef.current,
|
||||||
|
scrollTop: snapshot?.scrollTop,
|
||||||
|
visibleCount: snapshot?.visibleCount,
|
||||||
|
});
|
||||||
|
} else if (!isComposersBrowsePath(path)) {
|
||||||
|
useComposerBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [serverId, scrollSnapshotRef]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
letterFilter,
|
||||||
|
setLetterFilter,
|
||||||
|
starredOnly,
|
||||||
|
setStarredOnly,
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
peekComposerBrowseScrollRestore,
|
||||||
|
useComposerBrowseSessionStore,
|
||||||
|
} from '../store/composerBrowseSessionStore';
|
||||||
|
import { shouldRestoreComposerBrowseSession } from '../utils/navigation/albumDetailNavigation';
|
||||||
|
|
||||||
|
type PendingScroll = {
|
||||||
|
scrollTop: number;
|
||||||
|
visibleCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseComposersBrowseScrollRestoreArgs = {
|
||||||
|
serverId: string;
|
||||||
|
scrollBodyEl: HTMLElement | null;
|
||||||
|
visibleCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
loadingMore: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
loadMore: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseComposersBrowseScrollRestoreResult = {
|
||||||
|
isScrollRestorePending: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readPendingScrollRestore(
|
||||||
|
serverId: string,
|
||||||
|
navigationType: NavigationType,
|
||||||
|
locationState: unknown,
|
||||||
|
): PendingScroll | null {
|
||||||
|
if (!shouldRestoreComposerBrowseSession(navigationType, locationState) || !serverId) return null;
|
||||||
|
return peekComposerBrowseScrollRestore(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore Composers in-page scroll after returning from composer detail. */
|
||||||
|
export function useComposersBrowseScrollRestore({
|
||||||
|
serverId,
|
||||||
|
scrollBodyEl,
|
||||||
|
visibleCount,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
|
}: UseComposersBrowseScrollRestoreArgs): UseComposersBrowseScrollRestoreResult {
|
||||||
|
const navigationType = useNavigationType();
|
||||||
|
const location = useLocation();
|
||||||
|
const initRef = useRef(false);
|
||||||
|
const pendingRef = useRef<PendingScroll | null>(null);
|
||||||
|
const doneRef = useRef(false);
|
||||||
|
|
||||||
|
if (!initRef.current) {
|
||||||
|
initRef.current = true;
|
||||||
|
pendingRef.current = readPendingScrollRestore(serverId, navigationType, location.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
|
||||||
|
() => readPendingScrollRestore(serverId, navigationType, location.state) !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const pending = pendingRef.current;
|
||||||
|
if (doneRef.current || !pending) return;
|
||||||
|
if (!scrollBodyEl || loading) return;
|
||||||
|
|
||||||
|
const needsMore = visibleCount < pending.visibleCount && hasMore;
|
||||||
|
if (needsMore) {
|
||||||
|
if (!loadingMore) loadMore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loadingMore) return;
|
||||||
|
|
||||||
|
scrollBodyEl.scrollTop = pending.scrollTop;
|
||||||
|
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||||
|
pendingRef.current = null;
|
||||||
|
doneRef.current = true;
|
||||||
|
setIsScrollRestorePending(false);
|
||||||
|
useComposerBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||||
|
}, [
|
||||||
|
scrollBodyEl,
|
||||||
|
visibleCount,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
|
serverId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { isScrollRestorePending };
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||||
|
import { syncLiveSearchRouteScope } from './useLiveSearchRouteScope';
|
||||||
|
|
||||||
|
describe('syncLiveSearchRouteScope', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useLiveSearchScopeStore.setState({ query: '', scope: null, undoStack: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates scope on supported browse routes', () => {
|
||||||
|
syncLiveSearchRouteScope('/albums');
|
||||||
|
expect(useLiveSearchScopeStore.getState().scope).toBe('albums');
|
||||||
|
|
||||||
|
syncLiveSearchRouteScope('/tracks');
|
||||||
|
expect(useLiveSearchScopeStore.getState().scope).toBe('tracks');
|
||||||
|
|
||||||
|
syncLiveSearchRouteScope('/composers');
|
||||||
|
expect(useLiveSearchScopeStore.getState().scope).toBe('composers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears scope and query when leaving browse routes', () => {
|
||||||
|
useLiveSearchScopeStore.setState({ query: 'beatles', scope: 'albums' });
|
||||||
|
|
||||||
|
syncLiveSearchRouteScope('/album/abc123');
|
||||||
|
|
||||||
|
expect(useLiveSearchScopeStore.getState().scope).toBeNull();
|
||||||
|
expect(useLiveSearchScopeStore.getState().query).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears query when leaving browse with scope already cleared (ghost mode)', () => {
|
||||||
|
useLiveSearchScopeStore.setState({ query: 'beatles', scope: null });
|
||||||
|
|
||||||
|
syncLiveSearchRouteScope('/album/abc123');
|
||||||
|
|
||||||
|
expect(useLiveSearchScopeStore.getState().scope).toBeNull();
|
||||||
|
expect(useLiveSearchScopeStore.getState().query).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves query when switching between browse routes', () => {
|
||||||
|
useLiveSearchScopeStore.setState({ query: 'jazz', scope: 'albums' });
|
||||||
|
|
||||||
|
syncLiveSearchRouteScope('/artists');
|
||||||
|
|
||||||
|
expect(useLiveSearchScopeStore.getState().scope).toBe('artists');
|
||||||
|
expect(useLiveSearchScopeStore.getState().query).toBe('jazz');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '../store/albumBrowseSessionStore';
|
||||||
|
import { isArtistsBrowsePath } from '../store/artistBrowseSessionStore';
|
||||||
|
import { isTracksBrowsePath } from '../store/advancedSearchSessionStore';
|
||||||
|
import { isComposersBrowsePath } from '../store/composerBrowseSessionStore';
|
||||||
|
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||||
|
|
||||||
|
/** Keep scope badge in sync with browse routes; clear field text when leaving browse. */
|
||||||
|
export function syncLiveSearchRouteScope(pathname: string): void {
|
||||||
|
const store = useLiveSearchScopeStore.getState();
|
||||||
|
|
||||||
|
if (isArtistsBrowsePath(pathname)) {
|
||||||
|
store.setScope('artists');
|
||||||
|
} else if (isAlbumsBrowsePath(pathname)) {
|
||||||
|
store.setScope('albums');
|
||||||
|
} else if (isNewReleasesBrowsePath(pathname)) {
|
||||||
|
store.setScope('newReleases');
|
||||||
|
} else if (isTracksBrowsePath(pathname)) {
|
||||||
|
store.setScope('tracks');
|
||||||
|
} else if (isComposersBrowsePath(pathname)) {
|
||||||
|
store.setScope('composers');
|
||||||
|
} else {
|
||||||
|
if (store.scope != null) store.clearScope();
|
||||||
|
if (store.query !== '') store.setQuery('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Activate the browse scope badge when a supported route is open; clear on leave. */
|
||||||
|
export function useLiveSearchRouteScope() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncLiveSearchRouteScope(location.pathname);
|
||||||
|
}, [location.pathname]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { navigateToComposerDetail } from '../utils/navigation/albumDetailNavigation';
|
||||||
|
|
||||||
|
/** Navigate to composer detail, remembering the current page for the back button. */
|
||||||
|
export function useNavigateToComposer() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
return useCallback(
|
||||||
|
(composerId: string, opts?: { search?: string }) => {
|
||||||
|
navigateToComposerDetail(navigate, location, composerId, opts);
|
||||||
|
},
|
||||||
|
[navigate, location],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
|
import { useSongBrowseList } from './useSongBrowseList';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||||
|
|
||||||
|
vi.mock('../api/subsonicSearch', () => ({
|
||||||
|
searchSongsPaged: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api/navidromeBrowse', () => ({
|
||||||
|
ndListSongs: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/library/advancedSearchLocal', () => ({
|
||||||
|
runLocalSongBrowse: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/library/browseTextSearch', () => ({
|
||||||
|
BROWSE_TEXT_DEBOUNCE_NETWORK_MS: 10,
|
||||||
|
BROWSE_TEXT_DEBOUNCE_RACE_MS: 10,
|
||||||
|
browseRaceCountsSongs: vi.fn(),
|
||||||
|
loadMoreLocalBrowseSongs: vi.fn(async () => []),
|
||||||
|
raceBrowseWithLocalFallback: vi.fn(async () => null),
|
||||||
|
runLocalBrowseSongPage: vi.fn(async () => []),
|
||||||
|
runNetworkBrowseSongPage: vi.fn(async () => [{ id: 'fresh' } as SubsonicSong]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const stashedSong = { id: 'stashed', title: 'Stashed', artist: 'A', duration: 180 } as SubsonicSong;
|
||||||
|
|
||||||
|
describe('useSongBrowseList restore hold', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthStore.setState({ activeServerId: 'srv-1' });
|
||||||
|
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps stashed songs after fetchSongPage identity changes until query edits', async () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ searchQuery }) => useSongBrowseList({
|
||||||
|
enabled: true,
|
||||||
|
searchQuery,
|
||||||
|
initialRestore: {
|
||||||
|
query: 'jazz',
|
||||||
|
songs: [stashedSong],
|
||||||
|
offset: 1,
|
||||||
|
hasMore: false,
|
||||||
|
localSearchMode: true,
|
||||||
|
browseUnsupported: false,
|
||||||
|
hasSearched: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ initialProps: { searchQuery: 'jazz' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.songs).toEqual([stashedSong]);
|
||||||
|
|
||||||
|
rerender({ searchQuery: 'jazz' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.songs).toEqual([stashedSong]);
|
||||||
|
}, { timeout: 500 });
|
||||||
|
|
||||||
|
rerender({ searchQuery: 'jazzx' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.songs[0]?.id).toBe('fresh');
|
||||||
|
}, { timeout: 500 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,16 +42,19 @@ export type SongBrowseListRestore = {
|
|||||||
|
|
||||||
type UseSongBrowseListArgs = {
|
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,10 +128,15 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
if (skipInitialFetchRef.current) {
|
|
||||||
skipInitialFetchRef.current = false;
|
if (holdRestoredListRef.current) {
|
||||||
|
const expected = heldRestoredQueryRef.current;
|
||||||
|
if (searchQuery.trim() !== expected || debouncedQuery !== expected) {
|
||||||
|
holdRestoredListRef.current = false;
|
||||||
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setSongs([]);
|
setSongs([]);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: 'Общий трек',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 && (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
// Defer stash teardown until after AppShell's route-change scroll reset effect.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
clearAdvancedSearchLeaveSnapshots();
|
||||||
if (hadRestoreOnMountRef.current) {
|
if (hadRestoreOnMountRef.current) {
|
||||||
useAdvancedSearchSessionStore.getState().clearReturnStash();
|
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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { ALL_SENTINEL } from '../utils/componentHelpers/artistsHelpers';
|
||||||
|
|
||||||
|
export type ComposerBrowseViewMode = 'grid' | 'list';
|
||||||
|
|
||||||
|
/** Browse state restored when returning to Composers via back from composer detail. */
|
||||||
|
export interface ComposerBrowseReturnState {
|
||||||
|
filter: string;
|
||||||
|
letterFilter: string;
|
||||||
|
starredOnly: boolean;
|
||||||
|
viewMode: ComposerBrowseViewMode;
|
||||||
|
scrollTop?: number;
|
||||||
|
visibleCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_COMPOSER_BROWSE_RETURN_STATE: ComposerBrowseReturnState = {
|
||||||
|
filter: '',
|
||||||
|
letterFilter: ALL_SENTINEL,
|
||||||
|
starredOnly: false,
|
||||||
|
viewMode: 'grid',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ComposerBrowseSessionStore {
|
||||||
|
returnStashByServer: Record<string, ComposerBrowseReturnState>;
|
||||||
|
stashReturnState: (serverId: string, state: ComposerBrowseReturnState) => void;
|
||||||
|
clearReturnStash: (serverId: string) => void;
|
||||||
|
peekReturnStash: (serverId: string) => ComposerBrowseReturnState | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useComposerBrowseSessionStore = create<ComposerBrowseSessionStore>((set, get) => ({
|
||||||
|
returnStashByServer: {},
|
||||||
|
|
||||||
|
stashReturnState: (serverId, state) => {
|
||||||
|
if (!serverId) return;
|
||||||
|
set((s) => ({
|
||||||
|
returnStashByServer: {
|
||||||
|
...s.returnStashByServer,
|
||||||
|
[serverId]: {
|
||||||
|
filter: state.filter,
|
||||||
|
letterFilter: state.letterFilter,
|
||||||
|
starredOnly: state.starredOnly,
|
||||||
|
viewMode: state.viewMode,
|
||||||
|
...(typeof state.scrollTop === 'number' ? { scrollTop: state.scrollTop } : {}),
|
||||||
|
...(typeof state.visibleCount === 'number' ? { visibleCount: state.visibleCount } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearReturnStash: (serverId) => {
|
||||||
|
if (!serverId) return;
|
||||||
|
const next = { ...get().returnStashByServer };
|
||||||
|
delete next[serverId];
|
||||||
|
set({ returnStashByServer: next });
|
||||||
|
},
|
||||||
|
|
||||||
|
peekReturnStash: (serverId) => {
|
||||||
|
if (!serverId) return null;
|
||||||
|
const stash = get().returnStashByServer[serverId];
|
||||||
|
if (!stash) return null;
|
||||||
|
return {
|
||||||
|
filter: stash.filter,
|
||||||
|
letterFilter: stash.letterFilter,
|
||||||
|
starredOnly: stash.starredOnly,
|
||||||
|
viewMode: stash.viewMode,
|
||||||
|
...(typeof stash.scrollTop === 'number' ? { scrollTop: stash.scrollTop } : {}),
|
||||||
|
...(typeof stash.visibleCount === 'number' ? { visibleCount: stash.visibleCount } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function peekComposerBrowseScrollRestore(
|
||||||
|
serverId: string,
|
||||||
|
): { scrollTop: number; visibleCount: number } | null {
|
||||||
|
const stash = useComposerBrowseSessionStore.getState().peekReturnStash(serverId);
|
||||||
|
if (!stash) return null;
|
||||||
|
if (typeof stash.scrollTop !== 'number' || typeof stash.visibleCount !== 'number') return null;
|
||||||
|
return {
|
||||||
|
scrollTop: Math.max(0, stash.scrollTop),
|
||||||
|
visibleCount: Math.max(0, stash.visibleCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when pathname is the Composers browse route (`/composers`). */
|
||||||
|
export function isComposersBrowsePath(pathname: string): boolean {
|
||||||
|
return pathname === '/composers';
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
scopedBrowseSearchQuery,
|
||||||
|
useLiveSearchScopeStore,
|
||||||
|
} from './liveSearchScopeStore';
|
||||||
|
|
||||||
|
describe('liveSearchScopeStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useLiveSearchScopeStore.setState({ query: '', scope: null, undoStack: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns browse query only when the expected scope is active', () => {
|
||||||
|
useLiveSearchScopeStore.setState({ query: 'beatles', scope: 'artists' });
|
||||||
|
expect(scopedBrowseSearchQuery('beatles', 'artists', 'artists')).toBe('beatles');
|
||||||
|
expect(scopedBrowseSearchQuery('beatles', null, 'artists')).toBe('');
|
||||||
|
useLiveSearchScopeStore.setState({ query: 'abbey', scope: 'albums' });
|
||||||
|
expect(scopedBrowseSearchQuery('abbey', 'albums', 'albums')).toBe('abbey');
|
||||||
|
expect(scopedBrowseSearchQuery('abbey', 'artists', 'albums')).toBe('');
|
||||||
|
useLiveSearchScopeStore.setState({ query: 'jazz', scope: 'newReleases' });
|
||||||
|
expect(scopedBrowseSearchQuery('jazz', 'newReleases', 'newReleases')).toBe('jazz');
|
||||||
|
useLiveSearchScopeStore.setState({ query: 'track', scope: 'tracks' });
|
||||||
|
expect(scopedBrowseSearchQuery('track', 'tracks', 'tracks')).toBe('track');
|
||||||
|
expect(scopedBrowseSearchQuery('track', 'albums', 'tracks')).toBe('');
|
||||||
|
useLiveSearchScopeStore.setState({ query: 'bach', scope: 'composers' });
|
||||||
|
expect(scopedBrowseSearchQuery('bach', 'composers', 'composers')).toBe('bach');
|
||||||
|
expect(scopedBrowseSearchQuery('bach', 'artists', 'composers')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undoes query and scope badge changes', () => {
|
||||||
|
useLiveSearchScopeStore.getState().setScope('artists');
|
||||||
|
useLiveSearchScopeStore.getState().setQuery('ab', { recordUndo: true });
|
||||||
|
useLiveSearchScopeStore.getState().setQuery('a', { recordUndo: true });
|
||||||
|
useLiveSearchScopeStore.getState().clearScope({ recordUndo: true });
|
||||||
|
|
||||||
|
expect(useLiveSearchScopeStore.getState().scope).toBeNull();
|
||||||
|
expect(useLiveSearchScopeStore.getState().undo()).toBe(true);
|
||||||
|
expect(useLiveSearchScopeStore.getState().scope).toBe('artists');
|
||||||
|
expect(useLiveSearchScopeStore.getState().query).toBe('a');
|
||||||
|
expect(useLiveSearchScopeStore.getState().undo()).toBe(true);
|
||||||
|
expect(useLiveSearchScopeStore.getState().query).toBe('ab');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not record undo for programmatic setQuery by default', () => {
|
||||||
|
useLiveSearchScopeStore.getState().setQuery('test');
|
||||||
|
expect(useLiveSearchScopeStore.getState().undo()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/** Page-scoped live search mode — badge in the header search field. */
|
||||||
|
export type LiveSearchScope = 'artists' | 'albums' | 'newReleases' | 'tracks' | 'composers';
|
||||||
|
|
||||||
|
export type LiveSearchSnapshot = {
|
||||||
|
query: string;
|
||||||
|
scope: LiveSearchScope | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LiveSearchMutationOpts = {
|
||||||
|
/** Push the current field state onto the search-local undo stack. */
|
||||||
|
recordUndo?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LiveSearchScopeStore {
|
||||||
|
query: string;
|
||||||
|
scope: LiveSearchScope | null;
|
||||||
|
undoStack: LiveSearchSnapshot[];
|
||||||
|
setQuery: (query: string, options?: LiveSearchMutationOpts) => void;
|
||||||
|
setScope: (scope: LiveSearchScope | null, options?: LiveSearchMutationOpts) => void;
|
||||||
|
clearScope: (options?: LiveSearchMutationOpts) => void;
|
||||||
|
recordUndoSnapshot: () => void;
|
||||||
|
undo: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_UNDO = 50;
|
||||||
|
|
||||||
|
function snapshotsEqual(a: LiveSearchSnapshot, b: LiveSearchSnapshot): boolean {
|
||||||
|
return a.query === b.query && a.scope === b.scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLiveSearchScopeStore = create<LiveSearchScopeStore>((set, get) => ({
|
||||||
|
query: '',
|
||||||
|
scope: null,
|
||||||
|
undoStack: [],
|
||||||
|
|
||||||
|
recordUndoSnapshot: () => {
|
||||||
|
const snap: LiveSearchSnapshot = { query: get().query, scope: get().scope };
|
||||||
|
set((s) => {
|
||||||
|
const last = s.undoStack[s.undoStack.length - 1];
|
||||||
|
if (last && snapshotsEqual(last, snap)) return s;
|
||||||
|
return { undoStack: [...s.undoStack, snap].slice(-MAX_UNDO) };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setQuery: (query, options) => {
|
||||||
|
if (get().query === query) return;
|
||||||
|
if (options?.recordUndo) get().recordUndoSnapshot();
|
||||||
|
set({ query });
|
||||||
|
},
|
||||||
|
|
||||||
|
setScope: (scope, options) => {
|
||||||
|
if (get().scope === scope) return;
|
||||||
|
if (options?.recordUndo) get().recordUndoSnapshot();
|
||||||
|
set({ scope });
|
||||||
|
},
|
||||||
|
|
||||||
|
clearScope: (options) => {
|
||||||
|
if (get().scope == null) return;
|
||||||
|
if (options?.recordUndo) get().recordUndoSnapshot();
|
||||||
|
set({ scope: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
undo: () => {
|
||||||
|
const stack = get().undoStack;
|
||||||
|
if (stack.length === 0) return false;
|
||||||
|
const prev = stack[stack.length - 1]!;
|
||||||
|
set({ query: prev.query, scope: prev.scope, undoStack: stack.slice(0, -1) });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Browse filter text when the header scope badge matches the page. */
|
||||||
|
export function scopedBrowseSearchQuery(
|
||||||
|
query: string,
|
||||||
|
activeScope: LiveSearchScope | null,
|
||||||
|
expectedScope: LiveSearchScope,
|
||||||
|
): string {
|
||||||
|
return activeScope === expectedScope ? query : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScopedBrowseSearchQuery(expectedScope: LiveSearchScope): string {
|
||||||
|
const query = useLiveSearchScopeStore(s => s.query);
|
||||||
|
const scope = useLiveSearchScopeStore(s => s.scope);
|
||||||
|
return scopedBrowseSearchQuery(query, scope, expectedScope);
|
||||||
|
}
|
||||||
@@ -85,6 +85,12 @@
|
|||||||
contain-intrinsic-size: 0 220px;
|
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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user