From e402fa250a51b795e4edce4adcee60cfd9b85d8e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:19:48 -0600 Subject: [PATCH] feat(myqbz): collapsible My QBZ sidebar with per-element hide and toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../translations/de/LC_MESSAGES/qbz-ui.po | 33 ++ .../translations/en/LC_MESSAGES/qbz-ui.po | 33 ++ .../translations/es/LC_MESSAGES/qbz-ui.po | 33 ++ .../translations/fr/LC_MESSAGES/qbz-ui.po | 33 ++ .../translations/ja/LC_MESSAGES/qbz-ui.po | 33 ++ .../translations/nl/LC_MESSAGES/qbz-ui.po | 33 ++ .../translations/pt/LC_MESSAGES/qbz-ui.po | 33 ++ .../translations/ru/LC_MESSAGES/qbz-ui.po | 33 ++ crates/qbz-mixtape/src/repo.rs | 32 ++ crates/qbz-qt/build.rs | 2 + .../qml/settings/AppearanceSettings.qml | 108 ---- .../qml/settings/NavigationSettings.qml | 142 +++++ crates/qbz-qt/qml/settings/SettingsView.qml | 16 + crates/qbz-qt/qml/shell/MyQbzSidebarMenu.qml | 78 +++ crates/qbz-qt/qml/shell/Sidebar.qml | 530 +++++++++++++++++- .../qml/views/myqbz/MyQbzDetailView.qml | 57 ++ crates/qbz-qt/src/main.rs | 7 + crates/qbz-qt/src/myqbz_bridge.rs | 9 + crates/qbz-qt/src/myqbz_detail_qt.rs | 6 + crates/qbz-qt/src/myqbz_qt.rs | 97 ++++ crates/qbz-qt/src/settings_qt.rs | 99 ++++ crates/qbz-qt/src/shell_bridge.rs | 38 ++ 22 files changed, 1375 insertions(+), 110 deletions(-) create mode 100644 crates/qbz-qt/qml/settings/NavigationSettings.qml create mode 100644 crates/qbz-qt/qml/shell/MyQbzSidebarMenu.qml diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index b685b42b4..d477f3eed 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 7c3e7838d..a3b1a2ec5 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index d5712f803..e4a033203 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 6a0505d2c..4166bb7ad 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 7fa0f48a9..6586fc277 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -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 を検索" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 84026eb2d..046fdc6eb 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index 1220a93aa..c9b09d2b4 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index 2fb57a542..b45c64071 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-mixtape/src/repo.rs b/crates/qbz-mixtape/src/repo.rs index c94160d1e..795af5822 100644 --- a/crates/qbz-mixtape/src/repo.rs +++ b/crates/qbz-mixtape/src/repo.rs @@ -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(); diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index c021ad9fa..c2b5cdb4b 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -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", diff --git a/crates/qbz-qt/qml/settings/AppearanceSettings.qml b/crates/qbz-qt/qml/settings/AppearanceSettings.qml index 9d68ceb44..82f7c1271 100644 --- a/crates/qbz-qt/qml/settings/AppearanceSettings.qml +++ b/crates/qbz-qt/qml/settings/AppearanceSettings.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) } diff --git a/crates/qbz-qt/qml/settings/NavigationSettings.qml b/crates/qbz-qt/qml/settings/NavigationSettings.qml new file mode 100644 index 000000000..79f2f5f9d --- /dev/null +++ b/crates/qbz-qt/qml/settings/NavigationSettings.qml @@ -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 } +} diff --git a/crates/qbz-qt/qml/settings/SettingsView.qml b/crates/qbz-qt/qml/settings/SettingsView.qml index 9ca204e6d..32e1f7c6d 100644 --- a/crates/qbz-qt/qml/settings/SettingsView.qml +++ b/crates/qbz-qt/qml/settings/SettingsView.qml @@ -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 { diff --git a/crates/qbz-qt/qml/shell/MyQbzSidebarMenu.qml b/crates/qbz-qt/qml/shell/MyQbzSidebarMenu.qml new file mode 100644 index 000000000..776565af0 --- /dev/null +++ b/crates/qbz-qt/qml/shell/MyQbzSidebarMenu.qml @@ -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) + } + } + } +} diff --git a/crates/qbz-qt/qml/shell/Sidebar.qml b/crates/qbz-qt/qml/shell/Sidebar.qml index d84e22810..d7f0627d9 100644 --- a/crates/qbz-qt/qml/shell/Sidebar.qml +++ b/crates/qbz-qt/qml/shell/Sidebar.qml @@ -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) } } } diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml index 9e4861440..42be96b04 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml @@ -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() } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index b0b87227b..a616a3bd8 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -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) { diff --git a/crates/qbz-qt/src/myqbz_bridge.rs b/crates/qbz-qt/src/myqbz_bridge.rs index f2225bfeb..188fdc52f 100644 --- a/crates/qbz-qt/src/myqbz_bridge.rs +++ b/crates/qbz-qt/src/myqbz_bridge.rs @@ -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); } diff --git a/crates/qbz-qt/src/myqbz_detail_qt.rs b/crates/qbz-qt/src/myqbz_detail_qt.rs index e14b340d2..805347efb 100644 --- a/crates/qbz-qt/src/myqbz_detail_qt.rs +++ b/crates/qbz-qt/src/myqbz_detail_qt.rs @@ -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, } @@ -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) { 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 diff --git a/crates/qbz-qt/src/myqbz_qt.rs b/crates/qbz-qt/src/myqbz_qt.rs index 019b29a55..d73d8bcd1 100644 --- a/crates/qbz-qt/src/myqbz_qt.rs +++ b/crates/qbz-qt/src/myqbz_qt.rs @@ -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 = Vec::new(); + let mut collections: Vec = 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 diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index a62652f8e..15a8db5f2 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -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>, 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 diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index 7f6c2eded..cea94790b 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -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()); }