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

@@ -5683,3 +5683,15 @@ msgstr "Du nutzt QBZ ohne Qobuz-Konto. Deine lokale Bibliothek und Downloads fun
msgid "Continuous playback"
msgstr "Kontinuierliche Wiedergabe"
msgid "Open"
msgstr "Öffnen"
msgid "Folder view"
msgstr "Ordneransicht"
msgid "Open media"
msgstr "Medium öffnen"
msgid "Open folder…"
msgstr "Ordner öffnen…"
@@ -1207,3 +1207,15 @@ msgstr "No track matches that."
msgid "Continuous playback"
msgstr "Continuous playback"
msgid "Open"
msgstr "Open"
msgid "Folder view"
msgstr "Folder view"
msgid "Open media"
msgstr "Open media"
msgid "Open folder…"
msgstr "Open folder…"
@@ -5689,3 +5689,15 @@ msgstr "Estás usando QBZ sin cuenta de Qobuz. Tu biblioteca local y tus descarg
msgid "Continuous playback"
msgstr "Reproducción continua"
msgid "Open"
msgstr "Abrir"
msgid "Folder view"
msgstr "Vista por carpetas"
msgid "Open media"
msgstr "Abrir medios"
msgid "Open folder…"
msgstr "Abrir carpeta…"
@@ -5681,3 +5681,15 @@ msgstr "Vous utilisez QBZ sans compte Qobuz. Votre bibliothèque locale et vos t
msgid "Continuous playback"
msgstr "Lecture continue"
msgid "Open"
msgstr "Ouvrir"
msgid "Folder view"
msgstr "Vue par dossiers"
msgid "Open media"
msgstr "Ouvrir un média"
msgid "Open folder…"
msgstr "Ouvrir un dossier…"
@@ -5666,3 +5666,15 @@ msgstr "Qobuz アカウントなしで QBZ を使っています。ローカル
msgid "Continuous playback"
msgstr "連続再生"
msgid "Open"
msgstr "開く"
msgid "Folder view"
msgstr "フォルダー表示"
msgid "Open media"
msgstr "メディアを開く"
msgid "Open folder…"
msgstr "フォルダーを開く…"
@@ -5671,3 +5671,15 @@ msgstr "Je gebruikt QBZ zonder Qobuz-account. Je lokale bibliotheek en downloads
msgid "Continuous playback"
msgstr "Doorlopend afspelen"
msgid "Open"
msgstr "Openen"
msgid "Folder view"
msgstr "Mapweergave"
msgid "Open media"
msgstr "Media openen"
msgid "Open folder…"
msgstr "Map openen…"
@@ -5681,3 +5681,15 @@ msgstr "Você está usando o QBZ sem conta Qobuz. Sua biblioteca local e seus do
msgid "Continuous playback"
msgstr "Reprodução contínua"
msgid "Open"
msgstr "Abrir"
msgid "Folder view"
msgstr "Vista de pastas"
msgid "Open media"
msgstr "Abrir mídia"
msgid "Open folder…"
msgstr "Abrir pasta…"
@@ -5700,3 +5700,15 @@ msgstr "Вы используете QBZ без аккаунта Qobuz. Лока
msgid "Continuous playback"
msgstr "Непрерывное воспроизведение"
msgid "Open"
msgstr "Открыть"
msgid "Folder view"
msgstr "Просмотр папок"
msgid "Open media"
msgstr "Открыть медиафайл"
msgid "Open folder…"
msgstr "Открыть папку…"
+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.