Files
qbz/crates/qbz-qt/qml/shell/Sidebar.qml
T
vicrodh 0799d59e01 feat(qbz-qt): Local Library — folder tree, windowed album grid, detail
The sidebar has had a "Local Library" row since the shell was ported and
nothing behind it: the nav entry did not even navigate. This builds the view.

QbzLocal is its own domain bridge (the phase-23 pattern, one #[cxx_qt::bridge]
per domain) over local_library_qt, which consumes qbz-library for the scanning,
the folder-vs-tag album identity and the queries rather than reimplementing any
of it. The view carries the folder tree, the album grid, album detail, the
track tabs, sorting and search, plus the scanning and empty states.

Windowed from the start, not as a later pass: real local libraries reach 16k
tracks and that is exactly the surface where the Slint port freezes. Artwork
is id-keyed and evicted with the window.

Playback is source-aware — a local track plays from disk through the player's
play_data seam, because the Qobuz tier-walk needs a client and fails on every
local advance. TrackRow gains an opt-out for its drag source: a local row's id
is a local database row, and the sidebar's drop handler forwards whatever it
receives to `playlist add-tracks` as a Qobuz catalog id, so dragging one onto a
playlist would have silently added an unrelated track.

Not included, and tracked as follow-ups: Plex union and thumbnails, multi-select
and the bulk bars, ephemeral folders, the tag editor, A-Z jump strips, the
group-by dropdown, the tree's resize handle, per-tab scroll restore, and local
favourites on rows. Album detail renders inside the view rather than as its own
routed page.

cc
2026-07-28 13:49:15 -06:00

656 lines
30 KiB
QML

