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

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

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

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

No files matched your search

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