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> = Mutex::new(None); +/// Session-only folder expand state (matches Tauri — not persisted). +static EXPANDED: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| Mutex::new(HashSet::new())); +/// (option, asc) — session scope (sidebar.rs SORT; Tauri persists in +/// localStorage, Slint keeps it in-session). +static SORT: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Mutex::new(("name".to_string(), true))); +static SEARCH: Mutex = Mutex::new(String::new()); + +/// Fetch playlists (Qobuz) + folders + membership + hidden set (library.db). +pub async fn load(runtime: &Arc>) { + log::debug!("[qbz-qt] sidebar load: fetching user playlists"); + let playlists: Vec = match runtime.core().get_user_playlists().await { + Ok(pls) => pls + .into_iter() + .map(|p| { + let cover_urls = { + let source = [&p.images300, &p.images150, &p.images] + .into_iter() + .flatten() + .find(|v| !v.is_empty()); + let mut out: Vec = Vec::new(); + if let Some(list) = source { + for url in list { + if !url.is_empty() && !out.contains(url) { + out.push(url.clone()); + } + if out.len() == 4 { + break; + } + } + } + out + }; + SidebarPlaylist { + id: p.id, + name: p.name, + tracks_count: p.tracks_count, + cover_urls, + position: 0, + } + }) + .collect(), + Err(e) => { + log::warn!("[qbz-qt] sidebar playlists load failed: {e}"); + Vec::new() + } + }; + + log::debug!("[qbz-qt] sidebar load: playlists fetch settled, reading folders"); + let (folders, folder_map, positions, hidden_playlists) = folders_blocking(); + log::debug!("[qbz-qt] sidebar load: folders read"); + let mut playlists = playlists; + for p in &mut playlists { + if let Some(pos) = positions.get(&p.id) { + p.position = *pos; + } + } + let (n_playlists, n_folders) = (playlists.len(), folders.len()); + *CACHE.lock().unwrap() = Some(SidebarData { + playlists, + folders, + folder_map, + hidden_playlists, + }); + log::info!("[qbz-qt] sidebar loaded: {n_playlists} playlists, {n_folders} folders"); +} + +/// library.db queries (folders.rs equivalents), run inline — rusqlite is +/// sync but the three reads are tiny. +fn folders_blocking() -> (Vec<(String, String)>, HashMap, HashMap, HashSet) { + let Some(uid) = UserDataPaths::load_last_user_id() else { + return Default::default(); + }; + let Some(path) = dirs::data_dir().map(|p| { + p.join("qbz") + .join("users") + .join(uid.to_string()) + .join("library.db") + }) else { + return Default::default(); + }; + if !path.exists() { + return Default::default(); + } + let Ok(db) = qbz_library::LibraryDatabase::open(&path) else { + return Default::default(); + }; + let folders: Vec<(String, String)> = db + .get_all_playlist_folders() + .unwrap_or_default() + .into_iter() + .filter(|f| !f.is_hidden) + .map(|f| (f.id, f.name)) + .collect(); + let mut folder_map = HashMap::new(); + let mut positions = HashMap::new(); + let mut hidden = HashSet::new(); + for s in db.get_all_playlist_settings().unwrap_or_default() { + if let Some(fid) = s.folder_id { + folder_map.insert(s.qobuz_playlist_id, fid); + } + positions.insert(s.qobuz_playlist_id, s.position); + if s.hidden { + hidden.insert(s.qobuz_playlist_id); + } + } + (folders, folder_map, positions, hidden) +} + +/// SidebarPlaylist order for the active sort (sidebar.rs comparators: +/// `recent`/`playcount` keep API order = newest-first; asc==true is always +/// the natural direction). +fn sort_playlists(playlists: &[SidebarPlaylist]) -> Vec { + let (sort, asc) = SORT.lock().unwrap().clone(); + let mut out: Vec = playlists.to_vec(); + match sort.as_str() { + "name" => out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())), + "tracks" => out.sort_by(|a, b| b.tracks_count.cmp(&a.tracks_count)), + "custom" => out.sort_by(|a, b| a.position.cmp(&b.position)), + _ => {} + } + if !asc { + out.reverse(); + } + out +} + +/// Rebuild the flattened entries from cache + expand + search and return +/// them (the caller publishes). +pub fn rebuild() -> Vec { + let Some(data) = CACHE.lock().unwrap().clone() else { + return Vec::new(); + }; + let expanded = EXPANDED.lock().unwrap().clone(); + let query = SEARCH.lock().unwrap().clone(); + let searching = !query.is_empty(); + let folder_ids: HashSet<&String> = data.folders.iter().map(|f| &f.0).collect(); + + let sorted = sort_playlists(&data.playlists); + let matches = + |p: &SidebarPlaylist| !searching || p.name.to_lowercase().contains(query.as_str()); + + let entry_for = |p: &SidebarPlaylist, indent: bool, folder_id: &str| SidebarEntry { + kind: "playlist".into(), + id: p.id.to_string(), + name: p.name.clone(), + expanded: false, + count: 0, + indent, + folder_id: folder_id.to_string(), + covers: p.cover_urls.clone(), + }; + + let mut entries: Vec = Vec::new(); + for (fid, fname) in &data.folders { + let members: Vec<&SidebarPlaylist> = sorted + .iter() + .filter(|p| data.folder_map.get(&p.id).map(|f| f == fid).unwrap_or(false)) + .filter(|p| matches(p)) + .filter(|p| !data.hidden_playlists.contains(&p.id)) + .collect(); + // While searching, skip folders with no matching playlists. + if searching && members.is_empty() { + continue; + } + // When searching, force-expand so matches inside are visible. + let is_exp = searching || expanded.contains(fid); + entries.push(SidebarEntry { + kind: "folder".into(), + id: fid.clone(), + name: fname.clone(), + expanded: is_exp, + count: members.len() as i32, + indent: false, + folder_id: String::new(), + covers: Vec::new(), + }); + if is_exp { + for p in members { + entries.push(entry_for(p, true, fid)); + } + } + } + // Root playlists — no folder, or a folder that no longer exists. + for p in &sorted { + let in_folder = data + .folder_map + .get(&p.id) + .map(|f| folder_ids.contains(f)) + .unwrap_or(false); + if !in_folder && matches(p) && !data.hidden_playlists.contains(&p.id) { + entries.push(entry_for(p, false, "")); + } + } + entries +} + +/// PlaylistView-style sort toggle (#657): re-pick flips direction, a new +/// option starts at its natural direction (asc == true everywhere). +pub fn set_sort(option: &str) { + let opt = match option { + "name" | "recent" | "tracks" | "playcount" | "custom" => option, + _ => "name", + }; + let mut s = SORT.lock().unwrap(); + if s.0 == opt { + s.1 = !s.1; + } else { + s.0 = opt.to_string(); + s.1 = true; + } +} + +pub fn sort_state() -> (String, bool) { + SORT.lock().unwrap().clone() +} + +pub fn set_search(query: &str) { + *SEARCH.lock().unwrap() = query.trim().to_lowercase(); +} + +/// Toggle a folder's expand state (or set it explicitly for the tree's +/// collapse-all). Returns the new state. +pub fn toggle_folder(id: &str) -> bool { + let mut expanded = EXPANDED.lock().unwrap(); + if !expanded.insert(id.to_string()) { + expanded.remove(id); + false + } else { + true + } +}