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:
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"
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user