feat(myqbz): collapsible My QBZ sidebar with per-element hide and toolbar

Opt-in collapsible My QBZ tree in the sidebar (Settings > Navigation): a
branding-titled parent over Mixtapes and Collections sections, each header a
link to its grid with a chevron that only collapses. Header text and icon on
the left, a Playlists-parity toolbar floating right (search, new, sort A-Z/Z-A,
refresh, collapse) with the chevron aligned to the playlist carets. The tree
feed rides QbzShell (QbzMyQbz is not reliably registered in the shell context)
and is published through publish_sidebar, so it lands reliably at startup.

Per-element visibility reuses the mixtape hidden flag (repo::set_hidden): a
row's right-click hides it from the sidebar, and the detail hero menu toggles
"Hide from sidebar" / "Show in sidebar". Collapse state for the playlist tree
and all three My QBZ sections is persisted. The collapsible tree and the flat
My QBZ entry are mutually exclusive — the classic row is suppressed whenever the
tree is on. Settings gains a Navigation section (Navigation + Search moved out
of Appearance). Detail view also gains a discreet, column-aligned sub-header
over the inline track list. Eight locales updated.

cc
This commit is contained in:
vicrodh committed 2026-09-10 20:19:48 -06:00
1 parent 66cd5fb1da
commit e402fa250a
22 files changed
+1375 -110

No files matched your search

