feat(navigation): add cascading local media menu

cx
This commit is contained in:
vicrodh committed 2026-08-22 21:25:29 -06:00
1 parent a2d0ada427
commit 04184d9d8f
17 files changed
+549 -53

No files matched your search

+229 -21
View File
@@ -125,8 +125,8 @@ Item {
// Discover — glyph-less rows (Slint has-glyph: false).
var discover = [
{ "label": nav.trs("Home"), "icon": "", "view": "home", "tab": "home", "enabled": true },
{ "label": nav.trs("Editor's Picks"), "icon": "", "view": "home", "tab": "editorPicks", "enabled": true },
{ "label": nav.trs("For You"), "icon": "", "view": "home", "tab": "forYou", "enabled": true }
{ "label": nav.trs("For You"), "icon": "", "view": "home", "tab": "forYou", "enabled": true },
{ "label": nav.trs("Editor's Picks"), "icon": "", "view": "home", "tab": "editorPicks", "enabled": true }
]
if (reco) {
discover.push({ "label": nav.trs("Recommendations"), "icon": "", "view": "home", "tab": "recommendations", "enabled": true })
@@ -161,19 +161,18 @@ Item {
"label": nav.trs("Local Library"),
"qobuz": false,
"enabled": true,
// The `Open` entry is APPENDED, not marked enabled:false —
// a disabled row still renders (NavFlyout.qml:329,364), and a
// permanently greyed "Open" would advertise a tab that mostly
// does not exist. `localEphemeralActive` is the same flag the
// tab bar and the pane read.
"entries": [
{ "label": nav.trs("Albums"), "icon": "disc", "view": "local", "tab": "albums", "enabled": true },
{ "label": nav.trs("Artists"), "icon": "user", "view": "local", "tab": "artists", "enabled": true },
{ "label": nav.trs("Folders"), "icon": "folder", "view": "local", "tab": "folders", "enabled": true },
{ "label": nav.trs("Tracks"), "icon": "music", "view": "local", "tab": "tracks", "enabled": true }
].concat(QbzLocal.localEphemeralActive
? [{ "label": QbzLocal.localEphemeralLabel, "icon": "folder-open", "view": "local", "tab": "ephemeral", "enabled": true }]
: [])
{ "label": nav.trs("Tracks"), "icon": "music", "view": "local", "tab": "tracks", "enabled": true },
{ "label": QbzSession.offline ? nav.trs("Folder view") : nav.trs("Folders"),
"icon": "folder", "view": "local", "tab": "folders", "enabled": true },
{ "label": QbzLocal.localEphemeralActive
? QbzLocal.localEphemeralLabel
: (QbzSession.offline ? nav.trs("Open media") : nav.trs("Open")),
"icon": "folder-open", "view": "", "tab": "", "enabled": true,
"submenu": nav.openMediaEntries() }
]
},
{
"id": "myqbz",
@@ -207,6 +206,32 @@ Item {
]
}
/// Second-level catalog for Local Library > Open. When a medium already
/// exists its own row returns to it and Close is appended; all three open
/// verbs remain available so replacing one medium never needs an extra
/// navigation round trip.
function openMediaEntries() {
var entries = []
if (QbzLocal.localEphemeralActive) {
entries.push({ "label": QbzLocal.localEphemeralLabel,
"icon": "folder-open", "view": "local",
"tab": "ephemeral", "enabled": true })
}
entries.push(
{ "label": nav.trs("Open folder…"), "icon": "folder-open",
"view": "", "tab": "", "action": "openFolder", "enabled": true },
{ "label": nav.trs("Open audio CD"), "icon": "disc",
"view": "", "tab": "", "action": "openCd", "enabled": true },
{ "label": nav.trs("Open SACD image…"), "icon": "disc-3",
"view": "", "tab": "", "action": "openSacd", "enabled": true })
if (QbzLocal.localEphemeralActive) {
entries.push({ "label": nav.trs("Close"), "icon": "x",
"view": "", "tab": "", "action": "closeMedia",
"enabled": true })
}
return entries
}
// Which section owns the highlight, derived from the LIVE view (both hosts
// used to carry this identical binding). Slint highlights off
// HeaderMenuState.open-index; the hosts OR that in via `openId`.
@@ -252,12 +277,13 @@ Item {
panel.show(section, withTitle === true, trigger.mapToItem(nav, 0, 0).x, nav.dropdownY)
}
// Sidebar form: menu to the RIGHT of the row (+6px), aligned to its top,
// always headed by the section name (Sidebar.slint:590).
function openBeside(trigger, section) {
// Sidebar form: menu to the RIGHT of the row (+6px), aligned to its top.
// The expanded row already names the section, while the compact icon does
// not; callers pass that visible-text fact explicitly.
function openBeside(trigger, section, withTitle) {
if (!section || section.enabled === false) return
var p = trigger.mapToItem(nav, trigger.width + 6, 0)
panel.show(section, true, p.x, p.y)
panel.show(section, withTitle === true, p.x, p.y)
}
function closeMenu() { panel.close() }
@@ -272,8 +298,26 @@ Item {
// per route, crates/qbz/src/main.rs:18026-18046). Entries without one
// (Collections, Mixtapes) are a plain section navigation.
function activate(entry) {
if (entry && entry.submenu) return
panel.close()
if (!entry || entry.enabled === false || entry.view === "") return
if (!entry || entry.enabled === false) return
if (entry.action === "openFolder") {
QbzLocal.ephemeralOpen()
return
}
if (entry.action === "openCd") {
QbzLocal.ephemeralOpenCd()
return
}
if (entry.action === "openSacd") {
QbzLocal.ephemeralOpenSacd()
return
}
if (entry.action === "closeMedia") {
QbzLocal.ephemeralClear()
return
}
if (entry.view === "") return
if (entry.tab && entry.tab !== "")
QbzShell.navigateToTab(entry.view, entry.tab)
else
@@ -356,13 +400,23 @@ Item {
}
Text {
height: parent.height
width: parent.width - (row.entry && row.entry.icon !== "" ? 23 : 0)
width: parent.width
- (row.entry && row.entry.icon !== "" ? 23 : 0)
- (row.entry && row.entry.submenu ? 19 : 0)
text: row.entry ? row.entry.label : ""
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
QbzIcon {
visible: row.entry && row.entry.submenu
width: visible ? 11 : 0
height: 11
anchors.verticalCenter: parent.verticalCenter
name: "chevron-right"
tintName: "muted"
}
}
MouseArea {
@@ -371,13 +425,27 @@ Item {
enabled: row.rowEnabled
hoverEnabled: row.rowEnabled
cursorShape: Qt.PointingHandCursor
onClicked: nav.activate(row.entry)
onClicked: {
if (row.entry && row.entry.submenu)
panel.openCascade(row, row.entry)
else
nav.activate(row.entry)
}
// Per-row hover keeps the idle timer paused while the cursor RESTS
// on a row (the panel-body tracker below never sees it — the row
// is on top of it).
onContainsMouseChanged: {
if (containsMouse) panel.hoveredRow = row.rowIndex
else if (panel.hoveredRow === row.rowIndex) panel.hoveredRow = -1
if (containsMouse) {
panel.hoveredRow = row.rowIndex
if (row.entry && row.entry.submenu)
panel.openCascade(row, row.entry)
else
panel.scheduleCascadeClose()
} else {
if (panel.hoveredRow === row.rowIndex) panel.hoveredRow = -1
if (panel.cascadeAnchorRow === row.rowIndex)
panel.scheduleCascadeClose()
}
}
}
}
@@ -400,15 +468,38 @@ Item {
property int hoveredRow: -1
// True while the pointer rests on the panel BODY (padding/gaps).
property bool bodyHovered: false
// The cascade remains live while the pointer crosses the panel edge.
// 280ms is long enough for a diagonal move but short enough that an
// abandoned submenu does not become perennial.
property int cascadeAnchorRow: -1
property bool cascadeGrace: false
// Pointer anywhere on the menu, or on the trigger that owns it — the
// idle countdown is paused while this holds.
readonly property bool menuHovered:
bodyHovered || hoveredRow >= 0 || nav.triggerHovered
|| cascade.menuHovered || cascadeGrace
function openCascade(row, entry) {
cascadeClose.stop()
panel.cascadeGrace = false
panel.cascadeAnchorRow = row.rowIndex
cascade.entries = entry.submenu || []
cascade.x = panel.width - 2
cascade.y = row.mapToItem(panel.contentItem, 0, 0).y
if (!cascade.visible) cascade.open()
}
function scheduleCascadeClose() {
if (!cascade.visible) return
panel.cascadeGrace = true
cascadeClose.restart()
}
function show(section, withTitle, px, py) {
// Switching sections: drop stale hover state (the new rows have
// not reported yet), exactly like the Slint `changed open-index`.
if (panel.sectionId !== section.id) panel.hoveredRow = -1
if (panel.sectionId !== section.id) cascade.close()
panel.sectionId = section.id
panel.title = withTitle ? section.label : ""
panel.entries = section.entries
@@ -421,6 +512,9 @@ Item {
panel.sectionId = ""
panel.hoveredRow = -1
panel.bodyHovered = false
panel.cascadeAnchorRow = -1
panel.cascadeGrace = false
cascade.close()
nav.triggerHovered = false
}
@@ -479,5 +573,119 @@ Item {
}
}
}
Timer {
id: cascadeClose
interval: 280
repeat: false
onTriggered: {
panel.cascadeGrace = false
if (panel.cascadeAnchorRow !== panel.hoveredRow && !cascade.menuHovered)
cascade.close()
}
}
// Child Popup, not a sibling: presses inside it count as part of the
// menu family for CloseOnPressOutside, and closing the parent tears it
// down automatically. The panels overlap by 2px so there is no dead
// seam; the timer above tolerates diagonal pointer travel.
Popup {
id: cascade
width: 224
padding: 0
topPadding: 6
bottomPadding: 6
height: 12 + cascade.entries.length * 32
closePolicy: Popup.NoAutoClose
property var entries: []
property int hoveredRow: -1
property bool bodyHovered: false
readonly property bool menuHovered: bodyHovered || hoveredRow >= 0
onClosed: {
cascade.entries = []
cascade.hoveredRow = -1
cascade.bodyHovered = false
panel.cascadeAnchorRow = -1
}
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
MouseArea {
anchors.fill: parent
hoverEnabled: true
onContainsMouseChanged: {
cascade.bodyHovered = containsMouse
if (containsMouse) {
cascadeClose.stop()
panel.cascadeGrace = false
} else {
panel.scheduleCascadeClose()
}
}
onClicked: {}
}
}
contentItem: Column {
Repeater {
model: cascade.entries
delegate: Rectangle {
id: cascadeRow
required property var modelData
required property int index
width: parent ? parent.width : 0
height: 32
color: cascadeArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
spacing: 9
QbzIcon {
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
name: cascadeRow.modelData.icon || ""
tintName: "secondary"
}
Text {
width: parent.width - 23
height: parent.height
text: cascadeRow.modelData.label || ""
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: cascadeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: nav.activate(cascadeRow.modelData)
onContainsMouseChanged: {
if (containsMouse) {
cascade.hoveredRow = cascadeRow.index
cascadeClose.stop()
panel.cascadeGrace = false
} else {
if (cascade.hoveredRow === cascadeRow.index)
cascade.hoveredRow = -1
panel.scheduleCascadeClose()
}
}
}
}
}
}
}
}
}
+2 -2
View File
@@ -413,7 +413,7 @@ Rectangle {
hoverEnabled: navRow.isEnabled
cursorShape: Qt.PointingHandCursor
onClicked: {
navFlyout.openBeside(navRow, navRow.section)
navFlyout.openBeside(navRow, navRow.section, root.mini)
// Opt-in "click lands on the first tab" — a no-op while the
// Appearance toggle is off, which is the default.
navFlyout.sectionClicked(navRow.section)
@@ -423,7 +423,7 @@ Rectangle {
// Hover-to-open; hovering another row overwrites the open
// menu (instant switch, single-open by construction).
navFlyout.triggerHovered = true
navFlyout.openBeside(navRow, navRow.section)
navFlyout.openBeside(navRow, navRow.section, root.mini)
} else if (navRow.section && navFlyout.openId === navRow.section.id) {
// Only the row owning the open menu clears the flag.
navFlyout.triggerHovered = false
+1
View File
@@ -290,6 +290,7 @@ mod playlist_import_qt;
// #[cxx_qt::bridge], so they must NOT appear in build.rs's rust_files.
mod playlist_manager_ops;
mod playlist_manager_qt;
mod playlist_snapshot_qt;
mod playlist_manager_rows;
// The SHARED playlist editor's controller (rename · description ·
// offline-only · delete), driven from the manager's three delegates, the
+16
View File
@@ -169,6 +169,22 @@ fn now_unix_secs() -> i64 {
.as_secs() as i64
}
/// Whether full offline-cache tracks may be served inside the subscription
/// grace window. Missing state fails open, matching the shared engine and the
/// reference frontend: only an explicit expired verdict may hide playback.
pub fn offline_playback_allowed() -> bool {
let now = now_unix_secs();
SUBSCRIPTION
.lock()
.ok()
.and_then(|guard| {
guard
.as_ref()
.map(|store| store.offline_playback_allowed(now).unwrap_or(true))
})
.unwrap_or(true)
}
/// D4 producer: a successful login verdict. Clears any running grace clock.
pub fn subscription_mark_valid() {
let now = now_unix_secs();
+7
View File
@@ -122,6 +122,13 @@ fn cached_ids() -> &'static StdMutex<HashSet<u64>> {
CACHED_IDS.get_or_init(|| StdMutex::new(HashSet::new()))
}
/// Snapshot of the ready offline-cache ids. Playlist availability intersects
/// this set with persisted playlist membership without holding the mutex over
/// a database read.
pub(crate) fn cached_ids_set() -> HashSet<u64> {
cached_ids().lock().map(|ids| ids.clone()).unwrap_or_default()
}
/// True if `track_id` has a ready offline copy. Used to seed each track row's
/// cache status at document build time — the rows carry the id as a STRING, so
/// this is the string-keyed front door onto [`is_cached_id`].
+11
View File
@@ -573,6 +573,17 @@ async fn load(runtime: &Runtime) -> PmData {
Vec::new()
})
};
crate::playlist_snapshot_qt::record_names_detached(
remote
.iter()
.map(|playlist| crate::playlist_snapshot_qt::SnapshotNameEntry {
qobuz_playlist_id: playlist.id,
name: playlist.name.clone(),
owner: Some(playlist.owner.name.clone()).filter(|owner| !owner.is_empty()),
track_count: Some(playlist.tracks_count),
})
.collect(),
);
let (folders, settings, play_counts, local_counts, locals, fav_ids) =
tokio::task::spawn_blocking(|| {
+6
View File
@@ -983,6 +983,12 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -
// `take` (not clone): the header helpers below still need `&pl`, and the
// track list is the heavy half of the response.
let tracks = pl.tracks.take().map(|c| c.items).unwrap_or_default();
crate::playlist_snapshot_qt::record_detail_detached(
playlist_id,
pl.name.clone(),
pl.owner.name.clone(),
tracks.iter().map(|track| track.id).collect(),
);
// Header artwork, two arms — 1:1 with the cards (PlaylistCard.qml):
// the playlist's OWN graphic, else the member-cover mosaic. `images[0]`
// (and the first track's sleeve) is a MEMBER-ALBUM cover: binding it
+92
View File
@@ -0,0 +1,92 @@
//! Persisted Qobuz playlist names and membership for Qt offline navigation.
//!
//! The schema and queries live in `qbz-library`; this file only joins those
//! frontend-neutral records to Qt's per-user database and ready-cache set.
//! Producers are detached because they consume data the online list/detail
//! loads already fetched and must never delay a render.
use std::collections::{HashMap, HashSet};
use qbz_library::qobuz_playlist_snapshot as repo;
pub use repo::SnapshotNameEntry;
pub fn record_names_detached(entries: Vec<SnapshotNameEntry>) {
if entries.is_empty() {
return;
}
let write = move || {
let result = crate::library_db_qt::with_db(true, |db| {
Ok(db.with_connection(|conn| repo::upsert_names(conn, &entries)))
});
if let Some(Err(error)) = result {
log::warn!("[qbz-qt] playlist snapshot names write failed: {error}");
}
};
if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::spawn_blocking(write);
} else {
std::thread::spawn(write);
}
}
pub fn record_detail_detached(playlist_id: u64, name: String, owner: String, track_ids: Vec<u64>) {
let write = move || {
let owner = Some(owner.as_str()).filter(|value| !value.is_empty());
let result = crate::library_db_qt::with_db(true, |db| {
Ok(db.with_connection(|conn| {
repo::replace_tracks(conn, playlist_id, &name, owner, &track_ids)
}))
});
match result {
Some(Ok(true)) => {}
Some(Ok(false)) => log::debug!(
"[qbz-qt] playlist snapshot: {playlist_id} is not in the user list; membership skipped"
),
Some(Err(error)) => {
log::warn!("[qbz-qt] playlist snapshot membership write failed: {error}")
}
None => {}
}
};
if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::spawn_blocking(write);
} else {
std::thread::spawn(write);
}
}
/// playlist id -> (persisted name, point-in-time Qobuz track count).
pub fn headers_blocking() -> HashMap<u64, (String, Option<u32>)> {
crate::library_db_qt::with_db(false, |db| Ok(db.with_connection(repo::all_headers)))
.and_then(Result::ok)
.map(|headers| {
headers
.into_iter()
.map(|header| (header.qobuz_playlist_id, (header.name, header.track_count)))
.collect()
})
.unwrap_or_default()
}
/// Playlists whose persisted membership contains at least one playable cache
/// entry. An expired subscription grace window makes the whole set empty.
pub fn available_offline_blocking() -> HashSet<u64> {
if !crate::offline_fwd::offline_playback_allowed() {
return HashSet::new();
}
let cached = crate::offline_qt::cached_ids_set();
if cached.is_empty() {
return HashSet::new();
}
crate::library_db_qt::with_db(false, |db| Ok(db.with_connection(repo::all_track_ids)))
.and_then(Result::ok)
.map(|memberships| {
memberships
.into_iter()
.filter(|(_, ids)| ids.iter().any(|id| cached.contains(id)))
.map(|(playlist_id, _)| playlist_id)
.collect()
})
.unwrap_or_default()
}
+89 -30
View File
@@ -181,35 +181,30 @@ pub fn teardown() {
/// Fetch playlists (Qobuz) + folders + membership + hidden set (library.db).
///
/// The Qobuz half is GATED on connectivity and, when refused, KEEPS whatever
/// the cache already holds. That distinction is load-bearing: this function is
/// also the body of `crate::reload_sidebar_including_local()`, the refresh verb
/// every local mutation uses, and it runs offline by design. Attempting the
/// fetch there would yield `Vec::new()` (the gate answers with an error), so
/// hiding a local playlist after going offline mid-session would WIPE the
/// Qobuz playlists out of the tree. The folders + locals reads below are
/// unconditional — they are the whole sidebar for a user with no Qobuz account.
/// Offline, the remote half is synthesized from local sidecars plus persisted
/// playlist membership that intersects the ready download cache. This is what
/// keeps only playable Qobuz playlists — and therefore only useful folders —
/// visible across both a connectivity flip and a cold app restart.
pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let offline = crate::offline_fwd::engine().status().is_offline();
let playlists: Vec<SidebarPlaylist> = if offline {
// Preserve, never substitute: an empty vector here is a WIPE, not a
// refresh. Empty on a cold offline start, which is correct — there is
// nothing to preserve yet.
let cached = CACHE
.lock()
.unwrap()
.as_ref()
.map(|d| d.playlists.clone())
.unwrap_or_default();
log::info!(
"[qbz-qt] sidebar load: offline — Qobuz fetch skipped, {} cached playlist(s) kept",
cached.len()
);
cached
log::info!("[qbz-qt] sidebar load: offline — Qobuz fetch skipped");
Vec::new()
} else {
log::debug!("[qbz-qt] sidebar load: fetching user playlists");
match runtime.core().get_user_playlists().await {
Ok(pls) => {
crate::playlist_snapshot_qt::record_names_detached(
pls.iter()
.map(|playlist| crate::playlist_snapshot_qt::SnapshotNameEntry {
qobuz_playlist_id: playlist.id,
name: playlist.name.clone(),
owner: Some(playlist.owner.name.clone())
.filter(|owner| !owner.is_empty()),
track_count: Some(playlist.tracks_count),
})
.collect(),
);
// Same response, second consumer: the playlist ownership /
// follow snapshot every PlaylistCard's tri-state overlay reads
// (`playlist_qt::set_user_playlists`). This is the earliest point
@@ -263,9 +258,63 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
};
log::debug!("[qbz-qt] sidebar load: playlists fetch settled, reading folders");
let (folders, folder_map, positions, hidden_playlists) = folders_blocking();
let (folders, folder_map, positions, hidden_playlists, local_counts) = folders_blocking();
log::debug!("[qbz-qt] sidebar load: folders read");
let mut playlists = playlists;
if offline {
let prior: HashMap<u64, SidebarPlaylist> = CACHE
.lock()
.unwrap()
.as_ref()
.map(|data| {
data.playlists
.iter()
.cloned()
.map(|playlist| (playlist.id, playlist))
.collect()
})
.unwrap_or_default();
let headers = crate::playlist_snapshot_qt::headers_blocking();
let available = crate::playlist_snapshot_qt::available_offline_blocking();
let mut ids: Vec<u64> = local_counts
.iter()
.filter(|(_, count)| **count > 0)
.map(|(id, _)| *id)
.collect();
for id in available {
if !ids.contains(&id) {
ids.push(id);
}
}
ids.sort_unstable();
for id in ids {
let local_count = local_counts.get(&id).copied().unwrap_or(0);
if let Some(mut cached) = prior.get(&id).cloned() {
if let Some((name, track_count)) = headers.get(&id) {
cached.name = name.clone();
cached.tracks_count = track_count.unwrap_or(local_count);
}
playlists.push(cached);
continue;
}
let (name, tracks_count) = headers
.get(&id)
.map(|(name, track_count)| (name.clone(), track_count.unwrap_or(local_count)))
.unwrap_or_else(|| {
(
qbz_i18n::t_args("Playlist ({} local)", &[&local_count.to_string()]),
local_count,
)
});
playlists.push(SidebarPlaylist {
id,
name,
tracks_count,
cover_urls: Vec::new(),
position: 0,
});
}
}
for p in &mut playlists {
// Assign, don't merge: a preserved cached row carries its previous
// position, and a settings row that has since been deleted must reset
@@ -302,10 +351,16 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
/// library.db queries (folders.rs equivalents), run inline — rusqlite is
/// sync but the three reads are tiny.
fn folders_blocking() -> (Vec<(String, String)>, HashMap<u64, String>, HashMap<u64, i32>, HashSet<u64>) {
let Some(uid) = UserDataPaths::load_last_user_id() else {
return Default::default();
};
fn folders_blocking() -> (
Vec<(String, String)>,
HashMap<u64, String>,
HashMap<u64, i32>,
HashSet<u64>,
HashMap<u64, u32>,
) {
// Guest/offline-only users live in users/0, the same convention as every
// other Qt library.db accessor.
let uid = UserDataPaths::load_last_user_id().unwrap_or(0);
let Some(path) = dirs::data_dir().map(|p| {
p.join("qbz")
.join("users")
@@ -339,7 +394,8 @@ fn folders_blocking() -> (Vec<(String, String)>, HashMap<u64, String>, HashMap<u
hidden.insert(s.qobuz_playlist_id);
}
}
(folders, folder_map, positions, hidden)
let local_counts = db.get_all_playlist_local_track_counts().unwrap_or_default();
(folders, folder_map, positions, hidden, local_counts)
}
/// A Qobuz playlist's track count from the session cache (`sidebar.rs:389`).
@@ -388,6 +444,7 @@ pub fn rebuild() -> Vec<SidebarEntry> {
let expanded = EXPANDED.lock().unwrap().clone();
let query = SEARCH.lock().unwrap().clone();
let searching = !query.is_empty();
let offline = crate::offline_fwd::engine().status().is_offline();
let folder_ids: HashSet<&String> = data.folders.iter().map(|f| &f.0).collect();
let sorted = sort_playlists(&data.playlists);
@@ -445,8 +502,10 @@ pub fn rebuild() -> Vec<SidebarEntry> {
.filter(|p| local_matches(p))
.collect(),
);
// While searching, skip folders with nothing matching in EITHER set.
if searching && members.is_empty() && local_members.is_empty() {
// Search hides empty result branches. Offline mode additionally hides
// folders with no playable member; Playlist Manager still reads every
// folder directly from library.db and therefore remains complete.
if (searching || offline) && members.is_empty() && local_members.is_empty() {
continue;
}
// When searching, force-expand so matches inside are visible.