diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e98c6d4..caaf46b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### CI — hot-path coverage gates block merges diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index 07932a94..5e44d19f 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -22,7 +22,7 @@ use crate::filter::{self, EntityKind, FilterOp, SqlFragment}; use crate::repos; use crate::search::{ 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, }; use crate::store::LibraryStore; @@ -305,6 +305,14 @@ fn server_has_indexed_tracks(store: &LibraryStore, server_id: &str) -> Result Option { + 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)] fn build_album( store: &LibraryStore, @@ -327,7 +335,7 @@ fn build_album( return build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied); } 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_tracks( @@ -340,7 +348,7 @@ fn build_album( 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); } build_album_from_tracks( @@ -1408,6 +1416,7 @@ mod tests { filters: Vec::new(), starred_only: None, restrict_album_ids: None, + query_album_title_only: None, sort: Vec::new(), limit: 50, offset: 0, diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs index cc03afca..ad304d2b 100644 --- a/src-tauri/crates/psysonic-library/src/dto.rs +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -532,6 +532,9 @@ pub struct LibraryAdvancedSearchRequest { /// `starred_only` — use one or the other. #[serde(default)] pub restrict_album_ids: Option>, + /// When true, album text search matches title/name only (not album artist). + #[serde(default)] + pub query_album_title_only: Option, #[serde(default)] pub sort: Vec, pub limit: u32, diff --git a/src-tauri/crates/psysonic-library/src/search.rs b/src-tauri/crates/psysonic-library/src/search.rs index b3c719ff..1075ea3c 100644 --- a/src-tauri/crates/psysonic-library/src/search.rs +++ b/src-tauri/crates/psysonic-library/src/search.rs @@ -146,6 +146,11 @@ pub(crate) fn fts_album_prefix_match_query(raw: &str) -> Option { }) } +/// Album title column only (All Albums scoped browse — not album artist). +pub(crate) fn fts_album_title_prefix_match_query(raw: &str) -> Option { + 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). pub(crate) fn fts_album_prefix_any_token_match_query(raw: &str) -> Option { 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] fn fts_track_match_query_or_across_display_columns() { let q = fts_track_match_query("manowar").unwrap(); diff --git a/src/api/library.ts b/src/api/library.ts index 1153657b..880efbfd 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -213,6 +213,8 @@ export interface LibraryAdvancedSearchRequest { offset?: number; /** Skip expensive COUNT queries (Live Search). */ skipTotals?: boolean; + /** Album text query matches title/name only (All Albums scoped browse). */ + queryAlbumTitleOnly?: boolean | null; } export interface LibraryAlbumDto { diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 26fd1a20..64c8a4c1 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -48,6 +48,7 @@ import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelect import { useAppActivityTracking } from '../hooks/useAppActivityTracking'; import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator'; import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority'; +import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope'; import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm'; import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav'; import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam'; @@ -101,6 +102,7 @@ export function AppShell() { const location = useLocation(); const prevPathnameRef = useRef(location.pathname); useCoverNavigationPriority(); + useLiveSearchRouteScope(); useNowPlayingPrewarm(); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); const offlineAlbums = useOfflineStore(s => s.albums); diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index 91878aee..7ba139ca 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -21,7 +21,7 @@ import { logLibrarySearch, } from '../utils/library/libraryDevLog'; import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum'; import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; @@ -38,6 +38,19 @@ import { albumCoverRefForSong } from '../cover/ref'; import { showToast } from '../utils/ui/toast'; import { useShareSearch } from '../hooks/useShareSearch'; import ShareSearchResults from './search/ShareSearchResults'; +import { + LiveSearchScopeBadge, + LiveSearchScopeGhostBadge, + createLiveSearchScopeBackspaceState, + handleLiveSearchScopeBackspace, + handleLiveSearchScopeUndo, + isLiveSearchDropdownBlocked, + liveSearchScopePlaceholderKey, + noteLiveSearchScopeQueryInput, + resetLiveSearchScopeBackspaceState, + resolveLiveSearchScopeGhost, +} from './search/liveSearchScopeUi'; +import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore'; import { resolveIndexKey } from '../utils/server/serverIndexKey'; type LiveSearchSource = 'local' | 'network'; @@ -98,7 +111,15 @@ function LiveSearchArtistThumb({ artist }: { artist: Pick 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(null); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -136,6 +157,14 @@ export default function LiveSearch() { void refreshLocalReady(); }, [refreshLocalReady, musicLibraryFilterVersion]); + useEffect(() => { + resetLiveSearchScopeBackspaceState(scopeBackspaceRef.current); + }, [scope]); + + useEffect(() => { + noteLiveSearchScopeQueryInput(scopeBackspaceRef.current, query); + }, [query]); + useEffect(() => { if (!indexEnabled || !serverId) return; let unlistenProgress: (() => void) | undefined; @@ -161,7 +190,16 @@ export default function LiveSearch() { setOpen(false); setQuery(''); setSearchSource(null); - }, []); + }, [setQuery]); + + const handleQueryChange = useCallback((value: string) => { + setQuery(value, { recordUndo: true }); + if (!value) { + setResults(null); + setOpen(false); + setSearchSource(null); + } + }, [setQuery]); /** Leave live search for a full-page route — cancel in-flight queries and reset overlay state. */ const leaveLiveSearchFor = useCallback((path: string) => { @@ -175,11 +213,19 @@ export default function LiveSearch() { setIsFocused(false); inputRef.current?.blur(); navigate(path); - }, [navigate]); + }, [navigate, setQuery]); const share = useShareSearch(query, closeSearch); useEffect(() => { + if (isLiveSearchDropdownBlocked(scope)) { + setResults(null); + setOpen(false); + setSearchSource(null); + setLoading(false); + return; + } + if (share.shareMatch) { setResults(null); setLoading(false); @@ -338,9 +384,9 @@ export default function LiveSearch() { abort.abort(); liveSearchGenRef.current += 1; }; - }, [query, share.shareMatch, serverId, indexEnabled, musicLibraryFilterVersion, t]); + }, [query, scope, share.shareMatch, serverId, indexEnabled, musicLibraryFilterVersion, t]); - const isSearchActive = isFocused || open || query.trim().length > 0; + const isSearchActive = isFocused || open || query.trim().length > 0 || scope != null; useEffect(() => { const root = ref.current; @@ -485,6 +531,9 @@ export default function LiveSearch() { ] : []; const handleKeyDown = (e: React.KeyboardEvent) => { + if (handleLiveSearchScopeUndo(e, undoLiveSearch)) return; + if (handleLiveSearchScopeBackspace(e, query, scope, clearScope, scopeBackspaceRef.current)) return; + if (isLiveSearchDropdownBlocked(scope)) return; if (share.shareMatch) { if (e.key === 'Enter') { e.preventDefault(); @@ -547,46 +596,50 @@ export default function LiveSearch() { requestAnimationFrame(() => inputRef.current?.focus()); }} > - {loading ? ( - -
- - ) : ( - - )} - setQuery(e.target.value)} - onFocus={() => { - setIsFocused(true); - if (results) setOpen(true); - }} - onBlur={() => setIsFocused(false)} - onKeyDown={handleKeyDown} - aria-autocomplete="list" - aria-controls="search-results" - aria-expanded={open} - autoComplete="off" - /> - {query && ( - - )} + onBlur={() => setIsFocused(false)} + onKeyDown={handleKeyDown} + aria-autocomplete="list" + aria-controls="search-results" + aria-expanded={open && !isLiveSearchDropdownBlocked(scope)} + autoComplete="off" + /> +
- )} -
{songs.length > 0 && ( {t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''} diff --git a/src/components/tracks/TracksPageChrome.tsx b/src/components/tracks/TracksPageChrome.tsx index 7ae10fee..b806f443 100644 --- a/src/components/tracks/TracksPageChrome.tsx +++ b/src/components/tracks/TracksPageChrome.tsx @@ -24,9 +24,12 @@ const TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 14; /** Tracks hub hero + song rails (above the browse-all list). */ export default function TracksPageChrome({ onLayoutReady, + hideDiscoveryChrome = false, }: { /** Fires once when hero + rails finish their initial load (or fail). */ onLayoutReady?: () => void; + /** When true, skip hero and song rails (active scoped search). */ + hideDiscoveryChrome?: boolean; }) { const perfFlags = usePerfProbeFlags(); const { t } = useTranslation(); @@ -79,15 +82,15 @@ export default function TracksPageChrome({ }, []); useEffect(() => { - if (!activeServerId) return; + if (!activeServerId || hideDiscoveryChrome) return; rerollHero(); rerollRandom(); reloadRated(); - }, [activeServerId, rerollHero, rerollRandom, reloadRated]); + }, [activeServerId, hideDiscoveryChrome, rerollHero, rerollRandom, reloadRated]); useEffect(() => { if (!onLayoutReady || layoutReadyNotifiedRef.current) return; - if (!activeServerId) { + if (hideDiscoveryChrome || !activeServerId) { layoutReadyNotifiedRef.current = true; onLayoutReady(); return; @@ -95,7 +98,7 @@ export default function TracksPageChrome({ if (heroLoading || randomLoading || ratedLoading) return; layoutReadyNotifiedRef.current = true; onLayoutReady(); - }, [activeServerId, onLayoutReady, heroLoading, randomLoading, ratedLoading]); + }, [activeServerId, hideDiscoveryChrome, onLayoutReady, heroLoading, randomLoading, ratedLoading]); const railSongs = useMemo( () => (hero ? random.filter(s => s.id !== hero.id) : random), @@ -108,12 +111,14 @@ export default function TracksPageChrome({

{t('tracks.title')}

-

{t('tracks.subtitle')}

+ {!hideDiscoveryChrome && ( +

{t('tracks.subtitle')}

+ )}
)} - {!perfFlags.disableMainstageHero && hero && ( + {!perfFlags.disableMainstageHero && !hideDiscoveryChrome && hero && (
{hero.albumId && hero.coverArt ? ( @@ -173,7 +178,7 @@ export default function TracksPageChrome({
)} - {!perfFlags.disableMainstageRails && ratedSupported && (ratedLoading || rated.length > 0) && ( + {!perfFlags.disableMainstageRails && !hideDiscoveryChrome && ratedSupported && (ratedLoading || rated.length > 0) && ( )} - {!perfFlags.disableMainstageRails && ( + {!perfFlags.disableMainstageRails && !hideDiscoveryChrome && ( { @@ -119,6 +121,7 @@ export function useAlbumBrowseFilters( restoredFromStashRef.current = true; const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, ALBUMS_SURFACE); if (restored) { + useLiveSearchScopeStore.getState().setQuery(restored.searchQuery ?? ''); setSelectedGenres(restored.selectedGenres); setYearFrom(restored.yearFrom); setYearTo(restored.yearTo); @@ -132,6 +135,7 @@ export function useAlbumBrowseFilters( if (restoredFromStashRef.current) return; useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, ALBUMS_SURFACE); + useLiveSearchScopeStore.getState().setQuery(''); setSelectedGenres([]); setYearFrom(''); setYearTo(''); diff --git a/src/hooks/useAlbumBrowseScrollReset.ts b/src/hooks/useAlbumBrowseScrollReset.ts new file mode 100644 index 00000000..f95752e0 --- /dev/null +++ b/src/hooks/useAlbumBrowseScrollReset.ts @@ -0,0 +1,34 @@ +import { useLayoutEffect, useRef, type RefObject } from 'react'; +import type { AlbumBrowseScrollSnapshot } from './useAlbumBrowseFilters'; + +type Args = { + scrollSnapshotRef: RefObject; + 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]); +} diff --git a/src/hooks/useAlbumGridBrowseFilters.ts b/src/hooks/useAlbumGridBrowseFilters.ts index d8a42d00..1379ee7c 100644 --- a/src/hooks/useAlbumGridBrowseFilters.ts +++ b/src/hooks/useAlbumGridBrowseFilters.ts @@ -10,6 +10,7 @@ import { useAlbumBrowseSessionStore, } from '../store/albumBrowseSessionStore'; import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation'; +import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore'; import { inpageScrollViewportIdForSurface, readInpageScrollTop, @@ -53,8 +54,11 @@ export function useAlbumGridBrowseFilters( const [selectedGenres, setSelectedGenres] = useState(() => initialState.selectedGenres); const restoredFromStashRef = useRef(false); - const filtersRef = useRef({ selectedGenres }); - filtersRef.current = { selectedGenres }; + const filtersRef = useRef({ selectedGenres, searchQuery: '' }); + filtersRef.current = { + selectedGenres, + searchQuery: useLiveSearchScopeStore.getState().query, + }; useEffect(() => { restoredFromStashRef.current = false; @@ -66,8 +70,11 @@ export function useAlbumGridBrowseFilters( if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) { restoredFromStashRef.current = true; const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, surface); - if (restored && !sameGenreSelection(restored.selectedGenres, filtersRef.current.selectedGenres)) { - setSelectedGenres(restored.selectedGenres); + if (restored) { + useLiveSearchScopeStore.getState().setQuery(restored.searchQuery ?? ''); + if (!sameGenreSelection(restored.selectedGenres, filtersRef.current.selectedGenres)) { + setSelectedGenres(restored.selectedGenres); + } } return; } @@ -75,6 +82,7 @@ export function useAlbumGridBrowseFilters( if (restoredFromStashRef.current) return; useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface); + useLiveSearchScopeStore.getState().setQuery(''); setSelectedGenres([]); }, [serverId, surface, navigationType, location.state]); @@ -93,6 +101,7 @@ export function useAlbumGridBrowseFilters( useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, surface, { ...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS, selectedGenres: filtersRef.current.selectedGenres, + searchQuery: filtersRef.current.searchQuery, scrollTop, displayCount: gridSnapshot?.albums.length ?? scrollSnapshot?.displayCount, albums: gridSnapshot?.albums, diff --git a/src/hooks/useArtistsBrowseFilters.ts b/src/hooks/useArtistsBrowseFilters.ts index 4eba4431..bd1dc4ad 100644 --- a/src/hooks/useArtistsBrowseFilters.ts +++ b/src/hooks/useArtistsBrowseFilters.ts @@ -10,6 +10,7 @@ import { } from '../store/artistBrowseSessionStore'; import { isArtistDetailPath } from '../store/albumBrowseSessionStore'; import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation'; +import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore'; export type ArtistBrowseScrollSnapshot = { scrollTop: number; @@ -38,9 +39,6 @@ export function useArtistsBrowseFilters( const location = useLocation(); const setShowArtistImages = useAuthStore(s => s.setShowArtistImages); - const [filter, setFilter] = useState( - () => returnStateForNavigation(serverId, navigationType, location.state).filter, - ); const [letterFilter, setLetterFilter] = useState( () => returnStateForNavigation(serverId, navigationType, location.state).letterFilter, ); @@ -56,7 +54,7 @@ export function useArtistsBrowseFilters( const showArtistImages = useAuthStore(s => s.showArtistImages); browseStateRef.current = { - filter, + filter: useLiveSearchScopeStore.getState().query, letterFilter, starredOnly, viewMode, @@ -74,7 +72,7 @@ export function useArtistsBrowseFilters( restoredFromStashRef.current = true; const restored = useArtistBrowseSessionStore.getState().peekReturnStash(serverId); if (restored) { - setFilter(restored.filter); + useLiveSearchScopeStore.getState().setQuery(restored.filter); setLetterFilter(restored.letterFilter); setStarredOnly(restored.starredOnly); setViewMode(restored.viewMode); @@ -86,7 +84,7 @@ export function useArtistsBrowseFilters( if (restoredFromStashRef.current) return; useArtistBrowseSessionStore.getState().clearReturnStash(serverId); - setFilter(''); + useLiveSearchScopeStore.getState().setQuery(''); setLetterFilter(DEFAULT_ARTIST_BROWSE_RETURN_STATE.letterFilter); setStarredOnly(false); setViewMode('grid'); @@ -110,8 +108,6 @@ export function useArtistsBrowseFilters( }, [serverId, scrollSnapshotRef]); return { - filter, - setFilter, letterFilter, setLetterFilter, starredOnly, diff --git a/src/hooks/useArtistsBrowseScrollReset.ts b/src/hooks/useArtistsBrowseScrollReset.ts new file mode 100644 index 00000000..e093abff --- /dev/null +++ b/src/hooks/useArtistsBrowseScrollReset.ts @@ -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; + getScrollRoot: () => HTMLElement | null; + isScrollRestorePending: boolean; + resetKey: string; + viewMode: 'grid' | 'list'; + listVirtualize: boolean; + listVirtualizer: Virtualizer; +}; + +/** 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, + ]); +} diff --git a/src/hooks/useBrowseAlbumTextSearch.ts b/src/hooks/useBrowseAlbumTextSearch.ts new file mode 100644 index 00000000..a9a99df0 --- /dev/null +++ b/src/hooks/useBrowseAlbumTextSearch.ts @@ -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(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 }; +} diff --git a/src/hooks/useComposersBrowseFilters.ts b/src/hooks/useComposersBrowseFilters.ts new file mode 100644 index 00000000..283ff452 --- /dev/null +++ b/src/hooks/useComposersBrowseFilters.ts @@ -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, +) { + 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( + () => returnStateForNavigation(serverId, navigationType, location.state).viewMode, + ); + + const browseStateRef = useRef(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, + }; +} diff --git a/src/hooks/useComposersBrowseScrollRestore.ts b/src/hooks/useComposersBrowseScrollRestore.ts new file mode 100644 index 00000000..bbe45459 --- /dev/null +++ b/src/hooks/useComposersBrowseScrollRestore.ts @@ -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(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 }; +} diff --git a/src/hooks/useLiveSearchRouteScope.test.ts b/src/hooks/useLiveSearchRouteScope.test.ts new file mode 100644 index 00000000..53fac976 --- /dev/null +++ b/src/hooks/useLiveSearchRouteScope.test.ts @@ -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'); + }); +}); diff --git a/src/hooks/useLiveSearchRouteScope.ts b/src/hooks/useLiveSearchRouteScope.ts new file mode 100644 index 00000000..1a5b67e8 --- /dev/null +++ b/src/hooks/useLiveSearchRouteScope.ts @@ -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]); +} diff --git a/src/hooks/useNavigateToComposer.ts b/src/hooks/useNavigateToComposer.ts new file mode 100644 index 00000000..01cf8e52 --- /dev/null +++ b/src/hooks/useNavigateToComposer.ts @@ -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], + ); +} diff --git a/src/hooks/useSongBrowseList.test.ts b/src/hooks/useSongBrowseList.test.ts new file mode 100644 index 00000000..0a649abe --- /dev/null +++ b/src/hooks/useSongBrowseList.test.ts @@ -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 }); + }); +}); diff --git a/src/hooks/useSongBrowseList.ts b/src/hooks/useSongBrowseList.ts index 9d6af37b..d635467c 100644 --- a/src/hooks/useSongBrowseList.ts +++ b/src/hooks/useSongBrowseList.ts @@ -42,16 +42,19 @@ export type SongBrowseListRestore = { type UseSongBrowseListArgs = { enabled: boolean; + /** Header scoped browse query (wide title/artist/album search). */ + searchQuery: string; initialRestore?: SongBrowseListRestore | null; }; /** Tracks hub song browse — all-library paging or filtered text search. */ -export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseListArgs) { +export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseSongBrowseListArgs) { const serverId = useAuthStore(s => s.activeServerId); const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); - const [query, setQuery] = useState(() => initialRestore?.query ?? ''); - const [debouncedQuery, setDebouncedQuery] = useState(() => initialRestore?.query.trim() ?? ''); + const [debouncedQuery, setDebouncedQuery] = useState( + () => initialRestore?.query.trim() ?? searchQuery.trim(), + ); const [songs, setSongs] = useState(() => initialRestore?.songs ?? []); const [offset, setOffset] = useState(() => initialRestore?.offset ?? 0); const [loading, setLoading] = useState(false); @@ -63,14 +66,25 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList const requestSeqRef = useRef(0); const localSearchModeRef = useRef(initialRestore?.localSearchMode ?? false); - const skipInitialFetchRef = useRef(initialRestore != null); + /** Keep stashed songs until the user edits the scoped query (survives fetchSongPage identity changes). */ + const holdRestoredListRef = useRef(initialRestore != null); + const heldRestoredQueryRef = useRef(initialRestore?.query.trim() ?? ''); + + const restoreQueryHoldRef = useRef( + initialRestore?.query.trim() ? initialRestore.query.trim() : null, + ); useEffect(() => { if (!enabled) return; + const incoming = searchQuery.trim(); + if (incoming !== '') { + restoreQueryHoldRef.current = null; + } + const effectiveQuery = incoming || restoreQueryHoldRef.current || ''; const debounceMs = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS; - const timer = window.setTimeout(() => setDebouncedQuery(query.trim()), debounceMs); + const timer = window.setTimeout(() => setDebouncedQuery(effectiveQuery), debounceMs); return () => window.clearTimeout(timer); - }, [query, indexEnabled, enabled]); + }, [searchQuery, indexEnabled, enabled]); const fetchSongPage = useCallback( async (q: string, pageOffset: number, isStale: () => boolean): Promise => { @@ -114,9 +128,14 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList useEffect(() => { if (!enabled) return; - if (skipInitialFetchRef.current) { - skipInitialFetchRef.current = false; - return; + + if (holdRestoredListRef.current) { + const expected = heldRestoredQueryRef.current; + if (searchQuery.trim() !== expected || debouncedQuery !== expected) { + holdRestoredListRef.current = false; + } else { + return; + } } let cancelled = false; @@ -152,7 +171,7 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList return () => { cancelled = true; }; - }, [debouncedQuery, fetchSongPage, enabled]); + }, [debouncedQuery, searchQuery, fetchSongPage, enabled]); const loadMore = useCallback(async () => { if (!enabled || loading || !hasMore) return; @@ -182,8 +201,6 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList }, [enabled, loading, hasMore, debouncedQuery, offset, fetchSongPage]); return { - query, - setQuery, songs, offset, loading, diff --git a/src/locales/de/search.ts b/src/locales/de/search.ts index 8a19de03..d85849d2 100644 --- a/src/locales/de/search.ts +++ b/src/locales/de/search.ts @@ -39,6 +39,21 @@ export const search = { recentSearches: 'Zuletzt gesucht', browse: 'Stöbern', emptyHint: 'Was möchtest du hören?', + scopeArtistsPlaceholder: 'Künstler suchen…', + scopeArtistsBadgeTooltip: 'Klicken zum Entfernen', + scopeArtistsGhostTooltip: 'Klicken — nur auf dieser Seite suchen', + scopeAlbumsPlaceholder: 'Album suchen…', + scopeAlbumsBadgeTooltip: 'Klicken zum Entfernen', + scopeAlbumsGhostTooltip: 'Klicken — nur auf dieser Seite suchen', + scopeNewReleasesPlaceholder: 'Neuerscheinungen suchen…', + scopeNewReleasesBadgeTooltip: 'Klicken zum Entfernen', + scopeNewReleasesGhostTooltip: 'Klicken — nur auf dieser Seite suchen', + scopeTracksPlaceholder: 'Titel, Künstler oder Album suchen…', + scopeTracksBadgeTooltip: 'Klicken zum Entfernen', + scopeTracksGhostTooltip: 'Klicken — nur auf dieser Seite suchen', + scopeComposersPlaceholder: 'Komponist suchen…', + scopeComposersBadgeTooltip: 'Klicken — entfernen', + scopeComposersGhostTooltip: 'Klicken — nur auf dieser Seite suchen', genres: 'Genres', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/en/search.ts b/src/locales/en/search.ts index 299f2f33..d00ffcd3 100644 --- a/src/locales/en/search.ts +++ b/src/locales/en/search.ts @@ -39,6 +39,21 @@ export const search = { recentSearches: 'Recent Searches', browse: 'Browse', emptyHint: 'What do you want to hear?', + scopeArtistsPlaceholder: 'Search for artist…', + scopeArtistsBadgeTooltip: 'Click to remove', + scopeArtistsGhostTooltip: 'Click to search on this page only', + scopeAlbumsPlaceholder: 'Search for album…', + scopeAlbumsBadgeTooltip: 'Click to remove', + scopeAlbumsGhostTooltip: 'Click to search on this page only', + scopeNewReleasesPlaceholder: 'Search new releases…', + scopeNewReleasesBadgeTooltip: 'Click to remove', + scopeNewReleasesGhostTooltip: 'Click to search on this page only', + scopeTracksPlaceholder: 'Find a track by title, artist or album…', + scopeTracksBadgeTooltip: 'Click to remove', + scopeTracksGhostTooltip: 'Click to search on this page only', + scopeComposersPlaceholder: 'Search for composer…', + scopeComposersBadgeTooltip: 'Click to remove', + scopeComposersGhostTooltip: 'Click to search on this page only', genres: 'Genres', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/es/search.ts b/src/locales/es/search.ts index bec0eae2..b95b41d9 100644 --- a/src/locales/es/search.ts +++ b/src/locales/es/search.ts @@ -39,6 +39,21 @@ export const search = { recentSearches: 'Búsquedas Recientes', browse: 'Explorar', emptyHint: '¿Qué quieres escuchar?', + scopeArtistsPlaceholder: 'Buscar artista…', + scopeArtistsBadgeTooltip: 'Clic para quitar', + scopeArtistsGhostTooltip: 'Clic — buscar solo en esta página', + scopeAlbumsPlaceholder: 'Buscar álbum…', + scopeAlbumsBadgeTooltip: 'Clic para quitar', + scopeAlbumsGhostTooltip: 'Clic — buscar solo en esta página', + scopeNewReleasesPlaceholder: 'Buscar novedades…', + scopeNewReleasesBadgeTooltip: 'Clic para quitar', + scopeNewReleasesGhostTooltip: 'Clic — buscar solo en esta página', + scopeTracksPlaceholder: 'Busca una canción por título, artista o álbum…', + scopeTracksBadgeTooltip: 'Clic para quitar', + scopeTracksGhostTooltip: 'Clic — buscar solo en esta página', + scopeComposersPlaceholder: 'Buscar compositor…', + scopeComposersBadgeTooltip: 'Clic — quitar', + scopeComposersGhostTooltip: 'Clic — buscar solo en esta página', genres: 'Géneros', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/fr/search.ts b/src/locales/fr/search.ts index 620a4c22..67a2bb51 100644 --- a/src/locales/fr/search.ts +++ b/src/locales/fr/search.ts @@ -39,6 +39,21 @@ export const search = { recentSearches: 'Recherches récentes', browse: 'Parcourir', emptyHint: 'Que veux-tu écouter ?', + scopeArtistsPlaceholder: 'Rechercher un artiste…', + scopeArtistsBadgeTooltip: 'Clic pour retirer', + scopeArtistsGhostTooltip: 'Clic — rechercher sur cette page seulement', + scopeAlbumsPlaceholder: 'Rechercher un album…', + scopeAlbumsBadgeTooltip: 'Clic pour retirer', + scopeAlbumsGhostTooltip: 'Clic — rechercher sur cette page seulement', + scopeNewReleasesPlaceholder: 'Rechercher dans les nouveautés…', + scopeNewReleasesBadgeTooltip: 'Clic pour retirer', + scopeNewReleasesGhostTooltip: 'Clic — rechercher sur cette page seulement', + scopeTracksPlaceholder: 'Chercher un titre par titre, artiste ou album…', + scopeTracksBadgeTooltip: 'Clic pour retirer', + scopeTracksGhostTooltip: 'Clic — rechercher sur cette page seulement', + scopeComposersPlaceholder: 'Rechercher un compositeur…', + scopeComposersBadgeTooltip: 'Clic — retirer', + scopeComposersGhostTooltip: 'Clic — rechercher sur cette page seulement', genres: 'Genres', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/nb/search.ts b/src/locales/nb/search.ts index 39dc835e..604c3e6b 100644 --- a/src/locales/nb/search.ts +++ b/src/locales/nb/search.ts @@ -39,6 +39,21 @@ export const search = { recentSearches: 'Siste søk', browse: 'Utforsk', emptyHint: 'Hva vil du høre?', + scopeArtistsPlaceholder: 'Søk etter artist…', + scopeArtistsBadgeTooltip: 'Klikk for å fjerne', + scopeArtistsGhostTooltip: 'Klikk — søk bare på denne siden', + scopeAlbumsPlaceholder: 'Søk etter album…', + scopeAlbumsBadgeTooltip: 'Klikk for å fjerne', + scopeAlbumsGhostTooltip: 'Klikk — søk bare på denne siden', + scopeNewReleasesPlaceholder: 'Søk i nye utgivelser…', + scopeNewReleasesBadgeTooltip: 'Klikk for å fjerne', + scopeNewReleasesGhostTooltip: 'Klikk — søk bare på denne siden', + scopeTracksPlaceholder: 'Finn et spor etter tittel, artist eller album…', + scopeTracksBadgeTooltip: 'Klikk for å fjerne', + scopeTracksGhostTooltip: 'Klikk — søk bare på denne siden', + scopeComposersPlaceholder: 'Søk komponist…', + scopeComposersBadgeTooltip: 'Klikk — fjern', + scopeComposersGhostTooltip: 'Klikk — søk bare på denne siden', genres: 'Sjangre', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/nl/search.ts b/src/locales/nl/search.ts index b7f9d523..46835a97 100644 --- a/src/locales/nl/search.ts +++ b/src/locales/nl/search.ts @@ -39,6 +39,21 @@ export const search = { recentSearches: 'Recente zoekopdrachten', browse: 'Bladeren', emptyHint: 'Wat wil je horen?', + scopeArtistsPlaceholder: 'Artiest zoeken…', + scopeArtistsBadgeTooltip: 'Klik om te verwijderen', + scopeArtistsGhostTooltip: 'Klik — alleen op deze pagina zoeken', + scopeAlbumsPlaceholder: 'Album zoeken…', + scopeAlbumsBadgeTooltip: 'Klik om te verwijderen', + scopeAlbumsGhostTooltip: 'Klik — alleen op deze pagina zoeken', + scopeNewReleasesPlaceholder: 'Zoek in nieuwe releases…', + scopeNewReleasesBadgeTooltip: 'Klik om te verwijderen', + scopeNewReleasesGhostTooltip: 'Klik — alleen op deze pagina zoeken', + scopeTracksPlaceholder: 'Zoek op titel, artiest of album…', + scopeTracksBadgeTooltip: 'Klik om te verwijderen', + scopeTracksGhostTooltip: 'Klik — alleen op deze pagina zoeken', + scopeComposersPlaceholder: 'Componist zoeken…', + scopeComposersBadgeTooltip: 'Klik — verwijderen', + scopeComposersGhostTooltip: 'Klik — alleen op deze pagina zoeken', genres: 'Genres', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/ro/search.ts b/src/locales/ro/search.ts index 23a216ea..2767f0c5 100644 --- a/src/locales/ro/search.ts +++ b/src/locales/ro/search.ts @@ -39,6 +39,21 @@ export const search = { recentSearches: 'Căutări Recente', browse: 'Răsfoiește', emptyHint: 'Ce vrei să auzi?', + scopeArtistsPlaceholder: 'Caută artist…', + scopeArtistsBadgeTooltip: 'Clic pentru a elimina', + scopeArtistsGhostTooltip: 'Clic — caută doar pe această pagină', + scopeAlbumsPlaceholder: 'Caută album…', + scopeAlbumsBadgeTooltip: 'Clic pentru a elimina', + scopeAlbumsGhostTooltip: 'Clic — caută doar pe această pagină', + scopeNewReleasesPlaceholder: 'Caută în lansări noi…', + scopeNewReleasesBadgeTooltip: 'Clic pentru a elimina', + scopeNewReleasesGhostTooltip: 'Clic — caută doar pe această pagină', + scopeTracksPlaceholder: 'Găsește o piesă după titlu, artist sau album…', + scopeTracksBadgeTooltip: 'Clic pentru a elimina', + scopeTracksGhostTooltip: 'Clic — caută doar pe această pagină', + scopeComposersPlaceholder: 'Caută compozitor…', + scopeComposersBadgeTooltip: 'Clic — elimină', + scopeComposersGhostTooltip: 'Clic — caută doar pe această pagină', genres: 'Genuri', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/ru/search.ts b/src/locales/ru/search.ts index 7923c18f..a1e74150 100644 --- a/src/locales/ru/search.ts +++ b/src/locales/ru/search.ts @@ -39,6 +39,21 @@ export const search = { recentSearches: 'Недавние запросы', browse: 'Обзор', emptyHint: 'Что хочешь послушать?', + scopeArtistsPlaceholder: 'Поиск исполнителя…', + scopeArtistsBadgeTooltip: 'Щелчок — удалить', + scopeArtistsGhostTooltip: 'Щелчок — искать только на этой странице', + scopeAlbumsPlaceholder: 'Поиск альбома…', + scopeAlbumsBadgeTooltip: 'Щелчок — удалить', + scopeAlbumsGhostTooltip: 'Щелчок — искать только на этой странице', + scopeNewReleasesPlaceholder: 'Поиск в новинках…', + scopeNewReleasesBadgeTooltip: 'Щелчок — удалить', + scopeNewReleasesGhostTooltip: 'Щелчок — искать только на этой странице', + scopeTracksPlaceholder: 'Найти трек по названию, исполнителю или альбому…', + scopeTracksBadgeTooltip: 'Щелчок — удалить', + scopeTracksGhostTooltip: 'Щелчок — искать только на этой странице', + scopeComposersPlaceholder: 'Поиск композитора…', + scopeComposersBadgeTooltip: 'Щелчок — удалить', + scopeComposersGhostTooltip: 'Щелчок — искать только на этой странице', genres: 'Жанры', shareLink: 'Ссылка для обмена', shareTrackTitle: 'Общий трек', diff --git a/src/locales/zh/search.ts b/src/locales/zh/search.ts index 9c71a7a6..ba35fd43 100644 --- a/src/locales/zh/search.ts +++ b/src/locales/zh/search.ts @@ -39,6 +39,21 @@ export const search = { recentSearches: '最近搜索', browse: '浏览', emptyHint: '你想听什么?', + scopeArtistsPlaceholder: '搜索艺术家…', + scopeArtistsBadgeTooltip: '单击移除', + scopeArtistsGhostTooltip: '单击 — 仅在此页面搜索', + scopeAlbumsPlaceholder: '搜索专辑…', + scopeAlbumsBadgeTooltip: '单击移除', + scopeAlbumsGhostTooltip: '单击 — 仅在此页面搜索', + scopeNewReleasesPlaceholder: '搜索新发布…', + scopeNewReleasesBadgeTooltip: '单击移除', + scopeNewReleasesGhostTooltip: '单击 — 仅在此页面搜索', + scopeTracksPlaceholder: '按标题、艺人或专辑搜索…', + scopeTracksBadgeTooltip: '单击移除', + scopeTracksGhostTooltip: '单击 — 仅在此页面搜索', + scopeComposersPlaceholder: '搜索作曲家…', + scopeComposersBadgeTooltip: '单击 — 移除', + scopeComposersGhostTooltip: '单击 — 仅在此页面搜索', genres: '流派', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index 3890ea55..d4aed6e5 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -36,11 +36,21 @@ import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useAlbumBrowseFilters, useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters'; import { useAlbumBrowseData } from '../hooks/useAlbumBrowseData'; import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore'; +import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset'; +import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch'; import { peekAlbumBrowseScrollRestore } from '../store/albumBrowseSessionStore'; import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation'; import { useAlbumCatalogYearBounds } from '../hooks/useAlbumCatalogYearBounds'; import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort'; import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode'; +import { resolveAlbumYearBounds } from '../utils/library/albumYearFilter'; +import { + filterAlbumsByCompilation, + filterAlbumsByGenres, + filterAlbumsByStarred, + filterAlbumsByYearBounds, +} from '../utils/library/albumBrowseFilters'; +import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore'; type SortType = AlbumBrowseSort; @@ -81,6 +91,14 @@ export default function Albums() { setLosslessOnly, } = useAlbumBrowseFilters(serverId, scrollSnapshotRef); + const albumsSearchQuery = useScopedBrowseSearchQuery('albums'); + const { textSearchAlbums, textSearchLoading } = useBrowseAlbumTextSearch( + albumsSearchQuery, + indexEnabled, + serverId, + losslessOnly, + ); + const { scrollBodyEl, bindScrollBody: bindAlbumsScrollBody, @@ -88,24 +106,7 @@ export default function Albums() { } = useInpageScrollViewport(); const starredOverrides = usePlayerStore(s => s.starredOverrides); - const { - albums, - loading, - loadingMore, - hasMore, - displayAlbums, - visibleAlbums, - genreFiltered, - serverFilterActive, - narrowGenreList, - genreCatalogOptions, - yearFilterActive, - debouncedYearFields, - compFilterActive, - pendingClientFilterMatch, - bindLoadMoreSentinel, - loadMore, - } = useAlbumBrowseData({ + const browseData = useAlbumBrowseData({ serverId, indexEnabled, musicLibraryFilterVersion, @@ -122,6 +123,55 @@ export default function Albums() { restoreDisplayCount: restoreDisplayCountRef.current, }); + const textSearchActive = textSearchAlbums != null; + const albumBrowsePlainLayout = + perfFlags.disableMainstageVirtualLists + || textSearchActive + || albumsSearchQuery.trim().length > 0; + + const textSearchYearBounds = useMemo( + () => resolveAlbumYearBounds(browseData.debouncedYearFields.from, browseData.debouncedYearFields.to), + [browseData.debouncedYearFields.from, browseData.debouncedYearFields.to], + ); + + const textSearchVisibleAlbums = useMemo(() => { + if (!textSearchActive || !textSearchAlbums) return null; + let out = textSearchAlbums; + if (selectedGenres.length > 0) out = filterAlbumsByGenres(out, selectedGenres); + if (textSearchYearBounds.active) out = filterAlbumsByYearBounds(out, textSearchYearBounds.bounds); + if (compFilter !== 'all') out = filterAlbumsByCompilation(out, compFilter); + if (starredOnly) out = filterAlbumsByStarred(out, starredOverrides); + return out; + }, [ + textSearchActive, + textSearchAlbums, + selectedGenres, + textSearchYearBounds.active, + textSearchYearBounds.bounds, + compFilter, + starredOnly, + starredOverrides, + ]); + + const albums = textSearchActive ? (textSearchAlbums ?? []) : browseData.albums; + const loading = textSearchActive ? textSearchLoading : browseData.loading; + const loadingMore = textSearchActive ? false : browseData.loadingMore; + const hasMore = textSearchActive ? false : browseData.hasMore; + const displayAlbums = textSearchActive ? (textSearchVisibleAlbums ?? []) : browseData.displayAlbums; + const visibleAlbums = textSearchActive ? (textSearchVisibleAlbums ?? []) : browseData.visibleAlbums; + const genreFiltered = textSearchActive ? selectedGenres.length > 0 : browseData.genreFiltered; + const serverFilterActive = textSearchActive + ? selectedGenres.length > 0 || textSearchYearBounds.active || losslessOnly || starredOnly + : browseData.serverFilterActive; + const narrowGenreList = browseData.narrowGenreList; + const genreCatalogOptions = browseData.genreCatalogOptions; + const yearFilterActive = browseData.yearFilterActive; + const debouncedYearFields = browseData.debouncedYearFields; + const compFilterActive = browseData.compFilterActive; + const pendingClientFilterMatch = textSearchActive ? false : browseData.pendingClientFilterMatch; + const bindLoadMoreSentinel = browseData.bindLoadMoreSentinel; + const loadMore = browseData.loadMore; + useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length); const { isScrollRestorePending } = useAlbumBrowseScrollRestore({ @@ -135,6 +185,22 @@ export default function Albums() { loadMore, }); + useAlbumBrowseScrollReset({ + scrollSnapshotRef, + getScrollRoot, + isScrollRestorePending, + resetKey: [ + albumsSearchQuery, + sort, + selectedGenres.join('\u0001'), + yearFilterActive ? `${debouncedYearFields.from}:${debouncedYearFields.to}` : '', + compFilter, + starredOnly, + losslessOnly, + serverId, + ].join('|'), + }); + const location = useLocation(); const navigate = useNavigate(); useEffect(() => { @@ -266,6 +332,7 @@ export default function Albums() { ); const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ + albumsSearchQuery, sort, genreFiltered, yearFilterActive, @@ -394,8 +461,9 @@ export default function Albums() { hasMore, selectionMode, sort, + albumsSearchQuery, perfFlags.disableMainstageGridCards, - perfFlags.disableMainstageVirtualLists, + albumBrowsePlainLayout, ]} > {loading && albums.length === 0 ? ( @@ -418,6 +486,10 @@ export default function Albums() {
{visibleEmptyMessage}
+ ) : !loading && textSearchActive && visibleAlbums.length === 0 ? ( +
+ {t('albums.noMatchingFilters')} +
) : (
@@ -427,7 +499,7 @@ export default function Albums() { items={displayAlbums} itemKey={(a, _i) => a.id} rowVariant="album" - disableVirtualization={perfFlags.disableMainstageVirtualLists} + disableVirtualization={albumBrowsePlainLayout} layoutSignal={displayAlbums.length} scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID} warmGridCovers={albumGridWarmCovers( diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index 4c68d23e..5b646581 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -9,8 +9,6 @@ import { useTranslation } from 'react-i18next'; import { useVirtualizer } from '@tanstack/react-virtual'; import { APP_MAIN_SCROLL_VIEWPORT_ID, ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight'; -import { useCardGridMetrics } from '../hooks/useCardGridMetrics'; -import { useRemeasureGridVirtualizer } from '../hooks/useRemeasureGridVirtualizer'; import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { @@ -32,10 +30,12 @@ import { ArtistsListView } from '../components/artists/ArtistsListView'; import InpageScrollSentinel from '../components/InpageScrollSentinel'; import { useArtistsBrowseFilters, type ArtistBrowseScrollSnapshot } from '../hooks/useArtistsBrowseFilters'; import { useArtistsBrowseScrollRestore } from '../hooks/useArtistsBrowseScrollRestore'; +import { useArtistsBrowseScrollReset } from '../hooks/useArtistsBrowseScrollReset'; import { useNavigateToArtist } from '../hooks/useNavigateToArtist'; import { peekArtistBrowseScrollRestore } from '../store/artistBrowseSessionStore'; import { readArtistBrowseRestore } from '../utils/navigation/albumDetailNavigation'; +import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; export default function Artists() { @@ -51,8 +51,6 @@ export default function Artists() { ); const { - filter, - setFilter, letterFilter, setLetterFilter, starredOnly, @@ -61,6 +59,8 @@ export default function Artists() { setViewMode, } = useArtistsBrowseFilters(serverId, scrollSnapshotRef); + const artistsSearchQuery = useScopedBrowseSearchQuery('artists'); + const { scrollBodyEl: artistsScrollBodyEl, bindScrollBody: bindArtistsScrollBody, @@ -91,13 +91,18 @@ export default function Artists() { }); const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch( - filter, + artistsSearchQuery, indexEnabled, serverId, ); const artists = textSearchArtists ?? catalogArtists; const loading = catalogLoading || textSearchLoading; const textSearchActive = textSearchArtists != null; + /** Scoped/plain text filter — canonical CSS grid, not row virtualization (small result sets). */ + const artistBrowsePlainLayout = + perfFlags.disableMainstageVirtualLists + || textSearchActive + || artistsSearchQuery.trim().length > 0; const { visibleCount, @@ -105,7 +110,7 @@ export default function Artists() { loadMore: sliceLoadMore, } = useClientSliceInfiniteScroll({ pageSize: PAGE_SIZE, - resetDeps: [filter, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId], + resetDeps: [artistsSearchQuery, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId], getScrollRoot: getArtistsScrollRoot, scrollRootEl: artistsScrollBodyEl, restoreDisplayCount: restoreVisibleCountRef.current, @@ -224,7 +229,7 @@ export default function Artists() { }); const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [ - filter, + artistsSearchQuery, letterFilter, starredOnly, viewMode, @@ -243,48 +248,6 @@ export default function Artists() { [getArtistsScrollRoot], ); - const artistGridMeasureRef = useRef(null); - const { gridCols: artistGridCols, rowHeightEst: artistGridRowHeightEst } = useCardGridMetrics( - artistGridMeasureRef, - viewMode === 'grid', - 'artist', - visible.length, - ); - - const artistVirtualRowCount = Math.max(0, Math.ceil(visible.length / Math.max(1, artistGridCols))); - - const artistGridOverscan = Math.max( - 2, - Math.ceil(artistsInpageScrollHeight / Math.max(1, artistGridRowHeightEst)), - ); - - const artistGridScrollMargin = useVirtualizerScrollMargin( - artistGridMeasureRef, - getInpageScrollElement, - { - active: !perfFlags.disableMainstageVirtualLists && viewMode === 'grid', - deps: [artistVirtualRowCount, artistGridCols], - }, - ); - - const artistGridVirtualizer = useVirtualizer({ - count: - perfFlags.disableMainstageVirtualLists || viewMode !== 'grid' - ? 0 - : artistVirtualRowCount, - getScrollElement: getInpageScrollElement, - estimateSize: () => artistGridRowHeightEst, - overscan: artistGridOverscan, - scrollMargin: artistGridScrollMargin, - }); - - useRemeasureGridVirtualizer(artistGridVirtualizer, { - active: !perfFlags.disableMainstageVirtualLists && viewMode === 'grid' && artistVirtualRowCount > 0, - gridCols: artistGridCols, - rowHeightEst: artistGridRowHeightEst, - virtualRowCount: artistVirtualRowCount, - }); - const artistListOverscan = Math.max( 12, Math.ceil(artistsInpageScrollHeight / ARTIST_LIST_ROW_EST), @@ -295,14 +258,14 @@ export default function Artists() { artistListWrapRef, getInpageScrollElement, { - active: !perfFlags.disableMainstageVirtualLists && viewMode === 'list', + active: !artistBrowsePlainLayout && viewMode === 'list', deps: [artistListFlatRows.length], }, ); const artistListVirtualizer = useVirtualizer({ count: - perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : artistListFlatRows.length, + artistBrowsePlainLayout || viewMode !== 'list' ? 0 : artistListFlatRows.length, getScrollElement: getInpageScrollElement, estimateSize: index => { const row = artistListFlatRows[index]; @@ -320,6 +283,27 @@ export default function Artists() { scrollMargin: artistListScrollMargin, }); + const browseScrollResetKey = [ + artistsSearchQuery, + letterFilter, + starredOnly, + viewMode, + serverId, + musicLibraryFilterVersion, + textSearchArtists?.length ?? '', + textSearchArtists?.[0]?.id ?? '', + ].join('\0'); + + useArtistsBrowseScrollReset({ + scrollSnapshotRef, + getScrollRoot: getArtistsScrollRoot, + isScrollRestorePending, + resetKey: browseScrollResetKey, + viewMode, + listVirtualize: !artistBrowsePlainLayout, + listVirtualizer: artistListVirtualizer, + }); + return (
- setFilter(e.target.value)} - id="artist-filter-input" - /> {textSearchLoading && (
)} @@ -432,13 +408,8 @@ export default function Artists() { {!loading && !pendingLetterMatch && viewMode === 'grid' && ( (null); const [reloadTick, setReloadTick] = useState(0); - const [filter, setFilter] = useState(''); - const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL); - const [starredOnly, setStarredOnly] = useState(false); - const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + + const scrollSnapshotRef = useRef({ scrollTop: 0, visibleCount: 0 }); + const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const serverId = useAuthStore(s => s.activeServerId ?? ''); + const restoreVisibleCountRef = useRef( + peekComposerBrowseScrollRestore(serverId)?.visibleCount, + ); + + const { + letterFilter, + setLetterFilter, + starredOnly, + setStarredOnly, + viewMode, + setViewMode, + } = useComposersBrowseFilters(serverId, scrollSnapshotRef); + + const composersSearchQuery = useScopedBrowseSearchQuery('composers'); // Compact tiles + initial-letter only → 200 per page is comfortable. const PAGE_SIZE = 200; @@ -89,28 +110,35 @@ export default function Composers() { bindScrollBody: bindComposersScrollBody, getScrollRoot, } = useInpageScrollViewport(); + const location = useLocation(); const navigate = useNavigate(); + const navigateToComposer = useNavigateToComposer(); const openContextMenu = usePlayerStore(state => state.openContextMenu); - const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); - const serverId = useAuthStore(s => s.activeServerId); const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch( - filter, + composersSearchQuery, indexEnabled, serverId, 'composers_browse', ); const composerSource = textSearchArtists ?? composers; + const textSearchActive = textSearchArtists != null; + const composerBrowsePlainLayout = + perfFlags.disableMainstageVirtualLists + || textSearchActive + || composersSearchQuery.trim().length > 0; const { visibleCount, loadingMore, bindSentinel, + loadMore: sliceLoadMore, } = useClientSliceInfiniteScroll({ pageSize: PAGE_SIZE, - resetDeps: [letterFilter, effectiveFilter, starredOnly, viewMode, composerSource], + resetDeps: [composersSearchQuery, letterFilter, starredOnly, viewMode, composerSource, serverId], getScrollRoot, scrollRootEl: scrollBodyEl, + restoreDisplayCount: restoreVisibleCountRef.current, }); useEffect(() => { @@ -165,6 +193,26 @@ export default function Composers() { const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]); const hasMore = visibleCount < filtered.length; + scrollSnapshotRef.current = { + scrollTop: scrollBodyEl?.scrollTop ?? 0, + visibleCount, + }; + + const { isScrollRestorePending } = useComposersBrowseScrollRestore({ + serverId, + scrollBodyEl, + visibleCount, + loading: loading || textSearchLoading, + loadingMore, + hasMore, + loadMore: sliceLoadMore, + }); + + useEffect(() => { + if (isScrollRestorePending || !readComposerBrowseRestore(location.state)) return; + navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null }); + }, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]); + const { groups, letters } = useMemo(() => { if (viewMode !== 'list') return { groups: {} as Record, letters: [] as string[] }; const g: Record = {}; @@ -213,14 +261,14 @@ export default function Composers() { composerListWrapRef, getInpageScrollElement, { - active: !perfFlags.disableMainstageVirtualLists && viewMode === 'list', + active: !composerBrowsePlainLayout && viewMode === 'list', deps: [composerListFlatRows.length], }, ); const composerListVirtualizer = useVirtualizer({ count: - perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : composerListFlatRows.length, + composerBrowsePlainLayout || viewMode !== 'list' ? 0 : composerListFlatRows.length, getScrollElement: getInpageScrollElement, estimateSize: index => { const row = composerListFlatRows[index]; @@ -239,12 +287,33 @@ export default function Composers() { }); const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ - filter, + composersSearchQuery, letterFilter, starredOnly, viewMode, ]); + const browseScrollResetKey = [ + composersSearchQuery, + letterFilter, + starredOnly, + viewMode, + serverId, + musicLibraryFilterVersion, + textSearchArtists?.length ?? '', + textSearchArtists?.[0]?.id ?? '', + ].join('\0'); + + useArtistsBrowseScrollReset({ + scrollSnapshotRef, + getScrollRoot, + isScrollRestorePending, + resetKey: browseScrollResetKey, + viewMode, + listVirtualize: !composerBrowsePlainLayout, + listVirtualizer: composerListVirtualizer, + }); + if (loadError) { return (
@@ -272,14 +341,6 @@ export default function Composers() {

{t('composers.title')}

- setFilter(e.target.value)} - id="composer-filter-input" - /> {textSearchLoading && (
)} @@ -342,7 +403,7 @@ export default function Composers() { items={visible} itemKey={(a, _i) => a.id} rowVariant="composer" - disableVirtualization={perfFlags.disableMainstageVirtualLists} + disableVirtualization={composerBrowsePlainLayout} layoutSignal={visible.length} wrapClassName="composer-grid-wrap" gridGap="var(--space-2)" @@ -350,7 +411,7 @@ export default function Composers() { renderItem={artist => (
navigate(`/composer/${artist.id}`)} + onClick={() => navigateToComposer(artist.id)} onContextMenu={(e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer'); @@ -368,7 +429,7 @@ export default function Composers() { )} {!loading && viewMode === 'list' && ( - perfFlags.disableMainstageVirtualLists ? ( + composerBrowsePlainLayout ? ( <> {letters.map(letter => (
@@ -378,7 +439,7 @@ export default function Composers() {