// Left navigation sidebar — QML port of crates/qbz-ui/ui/shell/Sidebar.slint.
//
// Three states (ShellState.sidebar-state): 0 = open 240px (icon + label),
// 1 = mini 64px (icons only), 2 = closed 0px. Width animates 160ms
// ease-in-out; the header's panel-left button cycles (QbzShell.cycleSidebar).
//
// Top-level section nav rows (Discover / Library / Local Library / My QBZ)
// replicate SidebarNavRow: 34px rows, radius 6, 16px icons, 13px/w500
// labels, surface-hover on hover, Discover + Library HIDDEN while offline
// (ADR-010 mount-site gating).
//
// POC-NOTE: in the Slint app these rows open dropdown flyout menus; the
// flyouts are out of scope — rows here navigate (only "home" exists, so
// only Discover is live) and carry the SidebarDirectRow active treatment
// (surface-hover bg + primary text/icon) for the current section.
// POC-NOTE: the playlist/folder tree below the nav is out of scope; the
// "PLAYLISTS" header + toolbar render for parity and the Slint empty state
// shows ("No playlists yet.").
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property bool mini: QbzShell.sidebarState === 1
// The current section — Discover (home) is the only real view.
property string activeNav: "discover"
// Playlist tree state (phase 7).
property bool searchOpen: false
property bool playlistsCollapsed: false
property string activePlaylistId: ""
// ---- 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
// (which stops at npb.top). Only the IN-SIDEBAR portion has to be
// reserved, and it shrinks when the band is hidden — that is why the
// height comes from the bridge and not a literal.
//
// Without this the playlist list keeps running to the sidebar's bottom
// edge and the rows sit UNDER the album art (Sidebar.slint:1145 reserves
// the same way).
readonly property bool largeDockActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
readonly property real dockReserve: largeDockActive
? Math.max(0, QbzShell.largeDockHeight - theme.npbLargeHeight)
: 0
// Flattened entries from the bridge + the url-keyed cover map.
readonly property var entries: parseEntries(QbzShell.sidebarJson)
property var coverMap: ({})
function parseEntries(json) {
var e = JSON.parse(json)
// Collect every cover url and dispatch the artwork window.
var urls = []
for (var i = 0; i < e.length; i++) {
for (var j = 0; j < e[i].covers.length; j++) urls.push(e[i].covers[j])
}
if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
return e
}
Connections {
target: QbzBridge
function onLibraryArtworkReady(key, path) {
var m = root.coverMap
m[key] = path
root.coverMap = Object.assign({}, m)
}
}
QbzTheme { id: theme }
width: QbzShell.sidebarState === 2 ? 0
: QbzShell.sidebarState === 1 ? theme.sidebarMiniWidth
: theme.sidebarOpenWidth
// surface-card @ 0.5 while the ambient background is active (phase 14).
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// Square edges; clip keeps content from spilling while the width
// animates (same as the Slint root).
clip: true
Behavior on width {
NumberAnimation { duration: 160; easing.type: Easing.InOutQuad }
}
// One section-nav row (SidebarNavRow / SidebarDirectRow metrics).
component NavRow: Rectangle {
id: navRow
property string navId: ""
property string name: ""
property string label: ""
property bool active: root.activeNav === navId
signal clicked()
width: parent ? parent.width : 0
height: 34
radius: 6
color: (navArea.containsMouse || active) ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: root.mini ? 0 : 8
anchors.rightMargin: root.mini ? 0 : 8
spacing: 10
Item {
width: root.mini ? parent.width : 16
height: parent.height
QbzIcon {
name: navRow.name
width: 16
height: 16
anchors.centerIn: parent
tintName: (navArea.containsMouse || navRow.active)
? "primary" : "secondary"
}
}
Text {
visible: !root.mini
height: parent.height
width: parent.width - (root.mini ? 0 : 26)
text: navRow.label
color: (navArea.containsMouse || navRow.active)
? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: navArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: navRow.clicked()
}
}
Column {
anchors.fill: parent
anchors.leftMargin: root.mini ? 8 : theme.spacingMd
anchors.rightMargin: root.mini ? 8 : theme.spacingMd
anchors.topMargin: root.mini ? 8 : theme.spacingMd
anchors.bottomMargin: root.mini ? 8 : theme.spacingMd
spacing: theme.spacingMd
// ---- Section nav -------------------------------------------
Column {
id: navColumn
width: parent.width
spacing: 2
// Qobuz-only sections — HIDDEN entirely while offline (ADR-010).
NavRow {
navId: "discover"
name: "compass"
label: QbzSession.tr("Discover", QbzSession.trRev)
visible: !QbzSession.offline
onClicked: {
root.activeNav = "discover"
QbzShell.navigateTo("home")
}
}
NavRow {
navId: "library"
name: "music-library-2"
label: QbzSession.tr("Library", QbzSession.trRev)
visible: !QbzSession.offline
onClicked: {
root.activeNav = "library"
QbzShell.navigateTo("library")
}
}
NavRow {
navId: "local"
name: "hard-drive"
label: QbzSession.tr("Local Library", QbzSession.trRev)
onClicked: {
root.activeNav = "local"
QbzShell.navigateTo("local")
}
}
NavRow {
navId: "myqbz"
name: "qbz-symbolic"
// Slint: MyQbzBrandingState.label, default "My QBZ".
label: QbzSession.tr("My QBZ", QbzSession.trRev)
onClicked: root.activeNav = "myqbz"
}
}
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// ---- Playlists header toolbar (hidden in the mini state) -----
Row {
visible: !root.mini
width: parent.width
height: 22
spacing: 4
// Title (hidden while the search input is open).
Text {
visible: !root.searchOpen
width: parent.width - 4 * 26
height: parent.height
text: QbzSession.tr("PLAYLISTS", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 10
font.letterSpacing: 1
verticalAlignment: Text.AlignVCenter
}
// Inline search input (filters entries, recursive).
Rectangle {
visible: root.searchOpen
width: parent.width - 4 * 26
height: 22
radius: 4
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
TextInput {
id: searchEdit
anchors.fill: parent
anchors.leftMargin: 6
color: theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: QbzShell.sidebarSearch(text)
Text {
visible: searchEdit.text === ""
anchors.fill: parent
text: QbzSession.tr("Search playlists", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
}
}
// Search toggle.
Rectangle {
width: 22
height: 22
radius: 4
color: (searchBtnArea.containsMouse || root.searchOpen) ? theme.surfaceHover : "transparent"
QbzIcon {
name: "search"
width: 15
height: 15
anchors.centerIn: parent
tintName: (searchBtnArea.containsMouse || root.searchOpen) ? "primary" : "muted"
}
MouseArea {
id: searchBtnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.searchOpen = !root.searchOpen
if (!root.searchOpen) {
searchEdit.text = ""
QbzShell.sidebarSearch("")
} else {
searchEdit.forceActiveFocus()
}
}
}
}
// New playlist (+) — single core call (create_playlist), then
// reload the tree. Slint opens a naming modal; the POC creates
// with the default name (POC-NOTE).
Rectangle {
width: 22
height: 22
radius: 4
color: plusArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "plus"
width: 16
height: 16
anchors.centerIn: parent
tintName: plusArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: plusArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.createPlaylist()
}
}
// Sort / more (...) menu.
Rectangle {
width: 22
height: 22
radius: 4
color: sortBtnArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "ellipsis"
width: 15
height: 15
anchors.centerIn: parent
tintName: sortBtnArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: sortBtnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: sortMenu.open()
}
Popup {
id: sortMenu
// Opens to the RIGHT of the "..." (overlay into the
// content area) — owner request in Sidebar.slint.
x: parent.width + 18
y: 26
width: 200
padding: 6
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceCard
radius: 8
border.width: 1
border.color: theme.borderSubtle
}
contentItem: Column {
spacing: 1
Row {
leftPadding: 8
topPadding: 4
bottomPadding: 2
spacing: 6
QbzIcon { name: "arrow-up-down"; width: 13; height: 13; tintName: "muted" }
Text {
text: QbzSession.tr("Sort by", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
}
}
Repeater {
model: [
{ "opt": "name", "label": QbzSession.tr("Name (A-Z)", QbzSession.trRev) },
{ "opt": "recent", "label": QbzSession.tr("Recent", QbzSession.trRev) },
{ "opt": "tracks", "label": QbzSession.tr("# of tracks", QbzSession.trRev) },
{ "opt": "playcount", "label": QbzSession.tr("Play Count", QbzSession.trRev) },
{ "opt": "custom", "label": QbzSession.tr("Custom", QbzSession.trRev) },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 30
radius: 5
color: sortOptArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 6
Text {
width: parent.width - 20
height: parent.height
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
font.weight: QbzShell.sidebarSortBy === modelData.opt ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
}
QbzIcon {
visible: QbzShell.sidebarSortBy === modelData.opt
name: QbzShell.sidebarSortAsc ? "chevron-up" : "chevron-down"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
}
MouseArea {
id: sortOptArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
QbzShell.sidebarSetSort(modelData.opt)
sortMenu.close()
}
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
// New folder / Manage / Import — visual stubs
// (POC-NOTE: folder management + importer are out
// of scope).
Repeater {
model: [
{ "icon": "folder-plus", "label": QbzSession.tr("New folder", QbzSession.trRev) },
{ "icon": "library-big", "label": QbzSession.tr("Manage playlists", QbzSession.trRev) },
{ "icon": "import", "label": QbzSession.tr("Import", QbzSession.trRev) },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 30
radius: 5
color: stubArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: modelData.icon; width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
Text {
height: parent.height
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: stubArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
}
}
}
}
}
}
// Collapse chevron (down = expanded, right = collapsed).
Rectangle {
width: 22
height: 22
radius: 4
color: collapseArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: root.playlistsCollapsed ? "chevron-right" : "chevron-down"
width: 15
height: 15
anchors.centerIn: parent
tintName: collapseArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: collapseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.playlistsCollapsed = !root.playlistsCollapsed
}
}
}
// ---- Playlist tree (folders + playlists, sidebar_qt.rs) --------
Item {
visible: !root.playlistsCollapsed
width: parent.width
// `root.dockReserve` keeps the rows clear of the Large-NPB cover
// (0 in every other mode). Clamped so a short window shrinks the
// list to nothing instead of giving it a negative height.
height: Math.max(0, parent.height - y - 16 - root.dockReserve)
Flickable {
id: plFlick
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: plColumn.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: plColumn
width: parent.width
spacing: 2
Repeater {
model: root.entries
delegate: Rectangle {
required property var modelData
property bool isFolder: modelData.kind === "folder"
property bool isActive: !isFolder && modelData.id === root.activePlaylistId
// Track-drag drop target (Sidebar.slint SidebarRow):
// the row self-detects the drop from the shared
// window-coord pointer and claims/releases it.
property bool dropHot: false
function recomputeDrop() {
if (isFolder || !QbzShell.dragActive) {
if (dropHot) { QbzShell.dragSetOver(""); dropHot = false }
return
}
const tl = mapToItem(null, 0, 0)
const hot = QbzShell.dragX >= tl.x && QbzShell.dragX <= tl.x + width
&& QbzShell.dragY >= tl.y && QbzShell.dragY <= tl.y + height
if (hot && !dropHot) QbzShell.dragSetOver(modelData.id)
else if (!hot && dropHot) QbzShell.dragSetOver("")
dropHot = hot
}
Connections {
target: QbzShell
function onDragXChanged() { recomputeDrop() }
function onDragYChanged() { recomputeDrop() }
function onDragActiveChanged() { recomputeDrop() }
}
width: plColumn.width
height: root.mini && modelData.indent ? 0 : 32
visible: !(root.mini && modelData.indent)
radius: 6
// success-bg + success-border while hot (Theme
// success family, #3fae6a at 10% / 30%).
color: dropHot ? "#3fae6a1a"
: ((rowArea.containsMouse || isActive) ? theme.surfaceHover : "transparent")
border.width: dropHot ? 1 : 0
border.color: "#3fae6a4d"
Row {
anchors.fill: parent
anchors.leftMargin: root.mini ? 0 : (modelData.indent ? 24 : 8)
anchors.rightMargin: root.mini ? 0 : 6
spacing: 8
// Leading icon slot (24px fixed).
Item {
width: 24
height: parent.height
// Folder icon (accent).
QbzIcon {
visible: isFolder
name: modelData.expanded && !root.mini ? "folder-open" : "folder"
width: 15
height: 15
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.mini ? 0 : -4.5
tintName: "accent"
}
// 2x2 micro-collage (or list-music glyph).
Rectangle {
visible: !isFolder && modelData.covers.length > 0
width: 20
height: 20
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.mini ? 0 : -2
radius: 3
color: theme.surfaceElevated
clip: true
Grid {
anchors.fill: parent
columns: 2
Repeater {
model: modelData.covers.slice(0, 4)
delegate: Image {
required property string modelData
width: 10
height: 10
source: root.coverMap[modelData] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
}
}
QbzIcon {
visible: !isFolder && modelData.covers.length === 0
name: "list-music"
width: 15
height: 15
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.mini ? 0 : -4.5
tintName: (rowArea.containsMouse || isActive) ? "primary" : "muted"
}
}
// Name (hidden in mini).
Text {
visible: !root.mini
width: parent.width - 24 - (isFolder ? 22 : 0)
height: parent.height
text: modelData.name
color: (rowArea.containsMouse || isActive) ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
font.weight: isFolder ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Folder count (hover) + chevron.
Text {
visible: !root.mini && isFolder && modelData.count > 0
height: parent.height
text: modelData.count
color: theme.textMuted
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
opacity: rowArea.containsMouse ? 1.0 : 0.0
}
QbzIcon {
visible: !root.mini && isFolder
name: modelData.expanded ? "chevron-down" : "chevron-right"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: rowArea.containsMouse ? "primary" : "muted"
}
}
// Active-row redundant shape cue: 3px accent bar.
Rectangle {
visible: isActive
x: 0
width: 3
height: parent.height - 12
anchors.verticalCenter: parent.verticalCenter
radius: 1.5
color: theme.accent
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (isFolder) {
QbzShell.sidebarToggleFolder(modelData.id)
} else {
root.activePlaylistId = modelData.id
QbzBridge.openPlaylist(modelData.id)
}
}
}
}
}
// Empty state.
Text {
visible: !root.mini && root.entries.length === 0 && !QbzSession.offline
width: plColumn.width
text: QbzSession.tr("No playlists yet.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
}
}
// Fill the rest.
Item { width: 1; height: 0; }
}
}