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> = Mutex::new(None); + +fn rank(i: usize, n: usize) -> f32 { + if n <= 1 { + 0.0 + } else { + i as f32 / n as f32 + } +} + +// --------------------------------------------------------------------------- +// Local favorites store (LocalFavoritesService, ADR-006 backend) +// --------------------------------------------------------------------------- + +static LOCAL_FAVS: OnceLock> = OnceLock::new(); + +/// Open the per-user local-favorites DB. Called on every session +/// activation (login / restore / offline entry) next to +/// `offline_fwd::init_for_user`. +pub fn init_local_favorites(base_dir: &std::path::Path) { + let path = base_dir.join(DB_FILE_NAME); + match LocalFavoritesService::new(&path) { + Ok(service) => { + let _ = LOCAL_FAVS.set(Mutex::new(service)); + log::info!("[qbz-qt] local favorites store opened"); + } + Err(e) => log::error!("[qbz-qt] local favorites store open failed: {e}"), + } +} + +fn local_favorites_list() -> Vec { + LOCAL_FAVS + .get() + .and_then(|s| s.lock().unwrap().list().ok()) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Fetch +// --------------------------------------------------------------------------- + +/// Page one favorites type until exhausted (favorites.rs loop). +async fn fetch_favorites( + runtime: &Arc>, + key: &str, +) -> Result<(Vec, usize), String> { + let mut total: usize; + let mut all_items: Vec = Vec::new(); + let mut offset = 0u32; + loop { + let value = runtime + .core() + .get_favorites(key, PAGE_SIZE, offset) + .await + .map_err(|e| e.to_string())?; + let branch = value.get(key); + total = branch + .and_then(|b| b.get("total")) + .and_then(|t| t.as_u64()) + .unwrap_or(0) as usize; + let page: Vec = branch + .and_then(|b| b.get("items")) + .and_then(|i| i.as_array()) + .cloned() + .unwrap_or_default(); + let page_len = page.len(); + all_items.extend(page); + offset += page_len as u32; + if page_len < PAGE_SIZE as usize + || (total > 0 && offset as usize >= total) + || all_items.len() >= MAX_ITEMS + { + break; + } + } + Ok((all_items, total)) +} + +fn parse_items(items: Vec, what: &str) -> Vec { + items + .into_iter() + .filter_map(|v| match serde_json::from_value::(v) { + Ok(item) => Some(item), + Err(e) => { + log::warn!("[qbz-qt] dropping malformed {what}: {e}"); + None + } + }) + .collect() +} + +fn mmss(secs: u32) -> String { + format!("{}:{:02}", secs / 60, secs % 60) +} + +// ---- mappers (favorites.rs / album_map.rs / search.rs ports) -------------- + +fn map_track(track: Track) -> FeedItem { + let mut title = track.title; + if let Some(version) = track.version.as_ref().filter(|v| !v.is_empty()) { + title = format!("{title} ({version})"); + } + let artwork_url = track + .album + .as_ref() + .and_then(|a| a.image.best().cloned()) + .unwrap_or_default(); + let album = track + .album + .as_ref() + .map(|a| a.title.clone()) + .unwrap_or_default(); + let album_id = track.album.as_ref().map(|a| a.id.clone()).unwrap_or_default(); + let genre = track + .album + .as_ref() + .and_then(|a| a.genre.as_ref()) + .map(|g| g.name.clone()) + .unwrap_or_default(); + let (artist, artist_id) = track + .performer + .map(|p| (p.name, p.id.to_string())) + .unwrap_or_default(); + FeedItem { + kind: "track".into(), + group: "favorites".into(), + source: "qobuz".into(), + id: track.id.to_string(), + title, + subtitle: artist.clone(), + artist, + artist_id, + album, + album_id, + genre, + duration: mmss(track.duration), + quality_tier: home_qt::quality_tier_from_depth(track.maximum_bit_depth).to_string(), + quality_detail: home_qt::quality_detail_from_parts( + track.maximum_bit_depth, + track.maximum_sampling_rate, + ), + explicit: track.parental_warning, + image_url: artwork_url, + is_favorite: true, + ..Default::default() + } + .keyed() +} + +fn map_album(album: Album) -> FeedItem { + let bit_depth = album + .audio_info + .as_ref() + .and_then(|a| a.maximum_bit_depth) + .or(album.maximum_bit_depth); + let sample_rate = album + .audio_info + .as_ref() + .and_then(|a| a.maximum_sampling_rate) + .or(album.maximum_sampling_rate); + let date = album + .dates + .as_ref() + .and_then(|d| d.original.clone().or(d.download.clone()).or(d.stream.clone())) + .or(album.release_date_original.clone()); + let artist = if !album.artist.name.is_empty() { + album.artist.name + } else { + album + .artists + .as_ref() + .and_then(|c| c.first().map(|a| a.name.clone())) + .unwrap_or_default() + }; + FeedItem { + kind: "album".into(), + group: "favorites".into(), + source: "qobuz".into(), + id: album.id, + title: album.title, + subtitle: artist.clone(), + artist, + artist_id: album.artist.id.to_string(), + genre: album.genre.map(|g| g.name).unwrap_or_default(), + year: qbz_text_utils::dates::release_label(date.as_deref()), + quality_tier: home_qt::quality_tier_from_depth(bit_depth).to_string(), + quality_detail: home_qt::quality_detail_from_parts(bit_depth, sample_rate), + image_url: album.image.best().cloned().unwrap_or_default(), + is_favorite: true, + ..Default::default() + } + .keyed() +} + +fn map_artist(artist: Artist) -> FeedItem { + FeedItem { + kind: "artist".into(), + group: "following".into(), + source: "qobuz".into(), + id: artist.id.to_string(), + title: artist.name, + image_url: artist + .image + .and_then(|img| img.best().cloned()) + .unwrap_or_default(), + is_favorite: true, + ..Default::default() + } + .keyed() +} + +fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem { + let cover_url = [&playlist.images300, &playlist.images150, &playlist.images] + .into_iter() + .flatten() + .find(|v| !v.is_empty()) + .and_then(|list| list.iter().find(|u| !u.is_empty()).cloned()) + .unwrap_or_default(); + let mut subtitle = playlist.owner.name.clone(); + if playlist.tracks_count > 0 { + let count = playlist.tracks_count; + let tracks_label = qbz_i18n::tf("{} track", "{} tracks", count as i64, &[&count.to_string()]); + subtitle = if subtitle.is_empty() { + tracks_label + } else { + format!("{} • {}", subtitle, tracks_label) + }; + } + let uid = UserDataPaths::load_last_user_id(); + let owned = uid.map(|uid| uid == playlist.owner.id).unwrap_or(false); + FeedItem { + kind: "playlist".into(), + group: if is_following { "following" } else { "favorites" }.into(), + source: "qobuz".into(), + id: playlist.id.to_string(), + title: playlist.name.clone(), + subtitle, + image_url: cover_url, + is_favorite: !is_following, + playlist_owned: owned, + playlist_following: is_following, + ..Default::default() + } + .keyed() +} + +/// The whole library load: fan out, normalize, merge (library_all.rs +/// ordering semantics), compute the tab counts. +pub async fn load_library(runtime: &Arc>) -> Result { + let t_fetch = Instant::now(); + + // Favorites per type (paged) — sequential shares one client; measured + // individually for the perf report. + let t = Instant::now(); + let (raw_tracks, tracks_total) = fetch_favorites(runtime, "tracks").await?; + log::info!("[qbz-qt][perf] favorites tracks fetch: {:?} ({} items)", t.elapsed(), raw_tracks.len()); + let t = Instant::now(); + let (raw_albums, albums_total) = fetch_favorites(runtime, "albums").await?; + log::info!("[qbz-qt][perf] favorites albums fetch: {:?} ({} items)", t.elapsed(), raw_albums.len()); + let t = Instant::now(); + let (raw_artists, artists_total) = fetch_favorites(runtime, "artists").await?; + log::info!("[qbz-qt][perf] favorites artists fetch: {:?} ({} items)", t.elapsed(), raw_artists.len()); + let t = Instant::now(); + let (raw_labels, labels_total) = fetch_favorites(runtime, "labels").await?; + log::info!("[qbz-qt][perf] favorites labels fetch: {:?} ({} items)", t.elapsed(), raw_labels.len()); + + // Playlists (owned + followed + locally hearted). + let t = Instant::now(); + let all_playlists = runtime + .core() + .get_user_playlists() + .await + .map_err(|e| e.to_string())?; + let uid = UserDataPaths::load_last_user_id(); + let following: Vec = match uid { + Some(uid) => all_playlists + .iter() + .filter(|p| p.owner.id != uid) + .map(|p| map_playlist_row(p, true)) + .collect(), + None => Vec::new(), + }; + let mut pl_favorites: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + if let Some(uid) = uid { + for p in all_playlists.iter().filter(|p| p.owner.id == uid) { + seen.insert(p.id); + pl_favorites.push(map_playlist_row(p, false)); + } + } + // Hearted playlist ids from the per-user library.db (favorites.rs). + let fav_pl_ids = crate::library_db_qt::favorite_playlist_ids(); + for fid in fav_pl_ids { + if !seen.insert(fid) { + continue; + } + if let Some(p) = all_playlists.iter().find(|p| p.id == fid) { + pl_favorites.push(map_playlist_row(p, p.owner.id != uid.unwrap_or(0))); + } else if let Ok(p) = runtime.core().get_playlist(fid).await { + pl_favorites.push(map_playlist_row(&p, false)); + } + } + let playlists_total = pl_favorites.len() as i64; + log::info!("[qbz-qt][perf] playlists fetch: {:?} ({} fav / {} following)", t.elapsed(), pl_favorites.len(), following.len()); + + // Purchases (group "purchases") — best-effort: a purchase-less account + // returns empty; a failure must not sink the library. + let t = Instant::now(); + let (purchase_albums, purchase_tracks) = fetch_purchases(runtime).await; + log::info!("[qbz-qt][perf] purchases fetch: {:?} ({} albums / {} tracks)", t.elapsed(), purchase_albums.len(), purchase_tracks.len()); + + log::info!("[qbz-qt][perf] total fetch wall: {:?}", t_fetch.elapsed()); + + // ---- Map + merge ------------------------------------------------------ + let t_map = Instant::now(); + let mut feed: Vec = Vec::new(); + + let tracks: Vec = parse_items(raw_tracks, "track"); + let n = tracks.len(); + for (i, item) in tracks.into_iter().map(map_track).enumerate() { + let mut item = item; + item.added_rank = rank(i, n); + feed.push(item); + } + let albums: Vec = parse_items(raw_albums, "album"); + let n = albums.len(); + for (i, item) in albums.into_iter().map(map_album).enumerate() { + let mut item = item; + item.added_rank = rank(i, n); + feed.push(item); + } + let artists: Vec = parse_items(raw_artists, "artist"); + let n = artists.len(); + for (i, item) in artists.into_iter().map(map_artist).enumerate() { + let mut item = item; + item.added_rank = rank(i, n); + feed.push(item); + } + let n = pl_favorites.len(); + for (i, item) in pl_favorites.into_iter().enumerate() { + let mut item = item; + item.added_rank = rank(i, n); + feed.push(item); + } + let n = following.len(); + for (i, item) in following.into_iter().enumerate() { + let mut item = item; + item.added_rank = rank(i, n); + feed.push(item); + } + let n = purchase_albums.len(); + for (i, mut item) in purchase_albums.into_iter().enumerate() { + item.added_rank = rank(i, n); + feed.push(item); + } + let n = purchase_tracks.len(); + for (i, mut item) in purchase_tracks.into_iter().enumerate() { + item.added_rank = rank(i, n); + feed.push(item); + } + + // Labels (no card artwork fields beyond the image + "{n} albums" line). + /// favorites.rs-local richer label shape (image + count). + #[derive(serde::Deserialize)] + struct FavLabel { + #[serde(default)] + id: u64, + #[serde(default)] + name: String, + #[serde(default)] + albums_count: Option, + #[serde(default)] + image: Option, + } + let labels: Vec = parse_items(raw_labels, "label"); + let n = labels.len(); + for (i, l) in labels.into_iter().enumerate() { + feed.push(FeedItem { + kind: "label".into(), + group: "following".into(), + source: "qobuz".into(), + subtitle: match l.albums_count { + Some(c) if c > 0 => format!("{c} albums"), + _ => String::new(), + }, + id: l.id.to_string(), + title: l.name, + image_url: extract_label_image(l.image.as_ref()), + is_favorite: true, + added_rank: rank(i, n), + ..Default::default() + } + .keyed()); + } + + // Local favorites layer (show-local default ON; scope "favorites" — + // see module docs for the "all" scope cut). + let locals = local_favorites_list(); + let n = locals.len(); + for (i, lf) in locals.into_iter().enumerate() { + feed.push(FeedItem { + kind: lf.kind, + group: "local".into(), + source: lf.source, + subtitle: lf.subtitle, + artist: lf.artist.clone(), + image_url: lf.artwork_url, + is_favorite: true, + added_rank: rank(i, n), + id: lf.id, + title: lf.title, + ..Default::default() + } + .keyed()); + } + + // Merge by recency proxy (stable — equal ranks keep source order). + feed.sort_by(|a, b| { + a.added_rank + .partial_cmp(&b.added_rank) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let all_total = feed.len() as i64; + log::info!( + "[qbz-qt][perf] map+merge: {:?} ({} feed items)", + t_map.elapsed(), + feed.len() + ); + + let counts = LibraryCounts { + tracks: tracks_total as i64, + albums: albums_total as i64, + artists: artists_total as i64, + playlists: playlists_total, + labels: labels_total as i64, + all: all_total, + }; + *LIBRARY.lock().unwrap() = Some(LibraryData { feed, counts }); + Ok(all_total as usize) +} + +/// Purchases via the offline-cache crate's pass-through (best-effort). +async fn fetch_purchases( + runtime: &Arc>, +) -> (Vec, Vec) { + let client_lock = runtime.core().client(); + let client = { + let guard = client_lock.read().await; + guard.as_ref().cloned() + }; + let Some(client) = client else { + return (Vec::new(), Vec::new()); + }; + let Ok(response) = qbz_offline_cache::purchases_service::get_user_purchases_all(&client).await + else { + return (Vec::new(), Vec::new()); + }; + let filtered = + qbz_offline_cache::purchases_service::filter_purchase_response(response, ""); + let albums = filtered + .albums + .items + .into_iter() + .map(|a| { + let tier = if a.hires { "hires" } else { "cd" }; + FeedItem { + kind: "album".into(), + group: "purchases".into(), + source: "qobuz".into(), + subtitle: a.artist.name.clone(), + artist: a.artist.name, + artist_id: a.artist.id.to_string(), + image_url: a.image.best().cloned().unwrap_or_default(), + quality_tier: tier.into(), + genre: a.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(), + id: a.id, + title: a.title, + ..Default::default() + } + .keyed() + }) + .collect(); + let tracks = filtered + .tracks + .items + .into_iter() + .map(|t| { + let (img, alb, aid) = t + .album + .as_ref() + .map(|a| (a.image.best().cloned().unwrap_or_default(), a.title.clone(), a.id.clone())) + .unwrap_or_default(); + let tier = if t.hires { "hires" } else { "cd" }; + FeedItem { + kind: "track".into(), + group: "purchases".into(), + source: "qobuz".into(), + subtitle: t.performer.name.clone(), + artist: t.performer.name, + artist_id: t.performer.id.to_string(), + album: alb, + album_id: aid, + image_url: img, + quality_tier: tier.into(), + id: t.id.to_string(), + title: t.title, + ..Default::default() + } + .keyed() + }) + .collect(); + (albums, tracks) +} + +/// Label image — label.rs `extract_label_image`: the nested "square" url. +fn extract_label_image(image: Option<&serde_json::Value>) -> String { + let Some(image) = image else { + return String::new(); + }; + for key in ["square", "large", "medium", "small", "thumbnail"] { + if let Some(url) = image.get(key).and_then(|v| v.as_str()) { + if !url.is_empty() { + return url.to_string(); + } + } + } + String::new() +} + +// --------------------------------------------------------------------------- +// Accessors for the bridge / favorite toggles +// --------------------------------------------------------------------------- + +pub fn with_library(f: impl FnOnce(&LibraryData) -> R) -> Option { + LIBRARY.lock().unwrap().as_ref().map(f) +} + +pub fn with_library_mut(f: impl FnOnce(&mut LibraryData) -> R) -> Option { + LIBRARY.lock().unwrap().as_mut().map(f) +} + +/// Flip a heart. Qobuz ids route to the favorites API; local/Plex ids to +/// the local-favorites store (library_all.rs `is_local_feed_id` routing). +/// Returns the new state, or None when the toggle failed. +pub async fn toggle_favorite( + runtime: &Arc>, + kind: &str, + id: &str, +) -> Option { + if is_local_feed_id(kind, id) { + return toggle_local(kind, id); + } + let fav_type = match kind { + "track" => "tracks", + "album" => "albums", + "artist" => "artists", + "label" => "labels", + "playlist" => "playlists", + _ => return None, + }; + let current = with_library(|d| { + d.feed + .iter() + .any(|i| i.kind == kind && i.id == id && i.is_favorite) + })?; + let result = if current { + runtime.core().remove_favorite(fav_type, id).await + } else { + runtime.core().add_favorite(fav_type, id).await + }; + match result { + Ok(()) => { + let new_state = !current; + set_feed_favorite(kind, id, new_state); + Some(new_state) + } + Err(e) => { + log::error!("[qbz-qt] favorite toggle ({kind}:{id}) failed: {e}"); + None + } + } +} + +fn toggle_local(kind: &str, id: &str) -> Option { + let service = LOCAL_FAVS.get()?.lock().unwrap(); + let new_state = if service.is_favorite(kind, id) { + service.unfavorite(kind, id).ok()?; + false + } else { + // Re-favorite: rebuild the snapshot from the feed row. + let snap = with_library(|d| { + d.feed + .iter() + .find(|i| i.kind == kind && i.id == id) + .map(|item| qbz_app::settings::local_favorites::LocalFavItem { + kind: kind.to_string(), + id: id.to_string(), + title: item.title.clone(), + subtitle: item.subtitle.clone(), + artwork_url: item.image_url.clone(), + artist: item.artist.clone(), + source: item.source.clone(), + favorited_at: 0, + }) + })??; + service.favorite(&snap).ok()?; + true + }; + set_feed_favorite(kind, id, new_state); + Some(new_state) +} + +/// library_all.rs `is_local_feed_id` — local tracks are file paths, local +/// albums group keys, local artists plain names; Qobuz ids are numeric. +fn is_local_feed_id(kind: &str, id: &str) -> bool { + match kind { + "track" | "artist" => id.parse::().is_err(), + "album" => id.starts_with("plex:") || id.contains('|') || id.contains('/'), + _ => false, + } +} + +/// Flip the favorite flag on the stored feed row (model-of-truth for the +/// `libraryFavoriteChanged` signal). +fn set_feed_favorite(kind: &str, id: &str, value: bool) { + with_library_mut(|d| { + if let Some(item) = d.feed.iter_mut().find(|i| i.kind == kind && i.id == id) { + item.is_favorite = value; + } + }); +} diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 9e3dc5ddd..8a13fc12e 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -10,6 +10,8 @@ mod auth_qt; mod artwork_qt; mod bridge; mod home_qt; +mod library_db_qt; +mod library_qt; mod nav_qt; mod now_playing; mod offline_fwd; @@ -121,6 +123,7 @@ 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()); + ui(move |mut b| { b.as_mut().set_session_user_name(QString::from(session.display_name.as_str())); b.as_mut().set_session_subscription(QString::from(session.subscription.as_str())); @@ -216,8 +219,9 @@ pub(crate) fn do_logout() { if let Err(e) = auth_qt::logout(&runtime).await { log::error!("[qbz-qt] logout failed: {e}"); } - // A later login must re-fetch Home (new user, fresh rails). + // A later login must re-fetch Home + Library (new user, fresh data). *HOME_LOADED.lock().unwrap() = false; + *LIBRARY_LOADED.lock().unwrap() = false; ui(|mut b| { b.as_mut().set_session_user_name(QString::from("")); b.as_mut().set_session_subscription(QString::from("")); @@ -227,6 +231,10 @@ pub(crate) fn do_logout() { b.as_mut().set_home_sections_json(QString::from("[]")); b.as_mut().set_home_error(QString::from("")); b.as_mut().set_home_loading(false); + b.as_mut().set_library_json(QString::from("[]")); + 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_screen(QString::from("login")); }); }); @@ -251,6 +259,16 @@ fn load_home_once() { // ============================ Playback (phase 4) =========================== +/// Track-row click (Library tracks): one-track queue through the core. +pub(crate) fn play_track(track_id: u64) { + let runtime = app(); + spawn(async move { + if let Err(e) = playback_qt::play_single_track(&runtime, track_id).await { + log::error!("[qbz-qt] play_track failed: {e}"); + } + }); +} + /// Album-card click on Home: resolve + enqueue + play through the core. pub(crate) fn play_album(album_id: String) { let runtime = app(); @@ -303,6 +321,161 @@ pub(crate) fn transport_cycle_repeat() { spawn(async move { playback_qt::cycle_repeat(&runtime).await }); } +// ============================ Library (phase 5) =========================== + +/// Whether the Library view has been loaded this session. +static LIBRARY_LOADED: Mutex = Mutex::new(false); + +/// Sidebar navigation: record the view and lazy-load its data. +pub(crate) fn navigate_to(view: &str) { + nav_qt::record(view); + if view == "library" { + load_library_once(); + } +} + +fn load_library_once() { + if *LIBRARY_LOADED.lock().unwrap() { + return; + } + if offline_fwd::engine().status().is_offline() { + log::info!("[qbz-qt] library load skipped (offline session)"); + return; + } + *LIBRARY_LOADED.lock().unwrap() = true; + reload_library(); +} + +/// Fetch + publish the whole library (feed + counts). Perf-instrumented +/// (phase-5 deliverable): wall timings land in the log. +pub(crate) fn reload_library() { + if offline_fwd::engine().status().is_offline() { + return; + } + log_rss("library load start"); + ui(|mut b| { + b.as_mut().set_library_loading(true); + b.as_mut().set_library_error(QString::from("")); + }); + let runtime = app(); + spawn(async move { + let t = std::time::Instant::now(); + match library_qt::load_library(&runtime).await { + Ok(total) => { + let t_ser = std::time::Instant::now(); + let (feed_json, counts_json) = library_qt::with_library(|d| { + ( + serde_json::to_string(&d.feed).unwrap_or_else(|_| "[]".into()), + serde_json::to_string(&d.counts).unwrap_or_else(|_| "{}".into()), + ) + }) + .unwrap_or_else(|| ("[]".into(), "{}".into())); + log::info!( + "[qbz-qt][perf] library serialize: {:?} ({} bytes)", + t_ser.elapsed(), + feed_json.len(), + ); + log::info!( + "[qbz-qt][perf] library load total: {:?} ({total} items)", + t.elapsed(), + ); + ui(move |mut b| { + b.as_mut().set_library_json(QString::from(feed_json.as_str())); + b.as_mut() + .set_library_counts_json(QString::from(counts_json.as_str())); + b.as_mut().set_library_loading(false); + }); + log_rss("library published"); + } + Err(e) => { + log::warn!("[qbz-qt] library load failed: {e}"); + ui(move |mut b| { + b.as_mut().set_library_error(QString::from(e.as_str())); + b.as_mut().set_library_loading(false); + }); + } + } + }); +} + +/// VmRSS (KiB) from /proc/self/status — phase-5 RSS-delta measurement. +fn log_rss(mark: &str) { + if let Ok(status) = std::fs::read_to_string("/proc/self/status") { + if let Some(line) = status.lines().find(|l| l.starts_with("VmRSS:")) { + log::info!("[qbz-qt][perf] RSS @ {mark}: {line}"); + } + } +} + +/// Windowed artwork dispatch for the Library feed: the view reports the +/// mounted window as artKeys; download the missing ones, emitting +/// `libraryArtworkReady` per store (id-keyed — the wrong-cover race fix +/// from the Slint round). Disk hits emit immediately. +pub(crate) fn library_artwork_window(keys_json: String) { + let keys: Vec = serde_json::from_str(&keys_json).unwrap_or_default(); + log::debug!("[qbz-qt] library_artwork_window: {} keys", keys.len()); + if keys.is_empty() { + return; + } + let Some(pairs) = library_qt::with_library(|d| { + keys.iter() + .filter_map(|k| { + d.feed + .iter() + .find(|i| &i.art_key == k) + .map(|i| (i.art_key.clone(), i.image_url.clone())) + }) + .filter(|(_, u)| !u.is_empty()) + .collect::>() + }) else { + return; + }; + let mut missing: Vec<(String, String)> = Vec::new(); + for (key, url) in pairs { + let path = artwork_qt::cached_path(&url); + if path.is_empty() { + missing.push((key, url)); + } else { + emit_library_artwork(key, path); + } + } + if missing.is_empty() { + return; + } + spawn(async move { + let urls: Vec = missing.iter().map(|(_, u)| u.clone()).collect(); + artwork_qt::download_missing(urls).await; + for (key, url) in missing { + let path = artwork_qt::cached_path(&url); + if !path.is_empty() { + emit_library_artwork(key, path); + } + } + }); +} + +fn emit_library_artwork(key: String, path: String) { + ui(move |mut b| { + b.as_mut() + .library_artwork_ready(QString::from(key.as_str()), QString::from(path.as_str())); + }); +} + +/// Card heart: toggle + signal the result (or the unchanged state on +/// failure, so the UI rolls back). +pub(crate) fn library_toggle_favorite(kind: String, id: String) { + let key = library_qt::feed_key(&kind, &id); + let runtime = app(); + spawn(async move { + if let Some(value) = library_qt::toggle_favorite(&runtime, &kind, &id).await { + ui(move |mut b| { + b.as_mut() + .library_favorite_changed(QString::from(key.as_str()), value); + }); + } + }); +} + /// `reloadHome()` invokable / auto-load worker: fetch + publish + artwork. pub(crate) fn reload_home() { if offline_fwd::engine().status().is_offline() { diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index 1fa44b5d3..ef3998834 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -117,6 +117,69 @@ pub async fn play_album( Ok(()) } +/// Play a single track as a one-element queue (Library track rows). The +/// queue meta is rebuilt from the Library feed row; the audio resolves by +/// id through the same `play_track_resolved` path. +pub async fn play_single_track( + runtime: &Arc>, + track_id: u64, +) -> Result<(), String> { + let id = track_id.to_string(); + let item = crate::library_qt::with_library(|d| { + d.feed + .iter() + .find(|i| i.kind == "track" && i.id == id) + .cloned() + }) + .flatten() + .ok_or_else(|| format!("track {track_id} not in the library feed"))?; + let duration_secs = { + let mut parts = item.duration.split(':'); + parts.next().and_then(|m| m.parse::().ok()).unwrap_or(0) * 60 + + parts.next().and_then(|s| s.parse::().ok()).unwrap_or(0) + }; + let qt = QueueTrack { + id: track_id, + title: item.title.clone(), + version: None, + artist: item.artist.clone(), + album: item.album.clone(), + album_version: None, + duration_secs, + artwork_url: if item.image_url.is_empty() { + None + } else { + Some(item.image_url.clone()) + }, + hires: item.quality_tier == "hires", + bit_depth: None, + sample_rate: None, + is_local: false, + album_id: if item.album_id.is_empty() { + None + } else { + Some(item.album_id.clone()) + }, + artist_id: item.artist_id.parse::().ok(), + streamable: true, + source: Some("qobuz".to_string()), + parental_warning: item.explicit, + source_item_id_hint: None, + context_kind: None, + context_id: None, + }; + runtime.core().set_queue(vec![qt], Some(0)).await; + publish_queue(runtime).await; + log::info!("[qbz-qt] play_single_track: playing {track_id}"); + runtime + .core() + .play_track_resolved(track_id, POC_QUALITY, None, None, 0) + .await + .map_err(|e| format!("play_track {track_id} failed: {e}"))?; + refresh_now_playing(runtime).await; + Ok(()) +} + // --------------------------------------------------------------------------- // Transport // ---------------------------------------------------------------------------