diff --git a/crates/Cargo.lock b/crates/Cargo.lock
index 47427cfb7..b28cac7a7 100644
--- a/crates/Cargo.lock
+++ b/crates/Cargo.lock
@@ -6624,8 +6624,10 @@ dependencies = [
"qbz-core",
"qbz-credentials",
"qbz-i18n",
+ "qbz-library",
"qbz-log",
"qbz-models",
+ "qbz-offline-cache",
"qbz-qobuz",
"qbz-text-utils",
"rand 0.10.1",
diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml
index 0172bc54b..f9145b8b8 100644
--- a/crates/qbz-qt/Cargo.toml
+++ b/crates/qbz-qt/Cargo.toml
@@ -29,6 +29,8 @@ qbz-app = { path = "../qbz-app" }
qbz-qobuz = { path = "../qbz-qobuz" }
qbz-models = { path = "../qbz-models" }
qbz-cache = { path = "../qbz-cache" }
+qbz-offline-cache = { path = "../qbz-offline-cache" }
+qbz-library = { path = "../qbz-library" }
qbz-text-utils = { path = "../qbz-text-utils" }
qbz-credentials = { path = "../qbz-credentials" }
qbz-i18n = { path = "../qbz-i18n" }
diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs
index f8db6158b..863fed55a 100644
--- a/crates/qbz-qt/build.rs
+++ b/crates/qbz-qt/build.rs
@@ -45,6 +45,7 @@ fn main() {
"qml/NowPlayingBarSmall.qml",
"qml/QueuePanel.qml",
"qml/HomeView.qml",
+ "qml/LibraryView.qml",
"qml/QbzTheme.qml",
"qml/QbzIcon.qml",
],
diff --git a/crates/qbz-qt/qml/AppShell.qml b/crates/qbz-qt/qml/AppShell.qml
index 5d0485d53..e04576e69 100644
--- a/crates/qbz-qt/qml/AppShell.qml
+++ b/crates/qbz-qt/qml/AppShell.qml
@@ -70,7 +70,8 @@ Rectangle {
Loader {
anchors.fill: parent
- source: QbzBridge.currentView === "home" ? "HomeView.qml" : ""
+ source: QbzBridge.currentView === "home" ? "HomeView.qml"
+ : QbzBridge.currentView === "library" ? "LibraryView.qml" : ""
}
}
}
diff --git a/crates/qbz-qt/qml/LibraryView.qml b/crates/qbz-qt/qml/LibraryView.qml
new file mode 100644
index 000000000..d27941b76
--- /dev/null
+++ b/crates/qbz-qt/qml/LibraryView.qml
@@ -0,0 +1,1067 @@
+// Library view — QML port of crates/qbz-ui/ui/favorites/FavoritesView.slint
+// + the Library "All" mixed feed (library_all.rs semantics).
+//
+// Data: QbzBridge.libraryJson (ONE JSON document — the full merged feed;
+// tabs/search/sort/source-filters derive HERE in JS, measured per the
+// phase brief) + libraryCountsJson (tab badges). Artwork is id-keyed
+// through the libraryArtworkReady signal into `artMap` (never a
+// wrong-cover race); windows are reported as artKeys via
+// libraryArtworkWindow, and artMap entries far outside the viewport are
+// pruned (the Slint eviction policy, QML-side).
+//
+// POC-NOTEs:
+// - Genre filter, multi-select, group modes, alpha jumps, play-all /
+// shuffle bulk actions, playlists-random, artists sidepanel, albums LIST
+// mode, per-tab persist of view/sort state: out of scope (stubs or
+// omitted; the All grid+list is the measured 1:1 focus).
+// - Offline: the generic OfflinePlaceholder replica mounts (the Slint
+// offline RAIL of playable cached favorites needs the offline cache —
+// not wired).
+// - Sort/search/source state is session-local (no ui_prefs persistence).
+
+import QtQuick
+import QtQuick.Controls
+import com.blitzfc.qbz
+
+Rectangle {
+ id: root
+ color: theme.surfaceMain
+
+ QbzTheme { id: theme }
+
+ // ============================ state ==================================
+ property string activeTab: "all"
+
+ readonly property var counts: JSON.parse(QbzBridge.libraryCountsJson)
+ // Full merged feed (parsed once per publish — timed for the report).
+ readonly property var feed: parseFeed(QbzBridge.libraryJson)
+ function parseFeed(json) {
+ var t = Date.now()
+ var f = JSON.parse(json)
+ console.log("[qbz-qt][perf] QML JSON.parse feed: " + (Date.now() - t)
+ + "ms (" + f.length + " items, " + json.length + " bytes)")
+ return f
+ }
+
+ // Decoded-cover map {artKey: file://path}, fed by the signal.
+ property var artMap: ({})
+
+ // All-tab derive state (LibraryAllState semantics).
+ property string search: ""
+ property string sortBy: "date" // "date" | "title" | "artist"
+ property bool sortAsc: false // date: false = newest first
+ property bool showPurchases: true
+ property bool showFavorites: true
+ property bool showFollowing: true
+ property bool showLocal: true
+ property string viewMode: "grid" // "grid" | "list"
+
+ // Other-tab state.
+ property string tabSearch: ""
+ property string albumsSort: "default" // default|title-asc|title-desc|artist-asc
+ property string playlistsSubTab: "favorites"
+
+ // ------------------------- derive (JS) ------------------------------
+ function visibleItems() {
+ var items = []
+ var i
+ if (activeTab === "all") {
+ var needle = search.toLowerCase()
+ var anyGroup = showPurchases || showFavorites || showFollowing
+ for (i = 0; i < feed.length; i++) {
+ var it = feed[i]
+ var isLocal = it.source === "local" || it.source === "plex"
+ if (isLocal) {
+ if (!showLocal) continue
+ } else if (anyGroup) {
+ var g = it.group
+ var ok = (g === "purchases" && showPurchases)
+ || (g === "favorites" && showFavorites)
+ || (g === "following" && showFollowing)
+ if (!ok) continue
+ }
+ if (needle !== ""
+ && it.title.toLowerCase().indexOf(needle) < 0
+ && it.artist.toLowerCase().indexOf(needle) < 0) continue
+ items.push(it)
+ }
+ // Canonical ascending order per field, then reverse for the
+ // other direction; "date" keeps model order (newest-first),
+ // reversed for oldest (library_all.rs derive).
+ if (sortBy === "title") {
+ items.sort(function (a, b) { return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : 1 })
+ if (!sortAsc) items.reverse()
+ } else if (sortBy === "artist") {
+ items.sort(function (a, b) { return a.artist.toLowerCase() < b.artist.toLowerCase() ? -1 : 1 })
+ if (!sortAsc) items.reverse()
+ } else if (sortAsc) {
+ items = items.slice().reverse()
+ }
+ return items
+ }
+ var tabNeedle = tabSearch.toLowerCase()
+ function hit(it) {
+ return tabNeedle === ""
+ || it.title.toLowerCase().indexOf(tabNeedle) >= 0
+ || it.artist.toLowerCase().indexOf(tabNeedle) >= 0
+ }
+ for (i = 0; i < feed.length; i++) {
+ var x = feed[i]
+ var keep = false
+ if (activeTab === "tracks") keep = x.kind === "track" && x.group === "favorites"
+ else if (activeTab === "albums") keep = x.kind === "album" && x.group === "favorites"
+ else if (activeTab === "artists") keep = x.kind === "artist"
+ else if (activeTab === "labels") keep = x.kind === "label"
+ else if (activeTab === "playlists") keep = x.kind === "playlist"
+ && (playlistsSubTab === "following" ? x.group === "following" : x.group === "favorites")
+ if (keep && hit(x)) items.push(x)
+ }
+ if (activeTab === "albums" && albumsSort !== "default") {
+ var field = albumsSort.indexOf("artist") === 0 ? "artist" : "title"
+ items.sort(function (a, b) {
+ var av = field === "artist" ? a.artist.toLowerCase() : a.title.toLowerCase()
+ var bv = field === "artist" ? b.artist.toLowerCase() : b.title.toLowerCase()
+ return av < bv ? -1 : 1
+ })
+ if (albumsSort.slice(-4) === "desc") items.reverse()
+ }
+ return items
+ }
+
+ // --------------------- windowed artwork -----------------------------
+ // Report the mounted window as artKeys; prune far-away covers.
+ function reportWindow(visibleArray, first, last) {
+ if (visibleArray.length === 0) return
+ last = Math.min(last, visibleArray.length - 1)
+ if (first > last) return
+ var keys = []
+ var keep = {}
+ var span = last - first + 1
+ var keepLo = Math.max(0, first - span)
+ var keepHi = Math.min(visibleArray.length - 1, last + span)
+ for (var i = keepLo; i <= keepHi; i++) keep[visibleArray[i].artKey] = true
+ for (i = first; i <= last; i++) {
+ var k = visibleArray[i].artKey
+ if (visibleArray[i].imageUrl !== "") keys.push(k)
+ }
+ var m = artMap
+ var changed = false
+ for (var key in m) {
+ if (!keep[key]) { delete m[key]; changed = true }
+ }
+ if (changed) artMap = Object.assign({}, m)
+ QbzBridge.libraryArtworkWindow(JSON.stringify(keys))
+ }
+
+ Connections {
+ target: QbzBridge
+ function onLibraryArtworkReady(key, path) {
+ var m = root.artMap
+ m[key] = path
+ // Rebind requires a NEW object reference (same-ref assignment
+ // is not a change in QML).
+ root.artMap = Object.assign({}, m)
+ }
+ function onLibraryFavoriteChanged(key, value) {
+ var f = root.feed
+ for (var i = 0; i < f.length; i++) {
+ if (f[i].artKey === key) { f[i].isFavorite = value; break }
+ }
+ root.feedChanged()
+ }
+ }
+
+ // Debounced window reporting (180ms, library_all.rs throttle).
+ Timer {
+ id: windowDebounce
+ interval: 180
+ onTriggered: root.reportWindow(root.visibleItems(), pendingFirst, pendingLast)
+ property int pendingFirst: 0
+ property int pendingLast: 0
+ }
+ function queueWindowReport(first, last) {
+ windowDebounce.pendingFirst = first
+ windowDebounce.pendingLast = last
+ windowDebounce.restart()
+ }
+
+ // ============================ components =============================
+
+ // Segmented tab (SegmentedTabBar's Segment) with count badge.
+ component SegTab: Rectangle {
+ id: segTabRoot
+ property string label: ""
+ property int count: 0
+ property bool active: false
+ signal clicked()
+
+ width: segRow.implicitWidth
+ height: segRow.implicitHeight
+ radius: 4
+ color: active ? theme.surfaceMain
+ : segArea.containsMouse ? theme.surfaceHover : "transparent"
+
+ Row {
+ id: segRow
+ leftPadding: 12
+ rightPadding: parent && parent.count > 0 ? 8 : 12
+ topPadding: 6
+ bottomPadding: 6
+ spacing: 7
+ Text {
+ text: parent.parent.label
+ color: parent.parent.active ? theme.textPrimary : theme.textMuted
+ font.pixelSize: theme.fontLegal
+ font.weight: theme.weightMedium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Rectangle {
+ visible: parent.parent.count > 0
+ width: Math.max(18, countText.implicitWidth + 10)
+ height: 16
+ radius: 8
+ anchors.verticalCenter: parent.verticalCenter
+ color: parent.parent.active ? "#26ffffff" : "#14ffffff"
+ Text {
+ id: countText
+ anchors.centerIn: parent
+ text: segTabRoot.count
+ color: segTabRoot.active ? theme.textPrimary : theme.textSecondary
+ font.pixelSize: 11
+ font.weight: theme.weightMedium
+ }
+ }
+ }
+ // Active underline (redundant shape cue).
+ Rectangle {
+ visible: active
+ x: 4
+ width: parent.width - 8
+ height: 2
+ y: parent.height - 2
+ radius: 1
+ color: theme.accent
+ }
+ MouseArea {
+ id: segArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: parent.clicked()
+ }
+ }
+
+ // Small toolbar toggle button (ToggleButton sm): 30px, active = accent.
+ component ToolToggle: Rectangle {
+ property string name: ""
+ property bool active: false
+ signal clicked()
+ width: 30
+ height: 30
+ radius: 6
+ color: active ? theme.surfaceElevated
+ : ttArea.containsMouse ? theme.surfaceHover : "transparent"
+ QbzIcon {
+ name: parent.name
+ width: 16
+ height: 16
+ anchors.centerIn: parent
+ tintName: parent.active ? "accent"
+ : ttArea.containsMouse ? "primary" : "secondary"
+ }
+ MouseArea {
+ id: ttArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: parent.clicked()
+ }
+ }
+
+ // Toolbar search box (ExpandableSearch sm replica, fixed width).
+ component SearchBox: Rectangle {
+ property string placeholder: ""
+ property alias text: edit.text
+ signal searchEdited()
+ width: 220
+ height: 30
+ radius: 6
+ color: theme.surfaceElevated
+ border.width: 1
+ border.color: theme.borderSubtle
+ Row {
+ anchors.fill: parent
+ anchors.leftMargin: 9
+ anchors.rightMargin: 9
+ spacing: 6
+ QbzIcon {
+ name: "search"
+ width: 13
+ height: 13
+ anchors.verticalCenter: parent.verticalCenter
+ tintName: "muted"
+ }
+ TextInput {
+ id: edit
+ width: parent.width - 19
+ height: parent.height
+ color: theme.textPrimary
+ font.pixelSize: 13
+ verticalAlignment: Text.AlignVCenter
+ clip: true
+ onTextEdited: searchEdited()
+ Text {
+ visible: edit.text === ""
+ anchors.fill: parent
+ text: placeholder
+ color: theme.textMuted
+ font.pixelSize: 13
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+ }
+ }
+
+ // Card heart (live favorite toggle).
+ component HeartBtn: Rectangle {
+ property bool fav: false
+ property string itemKind: ""
+ property string itemId: ""
+ width: 28
+ height: 28
+ radius: 14
+ color: hbArea.containsMouse ? "#3dffffff" : "#24ffffff"
+ border.width: 1.5
+ border.color: "#ccffffff"
+ QbzIcon {
+ name: parent.fav ? "heart-filled" : "heart"
+ width: 14
+ height: 14
+ anchors.centerIn: parent
+ tintName: parent.fav ? "favorite" : "primary"
+ }
+ MouseArea {
+ id: hbArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ // Optimistic flip; the signal confirms or rolls back.
+ parent.fav = !parent.fav
+ QbzBridge.libraryToggleFavorite(itemKind, itemId)
+ }
+ }
+ }
+
+ // Quality mini-badge (hi-res image / cd box).
+ component QualityMini: Item {
+ property string tier: ""
+ visible: tier !== ""
+ width: tier === "hires" ? 42 : 30
+ height: 30
+ Image {
+ visible: tier === "hires"
+ source: "assets/hi-res.svg"
+ width: 42
+ height: 28
+ anchors.centerIn: parent
+ sourceSize: Qt.size(84, 56)
+ fillMode: Image.PreserveAspectFit
+ }
+ Rectangle {
+ visible: tier === "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" }
+ }
+ }
+
+ // ===================== feed cards (inline components) ================
+
+ // GRID card — one per kind, 200x246 (AlbumCard metrics).
+ component FeedGridCard: Rectangle {
+ property var item: ({})
+ color: "transparent"
+
+ readonly property bool overlayOn: artArea.containsMouse
+
+ Column {
+ spacing: 0
+ // --- Artwork -------------------------------------------------
+ Rectangle {
+ width: 200
+ height: 200
+ radius: theme.radiusSm
+ color: theme.surfaceElevated
+ clip: true
+
+ Image {
+ anchors.fill: parent
+ source: root.artMap[item.artKey] || ""
+ fillMode: Image.PreserveAspectCrop
+ asynchronous: true
+ }
+ // Artist cards are circular in Slint (ArtistGridCard).
+ Rectangle {
+ visible: item.kind === "artist"
+ anchors.fill: parent
+ radius: 100
+ color: "transparent"
+ border.width: 0
+ }
+ // Hover scrim + play overlay (albums + tracks).
+ Rectangle {
+ anchors.fill: parent
+ color: "#000000"
+ opacity: overlayOn && (item.kind === "album" || item.kind === "track") ? 0.5 : 0.0
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+ }
+ Rectangle {
+ visible: item.kind === "album" || item.kind === "track"
+ anchors.centerIn: parent
+ width: 44
+ height: 44
+ radius: 22
+ opacity: overlayOn ? 1.0 : 0.0
+ 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: item.kind === "album" ? QbzBridge.playAlbum(item.id) : QbzBridge.playTrack(item.id)
+ }
+ }
+ // Heart (live).
+ HeartBtn {
+ x: parent.width - width - 8
+ y: 8
+ fav: item.isFavorite
+ itemKind: item.kind
+ itemId: item.id
+ opacity: (overlayOn || item.isFavorite) ? 1.0 : 0.0
+ }
+ // Source badge (show-local): bottom-right hard-drive glyph.
+ Rectangle {
+ visible: root.showLocal && (item.source === "local" || item.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: item.source === "plex" ? "accent" : "primary"
+ }
+ }
+ MouseArea {
+ id: artArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (item.kind === "album") QbzBridge.playAlbum(item.id)
+ else if (item.kind === "track") QbzBridge.playTrack(item.id)
+ // artist/playlist/label pages: out of scope (POC-NOTE).
+ }
+ }
+ }
+ Item { width: 1; height: 6 }
+ // --- Meta ----------------------------------------------------
+ Row {
+ width: 200
+ spacing: theme.spacingSm
+ Column {
+ width: parent.width - (qBadge.visible ? qBadge.width + theme.spacingSm : 0)
+ spacing: 2
+ Text {
+ width: parent.width
+ height: 20
+ text: item.title
+ color: theme.textPrimary
+ font.pixelSize: theme.fontBody - 2
+ font.weight: theme.weightMedium
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+ Text {
+ width: parent.width
+ height: 18
+ text: item.kind === "track" ? item.artist
+ : item.subtitle !== "" ? item.subtitle : item.artist
+ color: theme.textMuted
+ font.pixelSize: theme.fontLink - 1
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+ }
+ QualityMini { tier: item.qualityTier; id: qBadge; anchors.verticalCenter: parent.verticalCenter }
+ }
+ }
+ }
+
+ // LIST row — 44px (QueueRow-like; FeedListRow approximation).
+ component FeedListRow: Rectangle {
+ property var item: ({})
+ height: 44
+ radius: theme.radiusSm
+ color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
+
+ // Declared BEFORE the content so the heart button keeps its clicks.
+ MouseArea {
+ id: rowArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (item.kind === "album") QbzBridge.playAlbum(item.id)
+ else if (item.kind === "track") QbzBridge.playTrack(item.id)
+ }
+ }
+
+ Row {
+ anchors.fill: parent
+ anchors.leftMargin: theme.spacingSm
+ anchors.rightMargin: theme.spacingXs
+ anchors.topMargin: 4
+ anchors.bottomMargin: 4
+ spacing: 9
+ Rectangle {
+ width: 34
+ height: 34
+ radius: 4
+ anchors.verticalCenter: parent.verticalCenter
+ color: theme.surfaceElevated
+ clip: true
+ Image {
+ anchors.fill: parent
+ source: root.artMap[item.artKey] || ""
+ fillMode: Image.PreserveAspectCrop
+ asynchronous: true
+ }
+ }
+ Column {
+ width: parent.width - 34 - heartBtn.width
+ - (qMini.visible ? qMini.width + 9 : 0)
+ - (durText.visible ? durText.implicitWidth + 9 : 0) - 9 * 2
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+ Text {
+ width: parent.width
+ text: item.title
+ color: theme.textPrimary
+ font.pixelSize: 12
+ font.weight: theme.weightMedium
+ elide: Text.ElideRight
+ }
+ Text {
+ width: parent.width
+ text: item.subtitle !== "" ? item.subtitle : item.artist
+ color: theme.textMuted
+ font.pixelSize: 10
+ elide: Text.ElideRight
+ }
+ }
+ QualityMini { id: qMini; tier: item.qualityTier; anchors.verticalCenter: parent.verticalCenter }
+ Text {
+ id: durText
+ visible: item.duration !== ""
+ anchors.verticalCenter: parent.verticalCenter
+ text: item.duration
+ color: theme.textMuted
+ font.pixelSize: 11
+ }
+ Rectangle {
+ id: heartBtn
+ width: 28
+ height: 28
+ radius: 6
+ anchors.verticalCenter: parent.verticalCenter
+ color: lhArea.containsMouse ? theme.surfaceElevated : "transparent"
+ QbzIcon {
+ name: item.isFavorite ? "heart-filled" : "heart"
+ width: 15
+ height: 15
+ anchors.centerIn: parent
+ tintName: item.isFavorite ? "favorite" : "muted"
+ }
+ MouseArea {
+ id: lhArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: QbzBridge.libraryToggleFavorite(item.kind, item.id)
+ }
+ }
+ }
+ }
+
+ // ============================ view ===================================
+
+ // Offline gate (OfflinePlaceholder replica; the Slint offline RAIL is
+ // out of scope — see header note).
+ Column {
+ visible: QbzBridge.offline
+ anchors.centerIn: parent
+ spacing: 0
+ QbzIcon {
+ name: "cloud-off"
+ width: 56
+ height: 56
+ anchors.horizontalCenter: parent.horizontalCenter
+ tintName: "muted"
+ }
+ Item { width: 1; height: 18 }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: QbzBridge.tr("You're offline")
+ color: theme.textPrimary
+ font.pixelSize: theme.fontHeading
+ font.weight: theme.weightSemibold
+ }
+ Item { width: 1; height: 8 }
+ Text {
+ width: 420
+ text: QbzBridge.offlineMode === 2
+ ? QbzBridge.tr("Offline mode is enabled. Disable it in Settings to use Qobuz.")
+ : QbzBridge.tr("No internet connection. Your local library and downloads keep working.")
+ color: theme.textSecondary
+ font.pixelSize: theme.fontBody
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ Column {
+ anchors.fill: parent
+ spacing: 0
+ visible: !QbzBridge.offline
+
+ // --- Toolbar: tab menu (left) + per-tab controls (right) ---------
+ Item {
+ width: parent.width
+ height: 56
+
+ // FavTabMenu (SegmentedTabBar with count badges).
+ Rectangle {
+ x: 32
+ y: 25 - height / 2
+ width: tabRow.width
+ height: tabRow.height
+ color: theme.surfaceElevated
+ radius: 6
+ Row {
+ id: tabRow
+ padding: 3
+ spacing: 4
+ SegTab { label: QbzBridge.tr("All"); count: counts.all || 0; active: root.activeTab === "all"; onClicked: root.activeTab = "all" }
+ SegTab { label: QbzBridge.tr("Tracks"); count: counts.tracks || 0; active: root.activeTab === "tracks"; onClicked: root.activeTab = "tracks" }
+ SegTab { label: QbzBridge.tr("Albums"); count: counts.albums || 0; active: root.activeTab === "albums"; onClicked: root.activeTab = "albums" }
+ SegTab { label: QbzBridge.tr("Artists"); count: counts.artists || 0; active: root.activeTab === "artists"; onClicked: root.activeTab = "artists" }
+ SegTab { label: QbzBridge.tr("Playlists"); count: counts.playlists || 0; active: root.activeTab === "playlists"; onClicked: root.activeTab = "playlists" }
+ SegTab { label: QbzBridge.tr("Labels"); count: counts.labels || 0; active: root.activeTab === "labels"; onClicked: root.activeTab = "labels" }
+ }
+ }
+
+ // Per-tab controls.
+ Row {
+ x: parent.width - width - 32
+ y: 25 - height / 2
+ height: 30
+ spacing: 8
+
+ // ===== All toolbar =====
+ Row {
+ visible: root.activeTab === "all"
+ spacing: 8
+ height: parent.height
+ SearchBox {
+ placeholder: QbzBridge.tr("Search your library")
+ onSearchEdited: {
+ root.search = text
+ allSearchDebounce.restart()
+ }
+ Timer {
+ id: allSearchDebounce
+ interval: 250
+ onTriggered: root.queueWindowReport(0, 59)
+ }
+ }
+ // Source switches (tooltips are the Slint copies).
+ ToolToggle { name: "shopping-bag"; active: root.showPurchases; onClicked: root.showPurchases = !root.showPurchases }
+ ToolToggle { name: "heart"; active: root.showFavorites; onClicked: root.showFavorites = !root.showFavorites }
+ ToolToggle { name: "user-plus"; active: root.showFollowing; onClicked: root.showFollowing = !root.showFollowing }
+ ToolToggle { name: "hard-drive"; active: root.showLocal; onClicked: root.showLocal = !root.showLocal }
+ // Genre filter — INERT stub (out of scope).
+ ToolToggle { name: "list-filter"; active: false }
+ // Grid / list toggle.
+ ToolToggle {
+ name: root.viewMode === "list" ? "layout-grid" : "list"
+ active: false
+ onClicked: root.viewMode = root.viewMode === "list" ? "grid" : "list"
+ }
+ // Sort menu (PlaylistView-style: field + direction caret).
+ Rectangle {
+ width: allSortRow.width
+ height: 30
+ radius: 6
+ color: allSortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
+ Row {
+ id: allSortRow
+ height: parent.height
+ leftPadding: 10
+ rightPadding: 10
+ spacing: 6
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: QbzBridge.tr("Sort") + ": " + (
+ root.sortBy === "title" ? QbzBridge.tr("Title")
+ : root.sortBy === "artist" ? QbzBridge.tr("Artist")
+ : QbzBridge.tr("Date added"))
+ color: theme.textSecondary
+ font.pixelSize: 12
+ }
+ QbzIcon {
+ name: root.sortAsc ? "chevron-up" : "chevron-down"
+ width: 12
+ height: 12
+ anchors.verticalCenter: parent.verticalCenter
+ tintName: "secondary"
+ }
+ }
+ MouseArea {
+ id: allSortArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: allSortMenu.open()
+ }
+ Popup {
+ id: allSortMenu
+ x: parent.width - 172
+ y: parent.height + 4
+ width: 172
+ padding: 5
+ closePolicy: Popup.CloseOnPressOutside
+ background: Rectangle {
+ color: theme.surfaceMain
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderMuted
+ }
+ contentItem: Column {
+ Repeater {
+ model: [
+ { "field": "date", "label": QbzBridge.tr("Date added") },
+ { "field": "title", "label": QbzBridge.tr("Title") },
+ { "field": "artist", "label": QbzBridge.tr("Artist") },
+ ]
+ delegate: Rectangle {
+ required property var modelData
+ width: parent ? parent.width : 0
+ height: 33
+ radius: 5
+ color: sortOptArea.containsMouse ? theme.surfaceHover : "transparent"
+ Row {
+ anchors.fill: parent
+ anchors.leftMargin: 8
+ spacing: 6
+ Text {
+ width: parent.width - 26
+ height: parent.height
+ text: modelData.label
+ color: theme.textSecondary
+ font.pixelSize: 13
+ font.weight: root.sortBy === modelData.field ? theme.weightSemibold : theme.weightRegular
+ verticalAlignment: Text.AlignVCenter
+ }
+ QbzIcon {
+ visible: root.sortBy === modelData.field
+ name: root.sortAsc ? "chevron-up" : "chevron-down"
+ width: 12
+ height: 12
+ anchors.verticalCenter: parent.verticalCenter
+ tintName: "accent"
+ }
+ }
+ MouseArea {
+ id: sortOptArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ // Re-pick flips direction; a
+ // new field resets to its
+ // natural default.
+ if (root.sortBy === modelData.field) {
+ root.sortAsc = !root.sortAsc
+ } else {
+ root.sortBy = modelData.field
+ root.sortAsc = modelData.field !== "date"
+ }
+ allSortMenu.close()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // ===== Other-tab toolbars =====
+ SearchBox {
+ visible: root.activeTab !== "all"
+ placeholder: QbzBridge.tr("Search")
+ onSearchEdited: root.tabSearch = text
+ }
+ // Albums sort popup (real, JS-side).
+ Rectangle {
+ visible: root.activeTab === "albums"
+ width: albumsSortRow.width
+ height: 30
+ radius: 6
+ color: albumsSortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
+ Row {
+ id: albumsSortRow
+ height: parent.height
+ leftPadding: 10
+ rightPadding: 10
+ spacing: 6
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: QbzBridge.tr("Sort") + ": " + root.albumsSort
+ color: theme.textSecondary
+ font.pixelSize: 12
+ }
+ QbzIcon { name: "chevron-down"; width: 12; height: 12; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
+ }
+ MouseArea {
+ id: albumsSortArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: albumsSortMenu.open()
+ }
+ Popup {
+ id: albumsSortMenu
+ x: parent.width - 172
+ y: parent.height + 4
+ width: 172
+ padding: 5
+ closePolicy: Popup.CloseOnPressOutside
+ background: Rectangle {
+ color: theme.surfaceMain
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderMuted
+ }
+ contentItem: Column {
+ Repeater {
+ model: ["default", "title-asc", "title-desc", "artist-asc"]
+ delegate: Rectangle {
+ required property string modelData
+ width: parent ? parent.width : 0
+ height: 33
+ radius: 5
+ color: abSortOptArea.containsMouse ? theme.surfaceHover : "transparent"
+ Text {
+ anchors.fill: parent
+ anchors.leftMargin: 8
+ text: modelData
+ color: theme.textSecondary
+ font.pixelSize: 13
+ font.weight: root.albumsSort === modelData ? theme.weightSemibold : theme.weightRegular
+ verticalAlignment: Text.AlignVCenter
+ }
+ MouseArea {
+ id: abSortOptArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: { root.albumsSort = modelData; albumsSortMenu.close() }
+ }
+ }
+ }
+ }
+ }
+ }
+ // Playlists sub-tab (Library / Following).
+ Rectangle {
+ visible: root.activeTab === "playlists"
+ width: plSubRow.width
+ height: 30
+ radius: 6
+ color: theme.surfaceElevated
+ Row {
+ id: plSubRow
+ padding: 3
+ spacing: 4
+ Repeater {
+ model: [
+ { "id": "favorites", "label": QbzBridge.tr("Library") },
+ { "id": "following", "label": QbzBridge.tr("Following") },
+ ]
+ delegate: Rectangle {
+ required property var modelData
+ property bool active: root.playlistsSubTab === modelData.id
+ width: plSubText.implicitWidth + 20
+ height: 24
+ radius: 4
+ color: active ? theme.surfaceMain : "transparent"
+ Text {
+ id: plSubText
+ anchors.centerIn: parent
+ text: modelData.label
+ color: parent.active ? theme.textPrimary : theme.textMuted
+ font.pixelSize: 12
+ font.weight: theme.weightMedium
+ }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.playlistsSubTab = modelData.id
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: theme.borderSubtle
+ }
+
+ // ===================== SCROLLING CONTENT =========================
+ Item {
+ width: parent.width
+ height: parent.height - 57
+ clip: true
+
+ // Loading.
+ BusyIndicator {
+ anchors.centerIn: parent
+ running: QbzBridge.libraryLoading
+ visible: QbzBridge.libraryLoading
+ }
+
+ // Error + retry.
+ Column {
+ visible: !QbzBridge.libraryLoading && QbzBridge.libraryError !== ""
+ anchors.centerIn: parent
+ spacing: 10
+ Text {
+ text: QbzBridge.tr("Couldn't load your Library.")
+ color: theme.textPrimary
+ font.pixelSize: theme.fontBody
+ font.weight: theme.weightSemibold
+ }
+ Text {
+ text: QbzBridge.libraryError
+ color: theme.textMuted
+ font.pixelSize: theme.fontLegal
+ }
+ Rectangle {
+ width: retryText.implicitWidth + 28
+ height: 32
+ radius: 6
+ anchors.horizontalCenter: parent.horizontalCenter
+ color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
+ border.width: 1
+ border.color: theme.borderSubtle
+ Text {
+ id: retryText
+ anchors.centerIn: parent
+ text: QbzBridge.tr("Retry")
+ color: theme.textPrimary
+ font.pixelSize: theme.fontLegal
+ }
+ MouseArea {
+ id: retryArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: QbzBridge.reloadLibrary()
+ }
+ }
+ }
+
+ // ============ GRID (all tabs; All in grid mode) ==============
+ GridView {
+ id: grid
+ anchors.fill: parent
+ anchors.leftMargin: 32
+ anchors.rightMargin: 32
+ anchors.topMargin: 16
+ visible: !QbzBridge.libraryLoading && QbzBridge.libraryError === ""
+ && (root.activeTab !== "all" || root.viewMode === "grid")
+ && root.activeTab !== "tracks"
+ cellWidth: 220
+ cellHeight: 266
+ cacheBuffer: 266 * 2
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+ model: root.visibleItems()
+
+ onContentYChanged: root.gridWindowReport()
+ onModelChanged: root.gridWindowReport()
+ onWidthChanged: root.gridWindowReport()
+ Component.onCompleted: root.gridWindowReport()
+
+ delegate: FeedGridCard {
+ width: 200
+ height: 246
+ item: modelData
+ }
+ }
+
+ // ============ LIST (All list mode + Tracks tab) ==============
+ ListView {
+ id: list
+ anchors.fill: parent
+ anchors.leftMargin: 32
+ anchors.rightMargin: 32
+ anchors.topMargin: 10
+ visible: !QbzBridge.libraryLoading && QbzBridge.libraryError === ""
+ && ((root.activeTab === "all" && root.viewMode === "list")
+ || root.activeTab === "tracks")
+ cacheBuffer: 44 * 10
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+ model: root.visibleItems()
+ onContentYChanged: root.listWindowReport()
+ onModelChanged: root.listWindowReport()
+ Component.onCompleted: root.listWindowReport()
+
+ delegate: FeedListRow {
+ width: list.width
+ item: modelData
+ }
+ }
+ }
+ }
+
+ function gridWindowReport() {
+ var cols = Math.max(1, Math.floor(grid.width / grid.cellWidth))
+ var firstRow = Math.max(0, Math.floor(grid.contentY / grid.cellHeight) - 1)
+ var lastRow = Math.ceil((grid.contentY + grid.height) / grid.cellHeight) + 1
+ var m = grid.model
+ queueWindowReport(firstRow * cols, Math.min(m.length - 1, lastRow * cols - 1))
+ }
+ function listWindowReport() {
+ var first = Math.max(0, Math.floor(list.contentY / 44) - 4)
+ var last = Math.ceil((list.contentY + list.height) / 44) + 4
+ queueWindowReport(first, Math.min(list.model.length - 1, last))
+ }
+}
diff --git a/crates/qbz-qt/qml/Sidebar.qml b/crates/qbz-qt/qml/Sidebar.qml
index 0f621ed72..9cc8ad014 100644
--- a/crates/qbz-qt/qml/Sidebar.qml
+++ b/crates/qbz-qt/qml/Sidebar.qml
@@ -115,15 +115,20 @@ Rectangle {
name: "compass"
label: QbzBridge.tr("Discover")
visible: !QbzBridge.offline
- onClicked: root.activeNav = "discover"
+ onClicked: {
+ root.activeNav = "discover"
+ QbzBridge.navigateTo("home")
+ }
}
NavRow {
navId: "library"
name: "music-library-2"
label: QbzBridge.tr("Library")
visible: !QbzBridge.offline
- // Inert — the view lands in phase 3.
- onClicked: root.activeNav = "library"
+ onClicked: {
+ root.activeNav = "library"
+ QbzBridge.navigateTo("library")
+ }
}
NavRow {
navId: "local"
diff --git a/crates/qbz-qt/qml/assets/icons/accent/chevron-up.svg b/crates/qbz-qt/qml/assets/icons/accent/chevron-up.svg
new file mode 100644
index 000000000..cd9310d01
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/chevron-up.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/accent/heart-filled.svg
new file mode 100644
index 000000000..9a5fd689a
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/heart-filled.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/list.svg b/crates/qbz-qt/qml/assets/icons/accent/list.svg
new file mode 100644
index 000000000..0a2aeaca3
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/list.svg
@@ -0,0 +1,8 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/shopping-bag.svg b/crates/qbz-qt/qml/assets/icons/accent/shopping-bag.svg
new file mode 100644
index 000000000..318e6e5f1
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/shopping-bag.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/user-plus.svg b/crates/qbz-qt/qml/assets/icons/accent/user-plus.svg
new file mode 100644
index 000000000..39c253602
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/user-plus.svg
@@ -0,0 +1 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/chevron-up.svg b/crates/qbz-qt/qml/assets/icons/black/chevron-up.svg
new file mode 100644
index 000000000..35c3160df
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/chevron-up.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/black/heart-filled.svg
new file mode 100644
index 000000000..fbad8f5cd
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/heart-filled.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/list.svg b/crates/qbz-qt/qml/assets/icons/black/list.svg
new file mode 100644
index 000000000..5b75857c2
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/list.svg
@@ -0,0 +1,8 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/shopping-bag.svg b/crates/qbz-qt/qml/assets/icons/black/shopping-bag.svg
new file mode 100644
index 000000000..d946a956e
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/shopping-bag.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/user-plus.svg b/crates/qbz-qt/qml/assets/icons/black/user-plus.svg
new file mode 100644
index 000000000..04fd10180
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/user-plus.svg
@@ -0,0 +1 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/favorite/heart-filled.svg
new file mode 100644
index 000000000..a85714daa
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/heart-filled.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/heart.svg b/crates/qbz-qt/qml/assets/icons/favorite/heart.svg
new file mode 100644
index 000000000..f2c4e410f
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/heart.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/chevron-up.svg b/crates/qbz-qt/qml/assets/icons/muted/chevron-up.svg
new file mode 100644
index 000000000..97e6757ca
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/chevron-up.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/muted/heart-filled.svg
new file mode 100644
index 000000000..0ad849226
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/heart-filled.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/list.svg b/crates/qbz-qt/qml/assets/icons/muted/list.svg
new file mode 100644
index 000000000..12155a099
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/list.svg
@@ -0,0 +1,8 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/shopping-bag.svg b/crates/qbz-qt/qml/assets/icons/muted/shopping-bag.svg
new file mode 100644
index 000000000..221212801
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/shopping-bag.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/user-plus.svg b/crates/qbz-qt/qml/assets/icons/muted/user-plus.svg
new file mode 100644
index 000000000..004f13ceb
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/user-plus.svg
@@ -0,0 +1 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/chevron-up.svg b/crates/qbz-qt/qml/assets/icons/primary/chevron-up.svg
new file mode 100644
index 000000000..8e39ce1ec
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/chevron-up.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/primary/heart-filled.svg
new file mode 100644
index 000000000..6beddf866
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/heart-filled.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/list.svg b/crates/qbz-qt/qml/assets/icons/primary/list.svg
new file mode 100644
index 000000000..b900baa8e
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/list.svg
@@ -0,0 +1,8 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/shopping-bag.svg b/crates/qbz-qt/qml/assets/icons/primary/shopping-bag.svg
new file mode 100644
index 000000000..2390dcf13
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/shopping-bag.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/user-plus.svg b/crates/qbz-qt/qml/assets/icons/primary/user-plus.svg
new file mode 100644
index 000000000..575a4e1ab
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/user-plus.svg
@@ -0,0 +1 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/chevron-up.svg b/crates/qbz-qt/qml/assets/icons/secondary/chevron-up.svg
new file mode 100644
index 000000000..be5c94d5e
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/chevron-up.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/secondary/heart-filled.svg
new file mode 100644
index 000000000..a046a2c1b
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/heart-filled.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/list.svg b/crates/qbz-qt/qml/assets/icons/secondary/list.svg
new file mode 100644
index 000000000..99b73b161
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/list.svg
@@ -0,0 +1,8 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/shopping-bag.svg b/crates/qbz-qt/qml/assets/icons/secondary/shopping-bag.svg
new file mode 100644
index 000000000..df558b1b0
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/shopping-bag.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/user-plus.svg b/crates/qbz-qt/qml/assets/icons/secondary/user-plus.svg
new file mode 100644
index 000000000..e46f637eb
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/user-plus.svg
@@ -0,0 +1 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/chevron-up.svg b/crates/qbz-qt/qml/assets/icons/warning/chevron-up.svg
new file mode 100644
index 000000000..b67b80337
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/chevron-up.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/heart-filled.svg b/crates/qbz-qt/qml/assets/icons/warning/heart-filled.svg
new file mode 100644
index 000000000..ec87a618f
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/heart-filled.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/list.svg b/crates/qbz-qt/qml/assets/icons/warning/list.svg
new file mode 100644
index 000000000..54f721c9e
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/list.svg
@@ -0,0 +1,8 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/shopping-bag.svg b/crates/qbz-qt/qml/assets/icons/warning/shopping-bag.svg
new file mode 100644
index 000000000..a5129dc45
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/shopping-bag.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/user-plus.svg b/crates/qbz-qt/qml/assets/icons/warning/user-plus.svg
new file mode 100644
index 000000000..4837936a9
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/user-plus.svg
@@ -0,0 +1 @@
+
diff --git a/crates/qbz-qt/src/auth_qt.rs b/crates/qbz-qt/src/auth_qt.rs
index 51d44bdaa..b14e358f0 100644
--- a/crates/qbz-qt/src/auth_qt.rs
+++ b/crates/qbz-qt/src/auth_qt.rs
@@ -148,6 +148,8 @@ where
// recovery: a successful login ends any unauthenticated offline session.
if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) {
crate::offline_fwd::init_for_user(&dir);
+ // Phase 5: local-favorites store (Library show-local + hearts).
+ crate::library_qt::init_local_favorites(&dir);
}
crate::offline_fwd::subscription_mark_valid();
crate::offline_fwd::engine().set_offline_session(false);
@@ -300,6 +302,8 @@ where
// unauthenticated offline session.
if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) {
crate::offline_fwd::init_for_user(&dir);
+ // Phase 5: local-favorites store (Library show-local + hearts).
+ crate::library_qt::init_local_favorites(&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 ae3b714ff..3ca469284 100644
--- a/crates/qbz-qt/src/bridge.rs
+++ b/crates/qbz-qt/src/bridge.rs
@@ -103,6 +103,16 @@ pub mod qbz_bridge {
// the exact same {id, title, kind, items:[{...}]} object graph.
#[qproperty(QString, home_sections_json)]
+ // --- Library view --------------------------------------------------
+ #[qproperty(bool, library_loading)]
+ #[qproperty(QString, library_error)]
+ // One JSON document: the FULL merged feed (tabs/search/sort/source
+ // filters derive QML-side from the parsed array — see library_qt.rs
+ // for the rationale + the measured parse cost).
+ #[qproperty(QString, library_json)]
+ // {tracks, albums, artists, playlists, labels, all} — tab badges.
+ #[qproperty(QString, library_counts_json)]
+
type QbzBridge = super::QbzBridgeRust;
/// Called once from Main.qml's Component.onCompleted: registers the
@@ -183,6 +193,41 @@ pub mod qbz_bridge {
/// and play through the core's resolved path.
#[qinvokable]
fn play_album(self: Pin<&mut QbzBridge>, album_id: QString);
+
+ /// Sidebar navigation: record a content view ("home" | "library")
+ /// and lazy-load its data on first visit.
+ #[qinvokable]
+ fn navigate_to(self: Pin<&mut QbzBridge>, view: QString);
+
+ /// Track-row click (Library): play the track as a 1-element queue.
+ #[qinvokable]
+ fn play_track(self: Pin<&mut QbzBridge>, track_id: QString);
+
+ /// Library retry button / manual refresh.
+ #[qinvokable]
+ fn reload_library(self: Pin<&mut QbzBridge>);
+
+ /// Windowed artwork: the grid/list reports the mounted window as a
+ /// JSON array of artKeys (tab views filter the feed, so raw indices
+ /// don't map); Rust dispatches covers for those keys (id-keyed via
+ /// `libraryArtworkReady`).
+ #[qinvokable]
+ fn library_artwork_window(self: Pin<&mut QbzBridge>, keys_json: QString);
+
+ /// Card heart: toggle favorite (Qobuz API or the local store,
+ /// routed by id shape); the result arrives via
+ /// `libraryFavoriteChanged`.
+ #[qinvokable]
+ fn library_toggle_favorite(self: Pin<&mut QbzBridge>, kind: QString, id: QString);
+
+ /// Emitted when a dispatched cover lands on disk (id-keyed —
+ /// `{kind}:{id}`); QML updates its artwork map.
+ #[qsignal]
+ fn library_artwork_ready(self: Pin<&mut QbzBridge>, key: QString, path: QString);
+
+ /// Emitted after a heart toggle (optimistic flip + rollback).
+ #[qsignal]
+ fn library_favorite_changed(self: Pin<&mut QbzBridge>, key: QString, value: bool);
}
impl cxx_qt::Threading for QbzBridge {}
@@ -237,6 +282,10 @@ pub struct QbzBridgeRust {
home_loading: bool,
home_error: QString,
home_sections_json: QString,
+ library_loading: bool,
+ library_error: QString,
+ library_json: QString,
+ library_counts_json: QString,
}
impl Default for QbzBridgeRust {
@@ -281,6 +330,10 @@ impl Default for QbzBridgeRust {
home_loading: false,
home_error: QString::default(),
home_sections_json: QString::from("[]"),
+ library_loading: false,
+ library_error: QString::default(),
+ library_json: QString::from("[]"),
+ library_counts_json: QString::from("{}"),
}
}
}
@@ -383,4 +436,26 @@ impl qbz_bridge::QbzBridge {
pub fn play_album(self: Pin<&mut Self>, album_id: QString) {
crate::play_album(album_id.to_string());
}
+
+ pub fn navigate_to(self: Pin<&mut Self>, view: QString) {
+ crate::navigate_to(&view.to_string());
+ }
+
+ pub fn play_track(self: Pin<&mut Self>, track_id: QString) {
+ if let Ok(id) = track_id.to_string().parse::() {
+ crate::play_track(id);
+ }
+ }
+
+ pub fn reload_library(self: Pin<&mut Self>) {
+ crate::reload_library();
+ }
+
+ pub fn library_artwork_window(self: Pin<&mut Self>, keys_json: QString) {
+ crate::library_artwork_window(keys_json.to_string());
+ }
+
+ pub fn library_toggle_favorite(self: Pin<&mut Self>, kind: QString, id: QString) {
+ crate::library_toggle_favorite(kind.to_string(), id.to_string());
+ }
}
diff --git a/crates/qbz-qt/src/home_qt.rs b/crates/qbz-qt/src/home_qt.rs
index a8725f982..118d6e384 100644
--- a/crates/qbz-qt/src/home_qt.rs
+++ b/crates/qbz-qt/src/home_qt.rs
@@ -411,6 +411,26 @@ pub(crate) fn format_rate(rate: f64) -> String {
}
}
+/// Tier from a bare bit depth (album_map.rs `tier`): >16 is Hi-Res, a
+/// known depth <= 16 is CD, unknown hides the badge.
+pub(crate) fn quality_tier_from_depth(bit_depth: Option) -> &'static str {
+ match bit_depth {
+ Some(b) if b > 16 => "hires",
+ Some(_) => "cd",
+ None => "",
+ }
+}
+
+/// Bare exact-quality detail from parts, Hz- or kHz-tolerant (quality.rs
+/// `detail`): "24-bit / 96 kHz".
+pub(crate) fn quality_detail_from_parts(bit_depth: Option, sample_rate: Option) -> String {
+ let hi_res = matches!(bit_depth, Some(depth) if depth >= 24);
+ let depth = bit_depth.unwrap_or(if hi_res { 24 } else { 16 });
+ let rate = sample_rate.unwrap_or(if hi_res { 96.0 } else { 44.1 });
+ let rate = if rate >= 1000.0 { rate / 1000.0 } else { rate };
+ format!("{depth}-bit / {} kHz", format_rate(rate))
+}
+
// ---------------------------------------------------------------------------
// Personalized rails — ported from foryou.rs (live-session only; the reco /
// blacklist stores are skipped, see module docs).
diff --git a/crates/qbz-qt/src/library_db_qt.rs b/crates/qbz-qt/src/library_db_qt.rs
new file mode 100644
index 000000000..e089f2bfa
--- /dev/null
+++ b/crates/qbz-qt/src/library_db_qt.rs
@@ -0,0 +1,33 @@
+//! Per-user library.db access — the one query the Playlists tab needs
+//! (hearted playlist ids), ported from `crates/qbz/src/library_db.rs`.
+//! Opens `/qbz/users//library.db` on demand via the
+//! `qbz-library` backend crate (ADR-006 — no glue copied).
+
+use qbz_app::user_data::UserDataPaths;
+
+/// Hearted playlist ids (favorites.rs `db.get_favorite_playlist_ids`).
+/// Empty on any failure (missing user, missing db) — the Library sub-tab
+/// then shows owned playlists only.
+pub fn favorite_playlist_ids() -> Vec {
+ let Some(uid) = UserDataPaths::load_last_user_id() else {
+ return Vec::new();
+ };
+ let Some(path) = dirs::data_dir().map(|p| {
+ p.join("qbz")
+ .join("users")
+ .join(uid.to_string())
+ .join("library.db")
+ }) else {
+ return Vec::new();
+ };
+ if !path.exists() {
+ return Vec::new();
+ }
+ match qbz_library::LibraryDatabase::open(&path) {
+ Ok(db) => db.get_favorite_playlist_ids().unwrap_or_default(),
+ Err(e) => {
+ log::warn!("[qbz-qt] library.db open failed: {e}");
+ Vec::new()
+ }
+ }
+}
diff --git a/crates/qbz-qt/src/library_qt.rs b/crates/qbz-qt/src/library_qt.rs
new file mode 100644
index 000000000..c578a1ef9
--- /dev/null
+++ b/crates/qbz-qt/src/library_qt.rs
@@ -0,0 +1,742 @@
+//! Library view data layer — Slint-free port of `crates/qbz/src/favorites.rs`
+//! (per-tab loaders) + `crates/qbz/src/library_all.rs` (the mixed All feed).
+//!
+//! One fetch fans out to every source (Qobuz favorites paged per type,
+//! playlists, purchases, the local-favorites store), normalizes each into a
+//! `FeedItem`, and merges the All feed by the recency proxy
+//! (`added_rank = index / len` per source, stable sort — library_all.rs).
+//!
+//! Transport: ONE JSON document on the bridge (`libraryJson`); the QML side
+//! parses once and derives tabs/search/sort/source-filters in JS (the Slint
+//! app derives in Rust because Slint JS is weak — QML JS is real; the
+//! JSON.parse cost at owner scale is MEASURED and reported, per the phase
+//! brief). Artwork is id-keyed via the `libraryArtworkReady` signal (see
+//! bridge.rs) so a cover never lands on the wrong row and decoded-cover RAM
+//! scales with the viewport.
+//!
+//! POC-NOTEs:
+//! - Artist/album blacklist filtering: skipped (store not open).
+//! - Genre filter ("library-all" context): skipped (genre_filter glue is
+//! Slint-side; the toolbar button is an inert stub).
+//! - Local scope "all" (the whole local library in the feed): NOT wired —
+//! it needs the qbz-library scan DB, which the POC never opens. The
+//! show-local toggle and the "favorites" scope (hearted local items) work
+//! via the LocalFavoritesService (now initialized, per owner).
+//! - Playlist follow/copy affordances: hearts route to the favorites API
+//! only; subscribe/unsubscribe is not wired.
+//! - Purchase download states / Play purchases / multi-select / group
+//! modes / alpha jumps: out of scope.
+
+use std::sync::{Arc, Mutex, OnceLock};
+use std::time::Instant;
+
+use qbz_app::settings::local_favorites::{LocalFavoritesService, DB_FILE_NAME};
+use qbz_app::shell::AppRuntime;
+use qbz_app::user_data::UserDataPaths;
+use qbz_core::LoggingAdapter;
+use qbz_models::{Album, Artist, Playlist, Track};
+use serde::Serialize;
+
+use crate::home_qt;
+
+/// Matches favorites.rs paging.
+const PAGE_SIZE: u32 = 500;
+const MAX_ITEMS: usize = 10_000;
+
+/// One normalized row (superset of the per-tab cards + the All feed item).
+#[derive(Clone, Default, Serialize)]
+pub struct FeedItem {
+ pub kind: String, // track | album | artist | playlist | label
+ pub group: String, // favorites | following | purchases | local
+ pub source: String, // qobuz | local | plex
+ pub id: String,
+ pub title: String,
+ pub subtitle: String,
+ pub artist: String,
+ #[serde(rename = "artistId")]
+ pub artist_id: String,
+ pub album: String,
+ #[serde(rename = "albumId")]
+ pub album_id: String,
+ #[serde(rename = "imageUrl")]
+ pub image_url: String,
+ /// Stable artwork key (`{kind}:{id}`) — library_all.rs `feed_key`.
+ #[serde(rename = "artKey")]
+ pub art_key: String,
+ #[serde(rename = "qualityTier")]
+ pub quality_tier: String,
+ #[serde(rename = "qualityDetail")]
+ pub quality_detail: String,
+ #[serde(rename = "isFavorite")]
+ pub is_favorite: bool,
+ pub genre: String,
+ pub year: String,
+ pub duration: String,
+ pub explicit: bool,
+ #[serde(rename = "playlistOwned")]
+ pub playlist_owned: bool,
+ #[serde(rename = "playlistFollowing")]
+ pub playlist_following: bool,
+ /// Recency proxy in [0,1]; 0 = most-recently added (per source).
+ pub added_rank: f32,
+}
+
+impl FeedItem {
+ fn keyed(mut self) -> Self {
+ self.art_key = feed_key(&self.kind, &self.id);
+ self
+ }
+}
+
+/// library_all.rs `feed_key` — Qobuz numeric ids overlap across entity
+/// types, so the kind prefixes the key.
+pub fn feed_key(kind: &str, id: &str) -> String {
+ format!("{kind}:{id}")
+}
+
+#[derive(Clone, Serialize)]
+pub struct LibraryCounts {
+ pub tracks: i64,
+ pub albums: i64,
+ pub artists: i64,
+ pub playlists: i64,
+ pub labels: i64,
+ pub all: i64,
+}
+
+pub struct LibraryData {
+ pub feed: Vec,
+ pub counts: LibraryCounts,
+}
+
+static LIBRARY: Mutex