feat(search): scoped live search on browse pages (#938)

* feat(artists): scoped live search badge replaces page filter

Move Artists browse text search into the header Live Search with a page
scope badge (Users icon), field-local undo, and double-click/backspace
to clear scope. Block the live-search dropdown while scoped so results
only filter the Artists grid; mobile overlay follows the same rules.

* fix(artists): plain grid for scoped search fixes broken card layout

Route the browse grid through VirtualCardGrid, switch to non-virtual CSS
grid when live search filters the catalog, reset scroll on filter changes,
and skip content-visibility on plain tiles to avoid blank/black cards.

* fix(search): scope badge double-Backspace and single clear control

Require two Backspaces on an empty scoped field after prior text input;
one Backspace still clears the badge when the field was never filled.
Move live-search clear/advanced controls inside the field pill, drop the
extra outer clear button, and use type=text to avoid native search clears.

* fix(search): drop duplicate outer live-search clear button

Keep the native in-field clear on type=search and the original pill layout;
remove only the extra × control outside the search border. Reset dropdown
state when the query is cleared via the native control.

* refactor(search): generic scoped browse query helper, drop dead code

Rename artistsBrowseSearchQuery to scopedBrowseSearchQuery with an
expectedScope argument; wire Artists via useScopedBrowseSearchQuery.
Remove unused liveSearchScoped dropdown helper (scoped mode blocks it).

* feat(search): ghost scope badge and single-click badge remove

After clearing the artists scope on /artists, show a faded ghost chip to
restore page-only search while keeping the global search placeholder.
Active badge removes on one click; tooltips and styles updated.

* feat(search): scoped live search for All Albums and New Releases

Wire albums and newReleases scope badges with debounced album title search
(local index title-only FTS + filtered search3). Plain grid, scroll reset,
and session query restore on album grid browse pages.

* fix(browse): preserve scroll restore after album/artist detail back

Only reset in-page scroll when filter resetKey changes, not when
isScrollRestorePending clears after session restore.

* feat(search): scoped live search for Tracks browse

Wire /tracks to header live search with wide title/artist/album FTS,
hide hero and discovery rails while search is active, and remove the
inline search field from the browse list.

* fix(search): clear header query when leaving scoped browse pages

Prevent global live search from firing on album/detail routes after a
scoped browse query; browse session stashes still restore on back.

* fix(tracks): restore scroll after back from detail during scoped search

Hold stashed song results across fetchSongPage churn, defer leave-stash
teardown past AppShell scroll reset, restore tracks scroll after the list
is ready, and save scroll snapshot when opening artist from song context menu.

* fix(tracks): hide discovery headings during scoped search

Hide the page subtitle and "Browse all tracks" section title when
tracks search is active, matching hero/rails chrome behavior.

* feat(search): scoped live search for Composers browse

Wire /composers to header live search with composers scope badge,
session stash, scroll restore, and plain grid/list during text filter.
Remove the in-page filter input; add i18n and navigation helpers.

* docs: CHANGELOG and credits for scoped browse live search (PR #938)
This commit is contained in:
cucadmuh
2026-06-01 13:04:36 +03:00
committed by GitHub
parent ddf10ee01d
commit 4ac373a65b
59 changed files with 2301 additions and 392 deletions
@@ -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<bo
.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)]
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,
@@ -532,6 +532,9 @@ pub struct LibraryAdvancedSearchRequest {
/// `starred_only` — use one or the other.
#[serde(default)]
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)]
pub sort: Vec<LibrarySortClause>,
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).
pub(crate) fn fts_album_prefix_any_token_match_query(raw: &str) -> Option<String> {
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();