diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs
index 1f0545f5c..da1047c5e 100644
--- a/crates/qbz-qt/build.rs
+++ b/crates/qbz-qt/build.rs
@@ -40,6 +40,7 @@ fn main() {
"qml/Main.qml",
"qml/LoginScreen.qml",
"qml/AppShell.qml",
+ "qml/AlbumCard.qml",
"qml/Sidebar.qml",
"qml/HeaderBar.qml",
"qml/NowPlayingBarSmall.qml",
diff --git a/crates/qbz-qt/qml/AlbumCard.qml b/crates/qbz-qt/qml/AlbumCard.qml
new file mode 100644
index 000000000..62107ee37
--- /dev/null
+++ b/crates/qbz-qt/qml/AlbumCard.qml
@@ -0,0 +1,428 @@
+// THE shared album card — QML replica of discover/AlbumCard.slint, used
+// by BOTH the Home rails and the Library All grid (the Slint mounts the
+// same component in both places).
+//
+// 200x246: 200px artwork (Radius.sm) + placeholder, hover scrim with
+// genre/year meta, pin badge (top-right), favorite / play / more overlay
+// buttons, award ribbon, source badge (opt-in), then the title/artist
+// lines with the icon-only quality badge.
+//
+// Live wiring: play (art click + overlay play), favorite heart (optimistic
+// + signal), pin badge (pinned store), ⋯ context menu (Play / Play next /
+// Add to queue wired through playback_qt; favorite wired; Open album +
+// Block inert — POC-NOTEs: no album page / blacklist store).
+
+import QtQuick
+import QtQuick.Controls
+import com.blitzfc.qbz
+
+Rectangle {
+ id: root
+
+ // --- card data (the ONE contract both hosts fill) --------------------
+ property string albumId: ""
+ property string title: ""
+ property string artist: ""
+ property string artistId: ""
+ property string genre: ""
+ property string year: ""
+ property string qualityTier: ""
+ property string ribbon: ""
+ property string ribbonKind: ""
+ // Artwork image source (file://… or "") — the host's cache lookup.
+ property string artSource: ""
+ property bool isFavorite: false
+ property bool isPinned: false
+ // Source badge (Library show-local): "local" | "plex" | "" (hidden).
+ property string source: ""
+
+ QbzTheme { id: theme }
+
+ width: 200
+ height: 246
+ color: "transparent"
+
+ readonly property bool overlayOn: artArea.containsMouse || pinArea.containsMouse
+ || favArea.containsMouse || playArea.containsMouse || moreArea.containsMouse
+
+ function toggleFavorite() {
+ root.isFavorite = !root.isFavorite
+ QbzBridge.libraryToggleFavorite("album", root.albumId)
+ }
+ function togglePin() {
+ root.isPinned = !root.isPinned
+ QbzBridge.togglePin("album", root.albumId, root.title, root.artist, "")
+ }
+
+ Column {
+ spacing: 0
+
+ // --- Artwork + hover overlay -----------------------------------
+ Rectangle {
+ width: 200
+ height: 200
+ radius: theme.radiusSm
+ color: theme.surfaceElevated
+ clip: true
+
+ Image {
+ anchors.fill: parent
+ source: root.artSource
+ fillMode: Image.PreserveAspectCrop
+ asynchronous: true
+ }
+
+ // Hover scrim.
+ Rectangle {
+ anchors.fill: parent
+ color: "#000000"
+ opacity: root.overlayOn ? 0.6 : 0.0
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+ }
+
+ // Hover meta — genre + year, top-left.
+ Column {
+ x: 12
+ y: 12
+ spacing: 2
+ opacity: root.overlayOn ? 1.0 : 0.0
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+ Text {
+ visible: root.genre !== ""
+ text: root.genre
+ height: 20
+ color: "#ebffffff"
+ font.pixelSize: 13
+ font.weight: theme.weightBold
+ verticalAlignment: Text.AlignVCenter
+ }
+ Text {
+ visible: root.year !== ""
+ text: root.year
+ height: 17
+ color: "#ccffffff"
+ font.pixelSize: 12
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+
+ // Card-open + hover detector (declared before the action
+ // buttons so those win the pointer).
+ MouseArea {
+ id: artArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: QbzBridge.playAlbum(root.albumId)
+ }
+
+ // Pin badge — top-right. Filled accent pin always visible when
+ // pinned; outline pin hover-revealed otherwise. Always-mounted
+ // (opacity) so its hover joins overlayOn.
+ Rectangle {
+ x: parent.width - width - 8
+ y: 8
+ width: 26
+ height: 26
+ radius: 13
+ color: pinArea.containsMouse ? "#cc000000" : "#99000000"
+ opacity: root.overlayOn ? 1.0 : (root.isPinned ? 1.0 : 0.0)
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+ QbzIcon {
+ name: root.isPinned ? "pin-filled" : "pin"
+ width: 14
+ height: 14
+ anchors.centerIn: parent
+ tintName: root.isPinned ? "accent" : "primary"
+ }
+ MouseArea {
+ id: pinArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.togglePin()
+ }
+ }
+
+ // Hover action buttons — favorite / play / more (y=120, h=44,
+ // centered, spacing 12).
+ Row {
+ y: 120
+ width: parent.width
+ height: 44
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 12
+ opacity: root.overlayOn ? 1.0 : 0.0
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+
+ Item { width: (200 - 44 - 2 * 36 - 2 * 12) / 2; height: 1 }
+ Rectangle {
+ width: 36
+ height: 36
+ radius: 18
+ anchors.verticalCenter: parent.verticalCenter
+ color: favArea.containsMouse ? "#3dffffff" : "#24ffffff"
+ border.width: 1.5
+ border.color: "#ccffffff"
+ QbzIcon {
+ name: root.isFavorite ? "heart-filled" : "heart"
+ width: 16
+ height: 16
+ anchors.centerIn: parent
+ tintName: root.isFavorite ? "favorite" : "primary"
+ }
+ MouseArea {
+ id: favArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.toggleFavorite()
+ }
+ }
+ Rectangle {
+ width: 44
+ height: 44
+ radius: 22
+ color: playArea.containsMouse ? "#d6ffffff" : "#ffffff"
+ QbzIcon {
+ name: "play-fill"
+ width: 18
+ height: 18
+ anchors.centerIn: parent
+ tintName: "black"
+ }
+ MouseArea {
+ id: playArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: QbzBridge.playAlbum(root.albumId)
+ }
+ }
+ Rectangle {
+ width: 36
+ height: 36
+ radius: 18
+ anchors.verticalCenter: parent.verticalCenter
+ color: moreArea.containsMouse ? "#3dffffff" : "#24ffffff"
+ border.width: 1.5
+ border.color: "#ccffffff"
+ QbzIcon {
+ name: "ellipsis"
+ width: 16
+ height: 16
+ anchors.centerIn: parent
+ tintName: "primary"
+ }
+ MouseArea {
+ id: moreArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: albumMenu.open()
+ }
+ }
+ Item { width: (200 - 44 - 2 * 36 - 2 * 12) / 2; height: 1 }
+ }
+
+ // Context menu (AlbumCard.slint's album-menu): 196px, items
+ // 33px, icon 15 + label 13.
+ Popup {
+ id: albumMenu
+ x: 150
+ y: 140
+ width: 196
+ padding: 5
+ closePolicy: Popup.CloseOnPressOutside
+ background: Rectangle {
+ color: theme.surfaceMain
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderMuted
+ }
+ contentItem: Column {
+ Repeater {
+ model: [
+ { "label": QbzBridge.tr("Open album"), "icon": "library-big", "action": "open" },
+ { "label": QbzBridge.tr("Play"), "icon": "play-fill", "action": "play" },
+ { "label": QbzBridge.tr("Play next"), "icon": "list-plus", "action": "next" },
+ { "label": QbzBridge.tr("Add to queue"), "icon": "list-end", "action": "queue" },
+ { "label": root.isFavorite ? QbzBridge.tr("Remove from Library") : QbzBridge.tr("Add to Library"), "icon": root.isFavorite ? "heart-filled" : "heart", "action": "favorite" },
+ { "label": QbzBridge.tr("Block this album"), "icon": "blind-eye", "action": "block" },
+ ]
+ delegate: Rectangle {
+ required property var modelData
+ width: parent ? parent.width : 0
+ height: 33
+ radius: 5
+ color: miArea.containsMouse ? theme.surfaceHover : "transparent"
+ Row {
+ anchors.fill: parent
+ anchors.leftMargin: 8
+ spacing: 8
+ QbzIcon {
+ name: modelData.icon
+ width: 15
+ height: 15
+ anchors.verticalCenter: parent.verticalCenter
+ tintName: "secondary"
+ }
+ Text {
+ height: parent.height
+ width: parent.width - 23
+ text: modelData.label
+ color: theme.textSecondary
+ font.pixelSize: 13
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+ }
+ MouseArea {
+ id: miArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ albumMenu.close()
+ var a = modelData.action
+ if (a === "play") QbzBridge.playAlbum(root.albumId)
+ else if (a === "next") QbzBridge.enqueueAlbum(root.albumId, "next")
+ else if (a === "queue") QbzBridge.enqueueAlbum(root.albumId, "later")
+ else if (a === "favorite") root.toggleFavorite()
+ // "open" (no album page) + "block" (no
+ // blacklist store): inert — POC-NOTE.
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Award ribbon — content-width, capped at the card width.
+ Rectangle {
+ visible: root.ribbon !== ""
+ x: 0
+ y: parent.height - height - 8
+ height: 20
+ width: Math.min(ribbonRow.width, 200)
+ color: root.ribbonKind === "press" ? "#d49511" : "#e0000000"
+ topRightRadius: 3
+ bottomRightRadius: 3
+ clip: true
+ Rectangle {
+ width: root.ribbonKind === "press" ? 0 : 3
+ height: parent.height
+ color: root.ribbonKind === "qobuzissime" ? "#8b5cf6" : "#eab308"
+ }
+ Row {
+ id: ribbonRow
+ height: parent.height
+ leftPadding: 10
+ rightPadding: 10
+ width: ribbonText.implicitWidth + 20
+ Text {
+ id: ribbonText
+ height: parent.height
+ text: root.ribbon
+ color: root.ribbonKind === "press" ? "#1f1407" : "#ffffff"
+ font.pixelSize: 9
+ font.weight: theme.weightSemibold
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ // Source badge (Library show-local): bottom-right of the art.
+ Rectangle {
+ visible: root.source === "local" || root.source === "plex"
+ x: parent.width - width - 6
+ y: parent.height - height - 6
+ width: 24
+ height: 24
+ radius: 4
+ color: "#b3000000"
+ QbzIcon {
+ name: "hard-drive"
+ width: 14
+ height: 14
+ anchors.centerIn: parent
+ tintName: root.source === "plex" ? "accent" : "primary"
+ }
+ }
+ }
+ Item { width: 1; height: 6 }
+
+ // --- Title / artist + quality badge ------------------------------
+ Row {
+ width: 200
+ height: 40
+ spacing: theme.spacingSm
+ Column {
+ width: parent.width - (qBadge.visible ? qBadge.width + theme.spacingSm : 0)
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+ Text {
+ width: parent.width
+ height: 20
+ text: root.title
+ color: titleArea.containsMouse ? theme.accent : theme.textPrimary
+ font.pixelSize: theme.fontBody - 2
+ font.weight: theme.weightMedium
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ MouseArea {
+ id: titleArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: QbzBridge.playAlbum(root.albumId)
+ }
+ }
+ Text {
+ width: parent.width
+ height: 18
+ text: root.artist
+ color: root.artistId !== "" && artistArea.containsMouse
+ ? theme.textPrimary : theme.textMuted
+ font.pixelSize: theme.fontLink - 1
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ MouseArea {
+ id: artistArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: root.artistId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
+ }
+ }
+ }
+ // Icon-only quality badge (QualityBadge.slint).
+ Item {
+ id: qBadge
+ visible: root.qualityTier !== ""
+ width: root.qualityTier === "hires" ? 42 : 30
+ height: 30
+ anchors.verticalCenter: parent.verticalCenter
+ Image {
+ visible: root.qualityTier === "hires"
+ source: "assets/hi-res.svg"
+ width: 42
+ height: 28
+ anchors.centerIn: parent
+ sourceSize: Qt.size(84, 56)
+ fillMode: Image.PreserveAspectFit
+ }
+ Rectangle {
+ visible: root.qualityTier === "cd"
+ width: 30
+ height: 30
+ radius: 3
+ color: theme.surfaceElevated
+ border.width: 1
+ border.color: theme.borderSubtle
+ QbzIcon { name: "cd"; width: 16; height: 16; anchors.centerIn: parent; tintName: "muted" }
+ }
+ }
+ }
+ }
+}
diff --git a/crates/qbz-qt/qml/AppShell.qml b/crates/qbz-qt/qml/AppShell.qml
index 46970abae..c32ec0d15 100644
--- a/crates/qbz-qt/qml/AppShell.qml
+++ b/crates/qbz-qt/qml/AppShell.qml
@@ -17,9 +17,14 @@ import com.blitzfc.qbz
Rectangle {
id: root
- // The window chrome is surface-card (header / sidebar / queue column /
- // NPB all paint it too); the content panel contrasts in surface-main.
- color: theme.surfaceCard
+ // TRANSPARENT — the chrome (header / sidebar / queue column / NPB)
+ // paints every pixel between the header's rounded top corners and the
+ // NPB's rounded bottom corners, so the window's rounded corners show
+ // through (frameless translucent window).
+ color: "transparent"
+
+ // The host ApplicationWindow (custom chrome: drag / maximize / resize).
+ property var hostWindow: null
QbzTheme { id: theme }
@@ -29,6 +34,10 @@ Rectangle {
anchors.right: parent.right
anchors.top: parent.top
height: theme.headerHeight
+ hostWindow: root.hostWindow
+ // Rounded top window corners (custom chrome); the bottom corners
+ // are covered by the same-color surfaces below.
+ radius: 12
}
NowPlayingBarSmall {
@@ -37,6 +46,9 @@ Rectangle {
anchors.right: parent.right
anchors.bottom: parent.bottom
height: theme.npbSmallHeight
+ // Rounded bottom window corners (custom chrome); the top corners
+ // are covered by the same-color surfaces above.
+ radius: 12
}
Sidebar {
diff --git a/crates/qbz-qt/qml/HeaderBar.qml b/crates/qbz-qt/qml/HeaderBar.qml
index 4a5809822..996edbda8 100644
--- a/crates/qbz-qt/qml/HeaderBar.qml
+++ b/crates/qbz-qt/qml/HeaderBar.qml
@@ -21,8 +21,33 @@ Rectangle {
id: root
color: theme.surfaceCard
+ // The host ApplicationWindow (custom chrome); null in previews.
+ property var hostWindow: null
+
QbzTheme { id: theme }
+ // Custom-chrome drag surface (declared FIRST so every interactive
+ // element above wins hit-testing): press-and-move starts the system
+ // move; double-click toggles maximize. The system grab starts only
+ // after a real movement so plain clicks/double-clicks still work.
+ MouseArea {
+ anchors.fill: parent
+ property bool dragStarted: false
+ onPressed: dragStarted = false
+ onPositionChanged: {
+ if (pressed && !dragStarted && root.hostWindow) {
+ dragStarted = true
+ root.hostWindow.startSystemMove()
+ }
+ }
+ onDoubleClicked: {
+ if (root.hostWindow) {
+ root.hostWindow.visibility = root.hostWindow.visibility === Window.Maximized
+ ? Window.Windowed : Window.Maximized
+ }
+ }
+ }
+
// Slint OfflineState.badge-state: 0 hidden / 1 hard offline / 2 manual /
// 3 logged out (wins over the others).
readonly property int badgeState: QbzBridge.offlineSession ? 3
@@ -195,7 +220,8 @@ Rectangle {
// --- Right controls: status badge + app menu --------------------------
Row {
id: rightControls
- x: root.width - width - theme.spacingMd + 2
+ // Shifts left of the drawn window controls (3x34 + 2x2 = 106px).
+ x: root.width - width - theme.spacingMd + 2 - 110
y: (root.height - height) / 2
height: 36
spacing: 4
@@ -335,6 +361,80 @@ Rectangle {
}
}
+ // --- Drawn window controls (WindowControls.slint, right placement:
+ // minimize · maximize · close; close gets the danger-red hover) ------
+ Row {
+ x: root.width - width - 8
+ y: (root.height - height) / 2
+ height: 26
+ spacing: 2
+ Rectangle {
+ width: 34
+ height: 26
+ radius: theme.radiusSm
+ color: wcMinArea.containsMouse ? theme.surfaceHover : "transparent"
+ QbzIcon {
+ name: "minus"
+ width: 14
+ height: 14
+ anchors.centerIn: parent
+ tintName: "secondary"
+ }
+ MouseArea {
+ id: wcMinArea
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: if (root.hostWindow) root.hostWindow.showMinimized()
+ }
+ }
+ Rectangle {
+ width: 34
+ height: 26
+ radius: theme.radiusSm
+ color: wcMaxArea.containsMouse ? theme.surfaceHover : "transparent"
+ QbzIcon {
+ name: root.hostWindow && root.hostWindow.visibility === Window.Maximized
+ ? "minimize-2" : "maximize-2"
+ width: 14
+ height: 14
+ anchors.centerIn: parent
+ tintName: "secondary"
+ }
+ MouseArea {
+ id: wcMaxArea
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: {
+ if (root.hostWindow) {
+ root.hostWindow.visibility = root.hostWindow.visibility === Window.Maximized
+ ? Window.Windowed : Window.Maximized
+ }
+ }
+ }
+ }
+ Rectangle {
+ width: 34
+ height: 26
+ radius: theme.radiusSm
+ color: wcCloseArea.containsMouse ? "#e81123" : "transparent"
+ QbzIcon {
+ name: "x"
+ width: 14
+ height: 14
+ anchors.centerIn: parent
+ tintName: wcCloseArea.containsMouse ? "primary" : "secondary"
+ }
+ MouseArea {
+ id: wcCloseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ // POC-NOTE: the Slint app hides-to-tray on close; the POC
+ // has no tray — close quits.
+ onClicked: Qt.quit()
+ }
+ }
+ }
+
// --- App menu (stub: user block + Log Out + Close) --------------------
// POC-NOTE: the Slint menu also carries Settings / Open Music Link /
// Keyboard Shortcuts / Documentation / Report an Issue / What's New /
diff --git a/crates/qbz-qt/qml/HomeView.qml b/crates/qbz-qt/qml/HomeView.qml
index 0a38ddba8..5adb1f0d2 100644
--- a/crates/qbz-qt/qml/HomeView.qml
+++ b/crates/qbz-qt/qml/HomeView.qml
@@ -97,261 +97,6 @@ Rectangle {
}
}
- // Album card (AlbumCard.slint): 200x246 — 200px art (radius 8) + 6px +
- // 40px title/artist row with the icon-only quality badge.
- component AlbumCard: Rectangle {
- property var card: ({})
- width: 200
- height: 246
- color: "transparent"
-
- readonly property bool overlayOn: artArea.containsMouse
-
- Column {
- spacing: 0
- // --- Artwork + hover overlay --------------------------------
- Rectangle {
- width: 200
- height: 200
- radius: theme.radiusSm
- color: theme.surfaceElevated
- clip: true
-
- Image {
- anchors.fill: parent
- source: card.artPath
- fillMode: Image.PreserveAspectCrop
- asynchronous: true
- }
- // Hover scrim.
- Rectangle {
- anchors.fill: parent
- color: "#000000"
- opacity: overlayOn ? 0.6 : 0.0
- Behavior on opacity { NumberAnimation { duration: 150 } }
- }
- // Hover meta — genre + year, top-left.
- Column {
- x: 12
- y: 12
- spacing: 2
- opacity: overlayOn ? 1.0 : 0.0
- Behavior on opacity { NumberAnimation { duration: 150 } }
- Text {
- visible: card.genre !== ""
- text: card.genre
- height: 20
- color: "#ebffffff"
- font.pixelSize: 13
- font.weight: theme.weightBold
- verticalAlignment: Text.AlignVCenter
- }
- Text {
- visible: card.year !== ""
- text: card.year
- height: 17
- color: "#ccffffff"
- font.pixelSize: 12
- verticalAlignment: Text.AlignVCenter
- }
- }
- // Card-open + hover detector (declared before the action
- // buttons so those win the pointer).
- MouseArea {
- id: artArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- // Phase 4: a card click plays the album (the album page
- // itself is out of scope — POC-NOTE).
- onClicked: QbzBridge.playAlbum(card.id)
- }
- // Hover action buttons — favorite / play / more (INERT;
- // POC-NOTE: playback + favorites land in later phases).
- Row {
- x: 0
- y: 120
- width: 200
- height: 44
- anchors.horizontalCenter: parent.horizontalCenter
- spacing: 12
- opacity: overlayOn ? 1.0 : 0.0
- Behavior on opacity { NumberAnimation { duration: 150 } }
-
- Item { width: (parent.width - 44 - 2 * 36 - 2 * 12) / 2; height: 1 }
- Rectangle {
- width: 36
- height: 36
- radius: 18
- anchors.verticalCenter: parent.verticalCenter
- color: favArea.containsMouse ? "#3dffffff" : "#24ffffff"
- border.width: 1.5
- border.color: "#ccffffff"
- QbzIcon {
- name: "heart"
- width: 16
- height: 16
- anchors.centerIn: parent
- tintName: "primary"
- }
- MouseArea { id: favArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor }
- }
- Rectangle {
- width: 44
- height: 44
- radius: 22
- color: playOvArea.containsMouse ? "#d6ffffff" : "#ffffff"
- // Black glyph on the white disc (Slint tints #000)
- // — the "black" baked variant.
- QbzIcon {
- name: "play-fill"
- width: 18
- height: 18
- anchors.centerIn: parent
- tintName: "black"
- }
- MouseArea {
- id: playOvArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: QbzBridge.playAlbum(card.id)
- }
- }
- Rectangle {
- width: 36
- height: 36
- radius: 18
- anchors.verticalCenter: parent.verticalCenter
- color: moreArea.containsMouse ? "#3dffffff" : "#24ffffff"
- border.width: 1.5
- border.color: "#ccffffff"
- QbzIcon {
- name: "ellipsis"
- width: 16
- height: 16
- anchors.centerIn: parent
- tintName: "primary"
- }
- MouseArea { id: moreArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor }
- }
- Item { width: (parent.width - 44 - 2 * 36 - 2 * 12) / 2; height: 1 }
- }
- // Award ribbon — content-width, capped at the card width.
- Rectangle {
- visible: card.ribbon !== ""
- x: 0
- y: parent.height - height - 8
- height: 20
- width: Math.min(ribbonRow.width, 200)
- color: card.ribbonKind === "press" ? "#d49511" : "#e0000000"
- topRightRadius: 3
- bottomRightRadius: 3
- clip: true
- Rectangle {
- width: card.ribbonKind === "press" ? 0 : 3
- height: parent.height
- color: card.ribbonKind === "qobuzissime" ? "#8b5cf6" : "#eab308"
- }
- Row {
- id: ribbonRow
- height: parent.height
- leftPadding: 10
- rightPadding: 10
- width: ribbonText.implicitWidth + 20
- Text {
- id: ribbonText
- height: parent.height
- text: card.ribbon
- color: card.ribbonKind === "press" ? "#1f1407" : "#ffffff"
- font.pixelSize: 9
- font.weight: theme.weightSemibold
- verticalAlignment: Text.AlignVCenter
- elide: Text.ElideRight
- }
- }
- }
- }
- Item { width: 1; height: 6 }
- // --- Title / artist + quality badge --------------------------
- Row {
- width: 200
- height: 40
- spacing: theme.spacingSm
- Column {
- width: parent.width - (qualityBadge.visible ? qualityBadge.width + theme.spacingSm : 0)
- anchors.verticalCenter: parent.verticalCenter
- spacing: 2
- Text {
- width: parent.width
- height: 20
- text: card.title
- color: titleArea.containsMouse ? theme.accent : theme.textPrimary
- font.pixelSize: theme.fontBody - 2
- font.weight: theme.weightMedium
- verticalAlignment: Text.AlignVCenter
- elide: Text.ElideRight
- MouseArea {
- id: titleArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- }
- }
- Text {
- width: parent.width
- height: 18
- text: card.artist
- color: card.artistId !== "" && artistArea.containsMouse
- ? theme.textPrimary : theme.textMuted
- font.pixelSize: theme.fontLink - 1
- verticalAlignment: Text.AlignVCenter
- elide: Text.ElideRight
- MouseArea {
- id: artistArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: card.artistId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
- }
- }
- }
- // Icon-only quality badge (QualityBadge.slint).
- Item {
- id: qualityBadge
- visible: card.qualityTier !== ""
- width: card.qualityTier === "hires" ? 42 : 30
- height: 30
- anchors.verticalCenter: parent.verticalCenter
- Image {
- visible: card.qualityTier === "hires"
- source: "assets/hi-res.svg"
- width: 42
- height: 28
- anchors.centerIn: parent
- sourceSize: Qt.size(84, 56)
- fillMode: Image.PreserveAspectFit
- }
- Rectangle {
- visible: card.qualityTier === "cd"
- width: 30
- height: 30
- radius: 3
- color: theme.surfaceElevated
- border.width: 1
- border.color: theme.borderSubtle
- QbzIcon {
- name: "cd"
- width: 16
- height: 16
- anchors.centerIn: parent
- tintName: "muted"
- }
- }
- }
- }
- }
- }
-
// Horizontal album rail (Carousel.slint): header + clipped ListView,
// page chevrons (per-page step like the Slint paging).
component AlbumRail: Column {
@@ -381,7 +126,22 @@ Rectangle {
clip: true
boundsBehavior: Flickable.StopAtBounds
model: sectionData.items
- delegate: AlbumCard { card: modelData }
+ delegate: AlbumCard {
+ albumId: modelData.id
+ title: modelData.title
+ artist: modelData.artist
+ artistId: modelData.artistId
+ genre: modelData.genre
+ year: modelData.year
+ qualityTier: modelData.qualityTier
+ ribbon: modelData.ribbon
+ ribbonKind: modelData.ribbonKind
+ artSource: modelData.artPath
+ isPinned: modelData.isPinned
+ // POC-NOTE: Home hearts are not seeded from fav_cache
+ // (store not open in the POC); toggles still hit the API.
+ isFavorite: false
+ }
}
// Cider-style edge fades (Carousel.slint): content dissolves
// into the page background at the scrolled edges.
diff --git a/crates/qbz-qt/qml/LibraryView.qml b/crates/qbz-qt/qml/LibraryView.qml
index 7e74871c5..582412b1b 100644
--- a/crates/qbz-qt/qml/LibraryView.qml
+++ b/crates/qbz-qt/qml/LibraryView.qml
@@ -169,6 +169,13 @@ Rectangle {
}
root.feedChanged()
}
+ function onPinChanged(key, value) {
+ var f = root.feed
+ for (var i = 0; i < f.length; i++) {
+ if (f[i].artKey === key) { f[i].isPinned = value; break }
+ }
+ root.feedChanged()
+ }
}
// Debounced window reporting (180ms, library_all.rs throttle).
diff --git a/crates/qbz-qt/qml/LoginScreen.qml b/crates/qbz-qt/qml/LoginScreen.qml
index 8ecd159a7..d4163f066 100644
--- a/crates/qbz-qt/qml/LoginScreen.qml
+++ b/crates/qbz-qt/qml/LoginScreen.qml
@@ -17,6 +17,10 @@ import com.blitzfc.qbz
Rectangle {
id: root
color: theme.surfaceMain
+ // Rounded window corners (custom chrome).
+ radius: 12
+ // Set by the Loader (custom chrome drag/maximize); unused here.
+ property var hostWindow: null
// Slint: in-out property tos-accepted: true.
property bool tosAccepted: true
diff --git a/crates/qbz-qt/qml/Main.qml b/crates/qbz-qt/qml/Main.qml
index 4d0e275dd..c93ec31c6 100644
--- a/crates/qbz-qt/qml/Main.qml
+++ b/crates/qbz-qt/qml/Main.qml
@@ -19,7 +19,10 @@ ApplicationWindow {
minimumHeight: 600
visible: true
title: "QBZ"
- color: "#0f0f0f"
+ // Custom chrome (phase 7): frameless + translucent so the window draws
+ // its own rounded corners, 1:1 with the Slint custom-chrome look.
+ flags: Qt.Window | Qt.FramelessWindowHint
+ color: "transparent"
FontLoader { id: interRegular; source: "assets/fonts/Inter_18pt-Regular.ttf" }
FontLoader { id: interMedium; source: "assets/fonts/Inter_18pt-Medium.ttf" }
@@ -36,6 +39,62 @@ ApplicationWindow {
source: QbzBridge.screen === "login"
? "LoginScreen.qml"
: (QbzBridge.screen === "shell" ? "AppShell.qml" : "")
+ // Hand the host window down for drag/maximize/resize (custom chrome).
+ onLoaded: if (screenLoader.item) screenLoader.item.hostWindow = window
+ }
+
+ // Rounded-window hairline frame (app.slint's no-frame 1px edge) —
+ // paints at the very rim, over everything, clipped by the corners.
+ Rectangle {
+ anchors.fill: parent
+ color: "transparent"
+ radius: 12
+ border.width: 1
+ border.color: "#14ffffff"
+ visible: window.visibility !== Window.Maximized && window.visibility !== Window.FullScreen
+ }
+
+ // Edge/corner resize grips (custom chrome — the compositor draws no
+ // border). 6px, invisible.
+ MouseArea {
+ anchors.left: parent.left; anchors.top: parent.top; anchors.bottom: parent.bottom; width: 6
+ cursorShape: Qt.SizeHorCursor
+ onPressed: window.startSystemResize(Qt.LeftEdge)
+ }
+ MouseArea {
+ anchors.right: parent.right; anchors.top: parent.top; anchors.bottom: parent.bottom; width: 6
+ cursorShape: Qt.SizeHorCursor
+ onPressed: window.startSystemResize(Qt.RightEdge)
+ }
+ MouseArea {
+ anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; height: 6
+ cursorShape: Qt.SizeVerCursor
+ onPressed: window.startSystemResize(Qt.TopEdge)
+ }
+ MouseArea {
+ anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 6
+ cursorShape: Qt.SizeVerCursor
+ onPressed: window.startSystemResize(Qt.BottomEdge)
+ }
+ MouseArea {
+ anchors.left: parent.left; anchors.top: parent.top; width: 12; height: 12
+ cursorShape: Qt.SizeFDiagCursor
+ onPressed: window.startSystemResize(Qt.TopEdge | Qt.LeftEdge)
+ }
+ MouseArea {
+ anchors.right: parent.right; anchors.top: parent.top; width: 12; height: 12
+ cursorShape: Qt.SizeBDiagCursor
+ onPressed: window.startSystemResize(Qt.TopEdge | Qt.RightEdge)
+ }
+ MouseArea {
+ anchors.left: parent.left; anchors.bottom: parent.bottom; width: 12; height: 12
+ cursorShape: Qt.SizeBDiagCursor
+ onPressed: window.startSystemResize(Qt.BottomEdge | Qt.LeftEdge)
+ }
+ MouseArea {
+ anchors.right: parent.right; anchors.bottom: parent.bottom; width: 12; height: 12
+ cursorShape: Qt.SizeFDiagCursor
+ onPressed: window.startSystemResize(Qt.BottomEdge | Qt.RightEdge)
}
// Splash (SplashScreen.slint): the same 720px dark card as the login
@@ -43,6 +102,7 @@ ApplicationWindow {
Rectangle {
anchors.fill: parent
color: "#0f0f0f"
+ radius: 12
visible: QbzBridge.screen === "splash"
Rectangle {
diff --git a/crates/qbz-qt/qml/Sidebar.qml b/crates/qbz-qt/qml/Sidebar.qml
index 9cc8ad014..815f5a6ed 100644
--- a/crates/qbz-qt/qml/Sidebar.qml
+++ b/crates/qbz-qt/qml/Sidebar.qml
@@ -18,6 +18,7 @@
// shows ("No playlists yet.").
import QtQuick
+import QtQuick.Controls
import com.blitzfc.qbz
Rectangle {
@@ -26,6 +27,32 @@ Rectangle {
property bool mini: QbzBridge.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: ""
+ // Flattened entries from the bridge + the url-keyed cover map.
+ readonly property var entries: parseEntries(QbzBridge.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) QbzBridge.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 }
@@ -158,7 +185,9 @@ Rectangle {
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: QbzBridge.tr("PLAYLISTS")
@@ -167,46 +196,406 @@ Rectangle {
font.letterSpacing: 1
verticalAlignment: Text.AlignVCenter
}
- Repeater {
- // POC-NOTE: search / new-playlist / sort / collapse are
- // visual stubs (the playlist tree is out of scope).
- model: [
- { "icon": "search", "size": 15 },
- { "icon": "plus", "size": 16 },
- { "icon": "ellipsis", "size": 15 },
- { "icon": "chevron-down", "size": 15 },
- ]
- delegate: Rectangle {
- required property var modelData
- width: 22
- height: 22
- radius: 4
- color: hdrArea.containsMouse ? theme.surfaceHover : "transparent"
- QbzIcon {
- name: modelData.icon
- width: modelData.size
- height: modelData.size
- anchors.centerIn: parent
- tintName: hdrArea.containsMouse ? "primary" : "muted"
- }
- MouseArea {
- id: hdrArea
+ // 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: QbzBridge.sidebarSearch(text)
+ Text {
+ visible: searchEdit.text === ""
anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
+ text: QbzBridge.tr("Search playlists")
+ 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 = ""
+ QbzBridge.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: QbzBridge.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: QbzBridge.tr("Sort by")
+ color: theme.textMuted
+ font.pixelSize: 11
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+ Repeater {
+ model: [
+ { "opt": "name", "label": QbzBridge.tr("Name (A-Z)") },
+ { "opt": "recent", "label": QbzBridge.tr("Recent") },
+ { "opt": "tracks", "label": QbzBridge.tr("# of tracks") },
+ { "opt": "playcount", "label": QbzBridge.tr("Play Count") },
+ { "opt": "custom", "label": QbzBridge.tr("Custom") },
+ ]
+ 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: QbzBridge.sidebarSortBy === modelData.opt ? theme.weightSemibold : theme.weightRegular
+ verticalAlignment: Text.AlignVCenter
+ }
+ QbzIcon {
+ visible: QbzBridge.sidebarSortBy === modelData.opt
+ name: QbzBridge.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: {
+ QbzBridge.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": QbzBridge.tr("New folder") },
+ { "icon": "library-big", "label": QbzBridge.tr("Manage playlists") },
+ { "icon": "import", "label": QbzBridge.tr("Import") },
+ ]
+ 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 list (out of scope) — the Slint empty state ----
- Text {
- visible: !root.mini
+ // ---- Playlist tree (folders + playlists, sidebar_qt.rs) --------
+ Item {
+ visible: !root.playlistsCollapsed
width: parent.width
- text: QbzBridge.tr("No playlists yet.")
- color: theme.textMuted
- font.pixelSize: 12
- wrapMode: Text.WordWrap
+ height: parent.height - y - 16
+
+ 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
+ width: plColumn.width
+ height: root.mini && modelData.indent ? 0 : 32
+ visible: !(root.mini && modelData.indent)
+ radius: 6
+ color: (rowArea.containsMouse || isActive) ? theme.surfaceHover : "transparent"
+
+ 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) {
+ QbzBridge.sidebarToggleFolder(modelData.id)
+ } else {
+ // POC-NOTE: no playlist view yet —
+ // the click only marks the row active.
+ root.activePlaylistId = modelData.id
+ }
+ }
+ }
+ }
+ }
+
+ // Empty state.
+ Text {
+ visible: !root.mini && root.entries.length === 0 && !QbzBridge.offline
+ width: plColumn.width
+ text: QbzBridge.tr("No playlists yet.")
+ color: theme.textMuted
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ }
+ }
+ }
}
// Fill the rest.
diff --git a/crates/qbz-qt/qml/assets/icons/accent/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/accent/arrow-up-down.svg
new file mode 100644
index 000000000..d78f70456
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/arrow-up-down.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/accent/blind-eye.svg
new file mode 100644
index 000000000..01dc9a4df
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/blind-eye.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/folder-open.svg b/crates/qbz-qt/qml/assets/icons/accent/folder-open.svg
new file mode 100644
index 000000000..5d1773f89
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/folder-open.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/accent/folder-plus.svg
new file mode 100644
index 000000000..a768b8e88
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/folder-plus.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/folder.svg b/crates/qbz-qt/qml/assets/icons/accent/folder.svg
new file mode 100644
index 000000000..e8bb637da
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/folder.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/import.svg b/crates/qbz-qt/qml/assets/icons/accent/import.svg
new file mode 100644
index 000000000..0d65555ff
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/import.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/library-big.svg b/crates/qbz-qt/qml/assets/icons/accent/library-big.svg
new file mode 100644
index 000000000..889609263
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/library-big.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/list-end.svg b/crates/qbz-qt/qml/assets/icons/accent/list-end.svg
new file mode 100644
index 000000000..3f8d400b1
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/list-end.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/list-plus.svg b/crates/qbz-qt/qml/assets/icons/accent/list-plus.svg
new file mode 100644
index 000000000..12866c593
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/list-plus.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/accent/maximize-2.svg
new file mode 100644
index 000000000..fc6e5b56f
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/maximize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/accent/minimize-2.svg
new file mode 100644
index 000000000..abc245a2f
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/minimize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/minus.svg b/crates/qbz-qt/qml/assets/icons/accent/minus.svg
new file mode 100644
index 000000000..a7f2e7371
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/minus.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/accent/pin-filled.svg
new file mode 100644
index 000000000..5c537c470
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/pin-filled.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/pin.svg b/crates/qbz-qt/qml/assets/icons/accent/pin.svg
new file mode 100644
index 000000000..b23b4bfe2
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/pin.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/black/arrow-up-down.svg
new file mode 100644
index 000000000..4dfa4f3bf
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/arrow-up-down.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/black/blind-eye.svg
new file mode 100644
index 000000000..5a0afdbd5
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/blind-eye.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/folder-open.svg b/crates/qbz-qt/qml/assets/icons/black/folder-open.svg
new file mode 100644
index 000000000..b58074688
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/folder-open.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/black/folder-plus.svg
new file mode 100644
index 000000000..b8b5f59ce
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/folder-plus.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/folder.svg b/crates/qbz-qt/qml/assets/icons/black/folder.svg
new file mode 100644
index 000000000..b87243acc
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/folder.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/import.svg b/crates/qbz-qt/qml/assets/icons/black/import.svg
new file mode 100644
index 000000000..39801d73c
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/import.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/library-big.svg b/crates/qbz-qt/qml/assets/icons/black/library-big.svg
new file mode 100644
index 000000000..3ae3336b0
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/library-big.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/list-end.svg b/crates/qbz-qt/qml/assets/icons/black/list-end.svg
new file mode 100644
index 000000000..6fd160fcc
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/list-end.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/list-plus.svg b/crates/qbz-qt/qml/assets/icons/black/list-plus.svg
new file mode 100644
index 000000000..3b5056194
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/list-plus.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/black/maximize-2.svg
new file mode 100644
index 000000000..97da55589
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/maximize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/black/minimize-2.svg
new file mode 100644
index 000000000..518339019
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/minimize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/minus.svg b/crates/qbz-qt/qml/assets/icons/black/minus.svg
new file mode 100644
index 000000000..a514b9e89
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/minus.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/black/pin-filled.svg
new file mode 100644
index 000000000..8b0bf1b62
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/pin-filled.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/pin.svg b/crates/qbz-qt/qml/assets/icons/black/pin.svg
new file mode 100644
index 000000000..c9fbfaa1a
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/pin.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/favorite/arrow-up-down.svg
new file mode 100644
index 000000000..ed4a291ee
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/arrow-up-down.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/favorite/blind-eye.svg
new file mode 100644
index 000000000..a36b4aa35
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/blind-eye.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/folder-open.svg b/crates/qbz-qt/qml/assets/icons/favorite/folder-open.svg
new file mode 100644
index 000000000..a66d670f9
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/folder-open.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/favorite/folder-plus.svg
new file mode 100644
index 000000000..87ee01281
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/folder-plus.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/folder.svg b/crates/qbz-qt/qml/assets/icons/favorite/folder.svg
new file mode 100644
index 000000000..e835c675c
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/folder.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/import.svg b/crates/qbz-qt/qml/assets/icons/favorite/import.svg
new file mode 100644
index 000000000..0566ee95e
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/import.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/library-big.svg b/crates/qbz-qt/qml/assets/icons/favorite/library-big.svg
new file mode 100644
index 000000000..a9d0adeee
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/library-big.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/list-end.svg b/crates/qbz-qt/qml/assets/icons/favorite/list-end.svg
new file mode 100644
index 000000000..4b0f8e0f4
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/list-end.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/list-plus.svg b/crates/qbz-qt/qml/assets/icons/favorite/list-plus.svg
new file mode 100644
index 000000000..df7e3dc97
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/list-plus.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/favorite/maximize-2.svg
new file mode 100644
index 000000000..350608cf8
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/maximize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/favorite/minimize-2.svg
new file mode 100644
index 000000000..e12e22d32
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/minimize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/minus.svg b/crates/qbz-qt/qml/assets/icons/favorite/minus.svg
new file mode 100644
index 000000000..d0aca1af1
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/minus.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/favorite/pin-filled.svg
new file mode 100644
index 000000000..1ad852226
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/pin-filled.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/pin.svg b/crates/qbz-qt/qml/assets/icons/favorite/pin.svg
new file mode 100644
index 000000000..2155fd497
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/pin.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/muted/arrow-up-down.svg
new file mode 100644
index 000000000..fad490b37
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/arrow-up-down.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/muted/blind-eye.svg
new file mode 100644
index 000000000..4fb0897ce
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/blind-eye.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/folder-open.svg b/crates/qbz-qt/qml/assets/icons/muted/folder-open.svg
new file mode 100644
index 000000000..ac6eebef4
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/folder-open.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/muted/folder-plus.svg
new file mode 100644
index 000000000..ec5800a50
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/folder-plus.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/folder.svg b/crates/qbz-qt/qml/assets/icons/muted/folder.svg
new file mode 100644
index 000000000..5c87dd816
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/folder.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/import.svg b/crates/qbz-qt/qml/assets/icons/muted/import.svg
new file mode 100644
index 000000000..5cff3a89d
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/import.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/library-big.svg b/crates/qbz-qt/qml/assets/icons/muted/library-big.svg
new file mode 100644
index 000000000..d1e286076
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/library-big.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/list-end.svg b/crates/qbz-qt/qml/assets/icons/muted/list-end.svg
new file mode 100644
index 000000000..377eadafe
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/list-end.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/list-plus.svg b/crates/qbz-qt/qml/assets/icons/muted/list-plus.svg
new file mode 100644
index 000000000..35a7adde0
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/list-plus.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/muted/maximize-2.svg
new file mode 100644
index 000000000..3c41e535c
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/maximize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/muted/minimize-2.svg
new file mode 100644
index 000000000..7190905d6
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/minimize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/minus.svg b/crates/qbz-qt/qml/assets/icons/muted/minus.svg
new file mode 100644
index 000000000..abd3f85d3
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/minus.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/muted/pin-filled.svg
new file mode 100644
index 000000000..64e0b9b7c
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/pin-filled.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/pin.svg b/crates/qbz-qt/qml/assets/icons/muted/pin.svg
new file mode 100644
index 000000000..4d9640417
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/pin.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/primary/arrow-up-down.svg
new file mode 100644
index 000000000..44084afef
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/arrow-up-down.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/primary/blind-eye.svg
new file mode 100644
index 000000000..964e74131
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/blind-eye.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/folder-open.svg b/crates/qbz-qt/qml/assets/icons/primary/folder-open.svg
new file mode 100644
index 000000000..96ea09e07
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/folder-open.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/primary/folder-plus.svg
new file mode 100644
index 000000000..c409a5261
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/folder-plus.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/folder.svg b/crates/qbz-qt/qml/assets/icons/primary/folder.svg
new file mode 100644
index 000000000..8a606cabe
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/folder.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/import.svg b/crates/qbz-qt/qml/assets/icons/primary/import.svg
new file mode 100644
index 000000000..15d9616ec
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/import.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/library-big.svg b/crates/qbz-qt/qml/assets/icons/primary/library-big.svg
new file mode 100644
index 000000000..3a62c415c
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/library-big.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/list-end.svg b/crates/qbz-qt/qml/assets/icons/primary/list-end.svg
new file mode 100644
index 000000000..cf2d7376b
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/list-end.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/list-plus.svg b/crates/qbz-qt/qml/assets/icons/primary/list-plus.svg
new file mode 100644
index 000000000..9db528626
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/list-plus.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/primary/maximize-2.svg
new file mode 100644
index 000000000..9c37cabf9
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/maximize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/primary/minimize-2.svg
new file mode 100644
index 000000000..fd47bb399
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/minimize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/minus.svg b/crates/qbz-qt/qml/assets/icons/primary/minus.svg
new file mode 100644
index 000000000..0a43998fa
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/minus.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/primary/pin-filled.svg
new file mode 100644
index 000000000..083790d63
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/pin-filled.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/pin.svg b/crates/qbz-qt/qml/assets/icons/primary/pin.svg
new file mode 100644
index 000000000..6346e7697
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/pin.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/secondary/arrow-up-down.svg
new file mode 100644
index 000000000..f6f62eda3
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/arrow-up-down.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/secondary/blind-eye.svg
new file mode 100644
index 000000000..071fe9d01
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/blind-eye.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/folder-open.svg b/crates/qbz-qt/qml/assets/icons/secondary/folder-open.svg
new file mode 100644
index 000000000..5304b08f6
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/folder-open.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/secondary/folder-plus.svg
new file mode 100644
index 000000000..1b11f1d04
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/folder-plus.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/folder.svg b/crates/qbz-qt/qml/assets/icons/secondary/folder.svg
new file mode 100644
index 000000000..27fb02298
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/folder.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/import.svg b/crates/qbz-qt/qml/assets/icons/secondary/import.svg
new file mode 100644
index 000000000..e2c23bafc
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/import.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/library-big.svg b/crates/qbz-qt/qml/assets/icons/secondary/library-big.svg
new file mode 100644
index 000000000..ab692d674
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/library-big.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/list-end.svg b/crates/qbz-qt/qml/assets/icons/secondary/list-end.svg
new file mode 100644
index 000000000..2283fc9bb
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/list-end.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/list-plus.svg b/crates/qbz-qt/qml/assets/icons/secondary/list-plus.svg
new file mode 100644
index 000000000..0b5cf8bf2
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/list-plus.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/secondary/maximize-2.svg
new file mode 100644
index 000000000..50b41040b
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/maximize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/secondary/minimize-2.svg
new file mode 100644
index 000000000..082d483c6
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/minimize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/minus.svg b/crates/qbz-qt/qml/assets/icons/secondary/minus.svg
new file mode 100644
index 000000000..4552548f2
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/minus.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/secondary/pin-filled.svg
new file mode 100644
index 000000000..e1dd9ff71
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/pin-filled.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/pin.svg b/crates/qbz-qt/qml/assets/icons/secondary/pin.svg
new file mode 100644
index 000000000..b446cd43b
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/pin.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/arrow-up-down.svg b/crates/qbz-qt/qml/assets/icons/warning/arrow-up-down.svg
new file mode 100644
index 000000000..dc2e7f560
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/arrow-up-down.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/blind-eye.svg b/crates/qbz-qt/qml/assets/icons/warning/blind-eye.svg
new file mode 100644
index 000000000..d19435b26
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/blind-eye.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/folder-open.svg b/crates/qbz-qt/qml/assets/icons/warning/folder-open.svg
new file mode 100644
index 000000000..fd61e459c
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/folder-open.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/folder-plus.svg b/crates/qbz-qt/qml/assets/icons/warning/folder-plus.svg
new file mode 100644
index 000000000..a92bdc926
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/folder-plus.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/folder.svg b/crates/qbz-qt/qml/assets/icons/warning/folder.svg
new file mode 100644
index 000000000..454920ba9
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/folder.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/import.svg b/crates/qbz-qt/qml/assets/icons/warning/import.svg
new file mode 100644
index 000000000..2f634900e
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/import.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/library-big.svg b/crates/qbz-qt/qml/assets/icons/warning/library-big.svg
new file mode 100644
index 000000000..3dca8423c
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/library-big.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/list-end.svg b/crates/qbz-qt/qml/assets/icons/warning/list-end.svg
new file mode 100644
index 000000000..fae91cc0c
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/list-end.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/list-plus.svg b/crates/qbz-qt/qml/assets/icons/warning/list-plus.svg
new file mode 100644
index 000000000..54e969657
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/list-plus.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/maximize-2.svg b/crates/qbz-qt/qml/assets/icons/warning/maximize-2.svg
new file mode 100644
index 000000000..8cfed918a
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/maximize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/minimize-2.svg b/crates/qbz-qt/qml/assets/icons/warning/minimize-2.svg
new file mode 100644
index 000000000..85cf37832
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/minimize-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/minus.svg b/crates/qbz-qt/qml/assets/icons/warning/minus.svg
new file mode 100644
index 000000000..cad3f688d
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/minus.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/pin-filled.svg b/crates/qbz-qt/qml/assets/icons/warning/pin-filled.svg
new file mode 100644
index 000000000..9d3465c79
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/pin-filled.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/pin.svg b/crates/qbz-qt/qml/assets/icons/warning/pin.svg
new file mode 100644
index 000000000..732e1df7c
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/pin.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/src/auth_qt.rs b/crates/qbz-qt/src/auth_qt.rs
index b14e358f0..e6d372161 100644
--- a/crates/qbz-qt/src/auth_qt.rs
+++ b/crates/qbz-qt/src/auth_qt.rs
@@ -150,6 +150,8 @@ where
crate::offline_fwd::init_for_user(&dir);
// Phase 5: local-favorites store (Library show-local + hearts).
crate::library_qt::init_local_favorites(&dir);
+ // Phase 7: pinned-items store (AlbumCard pin badges).
+ crate::sidebar_qt::init_pinned(&dir);
}
crate::offline_fwd::subscription_mark_valid();
crate::offline_fwd::engine().set_offline_session(false);
@@ -304,6 +306,8 @@ where
crate::offline_fwd::init_for_user(&dir);
// Phase 5: local-favorites store (Library show-local + hearts).
crate::library_qt::init_local_favorites(&dir);
+ // Phase 7: pinned-items store (AlbumCard pin badges).
+ crate::sidebar_qt::init_pinned(&dir);
}
crate::offline_fwd::engine().set_offline_session(true);
Ok(user_id)
diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs
index 3ca469284..3279c955f 100644
--- a/crates/qbz-qt/src/bridge.rs
+++ b/crates/qbz-qt/src/bridge.rs
@@ -113,6 +113,13 @@ pub mod qbz_bridge {
// {tracks, albums, artists, playlists, labels, all} — tab badges.
#[qproperty(QString, library_counts_json)]
+ // --- Sidebar playlist tree ---------------------------------------
+ // One JSON document: the flattened entries (folders + playlists,
+ // expand/sort/search applied Rust-side — sidebar_qt.rs).
+ #[qproperty(QString, sidebar_json)]
+ #[qproperty(QString, sidebar_sort_by)]
+ #[qproperty(bool, sidebar_sort_asc)]
+
type QbzBridge = super::QbzBridgeRust;
/// Called once from Main.qml's Component.onCompleted: registers the
@@ -228,6 +235,36 @@ pub mod qbz_bridge {
/// Emitted after a heart toggle (optimistic flip + rollback).
#[qsignal]
fn library_favorite_changed(self: Pin<&mut QbzBridge>, key: QString, value: bool);
+
+ /// AlbumCard ⋯ menu: Play next ("next") / Add to queue ("later").
+ #[qinvokable]
+ fn enqueue_album(self: Pin<&mut QbzBridge>, album_id: QString, mode: QString);
+
+ /// AlbumCard pin badge: toggle pin (album/artist/playlist).
+ #[qinvokable]
+ fn toggle_pin(self: Pin<&mut QbzBridge>, kind: QString, id: QString, title: QString, subtitle: QString, artwork_url: QString);
+ /// Emitted after a pin toggle (`{kind}:{id}` key like artKey).
+ #[qsignal]
+ fn pin_changed(self: Pin<&mut QbzBridge>, key: QString, value: bool);
+
+ /// Sidebar tree: rebuild + republish after load / sort / search /
+ /// folder toggle.
+ #[qinvokable]
+ fn reload_sidebar(self: Pin<&mut QbzBridge>);
+ #[qinvokable]
+ fn sidebar_set_sort(self: Pin<&mut QbzBridge>, option: QString);
+ #[qinvokable]
+ fn sidebar_search(self: Pin<&mut QbzBridge>, query: QString);
+ #[qinvokable]
+ fn sidebar_toggle_folder(self: Pin<&mut QbzBridge>, id: QString);
+ /// Sidebar "+" — create an empty playlist (single core call), then
+ /// reload the tree.
+ #[qinvokable]
+ fn create_playlist(self: Pin<&mut QbzBridge>);
+ /// Sidebar cover dispatch: JSON array of cover URLS (the tree's
+ /// collage is url-keyed, unlike the feed's artKey).
+ #[qinvokable]
+ fn sidebar_artwork_window(self: Pin<&mut QbzBridge>, urls_json: QString);
}
impl cxx_qt::Threading for QbzBridge {}
@@ -286,6 +323,9 @@ pub struct QbzBridgeRust {
library_error: QString,
library_json: QString,
library_counts_json: QString,
+ sidebar_json: QString,
+ sidebar_sort_by: QString,
+ sidebar_sort_asc: bool,
}
impl Default for QbzBridgeRust {
@@ -334,6 +374,9 @@ impl Default for QbzBridgeRust {
library_error: QString::default(),
library_json: QString::from("[]"),
library_counts_json: QString::from("{}"),
+ sidebar_json: QString::from("[]"),
+ sidebar_sort_by: QString::from("name"),
+ sidebar_sort_asc: true,
}
}
}
@@ -458,4 +501,42 @@ impl qbz_bridge::QbzBridge {
pub fn library_toggle_favorite(self: Pin<&mut Self>, kind: QString, id: QString) {
crate::library_toggle_favorite(kind.to_string(), id.to_string());
}
+
+ pub fn enqueue_album(self: Pin<&mut Self>, album_id: QString, mode: QString) {
+ crate::enqueue_album(album_id.to_string(), mode.to_string());
+ }
+
+ pub fn toggle_pin(self: Pin<&mut Self>, kind: QString, id: QString, title: QString, subtitle: QString, artwork_url: QString) {
+ crate::toggle_pin(
+ kind.to_string(),
+ id.to_string(),
+ title.to_string(),
+ subtitle.to_string(),
+ artwork_url.to_string(),
+ );
+ }
+
+ pub fn reload_sidebar(self: Pin<&mut Self>) {
+ crate::reload_sidebar();
+ }
+
+ pub fn sidebar_set_sort(self: Pin<&mut Self>, option: QString) {
+ crate::sidebar_set_sort(&option.to_string());
+ }
+
+ pub fn sidebar_search(self: Pin<&mut Self>, query: QString) {
+ crate::sidebar_set_search(&query.to_string());
+ }
+
+ pub fn sidebar_toggle_folder(self: Pin<&mut Self>, id: QString) {
+ crate::sidebar_toggle_folder(&id.to_string());
+ }
+
+ pub fn create_playlist(self: Pin<&mut Self>) {
+ crate::create_playlist();
+ }
+
+ pub fn sidebar_artwork_window(self: Pin<&mut Self>, urls_json: QString) {
+ crate::sidebar_artwork_window(urls_json.to_string());
+ }
}
diff --git a/crates/qbz-qt/src/home_qt.rs b/crates/qbz-qt/src/home_qt.rs
index 118d6e384..b42b86c0b 100644
--- a/crates/qbz-qt/src/home_qt.rs
+++ b/crates/qbz-qt/src/home_qt.rs
@@ -53,6 +53,8 @@ pub struct HomeCard {
pub ribbon: String,
#[serde(rename = "ribbonKind")]
pub ribbon_kind: String,
+ #[serde(rename = "isPinned", default)]
+ pub is_pinned: bool,
/// Slim rows ("Popular albums"): the 1-based rank ("" = none).
pub rank: String,
/// Playlist rows: the UPPERCASE first-tag category subtag.
@@ -274,7 +276,9 @@ pub(crate) fn map_album(album: DiscoverAlbum) -> HomeCard {
.or(album.image.thumbnail)
.or(album.image.small)
.unwrap_or_default();
+ let is_pinned = crate::sidebar_qt::is_pinned("album", &album.id);
HomeCard {
+ is_pinned,
id: album.id,
title: album.title,
artist,
diff --git a/crates/qbz-qt/src/library_qt.rs b/crates/qbz-qt/src/library_qt.rs
index c578a1ef9..c12e1a83e 100644
--- a/crates/qbz-qt/src/library_qt.rs
+++ b/crates/qbz-qt/src/library_qt.rs
@@ -77,6 +77,8 @@ pub struct FeedItem {
pub playlist_owned: bool,
#[serde(rename = "playlistFollowing")]
pub playlist_following: bool,
+ #[serde(rename = "isPinned", default)]
+ pub is_pinned: bool,
/// Recency proxy in [0,1]; 0 = most-recently added (per source).
pub added_rank: f32,
}
@@ -283,7 +285,9 @@ fn map_album(album: Album) -> FeedItem {
.and_then(|c| c.first().map(|a| a.name.clone()))
.unwrap_or_default()
};
+ let is_pinned = crate::sidebar_qt::is_pinned("album", &album.id);
FeedItem {
+ is_pinned,
kind: "album".into(),
group: "favorites".into(),
source: "qobuz".into(),
diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs
index 8a13fc12e..772fd4382 100644
--- a/crates/qbz-qt/src/main.rs
+++ b/crates/qbz-qt/src/main.rs
@@ -16,6 +16,7 @@ mod nav_qt;
mod now_playing;
mod offline_fwd;
mod playback_qt;
+mod sidebar_qt;
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock};
@@ -123,6 +124,8 @@ fn enter_shell(session: auth_qt::SessionInfo) {
load_home_once();
// Phase 4: the 1 Hz playback state pump (idempotent).
playback_qt::start_poll_loop(app());
+ // Phase 7: the sidebar playlist tree.
+ load_sidebar_once();
ui(move |mut b| {
b.as_mut().set_session_user_name(QString::from(session.display_name.as_str()));
@@ -235,6 +238,7 @@ pub(crate) fn do_logout() {
b.as_mut().set_library_counts_json(QString::from("{}"));
b.as_mut().set_library_error(QString::from(""));
b.as_mut().set_library_loading(false);
+ b.as_mut().set_sidebar_json(QString::from("[]"));
b.as_mut().set_screen(QString::from("login"));
});
});
@@ -259,6 +263,129 @@ fn load_home_once() {
// ============================ Playback (phase 4) ===========================
+// ============================ Sidebar + pins (phase 7) =====================
+
+/// Load + publish the sidebar tree (session entry; idempotent per session).
+fn load_sidebar_once() {
+ static LOADED: Mutex = Mutex::new(false);
+ if *LOADED.lock().unwrap() {
+ return;
+ }
+ if offline_fwd::engine().status().is_offline() {
+ log::info!("[qbz-qt] sidebar load skipped (offline session)");
+ return;
+ }
+ *LOADED.lock().unwrap() = true;
+ reload_sidebar();
+}
+
+/// Fetch playlists + folders and publish the flattened entries.
+pub(crate) fn reload_sidebar() {
+ if offline_fwd::engine().status().is_offline() {
+ return;
+ }
+ let runtime = app();
+ spawn(async move {
+ sidebar_qt::load(&runtime).await;
+ publish_sidebar();
+ });
+}
+
+fn publish_sidebar() {
+ let entries = sidebar_qt::rebuild();
+ let json = serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into());
+ log::debug!("[qbz-qt] sidebar published: {} entries ({} bytes)", entries.len(), json.len());
+ let (sort_by, sort_asc) = sidebar_qt::sort_state();
+ ui(move |mut b| {
+ b.as_mut().set_sidebar_json(QString::from(json.as_str()));
+ b.as_mut().set_sidebar_sort_by(QString::from(sort_by.as_str()));
+ b.as_mut().set_sidebar_sort_asc(sort_asc);
+ });
+}
+
+pub(crate) fn sidebar_set_sort(option: &str) {
+ sidebar_qt::set_sort(option);
+ publish_sidebar();
+}
+
+pub(crate) fn sidebar_set_search(query: &str) {
+ sidebar_qt::set_search(query);
+ publish_sidebar();
+}
+
+pub(crate) fn sidebar_toggle_folder(id: &str) {
+ sidebar_qt::toggle_folder(id);
+ publish_sidebar();
+}
+
+/// Sidebar cover dispatch: plain url list (the tree's collage is
+/// url-keyed). Disk hits emit immediately; misses download then emit.
+pub(crate) fn sidebar_artwork_window(urls_json: String) {
+ let urls: Vec = serde_json::from_str(&urls_json).unwrap_or_default();
+ let mut missing: Vec = Vec::new();
+ for url in urls {
+ let path = artwork_qt::cached_path(&url);
+ if path.is_empty() {
+ missing.push(url);
+ } else {
+ emit_library_artwork(url, path);
+ }
+ }
+ if missing.is_empty() {
+ return;
+ }
+ spawn(async move {
+ let urls = missing;
+ artwork_qt::download_missing(urls.clone()).await;
+ for url in urls {
+ let path = artwork_qt::cached_path(&url);
+ if !path.is_empty() {
+ emit_library_artwork(url, path);
+ }
+ }
+ });
+}
+
+/// Sidebar "+" — create an empty playlist with the default name, then
+/// reload the tree (the Slint flow opens a naming modal — POC-NOTE).
+pub(crate) fn create_playlist() {
+ let runtime = app();
+ spawn(async move {
+ match runtime
+ .core()
+ .create_playlist(&qbz_i18n::t("New Playlist"), None, false)
+ .await
+ {
+ Ok(p) => {
+ log::info!("[qbz-qt] playlist created: {} ({})", p.name, p.id);
+ sidebar_qt::load(&runtime).await;
+ publish_sidebar();
+ }
+ Err(e) => log::error!("[qbz-qt] create playlist failed: {e}"),
+ }
+ });
+}
+
+/// AlbumCard ⋯ menu: Play next / Add to queue.
+pub(crate) fn enqueue_album(album_id: String, mode: String) {
+ let runtime = app();
+ spawn(async move {
+ if let Err(e) = playback_qt::enqueue_album(&runtime, &album_id, &mode).await {
+ log::error!("[qbz-qt] enqueue_album failed: {e}");
+ }
+ });
+}
+
+/// AlbumCard pin badge: toggle + signal the result.
+pub(crate) fn toggle_pin(kind: String, id: String, title: String, subtitle: String, artwork_url: String) {
+ if let Some(value) = sidebar_qt::toggle_pin(&kind, &id, &title, &subtitle, &artwork_url) {
+ let key = format!("{kind}:{id}");
+ ui(move |mut b| {
+ b.as_mut().pin_changed(QString::from(key.as_str()), value);
+ });
+ }
+}
+
/// Track-row click (Library tracks): one-track queue through the core.
pub(crate) fn play_track(track_id: u64) {
let runtime = app();
diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs
index ef3998834..226a3138c 100644
--- a/crates/qbz-qt/src/playback_qt.rs
+++ b/crates/qbz-qt/src/playback_qt.rs
@@ -40,11 +40,10 @@ static PREMUTE_VOLUME: AtomicU64 = AtomicU64::new(0);
/// Fetch the album, build the queue (playback.rs `make_queue_track` port),
/// set it starting at track 1, and play through the core's resolved path.
-pub async fn play_album(
+async fn fetch_album_queue(
runtime: &Arc>,
album_id: &str,
-) -> Result<(), String> {
- log::info!("[qbz-qt] play_album: resolving {album_id}");
+) -> Result, String> {
let album = runtime
.core()
.get_album(album_id)
@@ -101,7 +100,38 @@ pub async fn play_album(
context_id: Some(album.id.clone()),
})
.collect();
+ Ok(tracks)
+}
+/// "Play next" / "Add to queue" for an album (AlbumCard ⋯ menu): resolve
+/// the album's tracks and insert them after the current track (mode
+/// "next") or append them (mode "later").
+pub async fn enqueue_album(
+ runtime: &Arc>,
+ album_id: &str,
+ mode: &str,
+) -> Result<(), String> {
+ let tracks = fetch_album_queue(runtime, album_id).await?;
+ log::info!("[qbz-qt] enqueue_album {album_id} ({mode}): {} tracks", tracks.len());
+ if mode == "next" {
+ // add_track_next inserts directly after the current track — feed
+ // in reverse so the album's first track lands first.
+ for track in tracks.into_iter().rev() {
+ runtime.core().add_track_next(track).await;
+ }
+ } else {
+ runtime.core().add_tracks(tracks).await;
+ }
+ publish_queue(runtime).await;
+ Ok(())
+}
+
+pub async fn play_album(
+ runtime: &Arc>,
+ album_id: &str,
+) -> Result<(), String> {
+ log::info!("[qbz-qt] play_album: resolving {album_id}");
+ let tracks = fetch_album_queue(runtime, album_id).await?;
let first_id = tracks[0].id;
let count = tracks.len();
runtime.core().set_queue(tracks, Some(0)).await;
diff --git a/crates/qbz-qt/src/sidebar_qt.rs b/crates/qbz-qt/src/sidebar_qt.rs
new file mode 100644
index 000000000..ac29ab70d
--- /dev/null
+++ b/crates/qbz-qt/src/sidebar_qt.rs
@@ -0,0 +1,346 @@
+//! Sidebar playlists + folders controller — Slint-free port of
+//! `crates/qbz/src/sidebar.rs` (load / sort / search / expand / rebuild).
+//!
+//! Model: Qobuz playlists (`get_user_playlists`) + folder metadata from the
+//! per-user library.db (`qbz-library` queries — same ones `folders.rs`
+//! wraps), session-only expand state (Tauri parity — not persisted),
+//! 5-option sort with direction toggle (#657), recursive name search.
+//! Entries publish as ONE JSON document (`sidebarJson`).
+//!
+//! POC-NOTEs:
+//! - LOCAL playlists (library.db `local:` entities), the offline
+//! D11.b synthesis, hidden-playlist filtering needs the hidden flag (it
+//! IS read and applied), move-to-folder, folder edit/delete, context
+//! menus, and the mini-state folder flyout: out of scope. Playlist
+//! click only marks the row active (no playlist view yet).
+
+use std::collections::{HashMap, HashSet};
+use std::path::Path;
+use std::sync::{Arc, Mutex, OnceLock};
+
+use qbz_app::settings::pinned_items::{PinnedItemsService, PinnedItem, DB_FILE_NAME};
+use qbz_app::shell::AppRuntime;
+use qbz_app::user_data::UserDataPaths;
+use qbz_core::LoggingAdapter;
+use serde::Serialize;
+
+// ---------------------------------------------------------------------------
+// Pinned items store (PinnedItemsService, ADR-006 backend) — same lifecycle
+// as the local-favorites store.
+// ---------------------------------------------------------------------------
+
+static PINNED: OnceLock> = OnceLock::new();
+
+/// Bind `/pinned_items.db` on every session activation (mirrors
+/// `pinned::init_for_user`; fail-open).
+pub fn init_pinned(base_dir: &Path) {
+ let path = base_dir.join(DB_FILE_NAME);
+ match PinnedItemsService::new(&path) {
+ Ok(service) => {
+ let _ = PINNED.set(Mutex::new(service));
+ log::info!("[qbz-qt] pinned items store opened");
+ }
+ Err(e) => log::error!("[qbz-qt] pinned items store open failed: {e}"),
+ }
+}
+
+pub fn is_pinned(kind: &str, id: &str) -> bool {
+ PINNED
+ .get()
+ .map(|s| s.lock().unwrap().is_pinned(kind, id))
+ .unwrap_or(false)
+}
+
+/// Toggle the pin state of an album/artist/playlist. Returns the new
+/// state, or None with no store bound / on error.
+pub fn toggle_pin(kind: &str, id: &str, title: &str, subtitle: &str, artwork_url: &str) -> Option {
+ let service = PINNED.get()?.lock().unwrap();
+ if service.is_pinned(kind, id) {
+ service.unpin(kind, id).ok()?;
+ Some(false)
+ } else {
+ service
+ .pin(&PinnedItem {
+ kind: kind.to_string(),
+ id: id.to_string(),
+ title: title.to_string(),
+ subtitle: subtitle.to_string(),
+ artwork_url: artwork_url.to_string(),
+ pinned_at: 0,
+ })
+ .ok()?;
+ Some(true)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Sidebar model
+// ---------------------------------------------------------------------------
+
+#[derive(Clone, Serialize)]
+pub struct SidebarEntry {
+ pub kind: String, // "folder" | "playlist"
+ pub id: String,
+ pub name: String,
+ pub expanded: bool,
+ pub count: i32,
+ pub indent: bool,
+ #[serde(rename = "folderId")]
+ pub folder_id: String,
+ /// Up to 4 de-duplicated cover urls for the 2x2 micro-collage.
+ pub covers: Vec,
+}
+
+#[derive(Clone)]
+struct SidebarPlaylist {
+ id: u64,
+ name: String,
+ tracks_count: u32,
+ cover_urls: Vec,
+ position: i32,
+}
+
+#[derive(Default, Clone)]
+struct SidebarData {
+ playlists: Vec,
+ /// (id, name), hidden folders already excluded.
+ folders: Vec<(String, String)>,
+ /// playlist id -> folder id.
+ folder_map: HashMap,
+ hidden_playlists: HashSet,
+}
+
+static CACHE: Mutex