From d555b8fde424fd935e7252e559a350cea42f6cbb Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:29:16 -0600 Subject: [PATCH] feat(qt): add album quick view and queue focus controls cx --- .../translations/de/LC_MESSAGES/qbz-ui.po | 15 + .../translations/en/LC_MESSAGES/qbz-ui.po | 18 + .../translations/es/LC_MESSAGES/qbz-ui.po | 15 + .../translations/fr/LC_MESSAGES/qbz-ui.po | 15 + .../translations/ja/LC_MESSAGES/qbz-ui.po | 15 + .../translations/nl/LC_MESSAGES/qbz-ui.po | 15 + .../translations/pt/LC_MESSAGES/qbz-ui.po | 15 + .../translations/ru/LC_MESSAGES/qbz-ui.po | 15 + crates/qbz-qt/build.rs | 2 + crates/qbz-qt/qml/cards/AlbumCard.qml | 113 +++- crates/qbz-qt/qml/controls/QualityInline.qml | 114 ++++ crates/qbz-qt/qml/shell/AlbumQuickView.qml | 635 ++++++++++++++++++ crates/qbz-qt/qml/shell/AppShell.qml | 10 + crates/qbz-qt/qml/shell/AudioStamp.qml | 120 +--- crates/qbz-qt/qml/theme/QbzKineticScroll.qml | 14 + crates/qbz-qt/qml/theme/QbzScrollBar.qml | 5 + crates/qbz-qt/qml/views/HomeView.qml | 11 + crates/qbz-qt/qml/views/QueueView.qml | 115 +++- .../qbz-qt/qml/views/library/FeedGridCell.qml | 3 + .../qml/views/local/LocalAlbumCollection.qml | 33 + .../qml/views/myqbz/MyQbzDetailView.qml | 38 +- crates/qbz-qt/src/album_bridge.rs | 28 + crates/qbz-qt/src/album_quick_view_qt.rs | 418 ++++++++++++ crates/qbz-qt/src/home_qt.rs | 56 +- crates/qbz-qt/src/library_bridge.rs | 8 + crates/qbz-qt/src/main.rs | 3 + 26 files changed, 1675 insertions(+), 174 deletions(-) create mode 100644 crates/qbz-qt/qml/controls/QualityInline.qml create mode 100644 crates/qbz-qt/qml/shell/AlbumQuickView.qml create mode 100644 crates/qbz-qt/src/album_quick_view_qt.rs diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 0cdb7bb7b..4ff001670 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -6015,3 +6015,18 @@ msgstr "Scrobbling ohne Anmeldung" msgid "Continue sending plays to connected scrobblers without a Qobuz session." msgstr "Wiedergaben auch ohne Qobuz-Sitzung an verbundene Scrobble-Dienste senden." + +msgid "Focus on playing" +msgstr "Wiedergabe fokussieren" + +msgid "Go to top" +msgstr "Nach oben" + +msgid "Quick view" +msgstr "Schnellansicht" + +msgid "Could not load album" +msgstr "Album konnte nicht geladen werden" + +msgid "This album has no tracks." +msgstr "Dieses Album enthält keine Titel." diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index ae22a125c..e5b3536b2 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -1617,3 +1617,21 @@ msgstr "Scrobble while logged out" msgid "Continue sending plays to connected scrobblers without a Qobuz session." msgstr "Continue sending plays to connected scrobblers without a Qobuz session." + +msgid "Focus on playing" +msgstr "Focus on playing" + +msgid "Go to top" +msgstr "Go to top" + +msgid "Quick view" +msgstr "Quick view" + +msgid "Loading album…" +msgstr "Loading album…" + +msgid "Could not load album" +msgstr "Could not load album" + +msgid "This album has no tracks." +msgstr "This album has no tracks." diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 3a0433809..a592071b3 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -6021,3 +6021,18 @@ msgstr "Scrobbling sin sesión" msgid "Continue sending plays to connected scrobblers without a Qobuz session." msgstr "Continúa enviando reproducciones a los servicios conectados aunque no haya una sesión de Qobuz." + +msgid "Focus on playing" +msgstr "Seguir la reproducción" + +msgid "Go to top" +msgstr "Ir arriba" + +msgid "Quick view" +msgstr "Vista rápida" + +msgid "Could not load album" +msgstr "No se pudo cargar el álbum" + +msgid "This album has no tracks." +msgstr "Este álbum no tiene pistas." diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 84e9c332e..83b3e71e4 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -6013,3 +6013,18 @@ msgstr "Scrobbler sans connexion" msgid "Continue sending plays to connected scrobblers without a Qobuz session." msgstr "Continuer d’envoyer les écoutes aux services connectés sans session Qobuz." + +msgid "Focus on playing" +msgstr "Suivre la lecture" + +msgid "Go to top" +msgstr "Aller en haut" + +msgid "Quick view" +msgstr "Aperçu rapide" + +msgid "Could not load album" +msgstr "Impossible de charger l’album" + +msgid "This album has no tracks." +msgstr "Cet album ne contient aucun titre." diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index eb08ca854..cfd6d5777 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -5997,3 +5997,18 @@ msgstr "ログアウト中もスクロブル" msgid "Continue sending plays to connected scrobblers without a Qobuz session." msgstr "Qobuzセッションがなくても、接続済みのスクロブルサービスに再生履歴を送信します。" + +msgid "Focus on playing" +msgstr "再生中の曲を追従" + +msgid "Go to top" +msgstr "一番上へ" + +msgid "Quick view" +msgstr "クイックビュー" + +msgid "Could not load album" +msgstr "アルバムを読み込めませんでした" + +msgid "This album has no tracks." +msgstr "このアルバムには曲がありません。" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 3fc11dc16..bf2d39058 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -6003,3 +6003,18 @@ msgstr "Scrobblen wanneer afgemeld" msgid "Continue sending plays to connected scrobblers without a Qobuz session." msgstr "Luisterbeurten naar verbonden scrobblediensten blijven sturen zonder Qobuz-sessie." + +msgid "Focus on playing" +msgstr "Afspelen volgen" + +msgid "Go to top" +msgstr "Naar boven" + +msgid "Quick view" +msgstr "Snelweergave" + +msgid "Could not load album" +msgstr "Album kon niet worden geladen" + +msgid "This album has no tracks." +msgstr "Dit album bevat geen nummers." diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index 9220485c7..9f5058a2f 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -6013,3 +6013,18 @@ msgstr "Scrobbling sem login" msgid "Continue sending plays to connected scrobblers without a Qobuz session." msgstr "Continuar enviando reproduções aos serviços conectados sem uma sessão do Qobuz." + +msgid "Focus on playing" +msgstr "Focar na reprodução" + +msgid "Go to top" +msgstr "Ir para o topo" + +msgid "Quick view" +msgstr "Vista rápida" + +msgid "Could not load album" +msgstr "Não foi possível carregar o álbum" + +msgid "This album has no tracks." +msgstr "Este álbum não tem faixas." diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index 6092ce45a..5b2840e55 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -6033,3 +6033,18 @@ msgstr "Скробблинг без входа" msgid "Continue sending plays to connected scrobblers without a Qobuz session." msgstr "Продолжать отправлять прослушивания в подключённые сервисы без сеанса Qobuz." + +msgid "Focus on playing" +msgstr "Следовать за воспроизведением" + +msgid "Go to top" +msgstr "Наверх" + +msgid "Quick view" +msgstr "Быстрый просмотр" + +msgid "Could not load album" +msgstr "Не удалось загрузить альбом" + +msgid "This album has no tracks." +msgstr "В этом альбоме нет треков." diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index c45477067..a7669741f 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -575,6 +575,7 @@ fn main() { "qml/shell/SidebarRowMenu.qml", "qml/shell/SidebarNowPlayingDock.qml", "qml/shell/AudioSettingsMenu.qml", + "qml/shell/AlbumQuickView.qml", "qml/shell/ViewModeMenu.qml", // Hotkeys (2026-08-03 hotkeys-port contract §4.4/§4.5, block // B3): the read-only cheatsheet + the editable customize @@ -731,6 +732,7 @@ fn main() { "qml/views/local/LocalIconSelect.qml", "qml/views/local/LocalTip.qml", "qml/controls/QualityBadgeFull.qml", + "qml/controls/QualityInline.qml", "qml/shell/AudioStamp.qml", "qml/shell/SongCardStamp.qml", "qml/shell/SpectrumBand.qml", diff --git a/crates/qbz-qt/qml/cards/AlbumCard.qml b/crates/qbz-qt/qml/cards/AlbumCard.qml index 806e2928c..21a3c2d67 100644 --- a/crates/qbz-qt/qml/cards/AlbumCard.qml +++ b/crates/qbz-qt/qml/cards/AlbumCard.qml @@ -3,8 +3,8 @@ // same component in both places). // // 200x246: 200px artwork (Radius.sm) + placeholder, hover scrim with -// genre/year meta, pin badge (top-right), favorite / play / more overlay -// buttons, award ribbon, source badge (opt-in), then the title/artist +// genre/year meta, Quick View + pin group (top-right), favorite / play / more +// overlay buttons, award ribbon, source badge (opt-in), then the title/artist // lines with the icon-only quality badge. // // Live wiring: play (art click + overlay play), favorite heart (optimistic @@ -107,18 +107,17 @@ Rectangle { // surfaces stay pixel-identical. // // ROUTING ONLY, since the My QBZ round (2026-08-01). It used to ALSO hide - // the heart, the pin badge and "Block this album" — see - // `catalogAffordances` for why that turned out to be two different - // questions wearing one flag. + // the card affordances — see the independent gates below for why routing, + // catalog actions, Quick View and pinning are different questions. property bool localMode: false - // --- Catalog-only affordances: heart, pin badge, "Block this album" ----- + // --- Catalog-only affordances: heart + "Block this album" -------------- // // Split out of `localMode` because a host can need one answer and not the // other, and My QBZ is that host. Its grid routes EVERY action through // `QbzMyQbz` (only it knows how to open a Plex album, a track or a // playlist), so it needs `localMode: true` — but a Qobuz ALBUM cell's - // `albumId` IS a catalog album id, and its heart / pin / block are the same + // `albumId` IS a catalog album id, and its heart / block are the same // entity every other AlbumCard in the app talks about. The blanket flag // hid the heart on those cells, which is the owner's report ("al overlay le // falta el botón de favorite"): a container that is multi-source PER ITEM @@ -134,6 +133,17 @@ Rectangle { // album page agree. Do NOT let a host paint its own heart on top of the // card — see the `selectMode` note below for the same mistake's other half. property bool catalogAffordances: !root.localMode + // Quick View is broader than the catalog-only heart/block family. + // Catalog cards keep it by default; local and heterogeneous hosts opt in + // only for rows whose id is genuinely an album key. + property bool quickViewAffordance: root.catalogAffordances + // Pin storage accepts string album keys too. Kept independent from the + // catalog family so a local album can be pinned without accidentally + // gaining the Qobuz heart or blacklist actions. + property bool pinAffordance: root.catalogAffordances + // Catalog hosts persist the remote snapshot URL. Local hosts override this + // with their resolved file/server artwork so Pinned does not draw blank. + property string pinArtworkUrl: root.artworkUrl // A Local Library album uses the separate LocalFavoritesService rather // than the Qobuz catalog seam. This additive flag exposes the same heart // geometry/menu row while handing the write back to that host. @@ -231,7 +241,8 @@ Rectangle { height: 246 + (root.plays > 0 ? 20 : 0) color: "transparent" - readonly property bool overlayOn: artArea.containsMouse || pinArea.containsMouse + readonly property bool overlayOn: artArea.containsMouse + || quickViewArea.containsMouse || pinArea.containsMouse || favBtn.hovered || playBtn.hovered || moreBtn.hovered function toggleFavorite() { @@ -244,7 +255,7 @@ Rectangle { function togglePin() { root.isPinned = !root.isPinned QbzLibrary.togglePin("album", root.albumId, root.title, root.artist, - root.artworkUrl) + root.pinArtworkUrl) } // Pin fan-out. The pinned store has no change-notify, so `pinChanged` @@ -412,10 +423,11 @@ Rectangle { Text { visible: root.genre !== "" text: root.genre - // Bounded because the tile's clip is gone: genre is - // unbounded catalog data and was the ONLY thing that could - // escape the 200px tile. 200 - 2*12 (the Column's x). - width: 176 + // Bounded because the tile's clip is gone. When the joined + // top-right group is present, stop 8px before it instead + // of letting a long genre run under both buttons. + width: root.quickViewAffordance && !root.selectMode + && !root.pulledDead ? 118 : 176 elide: Text.ElideRight height: 20 color: "#ebffffff" @@ -426,7 +438,8 @@ Rectangle { Text { visible: root.year !== "" text: root.year - width: 176 + width: root.quickViewAffordance && !root.selectMode + && !root.pulledDead ? 118 : 176 elide: Text.ElideRight height: 17 color: "#ccffffff" @@ -499,38 +512,81 @@ Rectangle { } } - // Pin badge — top-right. Hover-revealed like the overlay - // buttons (AlbumCard.slint: opacity follows overlay-on even - // when pinned — the pinned state reads in the icon swap only: - // filled accent pin vs outline). Always-mounted (opacity) so - // its hover joins overlayOn. + // Quick View + optional pin — one joined group in the old pin + // badge's top-right slot. Local album grids opt into BOTH without + // opting into unrelated catalog heart/block actions; a host that + // cannot pin may still expose Quick View alone. Always mounted + // (opacity), so either half's hover joins overlayOn and reveals it. Rectangle { - // …and hidden in select mode too (.slint:207 — the checkbox - // owns this corner). Pinning writes `album:{albumId}` into the - // pinned store, so it needs a catalog id, not a routing mode. - visible: root.catalogAffordances && !root.selectMode && !root.pulledDead + id: cardTopGroup + // Hidden in select mode too: the checkbox owns this corner. + // The Quick View controller uses the id router to load + // local/server keys from their physical source instead of the + // Qobuz endpoint. + visible: root.quickViewAffordance && !root.selectMode && !root.pulledDead x: parent.width - width - 8 y: 8 - width: 26 + width: root.pinAffordance ? 54 : 27 height: 26 radius: 13 - color: pinArea.containsMouse ? "#cc000000" : "#99000000" + color: (quickViewArea.containsMouse || pinArea.containsMouse) + ? "#cc000000" : "#99000000" opacity: root.overlayOn ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: 150 } } + QbzIcon { + name: "picture-in-picture-2" + width: 14 + height: 14 + x: Math.round((27 - width) / 2) + anchors.verticalCenter: parent.verticalCenter + tintName: quickViewArea.containsMouse ? "accent" : "white" + } + Rectangle { + visible: root.pinAffordance + x: 27 + y: 5 + width: 1 + height: 16 + color: "#45ffffff" + } + QbzIcon { + visible: root.pinAffordance name: root.isPinned ? "pin-filled" : "pin" width: 14 height: 14 - anchors.centerIn: parent + x: 27 + Math.round((27 - width) / 2) + anchors.verticalCenter: parent.verticalCenter // On a #99000000/#cc000000 scrim — dark under every theme. tintName: root.isPinned ? "accent" : "white" } + MouseArea { + id: quickViewArea + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 27 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzAlbum.openQuickView(root.albumId) + ToolTip.visible: containsMouse + ToolTip.text: QbzSession.tr("Quick view", QbzSession.trRev) + ToolTip.delay: 350 + } MouseArea { id: pinArea - anchors.fill: parent + visible: root.pinAffordance + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 27 hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: root.togglePin() + ToolTip.visible: containsMouse + ToolTip.text: QbzSession.tr(root.isPinned ? "Unpin" : "Pin", + QbzSession.trRev) + ToolTip.delay: 350 } } @@ -548,8 +604,7 @@ Rectangle { CardOverlayButton { id: favBtn // ABSENT, not present-and-dead, when `albumId` is not a - // catalog album id — the same gate as the menu's heart row - // and the pin badge. + // catalog album id — the same gate as the menu's heart row. visible: root.favoriteAffordance name: root.isFavorite ? "heart-filled" : "heart" active: root.isFavorite diff --git a/crates/qbz-qt/qml/controls/QualityInline.qml b/crates/qbz-qt/qml/controls/QualityInline.qml new file mode 100644 index 000000000..cd58f8317 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QualityInline.qml @@ -0,0 +1,114 @@ +// QualityInline — the compact one-line quality stamp shared by AudioStamp's +// first row and surfaces that need the same NPB Small treatment without its +// output-backend LEDs. +// +// The caller resolves the displayed tier/detail. That keeps AudioStamp's +// delivered-vs-source logic in one place while Album Quick View can show the +// album's catalog maximum through the exact same visual primitive. + +import QtQuick +import QtQuick.Controls +import "../theme" + +Item { + id: root + + QbzTheme { id: theme } + + property int maxWidth: 150 + property string tierLabel: "" + property string detail: "" + property bool downgraded: false + property string tooltipText: "" + + implicitWidth: Math.min(root.maxWidth, qualityLine.implicitWidth) + implicitHeight: qualityLine.implicitHeight + width: implicitWidth + height: implicitHeight + + Row { + id: qualityLine + anchors.left: parent.left + spacing: 5 + + Text { + id: tierText + text: root.tierLabel + font.pixelSize: 9 + font.weight: Font.Bold + font.letterSpacing: 0.3 + color: theme.textSecondary + anchors.verticalCenter: parent.verticalCenter + } + // Downgrade arrow (glyph, not a pill — ADR-008). Amber so it reads + // as "adjusted", not as an error. + Text { + id: arrowText + visible: root.downgraded + text: "↓" + font.pixelSize: 9 + font.weight: Font.Bold + color: "#eab308" + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: root.detail + font.pixelSize: 9 + font.weight: Font.DemiBold + color: theme.textPrimary + elide: Text.ElideRight + // Elide needs an explicit width. None of the sibling widths + // depend on this value, so this cannot form a binding loop. + width: Math.max(0, Math.min(implicitWidth, + root.maxWidth - tierText.implicitWidth - 5 + - (arrowText.visible ? arrowText.implicitWidth + 5 : 0))) + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: qualityHover + anchors.fill: parent + hoverEnabled: true + } + + // 1:1 with AudioStamp's former inline tooltip: the bubble bottom sits + // 9px above the anchor and its caret occupies the final 5px. + ToolTip { + id: qualityTip + visible: qualityHover.containsMouse && root.tooltipText !== "" + text: root.tooltipText + delay: 0 + timeout: -1 + padding: 0 + x: Math.round((root.width - width) / 2) + y: -height - 4 + contentItem: Text { + text: qualityTip.text + color: theme.textPrimary + font.pixelSize: 11 + font.weight: theme.weightMedium + leftPadding: 9 + rightPadding: 9 + topPadding: 5 + bottomPadding: 10 + } + background: Item { + Rectangle { + anchors.fill: parent + anchors.bottomMargin: 5 + color: theme.surfaceElevated + radius: 4 + } + Rectangle { + width: 7.07 + height: 7.07 + rotation: 45 + antialiasing: true + color: theme.surfaceElevated + x: Math.round((parent.width - width) / 2) + y: parent.height - 5 - height / 2 + } + } + } +} diff --git a/crates/qbz-qt/qml/shell/AlbumQuickView.qml b/crates/qbz-qt/qml/shell/AlbumQuickView.qml new file mode 100644 index 000000000..e18c7fdd1 --- /dev/null +++ b/crates/qbz-qt/qml/shell/AlbumQuickView.qml @@ -0,0 +1,635 @@ +// Album Quick View — the app-wide, navigation-free preview opened from the +// left half of AlbumCard's top-right button group. +// +// The 80% × 75% rule is a CEILING, not a requested size: `card` measures the +// longest visible title and the number of tracks, shrinks for compact albums, +// and only reaches those limits when content needs the room. The track body is +// the sole scrolling region. +// +// Data lives on QbzAlbum.quickViewJson, separate from AlbumView's progressive +// document. The controller generation-guards opens and closes, so a late +// response cannot repaint another album or reopen a dismissed modal. + +import QtQuick +import QtQuick.Controls +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Item { + id: root + + anchors.fill: parent + visible: root.doc.open === true + enabled: visible + + QbzTheme { id: theme } + + readonly property var doc: { + try { + return JSON.parse(QbzAlbum.quickViewJson || "{}") + } catch (e) { + return ({}) + } + } + readonly property var tracks: root.doc.tracks || [] + readonly property bool loading: root.doc.loading === true + readonly property bool failed: root.doc.failed === true + readonly property bool localAlbum: root.doc.isLocal === true + readonly property string qualityTierLabel: root.doc.qualityTier === "hires" ? "HI-RES" + : (root.doc.qualityTier === "mp3" ? "MP3" + : (root.doc.qualityTier === "lossless" ? "LOSSLESS" + : (root.doc.qualityTier === "" || root.doc.qualityTier === undefined ? "" : "CD"))) + readonly property string qualityTooltip: root.doc.qualityDetail + ? root.qualityTierLabel + ": " + root.doc.qualityDetail : "" + property var contextTrack: ({}) + + readonly property int rowHeight: 40 + readonly property int numberWidth: 48 + readonly property int durationWidth: 84 + readonly property int contextWidth: 72 + readonly property int tableLeft: 18 + // 14px house scrollbar plus breathing room: the row's context button never + // sits below the thumb. + readonly property int tableRight: 32 + + function tr(text) { + return QbzSession.tr(text, QbzSession.trRev) + } + + function longestTrackTitle() { + var longest = "" + for (var i = 0; i < root.tracks.length; i++) { + var candidate = root.tracks[i].title || "" + if (candidate.length > longest.length) + longest = candidate + } + return longest + } + + TextMetrics { + id: albumTitleMetrics + text: root.doc.title || root.tr("Loading album…") + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + } + TextMetrics { + id: artistMetrics + text: root.doc.artist || "" + font.pixelSize: theme.fontBody + } + TextMetrics { + id: trackTitleMetrics + text: root.longestTrackTitle() + font.pixelSize: theme.fontBody - 1 + font.weight: theme.weightMedium + } + + readonly property real preferredCardWidth: Math.max(520, Math.min(820, + Math.max(albumTitleMetrics.advanceWidth + 120, + artistMetrics.advanceWidth + 120, + trackTitleMetrics.advanceWidth + root.numberWidth + + root.durationWidth + root.contextWidth + 92))) + readonly property real bodyPreferredHeight: (root.loading || root.failed + || root.tracks.length === 0) ? 112 : root.tracks.length * root.rowHeight + // one 76px inline header + divider 1 + body + 10px bottom breathing room. + // Never turn either maximum into a minimum. + readonly property real preferredCardHeight: 87 + root.bodyPreferredHeight + + function closeQuickView() { + albumMenu.close() + trackMenu.close() + QbzAlbum.closeQuickView() + } + + function restoreShellFocus() { + var node = root + while (node.parent) { + if (node.parent.isQbzShellRoot === true) { + node.parent.forceActiveFocus() + return + } + node = node.parent + } + } + + function playTrack(trackId) { + if (!trackId) + return + if (root.localAlbum) + QbzAlbum.quickViewLocalAction("play", trackId) + else + QbzPlayer.playAlbumFrom(root.doc.albumId || "", trackId) + } + + function enqueueTrack(trackId, mode) { + if (!trackId) + return + if (root.localAlbum) + QbzAlbum.quickViewLocalAction(mode, trackId) + else + QbzPlayer.enqueueAlbumTrack(root.doc.albumId || "", trackId, mode) + } + + function albumPlaybackAction(action) { + if (root.localAlbum) { + QbzAlbum.quickViewLocalAction(action, "") + } else if (action === "play") { + QbzPlayer.playAlbum(root.doc.albumId || "") + } else if (action === "shuffle") { + QbzPlayer.playAlbumShuffled(root.doc.albumId || "") + } else { + QbzPlayer.enqueueAlbum(root.doc.albumId || "", action) + } + } + + function openTrackMenu(track, anchor, x, y) { + if (!track || !track.id) + return + root.contextTrack = track + trackMenu.entries = root.trackMenuEntries(track) + if (trackMenu.entries.length > 0) + trackMenu.openAtCursor(anchor, x, y) + } + + function albumMenuEntries() { + var t = root.tr + var entries = [ + { "label": t("Open album"), "icon": "library-big", "action": "open" }, + { "label": t("Play"), "icon": "play-fill", "action": "play" }, + { "label": t("Play next"), "icon": "list-start", "action": "next" } + ] + // The local queue owns a real block-tail operation. Catalog album + // enqueue does not, so its canonical menu has only one append row. + if (root.localAlbum) + entries.push({ "label": t("Play later"), "icon": "list-plus", "action": "later" }) + entries.push({ "label": t("Add to queue"), "icon": "list-end", "action": "queue" }) + if (!root.localAlbum) { + entries.push({ "sep": true }) + entries.push({ "label": t("Add to playlist"), "icon": "list-music", "action": "playlist" }) + entries.push({ "label": t("Add to mixtape"), "icon": "cassette-tape", "action": "mixtape" }) + entries.push({ "label": t("Share Qobuz link"), "icon": "link", "action": "share-qobuz" }) + entries.push({ "label": t("Share Album.link"), "icon": "link", "action": "share-albumlink" }) + } + return entries + } + + function trackMenuEntries(track) { + var t = root.tr + var entries = [] + if (track.available !== false) { + entries.push({ "label": t("Play now"), "icon": "play-fill", "action": "play" }) + entries.push({ "label": t("Play next"), "icon": "list-start", "action": "next" }) + entries.push({ "label": t("Play later"), "icon": "list-plus", "action": "later" }) + entries.push({ "label": t("Add to queue"), "icon": "list-end", "action": "queue" }) + if (!root.localAlbum) { + entries.push({ "sep": true }) + entries.push({ "label": t("Add to playlist"), "icon": "list-music", "action": "playlist" }) + } + } + // Quick View deliberately has no favourite or offline-cache actions, + // inline or in this menu. Context stays about playback/navigation. + if (track.available !== false && !root.localAlbum) { + entries.push({ "label": t("Share Qobuz link"), "icon": "link", "action": "share" }) + entries.push({ "label": t("Track info"), "icon": "info", "action": "info" }) + } + return entries + } + + function albumMenuAction(action) { + var albumId = root.doc.albumId || "" + if (albumId === "") + return + if (action === "open") { + root.closeQuickView() + QbzAlbum.openAlbum(albumId) + } else if (action === "play") { + root.albumPlaybackAction("play") + } else if (action === "next" || action === "later") { + root.albumPlaybackAction(action) + } else if (action === "queue") { + // Album enqueue has no block-tail mode; the canonical AlbumView + // menu therefore exposes one append action, not a duplicate + // Play-later row that would perform the same write. + root.albumPlaybackAction(root.localAlbum ? "queue" : "later") + } else if (action === "playlist") { + var ids = [] + for (var i = 0; i < root.tracks.length; i++) { + if (/^\d+$/.test(root.tracks[i].id || "") + && root.tracks[i].available !== false) + ids.push(root.tracks[i].id) + } + if (ids.length > 0) { + root.closeQuickView() + QbzPlaylistPicker.openForTracks(JSON.stringify(ids)) + } + } else if (action === "mixtape") { + root.closeQuickView() + QbzAlbum.addToMixtape(albumId) + } else if (action === "share-qobuz") { + QbzAlbum.shareQobuzLink(albumId) + } else if (action === "share-albumlink") { + QbzAlbum.shareAlbumLink(albumId) + } + } + + function trackMenuAction(action) { + var track = root.contextTrack || ({}) + if (!track.id) + return + if (action === "play") root.playTrack(track.id) + else if (action === "next" || action === "later" || action === "queue") + root.enqueueTrack(track.id, action) + else if (action === "playlist") { + root.closeQuickView() + QbzPlaylistPicker.openForTrack(track.id) + } else if (action === "share") { + QbzAlbum.shareTrackQobuz(track.id) + } else if (action === "info") { + // TrackInfo reparents into Overlay.overlay and returns here on + // close. Keeping this document open also keeps its owning object + // visible while the child popup is active. + trackInfo.openFor(track.id) + } + } + + onVisibleChanged: { + if (visible) + keyScope.forceActiveFocus() + else + root.restoreShellFocus() + } + FocusScope { + id: keyScope + anchors.fill: parent + Keys.onEscapePressed: root.closeQuickView() + } + + Rectangle { + anchors.fill: parent + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: root.closeQuickView() + // A plain Item overlay has no Popup modal grab: consume wheel + // events explicitly so the page behind it cannot scroll. + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + id: card + width: Math.min(root.width * 0.8, root.preferredCardWidth) + height: Math.min(root.height * 0.75, root.preferredCardHeight) + anchors.centerIn: parent + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + clip: true + + // Swallow all unused card-space input before it reaches the backdrop + // or the page below. The ListView, declared later, keeps its own wheel. + MouseArea { + anchors.fill: parent + onWheel: function (wheel) { wheel.accepted = true } + } + + Item { + id: heading + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 76 + + Column { + anchors.left: parent.left + anchors.leftMargin: 20 + anchors.right: headerActions.left + anchors.rightMargin: 20 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { + width: parent.width + text: root.loading ? root.tr("Loading album…") : (root.doc.title || "") + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Text { + visible: !root.loading && (root.doc.artist || "") !== "" + width: parent.width + text: root.doc.artist || "" + color: theme.textMuted + font.pixelSize: theme.fontBody + elide: Text.ElideRight + } + // NPB Small's condensed, inline quality line — shared with + // AudioStamp rather than the stacked QualityBadgeFull chip. + // A physical album adds its real SourceIcon immediately beside + // it; catalog albums keep the line unchanged. + Row { + visible: !root.loading && (root.qualityTierLabel !== "" + || (root.localAlbum && (root.doc.source || "") !== "")) + spacing: 8 + QualityInline { + visible: root.qualityTierLabel !== "" + maxWidth: Math.max(0, parent.parent.width + - (sourceIndicator.visible + ? sourceIndicator.width + parent.spacing : 0)) + tierLabel: root.qualityTierLabel + detail: root.doc.qualityDetail || "" + tooltipText: root.qualityTooltip + anchors.verticalCenter: parent.verticalCenter + } + SourceIcon { + id: sourceIndicator + visible: root.localAlbum && (root.doc.source || "") !== "" + kind: root.doc.source || "local" + mono: true + localTint: "muted" + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // One compact inline group: album transport, a visual separator, + // then the modal-only circular close action. + Row { + id: headerActions + anchors.right: parent.right + anchors.rightMargin: 20 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + QbzCircleAction { + primary: true + compactPrimary: true + diameterOverride: 28 + name: "play-fill" + btnEnabled: !root.loading && !root.failed && root.tracks.length > 0 + anchors.verticalCenter: parent.verticalCenter + onClicked: root.albumPlaybackAction("play") + } + QbzCircleAction { + name: "shuffle" + diameterOverride: 28 + btnEnabled: !root.loading && !root.failed && root.tracks.length > 0 + anchors.verticalCenter: parent.verticalCenter + onClicked: root.albumPlaybackAction("shuffle") + } + QbzCircleAction { + id: albumContextButton + name: "ellipsis" + diameterOverride: 28 + btnEnabled: !root.loading && !root.failed && root.tracks.length > 0 + anchors.verticalCenter: parent.verticalCenter + onClicked: function (mouse) { + albumMenu.entries = root.albumMenuEntries() + albumMenu.openAtCursor(albumContextButton, + mouse ? mouse.x : width / 2, + mouse ? mouse.y : height / 2) + } + } + Rectangle { + width: 1 + height: 20 + color: theme.borderStrong + anchors.verticalCenter: parent.verticalCenter + } + QbzCircleAction { + id: closeButton + name: "x" + diameterOverride: 28 + anchors.verticalCenter: parent.verticalCenter + onClicked: root.closeQuickView() + HoverHandler { id: closeHover } + ToolTip.visible: closeHover.hovered + ToolTip.text: root.tr("Close") + ToolTip.delay: 350 + } + } + } + + Rectangle { + id: divider + anchors.left: parent.left + anchors.right: parent.right + anchors.top: heading.bottom + height: 1 + color: theme.borderSubtle + } + + ListView { + id: trackList + visible: !root.loading && !root.failed && root.tracks.length > 0 + anchors.left: parent.left + anchors.leftMargin: root.tableLeft + anchors.right: parent.right + anchors.rightMargin: root.tableRight + anchors.top: divider.bottom + anchors.bottom: parent.bottom + anchors.bottomMargin: 10 + clip: true + reuseItems: true + boundsBehavior: Flickable.StopAtBounds + model: root.tracks + + delegate: Rectangle { + id: trackRow + required property var modelData + required property int index + width: ListView.view.width + height: root.rowHeight + radius: theme.radiusSm + color: rowArea.containsMouse || numberArea.containsMouse + || contextArea.containsMouse + ? theme.surfaceHover + : (index % 2 === 1 ? theme.alphaTier(4) : "transparent") + opacity: modelData.available === false ? 0.45 : 1.0 + + MouseArea { + id: rowArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) + root.openTrackMenu(trackRow.modelData, rowArea, + mouse.x, mouse.y) + } + onDoubleClicked: function (mouse) { + if (mouse.button === Qt.LeftButton + && trackRow.modelData.available !== false) + root.playTrack(trackRow.modelData.id) + } + } + + Row { + anchors.fill: parent + Item { + id: numberCell + width: root.numberWidth + height: parent.height + Text { + anchors.centerIn: parent + visible: !numberArea.containsMouse + || trackRow.modelData.available === false + text: trackRow.modelData.number || (trackRow.index + 1) + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + Rectangle { + anchors.centerIn: parent + visible: numberArea.containsMouse + && trackRow.modelData.available !== false + width: 26 + height: 26 + radius: 13 + color: numberArea.pressed + ? theme.accent : theme.accentHover + QbzIcon { + anchors.centerIn: parent + name: "play-fill" + width: 13 + height: 13 + tintName: theme.accentGlyphTint + } + } + // A permanent hit region owns the entire number cell. + // The old MouseArea existed only while its hover-made + // button was visible, which repeatedly stole/released + // hover from rowArea and made the button flicker. + MouseArea { + id: numberArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + cursorShape: trackRow.modelData.available !== false + ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) { + root.openTrackMenu(trackRow.modelData, + numberArea, + mouse.x, mouse.y) + } else if (trackRow.modelData.available !== false) { + root.playTrack(trackRow.modelData.id) + } + } + } + } + Text { + width: Math.max(0, parent.width - root.numberWidth + - root.durationWidth - root.contextWidth) + height: parent.height + text: trackRow.modelData.title || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody - 1 + font.weight: theme.weightMedium + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + width: root.durationWidth + height: parent.height + text: trackRow.modelData.duration || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + } + Item { + width: root.contextWidth + height: parent.height + Rectangle { + anchors.centerIn: parent + width: 28 + height: 28 + radius: theme.radiusSm + color: contextArea.containsMouse + ? theme.surfaceElevated : "transparent" + QbzIcon { + anchors.centerIn: parent + name: "ellipsis" + width: 15 + height: 15 + tintName: contextArea.containsMouse + ? "textPrimary" : "muted" + } + MouseArea { + id: contextArea + anchors.fill: parent + enabled: root.trackMenuEntries(trackRow.modelData).length > 0 + hoverEnabled: true + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: function (mouse) { + root.openTrackMenu(trackRow.modelData, + contextArea, + mouse.x, mouse.y) + } + } + } + } + } + } + } + + QbzScrollBar { + target: trackList + visible: trackList.visible && trackList.contentHeight > trackList.height + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.top: divider.bottom + anchors.bottom: parent.bottom + anchors.bottomMargin: 10 + } + + Column { + visible: root.loading + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: trackList.verticalCenter + spacing: 14 + QbzLoadingDots { + width: implicitWidth + height: implicitHeight + anchors.horizontalCenter: parent.horizontalCenter + phase: Math.floor(QbzShell.pulseMs / 300) % 3 + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: root.tr("Loading album…") + color: theme.textMuted + font.pixelSize: theme.fontBody + } + } + + QbzEmptyState { + visible: !root.loading && (root.failed || root.tracks.length === 0) + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: trackList.verticalCenter + iconName: root.failed ? "triangle-alert" : "list-music" + iconSize: 30 + iconOpacity: 0.55 + titleMuted: true + titleWeight: theme.weightMedium + title: root.failed ? root.tr("Could not load album") + : root.tr("This album has no tracks.") + } + } + + CardMenu { + id: albumMenu + menuWidth: 224 + onPicked: function (action) { root.albumMenuAction(action) } + } + CardMenu { + id: trackMenu + menuWidth: 224 + onPicked: function (action) { root.trackMenuAction(action) } + } + + // Instantiated once, not once per row. The info popup reparents to the + // window overlay; keeping Quick View alive gives it a valid return target. + TrackInfoModal { id: trackInfo } +} diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index 9acd31288..f7e2ae7b2 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -720,6 +720,16 @@ Rectangle { LinkResolverModal { anchors.fill: parent } + // Album Quick View — one navigation-free preview shared by every eligible + // AlbumCard. It owns no page lifetime: a card can live in Home, Search, + // Library or an artist rail, and Library's local/media-server ids are + // source-routed by its controller rather than sent to Qobuz. The preview + // always renders at shell level from its generation-guarded document. + // Self-gated on `quickViewJson.open`, so the closed state is an invisible, + // non-interactive Item. + AlbumQuickView { + anchors.fill: parent + } // TrackReplacementModal — "Find available version" for a track Qobuz // pulled from the catalogue (2026-08-17 unavailable-tracks contract §6), // opened from the playlist row's context menu. diff --git a/crates/qbz-qt/qml/shell/AudioStamp.qml b/crates/qbz-qt/qml/shell/AudioStamp.qml index c8d07bb68..91533b99f 100644 --- a/crates/qbz-qt/qml/shell/AudioStamp.qml +++ b/crates/qbz-qt/qml/shell/AudioStamp.qml @@ -28,6 +28,7 @@ import QtQuick import QtQuick.Controls import com.blitzfc.qbz +import "../controls" import "../theme" Item { @@ -96,118 +97,17 @@ Item { spacing: 1 // === Row 1 — quality, INLINE + BRIGHT (the focus) ================ - // Wrapped in an Item so the hover MouseArea can `anchors.fill` it — - // an anchored Item inside a Row is a QML error ("Cannot specify - // anchors for items inside Row"). - Item { + // Shared with Album Quick View; AudioStamp retains ownership of the + // delivered/source decision and supplies only the resolved display. + QualityInline { id: qualityRow anchors.right: parent.right - implicitWidth: qualityLine.implicitWidth - implicitHeight: qualityLine.implicitHeight - width: implicitWidth - height: implicitHeight - - Row { - id: qualityLine - anchors.right: parent.right - spacing: 5 - - Text { - id: tierText - // Delivered tier while downgraded; catalog tier otherwise. - text: root.showDelivered ? root.deliveredTierLabel : root.tierLabel - font.pixelSize: 9 - font.weight: Font.Bold - font.letterSpacing: 0.3 - color: theme.textSecondary - anchors.verticalCenter: parent.verticalCenter - } - // Downgrade arrow (glyph, not a pill — ADR-008). Amber so it - // reads as "adjusted", not as an error. - Text { - id: arrowText - visible: QbzPlayer.npQualityDowngraded - text: "↓" - font.pixelSize: 9 - font.weight: Font.Bold - color: "#eab308" - anchors.verticalCenter: parent.verticalCenter - } - Text { - // Delivered detail while downgraded; catalog otherwise. - text: root.showDelivered ? QbzPlayer.npQualityTrueDetail : QbzPlayer.npQualityDetail - font.pixelSize: 9 - font.weight: Font.DemiBold - color: theme.textPrimary - elide: Text.ElideRight - // Elide needs an explicit width. Derived from the clamp - // minus the sibling widths — none of which depend on this - // one, so there is no binding loop. - width: Math.max(0, Math.min(implicitWidth, - root.maxWidth - tierText.implicitWidth - 5 - - (arrowText.visible ? arrowText.implicitWidth + 5 : 0))) - anchors.verticalCenter: parent.verticalCenter - } - } - - MouseArea { - id: qualityHover - anchors.fill: parent - hoverEnabled: true - } - - // The bubble is 1:1 with the global shell/TooltipOverlay.slint the - // Slint stamp feeds: 11px/medium TEXT-PRIMARY (not secondary) on - // surface-elevated, radius 4 (not radiusSm=8), NO border, 5px/9px - // padding (not 6px), default line height (Slint sets none), and a - // downward caret. Centred on the control (TooltipOverlay clamps to - // the window and keeps the caret on the anchor); the BUBBLE bottom - // sits 9px above the anchor top (TooltipOverlay.slint:26) with the - // caret poking 5px below it, hence y = -height - 4. - ToolTip { - id: qualityTip - visible: qualityHover.containsMouse && root.qualityTooltip !== "" - text: root.qualityTooltip - delay: 0 - timeout: -1 - padding: 0 - x: Math.round((qualityRow.width - width) / 2) - y: -height - 4 - contentItem: Text { - text: qualityTip.text - color: theme.textPrimary - font.pixelSize: 11 - font.weight: theme.weightMedium - leftPadding: 9 - rightPadding: 9 - topPadding: 5 - // 5px bubble padding + the 5px caret zone below it. - bottomPadding: 10 - } - background: Item { - Rectangle { - anchors.fill: parent - anchors.bottomMargin: 5 - color: theme.surfaceElevated - radius: 4 - } - // Caret (TooltipOverlay.slint:56 draws a 10x6 Path). A - // 7.07px square rotated 45 deg is a 10px-wide diamond; - // centred on the bubble's bottom edge its lower half IS - // the caret. QtQuick.Shapes is not imported anywhere in - // this port, and shader effects are dead on the software - // path, so a rotated Rectangle is the way to draw it. - Rectangle { - width: 7.07 - height: 7.07 - rotation: 45 - antialiasing: true - color: theme.surfaceElevated - x: Math.round((parent.width - width) / 2) - y: parent.height - 5 - height / 2 - } - } - } + maxWidth: root.maxWidth + tierLabel: root.showDelivered ? root.deliveredTierLabel : root.tierLabel + detail: root.showDelivered + ? QbzPlayer.npQualityTrueDetail : QbzPlayer.npQualityDetail + downgraded: QbzPlayer.npQualityDowngraded + tooltipText: root.qualityTooltip } // === Row 2a — the LEDs (local output) ============================ diff --git a/crates/qbz-qt/qml/theme/QbzKineticScroll.qml b/crates/qbz-qt/qml/theme/QbzKineticScroll.qml index e7a48e903..128651877 100644 --- a/crates/qbz-qt/qml/theme/QbzKineticScroll.qml +++ b/crates/qbz-qt/qml/theme/QbzKineticScroll.qml @@ -18,6 +18,10 @@ Item { id: root required property Flickable target + /// User-originated wheel/touchpad takeover. Consumers with an opt-in + /// auto-follow mode use this to yield immediately; programmatic + /// `positionViewAtIndex` calls never emit it. + signal userScrollStarted() // QbzScrollBar declares us beside the view. Reparent to the Flickable // explicitly so this observer covers its whole VIEWPORT, including child @@ -106,6 +110,7 @@ Item { event.accepted = false; if (event.phase === Qt.ScrollBegin) { + root.userScrollStarted(); root._resetGesture(); return; } @@ -120,11 +125,20 @@ Item { return; } if (event.phase !== Qt.ScrollUpdate) + { + // Physical mouse wheels usually carry NoScrollPhase rather + // than Begin/Update/End. They still represent an explicit + // user takeover and must cancel a consumer's auto-follow. + if (event.angleDelta.x !== 0 || event.angleDelta.y !== 0 + || event.pixelDelta.x !== 0 || event.pixelDelta.y !== 0) + root.userScrollStarted(); return; + } // A physical wheel normally has angleDelta only and is handled by // Qt's process-wide wheel-deceleration path (main.rs). This half is // deliberately limited to high-resolution pixel gestures. + root.userScrollStarted(); const dx = event.pixelDelta.x; const dy = event.pixelDelta.y; if (dx !== 0 || dy !== 0) diff --git a/crates/qbz-qt/qml/theme/QbzScrollBar.qml b/crates/qbz-qt/qml/theme/QbzScrollBar.qml index e7ef63556..958843b6a 100644 --- a/crates/qbz-qt/qml/theme/QbzScrollBar.qml +++ b/crates/qbz-qt/qml/theme/QbzScrollBar.qml @@ -9,6 +9,9 @@ import QtQuick Item { id: root required property Flickable target + /// Emitted only for a physical wheel/touchpad gesture or a press on this + /// gutter, never when the target is positioned from code. + signal userScrollStarted() width: 14 visible: maxScroll > 0 @@ -42,6 +45,7 @@ Item { // while this observer supplies only a missing touchpad tail. QbzKineticScroll { target: root.target + onUserScrollStarted: root.userScrollStarted() } // Track. @@ -85,6 +89,7 @@ Item { hoverEnabled: true cursorShape: Qt.PointingHandCursor onPressed: { + root.userScrollStarted(); // Public Flickable semantics already cancel on a contentY write; // make the takeover explicit so scrollbar drag remains exact even // if the first pressed position equals the current one. diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml index 58bc9a2f4..4e1ff473c 100644 --- a/crates/qbz-qt/qml/views/HomeView.qml +++ b/crates/qbz-qt/qml/views/HomeView.qml @@ -878,6 +878,11 @@ Rectangle { Component { id: pAlbum AlbumCard { + readonly property bool localPinned: + modelData.isLocalAlbum === true + localMode: localPinned + quickViewAffordance: true + pinAffordance: true albumId: modelData.id title: modelData.title artist: modelData.artist @@ -891,6 +896,12 @@ Rectangle { // the user just dropped must restore the same // snapshot url, not blank it. artworkUrl: modelData.artUrl || "" + pinArtworkUrl: artworkUrl !== "" ? artworkUrl : artSource + onOpenRequested: QbzLocal.openAlbum(modelData.id) + onPlayRequested: QbzLocal.playAlbum(modelData.id, false) + onEnqueueRequested: function (mode) { + QbzLocal.enqueue("album", modelData.id, mode) + } } } Component { diff --git a/crates/qbz-qt/qml/views/QueueView.qml b/crates/qbz-qt/qml/views/QueueView.qml index ecc50697c..620cef41f 100644 --- a/crates/qbz-qt/qml/views/QueueView.qml +++ b/crates/qbz-qt/qml/views/QueueView.qml @@ -34,6 +34,46 @@ Rectangle { + (root.doc.currentIndex >= 0 ? 1 : 0) readonly property var currentRow: root.doc.currentIndex >= 0 ? root.rows[root.doc.currentIndex] : null + readonly property bool hasPlayingRow: root.doc.currentIndex >= 0 + && root.doc.currentIndex < root.rows.length + readonly property int actionRailWidth: 52 + + // Opt-in follow mode. It survives playback-driven document updates but + // yields immediately to any user scroll or queue-ordering gesture, so a + // track transition can never yank the viewport away while the user is + // arranging another part of the list. + property bool followPlaying: false + property bool programmaticListPosition: false + + function cancelPlayingFollow() { + root.followPlaying = false + } + + function positionListAt(index, mode) { + if (index < 0 || index >= root.rows.length) + return + root.programmaticListPosition = true + queueList.positionViewAtIndex(index, mode) + Qt.callLater(function () { root.programmaticListPosition = false }) + } + + function focusOnPlaying() { + if (!root.hasPlayingRow) + return + root.followPlaying = true + root.positionListAt(root.doc.currentIndex, ListView.Center) + } + + function followPlayingRow() { + if (root.followPlaying && root.hasPlayingRow) + root.positionListAt(root.doc.currentIndex, ListView.Center) + } + + function goToTop() { + root.cancelPlayingFollow() + if (root.rows.length > 0) + root.positionListAt(0, ListView.Beginning) + } Component.onCompleted: { QbzQueue.queueExtendedOpened() @@ -43,7 +83,14 @@ Rectangle { QbzShell.dragInlineVisual = false QbzQueue.queueExtendedClosed() } - onDocChanged: root.scheduleVisibleCovers() + onDocChanged: { + root.scheduleVisibleCovers() + if (!root.hasPlayingRow) + root.cancelPlayingFollow() + else if (root.followPlaying) + Qt.callLater(function () { root.followPlayingRow() }) + } + onSearchActiveChanged: if (root.searchActive) root.cancelPlayingFollow() // ----------------------------- artwork ------------------------------ @@ -219,6 +266,7 @@ Rectangle { function moveQueueRow(row, delta) { if (!row || root.searchActive || root.rowBlocked(row)) return + root.cancelPlayingFollow() var from var count var targetPhase = row.phase @@ -254,6 +302,7 @@ Rectangle { : ({}) function beginQueueDrag(row, visualIndex) { + root.cancelPlayingFollow() root.dragPhase = row.phase root.dragPhaseIndex = row.phaseIndex root.dragTrackId = row.id @@ -588,6 +637,9 @@ Rectangle { id: queueList anchors.left: parent.left anchors.right: queueScroll.left + // Reserve a narrow action rail so the two always-visible + // floating buttons never cover a row or the scrollbar. + anchors.rightMargin: root.actionRailWidth anchors.top: parent.top anchors.bottom: parent.bottom anchors.leftMargin: theme.spacingMd @@ -599,6 +651,10 @@ Rectangle { reuseItems: true model: root.rows onContentYChanged: root.scheduleVisibleCovers() + onMovementStarted: { + if (!root.programmaticListPosition) + root.cancelPlayingFollow() + } delegate: Item { id: rowHost @@ -698,6 +754,63 @@ Rectangle { anchors.top: parent.top anchors.bottom: parent.bottom target: queueList + onUserScrollStarted: root.cancelPlayingFollow() + } + + // Always-visible viewport actions. They occupy the reserved rail, + // while QbzContextMenu reparents to Overlay.overlay and therefore + // remains above them when opened near the lower-right corner. + Column { + id: viewportActions + z: 15 + anchors.right: queueScroll.left + anchors.rightMargin: 8 + anchors.bottom: parent.bottom + anchors.bottomMargin: theme.spacingMd + spacing: 8 + + Rectangle { + width: 36 + height: 36 + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: root.followPlaying ? theme.accent + : theme.borderSubtle + QbzIconButton { + anchors.centerIn: parent + btnSize: 34 + iconSize: 16 + name: "disc-3" + active: root.followPlaying + btnEnabled: root.hasPlayingRow + tooltip: tips + tooltipKey: "queue-focus-playing" + tooltipText: QbzSession.tr("Focus on playing", + QbzSession.trRev) + onClicked: root.focusOnPlaying() + } + } + + Rectangle { + width: 36 + height: 36 + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + QbzIconButton { + anchors.centerIn: parent + btnSize: 34 + iconSize: 16 + name: "chevron-up" + btnEnabled: root.rows.length > 0 + tooltip: tips + tooltipKey: "queue-go-top" + tooltipText: QbzSession.tr("Go to top", QbzSession.trRev) + onClicked: root.goToTop() + } + } } // QueueView's drag visual is the row itself, not the generic diff --git a/crates/qbz-qt/qml/views/library/FeedGridCell.qml b/crates/qbz-qt/qml/views/library/FeedGridCell.qml index a67903d8e..ce9db4a98 100644 --- a/crates/qbz-qt/qml/views/library/FeedGridCell.qml +++ b/crates/qbz-qt/qml/views/library/FeedGridCell.qml @@ -94,6 +94,9 @@ Item { // landed in the Home Pinned rail as a permanent grey placeholder, // across restarts. artworkUrl: cell.item.imageUrl || "" + // Local/server rows have no portable catalog URL; retain their + // resolved artwork snapshot for the per-machine Pinned rail. + pinArtworkUrl: artworkUrl !== "" ? artworkUrl : artSource // The source word is ALWAYS passed (the "Block this album" menu // gate reads it); the toolbar's show-local toggle only decides // whether the BADGE is drawn — FavoritesView.slint:1097 diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml index e176aeecc..2818afb2e 100644 --- a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml +++ b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml @@ -436,9 +436,40 @@ Item { width: 200 height: 246 + // AlbumCard updates these two properties + // optimistically, which intentionally replaces the + // bindings below. The card instances in this fixed + // Repeater survive ListView row reuse, so restore + // the bindings whenever a different slot object is + // installed; otherwise the next album could inherit + // the previous card's heart/pin state. + function restoreMutableBindings() { + albumCard.isFavorite = Qt.binding(function () { + return root.view + ? root.view.albumFavorite(cardCell.slot) + : cardCell.slot.isFavorite === true + }) + albumCard.isPinned = Qt.binding(function () { + return QbzLibrary.pinState( + "album", cardCell.slot.id) + }) + } + function scheduleMutableRestore() { + var expected = cardCell.slot + Qt.callLater(function () { + if (cardCell.slot === expected) + cardCell.restoreMutableBindings() + }) + } + onSlotChanged: cardCell.scheduleMutableRestore() + Component.onCompleted: + cardCell.scheduleMutableRestore() + AlbumCard { id: albumCard localMode: true + quickViewAffordance: true + pinAffordance: true albumId: cardCell.slot.id title: cardCell.slot.title artist: cardCell.slot.artist @@ -448,6 +479,8 @@ Item { qualityTier: cardCell.slot.qualityTier artSource: cardCell.slot.artPath || (root.view ? (root.view.artMap[cardCell.slot.artKey] || "") : "") + pinArtworkUrl: artSource + isPinned: QbzLibrary.pinState("album", albumId) // LocalLibraryView.slint:1267 `show-source-badge` // — the card takes the raw source word and the // BADGE is what the flag switches (blanking diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml index 71fd5a66d..76fac13dd 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml @@ -975,13 +975,15 @@ Rectangle { readonly property string cSourceKind: root.patchRev >= 0 ? (gcell.live.sourceKind || "") : "" readonly property bool cSelected: root.patchRev >= 0 && gcell.live.selected === true readonly property bool cFavorite: root.patchRev >= 0 && gcell.live.isFavorite === true - readonly property bool cPinned: root.patchRev >= 0 && gcell.live.isPinned === true + readonly property string cCardIdentity: + gcell.cItemType + ":" + gcell.cItemId /// Is THIS cell's `sourceItemId` a Qobuz catalog album /// id? The per-item gate for the card's catalog-only - /// affordances (heart, pin, "Block this album"), and the + /// affordances (heart and "Block this album"), and the /// QML twin of `myqbz_detail_qt::is_catalog_album` — the - /// two must agree, because Rust seeds `isFavorite` / - /// `isPinned` behind the same predicate. + /// two must agree for the favorite seed. Pinning has a + /// separate string-key gate below so local albums can + /// participate too. /// /// The STORED source (`source`, "qobuz" | "local"), not /// the resolved `sourceKind`: the stored word is what @@ -1021,9 +1023,9 @@ Rectangle { /// `rows/TrackRow.qml:233`, `views/library/FeedListRow.qml:39`); /// AlbumCard takes flat properties instead of an `item`, /// so the restore belongs to the recycling HOST. Keyed - /// on `cItemId`, which is derived from `index` and - /// therefore changes on every reuse. - onCItemIdChanged: gcell.rebindCardState() + /// on the type + id pair, which is derived from + /// `index` and therefore changes on every reuse. + onCCardIdentityChanged: gcell.rebindCardState() function rebindCardState() { // The change signal can fire while the delegate is // still being built, before the child below exists; @@ -1032,7 +1034,11 @@ Rectangle { if (!gcard) return gcard.isFavorite = Qt.binding(function () { return gcell.cFavorite }) - gcard.isPinned = Qt.binding(function () { return gcell.cPinned }) + gcard.isPinned = Qt.binding(function () { + return gcell.cItemType === "album" + ? QbzLibrary.pinState("album", gcell.cItemId) + : false + }) } AlbumCard { @@ -1048,10 +1054,16 @@ Rectangle { // AlbumCard's `catalogAffordances`). A My QBZ // container is multi-source per ROW: a Qobuz album // cell's id IS a catalog album id and its heart / - // pin / block are perfectly valid; a track, a - // playlist, a local or a Plex cell's is not, and - // there the three are ABSENT rather than dead. + // pin / block are perfectly valid. Local/server + // albums below opt back into pin independently; + // tracks/playlists still keep album affordances + // absent rather than dead. catalogAffordances: gcell.cCatalogAlbum + // The heterogeneous grid also uses AlbumCard for + // tracks/playlists. Quick View belongs only to its + // real album rows, including local/server albums. + quickViewAffordance: gcell.cItemType === "album" + pinAffordance: gcell.cItemType === "album" // Seeded by Rust behind the SAME predicate, then // settled by the card itself on // `QbzLibrary.libraryFavoriteChanged` / `pinChanged` @@ -1059,7 +1071,8 @@ Rectangle { // on a Home rail agrees everywhere, and no toggle // republishes this document. isFavorite: gcell.cFavorite - isPinned: gcell.cPinned + isPinned: pinAffordance + ? QbzLibrary.pinState("album", gcell.cItemId) : false albumId: gcell.cItemId title: gcell.cTitle artist: gcell.cSubtitle @@ -1076,6 +1089,7 @@ Rectangle { // `cArtUrl`). Without it a pinned My QBZ album // lands in the Pinned rail as a placeholder. artworkUrl: gcell.cArtUrl + pinArtworkUrl: artworkUrl !== "" ? artworkUrl : artSource // Badge ON: a My QBZ container is multi-source BY // DEFINITION and the sibling LIST arm gives every // row a Source column (col 4, the same diff --git a/crates/qbz-qt/src/album_bridge.rs b/crates/qbz-qt/src/album_bridge.rs index 56176fec0..8268b18cb 100644 --- a/crates/qbz-qt/src/album_bridge.rs +++ b/crates/qbz-qt/src/album_bridge.rs @@ -39,6 +39,10 @@ pub mod qbz_album_bridge { // ONE JSON document (album_qt.rs AlbumViewData: header + track rows // + works/goodies/related buckets); QML JSON.parse()s it once. #[qproperty(QString, album_json)] + // App-wide Album Quick View. This is deliberately separate from + // `album_json`: opening a card preview must not replace the document + // owned by AlbumView or mutate navigation state. + #[qproperty(QString, quick_view_json)] // --- Track Info modal (qml/shell/TrackInfoModal.qml) -------------- #[qproperty(bool, track_info_loading)] #[qproperty(QString, track_info_json)] @@ -58,6 +62,19 @@ pub mod qbz_album_bridge { /// Open the album detail view (pushes "album" on the nav stack). #[qinvokable] fn open_album(self: Pin<&mut QbzAlbum>, album_id: QString); + /// AlbumCard's picture-in-picture action. Fetches the compact album + /// document without navigating or touching `album_json`. + #[qinvokable] + fn open_quick_view(self: Pin<&mut QbzAlbum>, album_id: QString); + /// Every Quick View dismissal path (Escape, backdrop, close button, + /// or a handoff into another global modal). + #[qinvokable] + fn close_quick_view(self: Pin<&mut QbzAlbum>); + /// Playback/queue action over the exact physical rows shown by a + /// local/Plex/Jellyfin/Subsonic Quick View. Catalog previews keep + /// using QbzPlayer's album-context actions. + #[qinvokable] + fn quick_view_local_action(self: Pin<&mut QbzAlbum>, action: QString, track_id: QString); /// Info button on either now-playing bar: fetch + publish the Track /// Info document (track_info_qt.rs). #[qinvokable] @@ -125,6 +142,7 @@ use qbz_album_bridge::QbzAlbum; pub struct QbzAlbumRust { album_loading: bool, album_json: QString, + quick_view_json: QString, track_info_loading: bool, track_info_json: QString, album_info_loading: bool, @@ -137,6 +155,7 @@ impl Default for QbzAlbumRust { Self { album_loading: false, album_json: QString::from("{}"), + quick_view_json: QString::from("{}"), track_info_loading: false, track_info_json: QString::from("{}"), album_info_loading: false, @@ -170,6 +189,15 @@ impl qbz_album_bridge::QbzAlbum { pub fn open_album(self: Pin<&mut Self>, album_id: QString) { crate::open_album(album_id.to_string()); } + pub fn open_quick_view(self: Pin<&mut Self>, album_id: QString) { + crate::album_quick_view_qt::open(album_id.to_string()); + } + pub fn close_quick_view(self: Pin<&mut Self>) { + crate::album_quick_view_qt::close(); + } + pub fn quick_view_local_action(self: Pin<&mut Self>, action: QString, track_id: QString) { + crate::album_quick_view_qt::local_action(action.to_string(), track_id.to_string()); + } pub fn open_track_info(self: Pin<&mut Self>, track_id: QString) { crate::track_info_qt::open(track_id.to_string()); } diff --git a/crates/qbz-qt/src/album_quick_view_qt.rs b/crates/qbz-qt/src/album_quick_view_qt.rs new file mode 100644 index 000000000..78e968cb8 --- /dev/null +++ b/crates/qbz-qt/src/album_quick_view_qt.rs @@ -0,0 +1,418 @@ +//! Navigation-free Album Quick View controller. +//! +//! `AlbumCard` exists on many independently recycled surfaces. Its preview +//! therefore cannot borrow the full `QbzAlbum.albumJson` document: that +//! document belongs to AlbumView, has progressive carousel enrichment, and +//! replacing it from a card would repaint an unrelated page. This controller +//! fetches `/album/get` for catalog ids or a read-only physical-version +//! snapshot for local/media-server ids, maps the compact header + track table, +//! and publishes through the dedicated `QbzAlbum.quickViewJson` property. +//! +//! Every open/close advances `GENERATION`. A response for card A can neither +//! overwrite card B nor reopen the modal after the user dismissed it. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; + +use cxx_qt_lib::QString; +use qbz_library::LocalTrack; +use qbz_models::{Album, Track}; +use serde::Serialize; + +static GENERATION: AtomicU64 = AtomicU64::new(0); +static LOCAL_CONTEXT: Mutex> = Mutex::new(None); + +#[derive(Clone)] +struct LocalContext { + generation: u64, + tracks: Vec, +} + +#[derive(Default, Serialize)] +struct QuickViewDoc { + open: bool, + loading: bool, + failed: bool, + #[serde(rename = "requestedId")] + requested_id: String, + #[serde(rename = "albumId")] + album_id: String, + title: String, + artist: String, + #[serde(rename = "artistId")] + artist_id: String, + #[serde(rename = "qualityTier")] + quality_tier: String, + #[serde(rename = "qualityDetail")] + quality_detail: String, + /// Physical source indicator for a local/media-server preview. Catalog + /// previews leave it empty because their provenance is already implicit. + source: String, + /// Local/media-server previews use their physical-row playback path; + /// catalog previews retain the Qobuz album context/actions. + #[serde(rename = "isLocal")] + is_local: bool, + tracks: Vec, +} + +#[derive(Serialize)] +struct QuickTrack { + id: String, + number: String, + title: String, + duration: String, + /// Only an explicit Qobuz `streamable: false` turns this off; local and + /// media-server reachability is checked later by the playback preflight. + available: bool, +} + +fn publish(doc: QuickViewDoc) { + let json = serde_json::to_string(&doc).unwrap_or_else(|e| { + log::warn!("[qbz-qt] album quick view serialization failed: {e}"); + "{}".to_string() + }); + crate::album_bridge::ui(move |mut b| { + b.as_mut().set_quick_view_json(QString::from(json.as_str())); + }); +} + +fn publish_if_current(generation: u64, doc: QuickViewDoc) { + if GENERATION.load(Ordering::SeqCst) == generation { + publish(doc); + } +} + +fn loading_doc(album_id: &str) -> QuickViewDoc { + QuickViewDoc { + open: true, + loading: true, + requested_id: album_id.to_string(), + album_id: album_id.to_string(), + ..Default::default() + } +} + +fn failed_doc(album_id: String) -> QuickViewDoc { + QuickViewDoc { + open: true, + failed: true, + requested_id: album_id.clone(), + album_id, + ..Default::default() + } +} + +fn format_track_title(track: &Track) -> String { + match track + .version + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + { + Some(version) => format!("{} ({version})", track.title), + None => track.title.clone(), + } +} + +fn map_album(requested_id: String, album: Album) -> QuickViewDoc { + // `/album/get` normally carries the flat pair. The nested pair is kept as + // the first choice so this stays correct if the endpoint adopts the V2 + // album shape already used by Discover. + 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 artist = album.artist.name.clone(); + let artist_id = if album.artist.id == 0 { + String::new() + } else { + album.artist.id.to_string() + }; + let tracks = album + .tracks + .as_ref() + .map(|container| { + container + .items + .iter() + .enumerate() + .map(|(index, track)| QuickTrack { + id: track.id.to_string(), + // Quick View is a condensed flat table. A continuous + // ordinal avoids duplicated 1..N labels on multi-disc + // releases while preserving the API's track order. + number: (index + 1).to_string(), + title: format_track_title(track), + duration: format!("{}:{:02}", track.duration / 60, track.duration % 60), + available: track.is_streamable(), + }) + .collect() + }) + .unwrap_or_default(); + + QuickViewDoc { + open: true, + requested_id: requested_id.clone(), + // Use the requested id as the action context. It is the entity the + // card represented, even if a future API response aliases its id. + album_id: requested_id, + title: crate::album_qt::format_album_title(&album.title, album.version.as_deref()), + artist, + artist_id, + quality_tier: crate::home_qt::quality_tier_from_depth(bit_depth).to_string(), + quality_detail: crate::home_qt::quality_detail_from_parts(bit_depth, sample_rate), + is_local: false, + tracks, + ..Default::default() + } +} + +fn local_quality_rank(track: &LocalTrack) -> (u8, u32, u64) { + let tier = match crate::local_rows::tier_of(&track.format, track.bit_depth, track.sample_rate) { + "max" => 4, + "hires" => 3, + "cd" => 2, + "mp3" => 1, + _ => 0, + }; + ( + tier, + track.bit_depth.unwrap_or(0), + if track.sample_rate.is_finite() && track.sample_rate > 0.0 { + track.sample_rate as u64 + } else { + 0 + }, + ) +} + +/// Build a preview from the best physical copy without calling +/// `local_album_actions::open_versions`: that function owns the routed Local +/// Album page's selection/cache, and a navigation-free card preview must not +/// retarget it behind the user's back. +fn map_local_album( + requested_id: String, + tracks: Vec, +) -> Option<(QuickViewDoc, Vec)> { + let selected = crate::local_album_actions::split_versions(tracks) + .into_iter() + .next()? + .1; + let first = selected.first()?; + let title = if first.album_group_title.trim().is_empty() { + first.album.clone() + } else { + first.album_group_title.clone() + }; + let lead_artist = first + .album_artist + .as_deref() + .map(str::trim) + .filter(|artist| !artist.is_empty()) + .unwrap_or(first.artist.trim()) + .to_string(); + let artist = if selected.iter().all(|track| { + track + .album_artist + .as_deref() + .map(str::trim) + .filter(|artist| !artist.is_empty()) + .unwrap_or(track.artist.trim()) + == lead_artist.as_str() + }) { + lead_artist + } else { + qbz_i18n::t("Various Artists") + }; + let best = selected + .iter() + .max_by_key(|track| local_quality_rank(track)) + .unwrap_or(first); + // Keep the raw purchase spelling when it has its own source mark; every + // other physical source uses the same folded vocabulary as SourceIcon. + let raw_source = crate::local_rows::badge_source_raw(best.source.as_deref()); + let source = if raw_source.is_empty() { + crate::local_rows::badge_source(best.source.as_deref()) + } else { + raw_source + }; + let rows = selected + .iter() + .enumerate() + .map(|(index, track)| QuickTrack { + id: track.id.to_string(), + // The compact table is flat even for a box set, so its visible + // ordinal stays continuous across disc boundaries. + number: (index + 1).to_string(), + title: track.title.clone(), + duration: crate::local_rows::mmss(track.duration_secs), + // Local/server reachability is preflighted by the playback seam; + // the library row itself carries no catalog-rights withdrawal. + available: true, + }) + .collect(); + + Some(( + QuickViewDoc { + open: true, + requested_id: requested_id.clone(), + album_id: requested_id, + title, + artist, + quality_tier: crate::local_rows::tier_of( + &best.format, + best.bit_depth, + best.sample_rate, + ) + .to_string(), + quality_detail: crate::local_rows::detail_of( + &best.format, + best.bit_depth, + best.sample_rate, + ), + source, + is_local: true, + tracks: rows, + ..Default::default() + }, + selected, + )) +} + +fn clear_local_context() { + if let Ok(mut context) = LOCAL_CONTEXT.lock() { + *context = None; + } +} + +fn store_local_context_if_current(generation: u64, tracks: Vec) -> bool { + if GENERATION.load(Ordering::SeqCst) != generation { + return false; + } + let Ok(mut context) = LOCAL_CONTEXT.lock() else { + return false; + }; + if GENERATION.load(Ordering::SeqCst) != generation { + return false; + } + *context = Some(LocalContext { generation, tracks }); + true +} + +/// Open immediately in loading state, then replace it with the compact album +/// document. This never records navigation and never touches AlbumView's +/// progressive document/stash. +pub(crate) fn open(album_id: String) { + let album_id = album_id.trim().to_string(); + if album_id.is_empty() { + return; + } + + let generation = GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + clear_local_context(); + publish(loading_doc(&album_id)); + + if crate::library_qt::is_local_album_key(&album_id) { + crate::spawn(async move { + let requested_id = album_id.clone(); + let loaded = tokio::task::spawn_blocking(move || { + let tracks = crate::local_albums::fetch_album_tracks_blocking(&requested_id); + map_local_album(requested_id, tracks) + }) + .await + .ok() + .flatten(); + match loaded { + Some((doc, tracks)) => { + if store_local_context_if_current(generation, tracks) { + publish_if_current(generation, doc); + } + } + None => { + log::warn!("[qbz-qt] local album quick view load failed for {album_id}"); + publish_if_current(generation, failed_doc(album_id)); + } + } + }); + return; + } + + let runtime = crate::app(); + crate::spawn(async move { + match runtime.core().get_album(&album_id).await { + Ok(album) => publish_if_current(generation, map_album(album_id, album)), + Err(error) => { + log::warn!("[qbz-qt] album quick view load failed for {album_id}: {error}"); + publish_if_current(generation, failed_doc(album_id)); + } + } + }); +} + +/// Run an action over the exact local/media-server version published in the +/// preview. The generation check prevents a delayed click from acting on the +/// card that occupied the modal previously. +pub(crate) fn local_action(action: String, track_id: String) { + let generation = GENERATION.load(Ordering::SeqCst); + let context = LOCAL_CONTEXT.lock().ok().and_then(|context| { + context + .as_ref() + .filter(|context| context.generation == generation) + .cloned() + }); + let Some(context) = context else { + log::debug!("[qbz-qt] ignored stale local Quick View action '{action}'"); + return; + }; + let requested_track = !track_id.trim().is_empty(); + let selected_id = track_id.trim().parse::().ok(); + let selected_position = + selected_id.and_then(|id| context.tracks.iter().position(|track| track.id == id)); + if requested_track && selected_position.is_none() { + log::debug!("[qbz-qt] ignored local Quick View action for unknown track '{track_id}'"); + return; + } + let start = selected_position.unwrap_or(0); + let rows = if matches!(action.as_str(), "next" | "later" | "queue") { + match selected_id { + Some(id) => context + .tracks + .into_iter() + .find(|track| track.id == id) + .into_iter() + .collect(), + None => context.tracks, + } + } else { + context.tracks + }; + if rows.is_empty() { + return; + } + + let runtime = crate::app(); + crate::spawn(async move { + match action.as_str() { + "play" | "shuffle" => { + crate::local_playback::play_rows(&runtime, rows, start, action == "shuffle").await; + } + "next" | "later" | "queue" => { + crate::local_playback::enqueue_rows(&runtime, rows, action.clone()).await; + } + other => log::debug!("[qbz-qt] unknown local Quick View action '{other}'"), + } + }); +} + +/// Close and invalidate the request that may still be in flight. +pub(crate) fn close() { + GENERATION.fetch_add(1, Ordering::SeqCst); + clear_local_context(); + publish(QuickViewDoc::default()); +} diff --git a/crates/qbz-qt/src/home_qt.rs b/crates/qbz-qt/src/home_qt.rs index 2d451f720..06814c518 100644 --- a/crates/qbz-qt/src/home_qt.rs +++ b/crates/qbz-qt/src/home_qt.rs @@ -118,6 +118,14 @@ pub struct HomeCard { /// "playlist") — the mixed PinnedCarousel slot dispatch. #[serde(rename = "itemKind", default)] pub item_kind: String, + /// Pinned rail only: route an album snapshot back through QbzLocal instead + /// of handing its physical group/server key to the Qobuz catalog. + #[serde( + rename = "isLocalAlbum", + default, + skip_serializing_if = "std::ops::Not::not" + )] + pub is_local_album: bool, /// Playlist rows: the UPPERCASE first-tag category subtag. pub category: String, /// Playlist rows: up to four MEMBER covers for the card's mosaic arm, when @@ -260,28 +268,32 @@ pub struct Personalized { fn pinned_cards() -> Vec { crate::sidebar_qt::list_pinned() .into_iter() - .map(|p| HomeCard { - // The pinned rail is MIXED, so the heart / tri-state seeds are - // routed by the row's own stored kind rather than by the rail's. - is_favorite: crate::fav_cache_qt::is_favorite(&p.kind, &p.id), - playlist_owned: p.kind == "playlist" - && p.id - .parse::() - .map(crate::playlist_qt::is_owned) - .unwrap_or(false), - playlist_following: p.kind == "playlist" - && p.id - .parse::() - .map(crate::playlist_qt::is_following) - .unwrap_or(false), - id: p.id, - title: p.title, - artist: p.subtitle.clone(), - subtitle: p.subtitle, - art_url: p.artwork_url, - item_kind: p.kind, - is_pinned: true, - ..Default::default() + .map(|p| { + let is_local_album = p.kind == "album" && crate::library_qt::is_local_album_key(&p.id); + HomeCard { + // The pinned rail is MIXED, so the heart / tri-state seeds are + // routed by the row's own stored kind rather than by the rail's. + is_favorite: crate::fav_cache_qt::is_favorite(&p.kind, &p.id), + playlist_owned: p.kind == "playlist" + && p.id + .parse::() + .map(crate::playlist_qt::is_owned) + .unwrap_or(false), + playlist_following: p.kind == "playlist" + && p.id + .parse::() + .map(crate::playlist_qt::is_following) + .unwrap_or(false), + id: p.id, + title: p.title, + artist: p.subtitle.clone(), + subtitle: p.subtitle, + art_url: p.artwork_url, + item_kind: p.kind, + is_local_album, + is_pinned: true, + ..Default::default() + } }) .collect() } diff --git a/crates/qbz-qt/src/library_bridge.rs b/crates/qbz-qt/src/library_bridge.rs index f91f4ebba..302ef2d9c 100644 --- a/crates/qbz-qt/src/library_bridge.rs +++ b/crates/qbz-qt/src/library_bridge.rs @@ -134,6 +134,10 @@ pub mod qbz_library_bridge { subtitle: QString, artwork_url: QString, ); + /// Warm in-memory lookup for recycled card delegates. Unlike the pin + /// mutation, this performs no SQLite query and publishes nothing. + #[qinvokable] + fn pin_state(self: &QbzLibrary, kind: QString, id: QString) -> bool; /// Emitted after a pin toggle (`{kind}:{id}` key like artKey). #[qsignal] fn pin_changed(self: Pin<&mut QbzLibrary>, key: QString, value: bool); @@ -272,4 +276,8 @@ impl qbz_library_bridge::QbzLibrary { artwork_url.to_string(), ); } + + pub fn pin_state(&self, kind: QString, id: QString) -> bool { + crate::sidebar_qt::is_pinned(&kind.to_string(), &id.to_string()) + } } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index e94a6872c..2e81af959 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -284,6 +284,9 @@ mod offline_qt; // Shared multi-select bulk actions for Qobuz track listings (playlist, // artist, label — the album page has its own album-ordered variant). mod album_qt; +// Lightweight, navigation-free AlbumCard preview. It owns a generation- +// guarded document separate from the full AlbumView domain. +mod album_quick_view_qt; mod award_qt; mod bulk_tracks_qt; mod track_info_qt;