feat(qt): add album quick view and queue focus controls
cx
This commit is contained in:
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 "В этом альбоме нет треков."
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user