feat(qt): add album quick view and queue focus controls

cx
This commit is contained in:
vicrodh committed 2026-08-26 16:29:16 -06:00
1 parent aa66c58ea4
commit d555b8fde4
26 files changed
+1675 -174

No files matched your search

@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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 "このアルバムには曲がありません。"
@@ -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."
@@ -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."
@@ -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 "В этом альбоме нет треков."
+2
View File
@@ -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",
+84 -29
View File
@@ -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
@@ -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
}
}
}
}
+635
View File
@@ -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 }
}
+10
View File
@@ -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.
+10 -110
View File
@@ -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) ============================
@@ -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)
+5
View File
@@ -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.
+11
View File
@@ -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 {
+114 -1
View File
@@ -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
@@ -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
@@ -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
@@ -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
+28
View File
@@ -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());
}
+418
View File
@@ -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<Option<LocalContext>> = Mutex::new(None);
#[derive(Clone)]
struct LocalContext {
generation: u64,
tracks: Vec<LocalTrack>,
}
#[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<QuickTrack>,
}
#[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<LocalTrack>,
) -> Option<(QuickViewDoc, Vec<LocalTrack>)> {
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<LocalTrack>) -> 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::<i64>().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());
}
+34 -22
View File
@@ -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<HomeCard> {
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::<u64>()
.map(crate::playlist_qt::is_owned)
.unwrap_or(false),
playlist_following: p.kind == "playlist"
&& p.id
.parse::<u64>()
.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::<u64>()
.map(crate::playlist_qt::is_owned)
.unwrap_or(false),
playlist_following: p.kind == "playlist"
&& p.id
.parse::<u64>()
.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()
}
+8
View File
@@ -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())
}
}
+3
View File
@@ -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;