@@ -7039,3 +7039,36 @@ msgstr "Das dauert länger als gewöhnlich — MusicBrainz ist möglicherweise a
msgid "Remove from Mixtape"
msgstr "Aus Mixtape entfernen"
msgid "Show collapsible My QBZ in sidebar"
msgstr "Aufklappbares My QBZ in der Seitenleiste anzeigen"
msgid "Show Mixtapes"
msgstr "Mixtapes anzeigen"
msgid "Show Collections"
msgstr "Sammlungen anzeigen"
msgid "Hide standard My QBZ menu entry"
msgstr "Standard-My-QBZ-Menüeintrag ausblenden"
msgid "No mixtapes yet."
msgstr "Noch keine Mixtapes."
msgid "No collections yet."
msgstr "Noch keine Collections."
msgid "Show in sidebar"
msgstr "In Seitenleiste anzeigen"
msgid "New Mixtape"
msgstr "Neue Mixtape"
msgid "New Collection"
msgstr "Neue Collection"
msgid "Name (Z-A)"
msgstr "Name (Z-A)"
msgid "Search My QBZ"
msgstr "My QBZ durchsuchen"
@@ -2639,3 +2639,36 @@ msgstr "This is taking longer than usual — MusicBrainz may be busy. Try again
msgid "Remove from Mixtape"
msgstr "Remove from Mixtape"
msgid "Show collapsible My QBZ in sidebar"
msgstr "Show collapsible My QBZ in sidebar"
msgid "Show Mixtapes"
msgstr "Show Mixtapes"
msgid "Show Collections"
msgstr "Show Collections"
msgid "Hide standard My QBZ menu entry"
msgstr "Hide standard My QBZ menu entry"
msgid "No mixtapes yet."
msgstr "No mixtapes yet."
msgid "No collections yet."
msgstr "No collections yet."
msgid "Show in sidebar"
msgstr "Show in sidebar"
msgid "New Mixtape"
msgstr "New Mixtape"
msgid "New Collection"
msgstr "New Collection"
msgid "Name (Z-A)"
msgstr "Name (Z-A)"
msgid "Search My QBZ"
msgstr "Search My QBZ"
@@ -7045,3 +7045,36 @@ msgstr "Esto está tardando más de lo normal — MusicBrainz puede estar ocupad
msgid "Remove from Mixtape"
msgstr "Quitar del mixtape"
msgid "Show collapsible My QBZ in sidebar"
msgstr "Mostrar My QBZ colapsable en el sidebar"
msgid "Show Mixtapes"
msgstr "Mostrar mixtapes"
msgid "Show Collections"
msgstr "Mostrar colecciones"
msgid "Hide standard My QBZ menu entry"
msgstr "Ocultar la entrada estándar de My QBZ en el menú"
msgid "No mixtapes yet."
msgstr "Aún no hay mixtapes."
msgid "No collections yet."
msgstr "Aún no hay colecciones."
msgid "Show in sidebar"
msgstr "Mostrar en la barra lateral"
msgid "New Mixtape"
msgstr "Nueva mixtape"
msgid "New Collection"
msgstr "Nueva colección"
msgid "Name (Z-A)"
msgstr "Nombre (Z-A)"
msgid "Search My QBZ"
msgstr "Buscar en My QBZ"
@@ -7037,3 +7037,36 @@ msgstr "Cela prend plus de temps que d'habitude — MusicBrainz est peut-être o
msgid "Remove from Mixtape"
msgstr "Retirer de la mixtape"
msgid "Show collapsible My QBZ in sidebar"
msgstr "Afficher My QBZ repliable dans la barre latérale"
msgid "Show Mixtapes"
msgstr "Afficher les mixtapes"
msgid "Show Collections"
msgstr "Afficher les collections"
msgid "Hide standard My QBZ menu entry"
msgstr "Masquer l'entrée de menu My QBZ standard"
msgid "No mixtapes yet."
msgstr "Aucune mixtape pour le moment."
msgid "No collections yet."
msgstr "Aucune collection pour le moment."
msgid "Show in sidebar"
msgstr "Afficher dans la barre latérale"
msgid "New Mixtape"
msgstr "Nouvelle mixtape"
msgid "New Collection"
msgstr "Nouvelle collection"
msgid "Name (Z-A)"
msgstr "Nom (Z-A)"
msgid "Search My QBZ"
msgstr "Rechercher dans My QBZ"
@@ -7020,3 +7020,36 @@ msgstr "通常より時間がかかっています。MusicBrainzが混雑して
msgid "Remove from Mixtape"
msgstr "ミックステープから削除"
msgid "Show collapsible My QBZ in sidebar"
msgstr "サイドバーに折りたたみ式の My QBZ を表示"
msgid "Show Mixtapes"
msgstr "ミックステープを表示"
msgid "Show Collections"
msgstr "コレクションを表示"
msgid "Hide standard My QBZ menu entry"
msgstr "標準の My QBZ メニュー項目を非表示"
msgid "No mixtapes yet."
msgstr "ミックステープはまだありません。"
msgid "No collections yet."
msgstr "コレクションはまだありません。"
msgid "Show in sidebar"
msgstr "サイドバーに表示"
msgid "New Mixtape"
msgstr "新しいミックステープ"
msgid "New Collection"
msgstr "新しいコレクション"
msgid "Name (Z-A)"
msgstr "名前(Z-A)"
msgid "Search My QBZ"
msgstr "My QBZ を検索"
@@ -7027,3 +7027,36 @@ msgstr "Dit duurt langer dan normaal — MusicBrainz heeft het mogelijk druk. Pr
msgid "Remove from Mixtape"
msgstr "Verwijderen uit mixtape"
msgid "Show collapsible My QBZ in sidebar"
msgstr "Inklapbare My QBZ in de zijbalk tonen"
msgid "Show Mixtapes"
msgstr "Mixtapes tonen"
msgid "Show Collections"
msgstr "Collecties tonen"
msgid "Hide standard My QBZ menu entry"
msgstr "Standaard My QBZ-menu-item verbergen"
msgid "No mixtapes yet."
msgstr "Nog geen mixtapes."
msgid "No collections yet."
msgstr "Nog geen verzamelingen."
msgid "Show in sidebar"
msgstr "Tonen in zijbalk"
msgid "New Mixtape"
msgstr "Nieuwe mixtape"
msgid "New Collection"
msgstr "Nieuwe verzameling"
msgid "Name (Z-A)"
msgstr "Naam (Z-A)"
msgid "Search My QBZ"
msgstr "My QBZ doorzoeken"
@@ -7037,3 +7037,36 @@ msgstr "Isto está a demorar mais do que o normal — o MusicBrainz pode estar o
msgid "Remove from Mixtape"
msgstr "Remover do mixtape"
msgid "Show collapsible My QBZ in sidebar"
msgstr "Mostrar My QBZ recolhível na barra lateral"
msgid "Show Mixtapes"
msgstr "Mostrar mixtapes"
msgid "Show Collections"
msgstr "Mostrar coleções"
msgid "Hide standard My QBZ menu entry"
msgstr "Ocultar a entrada padrão de My QBZ no menu"
msgid "No mixtapes yet."
msgstr "Ainda não há mixtapes."
msgid "No collections yet."
msgstr "Ainda não há coleções."
msgid "Show in sidebar"
msgstr "Mostrar na barra lateral"
msgid "New Mixtape"
msgstr "Nova mixtape"
msgid "New Collection"
msgstr "Nova coleção"
msgid "Name (Z-A)"
msgstr "Nome (Z-A)"
msgid "Search My QBZ"
msgstr "Pesquisar em My QBZ"
@@ -7058,3 +7058,36 @@ msgstr "Это занимает больше времени, чем обычно
msgid "Remove from Mixtape"
msgstr "Удалить из микстейпа"
msgid "Show collapsible My QBZ in sidebar"
msgstr "Показывать сворачиваемый My QBZ на боковой панели"
msgid "Show Mixtapes"
msgstr "Показывать микстейпы"
msgid "Show Collections"
msgstr "Показывать коллекции"
msgid "Hide standard My QBZ menu entry"
msgstr "Скрыть стандартный пункт меню My QBZ"
msgid "No mixtapes yet."
msgstr "Пока нет микстейпов."
msgid "No collections yet."
msgstr "Пока нет коллекций."
msgid "Show in sidebar"
msgstr "Показать в боковой панели"
msgid "New Mixtape"
msgstr "Новый микстейп"
msgid "New Collection"
msgstr "Новая коллекция"
msgid "Name (Z-A)"
msgstr "Название (Я-А)"
msgid "Search My QBZ"
msgstr "Поиск в My QBZ"
+32
View File
@@ -179,6 +179,18 @@ pub fn set_kind(conn: &Connection, id: &str, new_kind: CollectionKind) -> Result
Ok(())
}
/// Hide (or unhide) a collection from the sidebar tree. Reuses the row's own
/// `hidden` column — the same "hide from sidebar" affordance playlists carry —
/// so a hidden Mixtape/Collection drops out of the collapsible sidebar feed
/// while remaining fully present in its grid and the management listing.
pub fn set_hidden(conn: &Connection, id: &str, hidden: bool) -> Result<()> {
conn.execute(
"UPDATE mixtape_collections SET hidden = ?1, updated_at = ?2 WHERE id = ?3",
params![hidden as i64, now_ms(), id],
)?;
Ok(())
}
pub fn set_custom_artwork(conn: &Connection, id: &str, path: Option<&str>) -> Result<()> {
conn.execute(
"UPDATE mixtape_collections SET custom_artwork_path = ?1, updated_at = ?2 WHERE id = ?3",
@@ -623,6 +635,26 @@ mod tests {
assert!(c.last_synced_at.is_some(), "artist collection stamps last_synced_at on create");
}
#[test]
fn set_hidden_toggles_the_flag() {
let conn = fresh_db();
let c = create_collection(
&conn,
CollectionKind::Mixtape,
"Hidden one",
None,
CollectionSourceType::Manual,
None,
)
.unwrap();
// Fresh collections are visible.
assert!(!get_collection(&conn, &c.id).unwrap().unwrap().hidden);
set_hidden(&conn, &c.id, true).unwrap();
assert!(get_collection(&conn, &c.id).unwrap().unwrap().hidden);
set_hidden(&conn, &c.id, false).unwrap();
assert!(!get_collection(&conn, &c.id).unwrap().unwrap().hidden);
}
#[test]
fn list_sorts_by_position_within_kind() {
let conn = fresh_db();
+2
View File
@@ -637,6 +637,7 @@ fn main() {
"qml/rows/TrackListHeader.qml",
"qml/rows/TrackRow.qml",
"qml/settings/AppearanceSettings.qml",
"qml/settings/NavigationSettings.qml",
"qml/settings/CustomThemeEditor.qml",
"qml/settings/IntegrationsSettings.qml",
"qml/settings/SettingsView.qml",
@@ -689,6 +690,7 @@ fn main() {
"qml/shell/Sidebar.qml",
"qml/shell/SidebarFolderFlyout.qml",
"qml/shell/SidebarRowMenu.qml",
"qml/shell/MyQbzSidebarMenu.qml",
"qml/shell/SidebarNowPlayingDock.qml",
"qml/shell/AudioSettingsMenu.qml",
"qml/shell/AlbumQuickView.qml",
@@ -267,114 +267,6 @@ Column {
SettingsDivider { }
SettingsSpacer { }
// ============================ NAVIGATION ============================
GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("NAVIGATION", QbzSession.trRev) }
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Show navigation in sidebar", QbzSession.trRev)
description: QbzSession.tr("Move the Discover, Library, Local Library and My QBZ sections out of the header and into the sidebar.", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.navInSidebar === true
onToggled: function (v) { QbzBridge.settingsBool("nav-in-sidebar", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
// ADR-010: only mounted when navigation is NOT in the sidebar.
visible: root.doc.navInSidebar !== true
label: QbzSession.tr("Compact header navigation", QbzSession.trRev)
description: QbzSession.tr("Use the icon-only section navigation in the header even while the sidebar is open.", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.navHeaderCompact === true
onToggled: function (v) { QbzBridge.settingsBool("nav-header-compact", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("My QBZ", QbzSession.trRev)
description: QbzSession.tr("Rename the My QBZ hub. Leave the name blank (or hit reset) to restore the default.", QbzSession.trRev)
Row {
spacing: 8
QbzLineEdit { kioskHost: root.kioskHost;
width: 150
anchors.verticalCenter: parent.verticalCenter
text: root.doc.myQbzLabel || ""
placeholder: QbzSession.tr("My QBZ", QbzSession.trRev)
onCommitted: function (v) { QbzBridge.settingsString("myqbz-label", v) }
}
SettingsButton { kioskHost: root.kioskHost;
anchors.verticalCenter: parent.verticalCenter
iconName: "rotate-ccw"
onClicked: QbzBridge.settingsString("myqbz-label", "")
}
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Invert swipe navigation direction", QbzSession.trRev)
description: QbzSession.tr("Swap the two-finger touchpad swipe: left goes back, right goes forward.", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.invertSwipeNavigation === true
onToggled: function (v) { QbzBridge.settingsBool("invert-swipe-navigation", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Click on menu item navigates to first tab", QbzSession.trRev)
description: QbzSession.tr("Clicking a section in the sidebar or title bar opens its first tab — including your chosen Local Library default — instead of only showing its menu", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.navClickFirstTab === true
onToggled: function (v) { QbzBridge.settingsBool("nav-click-first-tab", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Show Purchases", QbzSession.trRev)
description: QbzSession.tr("Show the Purchases section in the sidebar for browsing and downloading your purchased music", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.showPurchases === true
onToggled: function (v) { QbzBridge.settingsBool("show-purchases", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
// Nothing to place while the section itself is off (owner
// 2026-08-21) — absent rather than rendered-and-inert.
visible: root.doc.showPurchases === true
label: QbzSession.tr("Purchases in title bar", QbzSession.trRev)
description: QbzSession.tr("Place the Purchases entry in the custom title bar instead of the sidebar", QbzSession.trRev)
rowEnabled: !root.tbLocked
QbzToggle { kioskHost: root.kioskHost;
enabled: !root.tbLocked
checked: root.doc.navTbPurchases === true
onToggled: function (v) { QbzBridge.settingsBool("nav-tb-purchases", v) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// ============================== SEARCH ==============================
GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("SEARCH", QbzSession.trRev) }
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Intelligent Search", QbzSession.trRev)
description: QbzSession.tr("Smart search cache, ranking, and the search preview dropdown. On by default.", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.intelligentSearch === true
onToggled: function (v) { QbzBridge.settingsBool("intelligent-search", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Immersive search", QbzSession.trRev)
description: QbzSession.tr("What selecting a result in the Immersive search does. Disabled turns the in-immersive search off.", QbzSession.trRev)
QbzSelect { kioskHost: root.kioskHost;
menuWidth: 200
options: root.doc.immersiveSearchActions || []
currentIndex: root.doc.immersiveSearchActionIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("immersive-search-action", i) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// ========================= PLAYER & VISUALS =========================
GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("PLAYER & VISUALS", QbzSession.trRev) }
@@ -0,0 +1,142 @@
// Settings > Navigation — the navigation and search preferences split out
// of Appearance, which now keeps only the visual groups. Same settingsJson
// document, so every control keeps working unchanged; only the parent moved.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Column {
property bool kioskHost: false
id: root
property var doc: ({})
// ============================ NAVIGATION ============================
GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("NAVIGATION", QbzSession.trRev) }
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Show navigation in sidebar", QbzSession.trRev)
description: QbzSession.tr("Move the Discover, Library, Local Library and My QBZ sections out of the header and into the sidebar.", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.navInSidebar === true
onToggled: function (v) { QbzBridge.settingsBool("nav-in-sidebar", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
// ADR-010: only mounted when navigation is NOT in the sidebar.
visible: root.doc.navInSidebar !== true
label: QbzSession.tr("Compact header navigation", QbzSession.trRev)
description: QbzSession.tr("Use the icon-only section navigation in the header even while the sidebar is open.", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.navHeaderCompact === true
onToggled: function (v) { QbzBridge.settingsBool("nav-header-compact", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("My QBZ", QbzSession.trRev)
description: QbzSession.tr("Rename the My QBZ hub. Leave the name blank (or hit reset) to restore the default.", QbzSession.trRev)
Row {
spacing: 8
QbzLineEdit { kioskHost: root.kioskHost;
width: 150
anchors.verticalCenter: parent.verticalCenter
text: root.doc.myQbzLabel || ""
placeholder: QbzSession.tr("My QBZ", QbzSession.trRev)
onCommitted: function (v) { QbzBridge.settingsString("myqbz-label", v) }
}
SettingsButton { kioskHost: root.kioskHost;
anchors.verticalCenter: parent.verticalCenter
iconName: "rotate-ccw"
onClicked: QbzBridge.settingsString("myqbz-label", "")
}
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Show collapsible My QBZ in sidebar", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.collapsibleMyqbz === true
onToggled: function (v) { QbzBridge.settingsBool("collapsible-myqbz", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
visible: root.doc.collapsibleMyqbz === true
label: QbzSession.tr("Show Mixtapes", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.myqbzShowMixtapes === true
onToggled: function (v) { QbzBridge.settingsBool("myqbz-show-mixtapes", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
visible: root.doc.collapsibleMyqbz === true
label: QbzSession.tr("Show Collections", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.myqbzShowCollections === true
onToggled: function (v) { QbzBridge.settingsBool("myqbz-show-collections", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Invert swipe navigation direction", QbzSession.trRev)
description: QbzSession.tr("Swap the two-finger touchpad swipe: left goes back, right goes forward.", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.invertSwipeNavigation === true
onToggled: function (v) { QbzBridge.settingsBool("invert-swipe-navigation", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Click on menu item navigates to first tab", QbzSession.trRev)
description: QbzSession.tr("Clicking a section in the sidebar or title bar opens its first tab — including your chosen Local Library default — instead of only showing its menu", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.navClickFirstTab === true
onToggled: function (v) { QbzBridge.settingsBool("nav-click-first-tab", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Show Purchases", QbzSession.trRev)
description: QbzSession.tr("Show the Purchases section in the sidebar for browsing and downloading your purchased music", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.showPurchases === true
onToggled: function (v) { QbzBridge.settingsBool("show-purchases", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
// Nothing to place while the section itself is off (owner
// 2026-08-21) — absent rather than rendered-and-inert.
visible: root.doc.showPurchases === true
label: QbzSession.tr("Purchases in title bar", QbzSession.trRev)
description: QbzSession.tr("Place the Purchases entry in the custom title bar instead of the sidebar", QbzSession.trRev)
rowEnabled: !root.tbLocked
QbzToggle { kioskHost: root.kioskHost;
enabled: !root.tbLocked
checked: root.doc.navTbPurchases === true
onToggled: function (v) { QbzBridge.settingsBool("nav-tb-purchases", v) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// ============================== SEARCH ==============================
GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("SEARCH", QbzSession.trRev) }
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Intelligent Search", QbzSession.trRev)
description: QbzSession.tr("Smart search cache, ranking, and the search preview dropdown. On by default.", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.intelligentSearch === true
onToggled: function (v) { QbzBridge.settingsBool("intelligent-search", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Immersive search", QbzSession.trRev)
description: QbzSession.tr("What selecting a result in the Immersive search does. Disabled turns the in-immersive search off.", QbzSession.trRev)
QbzSelect { kioskHost: root.kioskHost;
menuWidth: 200
options: root.doc.immersiveSearchActions || []
currentIndex: root.doc.immersiveSearchActionIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("immersive-search-action", i) }
}
}
Item { width: 1; height: 40 }
}
@@ -70,6 +70,7 @@ Item {
{ label: QbzSession.tr("Audio", QbzSession.trRev), section: 0 },
{ label: QbzSession.tr("Playback", QbzSession.trRev), section: 1 },
{ label: QbzSession.tr("Appearance", QbzSession.trRev), section: 2 },
{ label: QbzSession.tr("Navigation", QbzSession.trRev), section: 10 },
{ label: QbzSession.tr("Offline", QbzSession.trRev), section: 3 },
{ label: QbzSession.tr("Local Library", QbzSession.trRev), section: 4 },
{ label: QbzSession.tr("Blacklist", QbzSession.trRev), section: 5 },
@@ -244,6 +245,12 @@ Item {
active: root.section === 2
onClicked: QbzBridge.settingsSetSection(2)
}
SubNavItem {
name: "compass"
label: QbzSession.tr("Navigation", QbzSession.trRev)
active: root.section === 10
onClicked: QbzBridge.settingsSetSection(10)
}
SubNavItem {
name: "cloud-download"
label: QbzSession.tr("Offline", QbzSession.trRev)
@@ -426,6 +433,15 @@ Item {
}
}
}
Panel {
panelIndex: 10
sourceComponent: Component {
NavigationSettings { kioskHost: root.kioskHost;
width: parent.width
doc: root.doc
}
}
}
Panel {
panelIndex: 3
sourceComponent: Component {
@@ -0,0 +1,78 @@
// MyQbzSidebarMenu — the row menu for a collapsible-sidebar My QBZ item
// (a mixtape or a collection). A deliberately MINIMAL sibling of
// SidebarRowMenu: one action, "Hide from sidebar", reusing the SAME translated
// label and the SAME per-element hidden-flag mechanism playlists carry, without
// threading a collection mode through the playlist menu's nine arms.
//
// The row's TEXT already navigates (openCard) and the chevron already
// collapses; this menu is the row's right-click affordance. Unhiding lives on
// the MyQBZ grid card, the way a hidden playlist is unhidden from the Playlist
// Manager rather than from the sidebar.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
QbzContextMenu {
id: root
menuWidth: 210
leftPadding: 0
rightPadding: 0
topPadding: 5
bottomPadding: 5
/// The clicked item's id (a v4 UUID). Set on every open.
property string itemId: ""
/// Open row-anchored, matching SidebarRowMenu's placement.
function openForItem(id, anchorItem, localX, localY) {
root.itemId = String(id)
root.openAtCursor(anchorItem, localX, localY)
}
// A QML file has no cross-file lexical scope, so QbzContextMenu's own
// `theme` is not reachable here — declare a local one (SidebarRowMenu:137).
QbzTheme { id: theme }
Rectangle {
id: hideRow
width: parent ? parent.width : 0
height: 33
color: hideArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 11
anchors.rightMargin: 16
spacing: 10
QbzIcon {
name: "eye-off"
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
tintName: hideArea.containsMouse ? "textPrimary" : "secondary"
}
Text {
width: parent.width - 25
height: parent.height
text: QbzSession.tr("Hide from sidebar", QbzSession.trRev)
color: hideArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: hideArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.close()
QbzShell.myqbzSetHidden(root.itemId, true)
}
}
}
}
+528 -2
View File
@@ -172,8 +172,100 @@ Rectangle {
// Playlist tree state (phase 7).
property bool searchOpen: false
property bool playlistsCollapsed: false
// PERSISTED collapse state (owner request 2026-09-10): read live from the
// republished settingsJson, toggled through `settingsBool`, so a collapsed
// section stays collapsed across restarts. Default expanded.
readonly property bool playlistsCollapsed: settingsDoc.sidebarPlaylistsCollapsed === true
property string activePlaylistId: ""
// ---- Collapsible My QBZ tree (opt-in) -------------------------------
// Collapse state persisted the same way as the playlist tree above. The
// parent "My QBZ" header collapses the whole block; each section collapses
// its own list.
readonly property bool myqbzCollapsed: settingsDoc.myqbzCollapsed === true
readonly property bool myqbzMixtapesCollapsed: settingsDoc.myqbzMixtapesCollapsed === true
readonly property bool myqbzCollectionsCollapsed: settingsDoc.myqbzCollectionsCollapsed === true
// The light tree feed (myqbz_qt::publish_sidebar_tree): both grids' rows as
// {mixtapes:[{id,name,hidden}], collections:[…]}. GUARDED parse, same
// precedent as `settingsDoc`. Hidden rows are dropped here so the sidebar
// honours the per-element "Hide from sidebar" flag.
// EXPLICIT refresh, not a declarative binding: a plain `var` block binding
// on QbzShell.myqbzTreeJson did not re-evaluate on republish here (the tree
// stayed empty though Rust logged the feed). The grids drive their model the
// same way — `onItemsChanged` + `Component.onCompleted` (MyQbzGridView.qml:
// 152-153) — so mirror that: a Connections handler re-parses on every
// change, and `Component.onCompleted` seeds whatever is already published.
// PLAIN arrays assigned together in refreshMyqbzTree — NOT derived
// bindings. A derived `var` binding on another `var` (myqbzMixtapes:
// _visibleRows(myqbzTree.mixtapes)) did not re-evaluate when the source was
// assigned imperatively (the feed arrived — proven in the log — yet the
// rows stayed empty). Assigning the final arrays directly makes the
// Repeater/empty-state bindings react the ordinary way.
property var myqbzMixtapes: []
property var myqbzCollections: []
// Raw feed rows (hidden dropped), kept so a search / sort change re-derives
// the visible arrays without a DB round trip.
property var _myqbzMixRaw: []
property var _myqbzColRaw: []
// Playlists-parity toolbar state: inline search over the tree, and a simple
// A–Z / Z–A name sort.
property bool myqbzSearchOpen: false
property string myqbzSearch: ""
property bool myqbzSortAsc: true
function _visibleRows(arr) {
var out = []
if (!arr) return out
for (var i = 0; i < arr.length; ++i)
if (arr[i] && arr[i].hidden !== true) out.push(arr[i])
return out
}
// Apply the live search filter + name sort to one raw list.
function _filterSortMyqbz(arr) {
var q = root.myqbzSearch.trim().toLowerCase()
var out = []
for (var i = 0; i < arr.length; ++i) {
var r = arr[i]
if (!r) continue
if (q !== "" && String(r.name).toLowerCase().indexOf(q) === -1) continue
out.push(r)
}
out.sort(function (a, b) {
var an = String(a.name).toLowerCase()
var bn = String(b.name).toLowerCase()
if (an === bn) return 0
var lt = an < bn
return root.myqbzSortAsc ? (lt ? -1 : 1) : (lt ? 1 : -1)
})
return out
}
function _applyMyqbz() {
root.myqbzMixtapes = _filterSortMyqbz(root._myqbzMixRaw)
root.myqbzCollections = _filterSortMyqbz(root._myqbzColRaw)
}
function refreshMyqbzTree() {
var t
try { t = JSON.parse(QbzShell.myqbzTreeJson) }
catch (e) { t = ({ "mixtapes": [], "collections": [] }) }
root._myqbzMixRaw = _visibleRows(t.mixtapes)
root._myqbzColRaw = _visibleRows(t.collections)
root._applyMyqbz()
}
onMyqbzSearchChanged: root._applyMyqbz()
onMyqbzSortAscChanged: root._applyMyqbz()
Connections {
target: QbzShell
function onMyqbzTreeJsonChanged() { root.refreshMyqbzTree() }
}
Component.onCompleted: root.refreshMyqbzTree()
// Master gate + the two section gates + the standard-entry suppressor.
readonly property bool myqbzTreeOn: settingsDoc.collapsibleMyqbz === true
readonly property bool myqbzShowMixtapes: settingsDoc.myqbzShowMixtapes !== false
readonly property bool myqbzShowCollections: settingsDoc.myqbzShowCollections !== false
// The collapsible tree REPLACES the flat My QBZ nav entry — its parent
// header click reaches the same surface — so the two are mutually
// exclusive: whenever the tree is on, the classic row is suppressed. (No
// separate toggle; keeping both would be the same entry twice.)
readonly property bool hideStandardMyqbz: myqbzTreeOn
// ---- Large-NPB dock space reservation -------------------------------
// The cover+spectrum dock is an AppShell-ROOT overlay pinned flush to the
// WINDOW bottom-left, so it extends the bar's height BELOW this sidebar
@@ -363,6 +455,37 @@ Rectangle {
visibleFolders: root.visibleFolders
}
// Right-click menu for a collapsible My QBZ tree row (mixtape/collection):
// one action, "Hide from sidebar".
MyQbzSidebarMenu { id: myqbzRowMenu }
// My QBZ header "+" — create a new Mixtape or Collection (app-global modal).
CardMenu {
id: myqbzPlusMenu
menuWidth: 190
entries: [
{ "label": QbzSession.tr("New Mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "mixtape" },
{ "label": QbzSession.tr("New Collection", QbzSession.trRev), "icon": "library-big", "action": "collection" },
]
onPicked: function (a) { QbzShell.myqbzCreateOpen(a) }
}
// My QBZ header "..." — a simple name sort plus refresh.
CardMenu {
id: myqbzMoreMenu
menuWidth: 190
entries: [
{ "label": QbzSession.tr("Name (A-Z)", QbzSession.trRev), "icon": "chevron-down", "action": "az" },
{ "label": QbzSession.tr("Name (Z-A)", QbzSession.trRev), "icon": "chevron-up", "action": "za" },
{ "sep": true },
{ "label": QbzSession.tr("Refresh", QbzSession.trRev), "icon": "refresh-cw", "action": "refresh" },
]
onPicked: function (a) {
if (a === "az") root.myqbzSortAsc = true
else if (a === "za") root.myqbzSortAsc = false
else if (a === "refresh") QbzShell.myqbzRefreshTree()
}
}
width: QbzShell.sidebarState === 2 ? 0
: QbzShell.sidebarState === 1 ? theme.sidebarMiniWidth
: theme.sidebarOpenWidth
@@ -528,6 +651,130 @@ Rectangle {
}
}
// A collapsible My QBZ tree HEADER: "My QBZ" / "Mixtapes" / "Collections".
// TWO hit-areas by owner intent — the TEXT is a link to that surface, the
// CHEVRON only collapses its list. `indent` nests the section headers under
// the parent. Muted, discreet; not a full nav row.
component MyQbzHeader: Rectangle {
id: mqHeader
property string label: ""
property bool expanded: true
property int indent: 0
property bool showChevron: true
signal linkClicked()
signal toggle()
width: parent ? parent.width : 0
height: 30
radius: 6
color: (mqLinkArea.containsMouse || mqChevArea.containsMouse)
? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8 + mqHeader.indent
anchors.rightMargin: 4
spacing: 4
Text {
id: mqLinkText
width: parent.width - 26
height: parent.height
text: mqHeader.label
color: mqLinkArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: mqHeader.indent > 0 ? 12 : 13
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
MouseArea {
id: mqLinkArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: mqHeader.linkClicked()
}
}
Rectangle {
width: 22
height: 22
radius: 4
visible: mqHeader.showChevron
anchors.verticalCenter: parent.verticalCenter
color: mqChevArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
name: mqHeader.expanded ? "chevron-down" : "chevron-right"
width: 14
height: 14
anchors.centerIn: parent
tintName: mqChevArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: mqChevArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: mqHeader.toggle()
}
}
}
}
// A collapsible My QBZ tree ITEM: one mixtape/collection. Click opens its
// detail (openCard); right-click offers "Hide from sidebar".
component MyQbzItem: Rectangle {
id: mqItem
property string itemId: ""
property string label: ""
property bool isActive: false
width: parent ? parent.width : 0
height: 28
radius: 6
color: (mqItemArea.containsMouse || mqItem.isActive)
? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
// Indented past the section header's text so items read as children.
anchors.leftMargin: 26
anchors.rightMargin: 8
spacing: 8
QbzIcon {
name: "music"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: (mqItemArea.containsMouse || mqItem.isActive) ? "textPrimary" : "muted"
}
Text {
width: parent.width - 21
height: parent.height
text: mqItem.label
color: (mqItemArea.containsMouse || mqItem.isActive)
? theme.textPrimary : theme.textSecondary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: mqItemArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
var lx = mqItem.width - myqbzRowMenu.menuWidth
myqbzRowMenu.openForItem(mqItem.itemId, mqItem, Math.max(0, lx), 26)
} else {
QbzShell.myqbzOpenCard(mqItem.itemId)
}
}
}
}
Column {
anchors.fill: parent
// Mini left/right are ASYMMETRIC on purpose — see `miniPadLeft` at the
@@ -563,7 +810,286 @@ Rectangle {
delegate: NavRow {
required property var modelData
section: modelData
// Offline-gated as before, PLUS the opt-in suppressor: with
// the collapsible tree on and "Hide standard My QBZ menu
// entry" checked, the flat My QBZ section row gives way to
// the tree block mounted right below this Repeater.
visible: !(modelData.qobuz && QbzSession.offline)
&& !(modelData.id === "myqbz" && root.hideStandardMyqbz)
}
}
// ---- Collapsible My QBZ tree (opt-in) ----------------------
// Sits right under the four section rows (My QBZ is the last of
// them), so its expanded tree reads as that section's contents.
// Hidden on the mini rail and whenever the opt-in is off. The
// parent "My QBZ" header collapses the whole block; each section
// header links to its grid and collapses its own list.
Column {
id: myqbzTree
visible: !root.mini && root.myqbzTreeOn
width: parent.width
spacing: 2
// Parent header TOOLBAR — Playlists-parity: branding icon +
// title (a link to the My QBZ surface) on the LEFT; search /
// new / more / collapse FLOATING on the right, their chevron
// aligned to the playlist carets (rightMargin 4 → the 22px box's
// 15px icon centres on width-15, same as the row carets and the
// Mixtapes/Collections sub-chevrons).
Item {
width: parent.width
height: 34
// Branding icon — user image, else the baked qbz-symbolic.
Item {
id: myqbzBrandIcon
anchors.left: parent.left
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
width: 18
height: 18
Image {
visible: navFlyout.brandingIconPath !== ""
source: navFlyout.brandingIconPath
width: 16
height: 16
anchors.centerIn: parent
fillMode: Image.PreserveAspectFit
mipmap: true
}
QbzIcon {
visible: navFlyout.brandingIconPath === ""
name: "qbz-symbolic"
width: 16
height: 16
anchors.centerIn: parent
tintName: "secondary"
}
}
// Floating right-hand controls.
Row {
id: myqbzHeaderBtns
anchors.right: parent.right
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
spacing: 4
// Search toggle.
Rectangle {
width: 22
height: 22
radius: 4
color: (myqbzSearchArea.containsMouse || root.myqbzSearchOpen) ? theme.surfaceHover : "transparent"
QbzIcon {
name: "search"
width: 15
height: 15
anchors.centerIn: parent
tintName: (myqbzSearchArea.containsMouse || root.myqbzSearchOpen) ? "textPrimary" : "muted"
}
MouseArea {
id: myqbzSearchArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.myqbzSearchOpen = !root.myqbzSearchOpen
if (!root.myqbzSearchOpen) {
myqbzSearchEdit.text = ""
root.myqbzSearch = ""
} else {
myqbzSearchEdit.forceActiveFocus()
}
}
}
}
// New (+).
Rectangle {
width: 22
height: 22
radius: 4
color: myqbzPlusArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "plus"
width: 16
height: 16
anchors.centerIn: parent
tintName: myqbzPlusArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: myqbzPlusArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: myqbzPlusMenu.openBelowLeft(myqbzPlusArea)
}
}
// More (…) — sort + refresh.
Rectangle {
width: 22
height: 22
radius: 4
color: myqbzMoreArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "ellipsis"
width: 15
height: 15
anchors.centerIn: parent
tintName: myqbzMoreArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: myqbzMoreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: myqbzMoreMenu.openBelowLeft(myqbzMoreArea)
}
}
// Collapse chevron (down = expanded).
Rectangle {
width: 22
height: 22
radius: 4
color: myqbzChevArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: root.myqbzCollapsed ? "chevron-right" : "chevron-down"
width: 15
height: 15
anchors.centerIn: parent
tintName: myqbzChevArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: myqbzChevArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.settingsBool("myqbz-collapsed", !root.myqbzCollapsed)
}
}
}
// Title — a link to the My QBZ surface (Collections). Fills
// between the icon and the floating controls.
Text {
visible: !root.myqbzSearchOpen
anchors.left: myqbzBrandIcon.right
anchors.leftMargin: 8
anchors.right: myqbzHeaderBtns.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: navFlyout.brandingLabel
color: myqbzTitleArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
font.weight: theme.weightMedium
elide: Text.ElideRight
MouseArea {
id: myqbzTitleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.navigateTo("collections")
}
}
// Inline search field (filters the tree client-side).
Rectangle {
visible: root.myqbzSearchOpen
anchors.left: myqbzBrandIcon.right
anchors.leftMargin: 8
anchors.right: myqbzHeaderBtns.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
height: 22
radius: 4
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
TextInput {
id: myqbzSearchEdit
anchors.fill: parent
anchors.leftMargin: 6
color: theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: root.myqbzSearch = text
Text {
visible: myqbzSearchEdit.text === ""
anchors.fill: parent
text: QbzSession.tr("Search My QBZ", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
}
}
}
// Mixtapes section (gated by its own opt-in).
MyQbzHeader {
visible: !root.myqbzCollapsed && root.myqbzShowMixtapes
width: parent.width
indent: 12
label: QbzSession.tr("Mixtapes", QbzSession.trRev)
expanded: !root.myqbzMixtapesCollapsed
onLinkClicked: QbzShell.navigateTo("mixtapes")
onToggle: QbzBridge.settingsBool("myqbz-mixtapes-collapsed", !root.myqbzMixtapesCollapsed)
}
Repeater {
model: (!root.myqbzCollapsed && root.myqbzShowMixtapes
&& !root.myqbzMixtapesCollapsed) ? root.myqbzMixtapes : []
delegate: MyQbzItem {
required property var modelData
width: myqbzTree.width
itemId: String(modelData.id)
label: String(modelData.name)
}
}
Text {
visible: !root.myqbzCollapsed && root.myqbzShowMixtapes
&& !root.myqbzMixtapesCollapsed && root.myqbzMixtapes.length === 0
width: parent.width
leftPadding: 26
height: 22
text: QbzSession.tr("No mixtapes yet.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Collections section (gated by its own opt-in).
MyQbzHeader {
visible: !root.myqbzCollapsed && root.myqbzShowCollections
width: parent.width
indent: 12
label: QbzSession.tr("Collections", QbzSession.trRev)
expanded: !root.myqbzCollectionsCollapsed
onLinkClicked: QbzShell.navigateTo("collections")
onToggle: QbzBridge.settingsBool("myqbz-collections-collapsed", !root.myqbzCollectionsCollapsed)
}
Repeater {
model: (!root.myqbzCollapsed && root.myqbzShowCollections
&& !root.myqbzCollectionsCollapsed) ? root.myqbzCollections : []
delegate: MyQbzItem {
required property var modelData
width: myqbzTree.width
itemId: String(modelData.id)
label: String(modelData.name)
}
}
Text {
visible: !root.myqbzCollapsed && root.myqbzShowCollections
&& !root.myqbzCollectionsCollapsed && root.myqbzCollections.length === 0
width: parent.width
leftPadding: 26
height: 22
text: QbzSession.tr("No collections yet.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
@@ -1081,7 +1607,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.playlistsCollapsed = !root.playlistsCollapsed
onClicked: QbzBridge.settingsBool("sidebar-playlists-collapsed", !root.playlistsCollapsed)
}
}
}
@@ -192,6 +192,13 @@ Rectangle {
readonly property bool loading: doc.loading === true
readonly property bool found: doc.found === true
// The collapsible My QBZ opt-in gates the sidebar hide/show toggle in the
// hero overflow — the concept is meaningless while the flat entry is used.
// GUARDED parse, the NavFlyout.qml:65-70 precedent.
readonly property bool collapsibleMyqbzOn: {
try { return JSON.parse(QbzBridge.settingsJson).collapsibleMyqbz === true }
catch (e) { return false }
}
readonly property string kind: doc.kind || ""
readonly property int itemCount: doc.itemCount || 0
readonly property string viewMode: doc.viewMode || "list"
@@ -816,6 +823,45 @@ Rectangle {
font.pixelSize: 12
}
}
// Sub-header for the expanded track list.
// The item's OWN columns (Item/Type/Source/
// Quality/Tracks/Year) name the collection
// rows, not these child tracks — so a small,
// muted, column-aligned label band sits over
// the inline tracks instead. Reuses the
// shared TrackListHeader with the SAME arms
// the inline TrackRow sets (menu only), so
// "#", "Title", "Duration", "Quality" land on
// their columns by construction. Shown only
// once the tracks have loaded (never over the
// spinner or the empty state).
Item {
visible: !rowCell.expandLoading
&& rowCell.tracksLoaded
&& rowCell.inlineTracks.length > 0
width: parent.width
height: visible ? 22 : 0
TrackListHeader {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
showArtwork: false
showSource: false
showAlbum: false
showFavorite: false
showDownload: false
showMenu: true
showReorder: false
}
// Discreet hairline under the labels.
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: theme.surfaceElevated
}
}
Repeater {
model: rowCell.inlineTracks
delegate: TrackRow {
@@ -1265,6 +1311,15 @@ Rectangle {
m.push({ "label": root.kind === "mixtape"
? t("Convert to Collection", r) : t("Convert to Mixtape", r),
"icon": "cassette-tape", "action": "convert" })
// Sidebar visibility — only meaningful with the collapsible tree on.
// The label reflects the CURRENT state (the mechanism playlists carry).
if (root.collapsibleMyqbzOn) {
m.push({ "sep": true })
m.push({ "label": root.doc.hidden === true
? t("Show in sidebar", r) : t("Hide from sidebar", r),
"icon": root.doc.hidden === true ? "eye" : "eye-off",
"action": "toggle-hidden" })
}
m.push({ "sep": true })
m.push({ "label": t("Delete", r), "icon": "trash-2", "action": "delete", "danger": true })
return m
@@ -1277,6 +1332,8 @@ Rectangle {
else if (a === "clear-cover") QbzMyQbz.removeCover()
else if (a === "play-mode") QbzMyQbz.togglePlayMode()
else if (a === "convert") QbzMyQbz.convertKind()
else if (a === "toggle-hidden")
QbzMyQbz.setCollectionHidden(root.doc.id, root.doc.hidden !== true)
else if (a === "delete") QbzMyQbz.openDelete()
}
+7
View File
@@ -1309,6 +1309,13 @@ pub(crate) fn publish_sidebar() {
.set_sidebar_sort_by(QString::from(sort_by.as_str()));
b.as_mut().set_sidebar_sort_asc(sort_asc);
});
// The collapsible My QBZ tree rides the SAME funnel: `load_sidebar_once()`
// publishes the sidebar once per session (the reliable startup point, after
// the QML singletons exist), and every sidebar mutation republishes here.
// Gated by the opt-in inside, so a flat-sidebar profile pays nothing.
// `myqbz_qt::reload_grids` still republishes it on mixtape/collection
// mutations, which the playlist path here does not see.
crate::myqbz_qt::publish_sidebar_tree();
}
pub(crate) fn sidebar_set_sort(option: &str) {
+9
View File
@@ -74,6 +74,11 @@ pub mod qbz_myqbz_bridge {
/// Grid card click → nav push + load the detail document.
#[qinvokable]
fn open_card(self: Pin<&mut QbzMyQbz>, id: QString);
/// Collapsible-sidebar row menu: hide (or unhide) a collection from the
/// sidebar tree — the same "hide from sidebar" affordance playlists use.
/// The grids and the management listing keep showing it.
#[qinvokable]
fn set_collection_hidden(self: Pin<&mut QbzMyQbz>, id: QString, hidden: bool);
/// `grid` is "mixtapes" | "collections" (the document's own identity).
#[qinvokable]
fn grid_search(self: Pin<&mut QbzMyQbz>, grid: QString, query: QString);
@@ -383,6 +388,10 @@ impl qbz_myqbz_bridge::QbzMyQbz {
crate::myqbz_detail_qt::open(id.to_string());
}
pub fn set_collection_hidden(self: Pin<&mut Self>, id: QString, hidden: bool) {
crate::myqbz_qt::set_collection_hidden(id.to_string(), hidden);
}
pub fn kiosk_grid_artwork(self: Pin<&mut Self>, grid: QString, first: i32, last: i32, px: i32) {
crate::myqbz_qt::kiosk_grid_artwork(&grid.to_string(), first, last, px);
}
+6
View File
@@ -343,6 +343,10 @@ pub struct DetailDoc {
pub filter_count: i32,
#[serde(rename = "hasAnyFilter")]
pub has_any_filter: bool,
/// Sidebar visibility (the collapsible My QBZ tree's per-element flag). The
/// hero overflow menu flips it — "Hide from sidebar" / "Show in sidebar" —
/// which is the unhide surface for a row the sidebar dropped.
pub hidden: bool,
pub items: Vec<DetailRow>,
}
@@ -381,6 +385,7 @@ impl Default for DetailDoc {
selected_count: 0,
filter_count: 0,
has_any_filter: false,
hidden: false,
items: Vec::new(),
}
}
@@ -1456,6 +1461,7 @@ fn apply(c: MixtapeCollection, stored_open: Vec<String>) {
d.meta = album_count_label(item_count);
d.item_count = item_count as i32;
d.play_mode = play_mode_str(c.play_mode).to_string();
d.hidden = c.hidden;
d.found = true;
// Header custom cover: non-empty AND resolvable. `cached_path` returns
+97
View File
@@ -408,6 +408,10 @@ pub(crate) fn init_for_user(dir: &Path, user_id: u64) {
dir.display()
),
}
// Seed the collapsible-sidebar tree for this account: the grids load lazily
// when the MyQBZ view opens, but the sidebar tree must be present from the
// first frame the shell paints (no-op when the opt-in is OFF).
publish_sidebar_tree();
}
/// Drop every per-user grid trace on logout, or the next account inherits the
@@ -422,6 +426,12 @@ pub(crate) fn teardown() {
}
*CREATE.lock().unwrap_or_else(|e| e.into_inner()) = CreateDoc::default();
publish_create();
// Clear the sidebar tree too, or the next account inherits these rows.
crate::shell_bridge::ui(move |mut b| {
b.as_mut().set_myqbz_tree_json(QString::from(
"{\"mixtapes\":[],\"collections\":[]}",
));
});
}
// ---------------------------------------------------------------------------
@@ -905,6 +915,93 @@ pub(crate) fn load_grid(grid: Grid) {
pub(crate) fn reload_grids() {
load_grid(Grid::Mixtapes);
load_grid(Grid::Collections);
publish_sidebar_tree();
}
/// Publish the collapsible-sidebar tree feed (opt-in). A LIGHT feed — id / name
/// / hidden per collection, split into the Mixtapes and Collections sections —
/// so `Sidebar.qml` renders the tree without touching the DB. Called from
/// `reload_grids()` (every mutation) and at session bind, so the tree tracks
/// creates, renames, deletes, kind-converts and hide toggles live (spec §3).
///
/// Skips the DB read entirely when the opt-in is OFF: a fresh/flat profile pays
/// nothing. The `hidden` flag reuses the mixtape row's own column — the same
/// "hide from sidebar" mechanism playlists use — so hidden rows ship in the
/// feed and the QML drops them (and the management listing shows them greyed).
pub(crate) fn publish_sidebar_tree() {
if !crate::settings_qt::collapsible_myqbz() {
return;
}
crate::spawn(async move {
let rows = tokio::task::spawn_blocking(|| list_collections(None))
.await
.unwrap_or_default();
let mut mixtapes: Vec<serde_json::Value> = Vec::new();
let mut collections: Vec<serde_json::Value> = Vec::new();
for c in rows {
let node = serde_json::json!({
"id": c.id,
"name": c.name,
"hidden": c.hidden,
});
match c.kind {
CollectionKind::Mixtape => mixtapes.push(node),
// Collections section mirrors the Collections grid: everything
// that is not a mixtape (plain collection + artist_collection).
_ => collections.push(node),
}
}
log::info!(
"[qbz-qt] myqbz sidebar tree published: {} mixtapes, {} collections",
mixtapes.len(),
collections.len()
);
let json = serde_json::json!({
"mixtapes": mixtapes,
"collections": collections,
})
.to_string();
// Published on QbzShell (not QbzMyQbz): the sidebar reads it, and
// QbzMyQbz is not reliably registered in the shell QML context.
crate::shell_bridge::ui(move |mut b| {
b.as_mut().set_myqbz_tree_json(QString::from(json.as_str()));
});
});
}
/// Toggle a collection's `hidden` flag from the collapsible sidebar tree's row
/// menu — the "Hide from sidebar" affordance, the SAME mechanism playlists
/// carry. Writes on a blocking worker, then `reload_grids()` re-lists the grids
/// and republishes the tree so the row vanishes (or returns) live. The grids
/// keep showing every row; only the sidebar drops the hidden ones. No-op with
/// an empty id; failures are logged, not toasted (a silent hide/unhide, like
/// the playlist one).
pub(crate) fn set_collection_hidden(id: String, hidden: bool) {
if id.is_empty() {
return;
}
crate::spawn(async move {
let write_id = id.clone();
let result = tokio::task::spawn_blocking(move || {
crate::library_db_qt::with_db(true, |db| {
Ok(db
.with_connection(|conn| qbz_mixtape::repo::set_hidden(conn, &write_id, hidden))
.map_err(|e| e.to_string()))
})
.unwrap_or_else(|| Err("library database unavailable".to_string()))
})
.await
.unwrap_or_else(|e| Err(format!("set-hidden task panicked: {e}")));
match result {
Ok(()) => {
reload_grids();
// Refresh the detail if THIS collection is open, so its hero
// menu flips between "Hide from sidebar" and "Show in sidebar".
crate::myqbz_detail_qt::reload_if_open(&id);
}
Err(e) => log::warn!("[qbz-qt] myqbz set_collection_hidden failed: {e}"),
}
});
}
/// Re-render both grids from cache after a live language switch: the eyebrow
+99
View File
@@ -910,6 +910,37 @@ pub fn nav_header_compact() -> bool {
pref_bool("nav_header_compact", NAV_HEADER_COMPACT_DEFAULT)
}
/// The collapsible "My QBZ" sidebar section (opt-in, OFF by default). When ON it
/// reveals the three sub-toggles below; a fresh profile keeps the flat entry.
pub fn collapsible_myqbz() -> bool {
pref_bool("collapsible_myqbz", false)
}
pub fn myqbz_show_mixtapes() -> bool {
pref_bool("myqbz_show_mixtapes", true)
}
pub fn myqbz_show_collections() -> bool {
pref_bool("myqbz_show_collections", true)
}
pub fn hide_standard_myqbz() -> bool {
pref_bool("hide_standard_myqbz", false)
}
// Sidebar tree collapse state — persisted so a collapsed section stays
// collapsed across restarts (owner request 2026-09-10). All default EXPANDED
// (false). The playlist tree and the three My QBZ sections each keep their own.
pub fn sidebar_playlists_collapsed() -> bool {
pref_bool("sidebar_playlists_collapsed", false)
}
pub fn myqbz_collapsed() -> bool {
pref_bool("myqbz_collapsed", false)
}
pub fn myqbz_mixtapes_collapsed() -> bool {
pref_bool("myqbz_mixtapes_collapsed", false)
}
pub fn myqbz_collections_collapsed() -> bool {
pref_bool("myqbz_collections_collapsed", false)
}
/// Playlist rows draw a 2x2 micro-collage of track covers (Slint
/// SidebarState.playlist-collage). Opt-OUT — default ON.
pub fn sidebar_playlist_collage() -> bool {
@@ -2092,6 +2123,23 @@ pub struct SettingsDoc {
pub nav_in_sidebar: bool,
#[serde(rename = "navHeaderCompact")]
pub nav_header_compact: bool,
#[serde(rename = "collapsibleMyqbz")]
pub collapsible_myqbz: bool,
#[serde(rename = "myqbzShowMixtapes")]
pub myqbz_show_mixtapes: bool,
#[serde(rename = "myqbzShowCollections")]
pub myqbz_show_collections: bool,
#[serde(rename = "hideStandardMyqbz")]
pub hide_standard_myqbz: bool,
// Persisted sidebar-tree collapse state (default expanded).
#[serde(rename = "sidebarPlaylistsCollapsed")]
pub sidebar_playlists_collapsed: bool,
#[serde(rename = "myqbzCollapsed")]
pub myqbz_collapsed: bool,
#[serde(rename = "myqbzMixtapesCollapsed")]
pub myqbz_mixtapes_collapsed: bool,
#[serde(rename = "myqbzCollectionsCollapsed")]
pub myqbz_collections_collapsed: bool,
#[serde(rename = "myQbzLabel")]
pub my_qbz_label: String,
#[serde(rename = "sidebarPlaylistCollage")]
@@ -2625,6 +2673,14 @@ pub async fn publish_snapshot() {
),
nav_in_sidebar: nav_in_sidebar(),
nav_header_compact: nav_header_compact(),
collapsible_myqbz: collapsible_myqbz(),
myqbz_show_mixtapes: myqbz_show_mixtapes(),
myqbz_show_collections: myqbz_show_collections(),
hide_standard_myqbz: hide_standard_myqbz(),
sidebar_playlists_collapsed: sidebar_playlists_collapsed(),
myqbz_collapsed: myqbz_collapsed(),
myqbz_mixtapes_collapsed: myqbz_mixtapes_collapsed(),
myqbz_collections_collapsed: myqbz_collections_collapsed(),
my_qbz_label: myqbz_label(),
sidebar_playlist_collage: pref_bool("sidebar_playlist_collage", true),
queue_track_artwork: pref_bool("queue_track_artwork", false),
@@ -3455,6 +3511,49 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
crate::shell_bridge::ui(move |mut b| b.as_mut().set_nav_header_compact(value));
Ok(Apply::None)
}
// Collapsible My QBZ sidebar opt-ins. Persisted here; the sidebar reads
// them live from the republished settingsJson (Phase 3 wires the tree).
"collapsible-myqbz" => {
save_pref("collapsible_myqbz", serde_json::json!(value));
// When turned ON, populate the tree now: `publish_sidebar_tree()`
// no-ops while the opt-in is OFF, so without this the sidebar would
// stay empty until the next mixtape mutation. (Turning OFF needs no
// push — the sidebar hides the tree off the republished settings.)
if value {
crate::myqbz_qt::publish_sidebar_tree();
}
Ok(Apply::None)
}
"myqbz-show-mixtapes" => {
save_pref("myqbz_show_mixtapes", serde_json::json!(value));
Ok(Apply::None)
}
"myqbz-show-collections" => {
save_pref("myqbz_show_collections", serde_json::json!(value));
Ok(Apply::None)
}
"hide-standard-myqbz" => {
save_pref("hide_standard_myqbz", serde_json::json!(value));
Ok(Apply::None)
}
// Sidebar-tree collapse state — persisted and read live from the
// republished settingsJson by Sidebar.qml (no bridge property needed).
"sidebar-playlists-collapsed" => {
save_pref("sidebar_playlists_collapsed", serde_json::json!(value));
Ok(Apply::None)
}
"myqbz-collapsed" => {
save_pref("myqbz_collapsed", serde_json::json!(value));
Ok(Apply::None)
}
"myqbz-mixtapes-collapsed" => {
save_pref("myqbz_mixtapes_collapsed", serde_json::json!(value));
Ok(Apply::None)
}
"myqbz-collections-collapsed" => {
save_pref("myqbz_collections_collapsed", serde_json::json!(value));
Ok(Apply::None)
}
"sidebar-playlist-collage" => {
save_pref("sidebar_playlist_collage", serde_json::json!(value));
// LIVE: the sidebar rows swap collage <-> list-music glyph off the
+38
View File
@@ -157,6 +157,14 @@ pub mod qbz_shell {
#[qproperty(QString, sidebar_json)]
#[qproperty(QString, sidebar_sort_by)]
#[qproperty(bool, sidebar_sort_asc)]
// Collapsible My QBZ tree feed (opt-in): both grids' rows as
// `{mixtapes:[{id,name,hidden}], collections:[…]}`. It lives on
// QbzShell — NOT QbzMyQbz — because the sidebar reads it: QbzMyQbz is
// not reliably registered in the shell QML context (NavFlyout.qml:89
// documents the same hazard), so a binding on it errors on first eval,
// captures no dependency and never updates. QbzShell is always resolved
// here. Published by `myqbz_qt::publish_sidebar_tree`.
#[qproperty(QString, myqbz_tree_json)]
// The MINI-RAIL folder flyout's own document (contract §4.7):
// `{folderId, folderName, count, rows:[{id,name,isLocal}]}`. It is a
// separate document from `sidebar_json` because a COLLAPSED folder's
@@ -693,6 +701,18 @@ pub mod qbz_shell {
/// header). Online the two verbs do the same thing.
#[qinvokable]
fn reload_sidebar(self: Pin<&mut QbzShell>);
/// Collapsible My QBZ tree actions, routed through QbzShell so the
/// sidebar never touches QbzMyQbz (not reliably registered here).
#[qinvokable]
fn myqbz_open_card(self: Pin<&mut QbzShell>, id: QString);
#[qinvokable]
fn myqbz_set_hidden(self: Pin<&mut QbzShell>, id: QString, hidden: bool);
/// Open the create modal (app-global MyQbzModals) for "mixtape" |
/// "collection", and force a tree republish, both from the sidebar.
#[qinvokable]
fn myqbz_create_open(self: Pin<&mut QbzShell>, kind: QString);
#[qinvokable]
fn myqbz_refresh_tree(self: Pin<&mut QbzShell>);
#[qinvokable]
fn sidebar_set_sort(self: Pin<&mut QbzShell>, option: QString);
#[qinvokable]
@@ -823,6 +843,7 @@ pub struct QbzShellRust {
sidebar_json: QString,
sidebar_sort_by: QString,
sidebar_sort_asc: bool,
myqbz_tree_json: QString,
sidebar_folder_popup_json: QString,
system_title_bar: bool,
system_title_bar_pref: bool,
@@ -924,6 +945,7 @@ impl Default for QbzShellRust {
sidebar_json: QString::from("[]"),
sidebar_sort_by: QString::from("name"),
sidebar_sort_asc: true,
myqbz_tree_json: QString::from("{\"mixtapes\":[],\"collections\":[]}"),
// FULL SHAPE, not "{}" — see the qproperty comment.
sidebar_folder_popup_json: QString::from(
r#"{"folderId":"","folderName":"","count":0,"rows":[]}"#,
@@ -1457,6 +1479,22 @@ impl qbz_shell::QbzShell {
crate::reload_sidebar_including_local();
}
pub fn myqbz_open_card(self: Pin<&mut Self>, id: QString) {
crate::myqbz_detail_qt::open(id.to_string());
}
pub fn myqbz_set_hidden(self: Pin<&mut Self>, id: QString, hidden: bool) {
crate::myqbz_qt::set_collection_hidden(id.to_string(), hidden);
}
pub fn myqbz_create_open(self: Pin<&mut Self>, kind: QString) {
crate::myqbz_qt::create_open(&kind.to_string());
}
pub fn myqbz_refresh_tree(self: Pin<&mut Self>) {
crate::myqbz_qt::publish_sidebar_tree();
}
pub fn sidebar_set_sort(self: Pin<&mut Self>, option: QString) {
crate::sidebar_set_sort(&option.to_string());
}