merge: integrate artist library fixes into release branch

cx
This commit is contained in:
vicrodh committed 2026-09-04 14:04:10 -06:00
commit c7cee95ac2
31 files changed
+1816 -96

No files matched your search

+4 -4
View File
@@ -106,13 +106,13 @@ jobs:
foreach ($f in "qbz.exe", "platforms\qwindows.dll", "qml\QtQuick\Controls\Basic\qmldir", "imageformats\qsvg.dll", "LICENSE.txt") {
if (-not (Test-Path "$root\$f")) { throw "installed layout is missing $f" }
}
foreach ($protocol in "qbz", "qobuzapp") {
foreach ($protocol in "qbz") {
if (-not (Test-Path "HKCU:\Software\Classes\$protocol")) { throw "$protocol protocol key missing" }
}
if (-not (Test-Path "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\QBZ.lnk")) { throw "Start-menu shortcut missing" }
$uninstall = Start-Process msiexec.exe -ArgumentList "/x `"$msi`" /qn /norestart" -Wait -PassThru
if ($uninstall.ExitCode -ne 0) { throw "synthetic MSI uninstall failed: $($uninstall.ExitCode)" }
foreach ($protocol in "qbz", "qobuzapp") {
foreach ($protocol in "qbz") {
if (Test-Path "HKCU:\Software\Classes\$protocol") { throw "$protocol protocol key survived uninstall" }
}
@@ -418,7 +418,7 @@ jobs:
foreach ($f in "qbz.exe","platforms\qwindows.dll","qml\QtQuick\Controls\Basic","imageformats\qsvg.dll","LICENSE.txt") {
if (-not (Test-Path "$root\$f")) { Write-Host "::error::installed layout is missing $f"; exit 1 }
}
foreach ($protocol in "qbz", "qobuzapp") {
foreach ($protocol in "qbz") {
if (-not (Test-Path "HKCU:\Software\Classes\$protocol")) { Write-Host "::error::$protocol protocol key missing"; exit 1 }
}
if (-not (Test-Path "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\QBZ.lnk")) { Write-Host "::error::Start-menu shortcut missing"; exit 1 }
@@ -429,7 +429,7 @@ jobs:
$u = Start-Process msiexec -ArgumentList "/x `"$msi`" /qn /l*v uninstall.log" -Wait -PassThru
if ($u.ExitCode -ne 0) { Get-Content uninstall.log -Tail 60; Write-Host "::error::msiexec /x exit $($u.ExitCode)"; exit 1 }
if (Test-Path "$root\qbz.exe") { Write-Host "::error::uninstall left $root\qbz.exe"; exit 1 }
foreach ($protocol in "qbz", "qobuzapp") {
foreach ($protocol in "qbz") {
if (Test-Path "HKCU:\Software\Classes\$protocol") { Write-Host "::error::uninstall left the $protocol protocol key"; exit 1 }
}
Write-Host "MSI install/launch/uninstall OK"
+1
View File
@@ -3877,6 +3877,7 @@ mod tests {
image: Default::default(),
label: None,
genre: None,
artist: None,
});
t
}
@@ -6711,3 +6711,21 @@ msgstr "Album-Cover in der Titelliste der Bibliothek anzeigen"
msgid "Display the album cover thumbnail between the track number and title. Off by default."
msgstr "Zeigt die Cover-Miniatur zwischen Titelnummer und Titel an. Standardmäßig deaktiviert."
msgid "Play random"
msgstr "Zufällig abspielen"
msgid "Play selected"
msgstr "Auswahl abspielen"
msgid "Cancel selection"
msgstr "Auswahl aufheben"
msgid "QOBUZ LINKS"
msgstr "QOBUZ-LINKS"
msgid "Open Qobuz links in QBZ"
msgstr "Qobuz-Links in QBZ öffnen"
msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here."
msgstr "Macht QBZ zum Handler für qobuzapp://-Links aus dem Browser. Ausgeschaltet behält die offizielle Qobuz-App sie; QBZ-eigene qbz://-Links öffnen immer hier."
@@ -2298,3 +2298,21 @@ msgstr "Show album artwork in Library track list"
msgid "Display the album cover thumbnail between the track number and title. Off by default."
msgstr "Display the album cover thumbnail between the track number and title. Off by default."
msgid "Play random"
msgstr "Play random"
msgid "Play selected"
msgstr "Play selected"
msgid "Cancel selection"
msgstr "Cancel selection"
msgid "QOBUZ LINKS"
msgstr "QOBUZ LINKS"
msgid "Open Qobuz links in QBZ"
msgstr "Open Qobuz links in QBZ"
msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here."
msgstr "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here."
@@ -6717,3 +6717,21 @@ msgstr "Mostrar carátulas de álbum en la lista de pistas de la Biblioteca"
msgid "Display the album cover thumbnail between the track number and title. Off by default."
msgstr "Muestra la miniatura de la carátula entre el número y el título de la pista. Desactivado por defecto."
msgid "Play random"
msgstr "Reproducir al azar"
msgid "Play selected"
msgstr "Reproducir selección"
msgid "Cancel selection"
msgstr "Cancelar selección"
msgid "QOBUZ LINKS"
msgstr "ENLACES DE QOBUZ"
msgid "Open Qobuz links in QBZ"
msgstr "Abrir enlaces de Qobuz en QBZ"
msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here."
msgstr "Hace que QBZ maneje los enlaces qobuzapp:// que salen de tu navegador. Apagado, la app oficial de Qobuz se queda con ellos; los enlaces qbz:// de QBZ siempre abren aquí."
@@ -6709,3 +6709,21 @@ msgstr "Afficher les pochettes dans la liste des pistes de la Bibliothèque"
msgid "Display the album cover thumbnail between the track number and title. Off by default."
msgstr "Affiche la miniature de la pochette entre le numéro et le titre de la piste. Désactivé par défaut."
msgid "Play random"
msgstr "Lecture aléatoire"
msgid "Play selected"
msgstr "Lire la sélection"
msgid "Cancel selection"
msgstr "Annuler la sélection"
msgid "QOBUZ LINKS"
msgstr "LIENS QOBUZ"
msgid "Open Qobuz links in QBZ"
msgstr "Ouvrir les liens Qobuz dans QBZ"
msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here."
msgstr "Fait de QBZ le gestionnaire des liens qobuzapp:// venant du navigateur. Désactivé, l'application Qobuz officielle les conserve ; les liens qbz:// de QBZ s'ouvrent toujours ici."
@@ -6692,3 +6692,21 @@ msgstr "ライブラリのトラックリストにアルバムアートワーク
msgid "Display the album cover thumbnail between the track number and title. Off by default."
msgstr "トラック番号とタイトルの間にアルバムカバーのサムネイルを表示します。デフォルトではオフです。"
msgid "Play random"
msgstr "ランダム再生"
msgid "Play selected"
msgstr "選択項目を再生"
msgid "Cancel selection"
msgstr "選択を解除"
msgid "QOBUZ LINKS"
msgstr "QOBUZ リンク"
msgid "Open Qobuz links in QBZ"
msgstr "Qobuz のリンクを QBZ で開く"
msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here."
msgstr "ブラウザーからの qobuzapp:// リンクを QBZ で処理します。オフの場合は公式の Qobuz アプリが処理します。QBZ 独自の qbz:// リンクは常にここで開きます。"
@@ -6699,3 +6699,21 @@ msgstr "Albumhoezen tonen in de nummerlijst van de bibliotheek"
msgid "Display the album cover thumbnail between the track number and title. Off by default."
msgstr "Toon de albumhoesminiatuur tussen het nummer en de titel. Standaard uit."
msgid "Play random"
msgstr "Willekeurig afspelen"
msgid "Play selected"
msgstr "Selectie afspelen"
msgid "Cancel selection"
msgstr "Selectie annuleren"
msgid "QOBUZ LINKS"
msgstr "QOBUZ-LINKS"
msgid "Open Qobuz links in QBZ"
msgstr "Qobuz-links openen in QBZ"
msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here."
msgstr "Maakt QBZ de handler voor qobuzapp://-links uit je browser. Uit: de officiële Qobuz-app houdt ze; QBZ's eigen qbz://-links openen altijd hier."
@@ -6709,3 +6709,21 @@ msgstr "Mostrar capas de álbum na lista de faixas da Biblioteca"
msgid "Display the album cover thumbnail between the track number and title. Off by default."
msgstr "Exibe a miniatura da capa entre o número e o título da faixa. Desligado por padrão."
msgid "Play random"
msgstr "Reproduzir aleatório"
msgid "Play selected"
msgstr "Reproduzir seleção"
msgid "Cancel selection"
msgstr "Cancelar seleção"
msgid "QOBUZ LINKS"
msgstr "LINKS DO QOBUZ"
msgid "Open Qobuz links in QBZ"
msgstr "Abrir links do Qobuz no QBZ"
msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here."
msgstr "Torna o QBZ o manipulador dos links qobuzapp:// vindos do navegador. Desligado, o app oficial do Qobuz fica com eles; os links qbz:// do QBZ sempre abrem aqui."
@@ -6730,3 +6730,21 @@ msgstr "Показывать обложки альбомов в списке т
msgid "Display the album cover thumbnail between the track number and title. Off by default."
msgstr "Показывает миниатюру обложки между номером и названием трека. Отключено по умолчанию."
msgid "Play random"
msgstr "Воспроизвести случайный"
msgid "Play selected"
msgstr "Воспроизвести выбранное"
msgid "Cancel selection"
msgstr "Отменить выбор"
msgid "QOBUZ LINKS"
msgstr "ССЫЛКИ QOBUZ"
msgid "Open Qobuz links in QBZ"
msgstr "Открывать ссылки Qobuz в QBZ"
msgid "Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here."
msgstr "Делает QBZ обработчиком ссылок qobuzapp:// из браузера. Если выключено, их оставляет официальное приложение Qobuz; собственные ссылки qbz:// всегда открываются здесь."
+7
View File
@@ -672,6 +672,13 @@ pub struct AlbumSummary {
pub label: Option<Label>,
/// Genre (when returned, e.g. on favorites track album objects).
pub genre: Option<Genre>,
/// The album's MAIN artist, when the track's embedded album carries it
/// (search results and favourites do — captured
/// `qobuz-api/search-results-response.json`). Distinct from the track's
/// `performer`, which is who is credited on THAT track; the artist page's
/// "In library" tab admits a track through either.
#[serde(default)]
pub artist: Option<Artist>,
}
/// Album model
@@ -1594,6 +1594,7 @@ mod tests {
image: Default::default(),
label: None,
genre: None,
artist: None,
}),
..Default::default()
}
@@ -2011,6 +2012,7 @@ mod tests {
image: Default::default(),
label: None,
genre: None,
artist: None,
}),
..Default::default()
}
@@ -2948,6 +2950,7 @@ mod entitlement_index_tests {
image: Default::default(),
label: None,
genre: None,
artist: None,
}),
..Default::default()
}],
@@ -428,6 +428,7 @@ mod tests {
image: ImageSet::default(),
label: None,
genre: None,
artist: None,
}
}
+1
View File
@@ -578,6 +578,7 @@ fn main() {
"qml/controls/ScopePanel.qml",
"qml/controls/QbzSegToggle.qml",
"qml/controls/QbzSelect.qml",
"qml/controls/QbzSplitButton.qml",
"qml/controls/QbzSlider.qml",
"qml/controls/QbzTabBar.qml",
"qml/controls/QbzTextArea.qml",
+6 -1
View File
@@ -198,6 +198,8 @@ Rectangle {
// there is no host-painted tick left in the tree, and a new one is a
// regression, not a shortcut.
property bool selectMode: false
/// Park the select indicator bottom-right of the art instead of top-right.
property bool selectMarkBottom: false
property bool selected: false
signal selectToggled()
@@ -551,7 +553,10 @@ Rectangle {
Rectangle {
visible: root.selectMode
x: parent.width - width - 8
y: 8
// Top-right by default (the Library bulk mode); the artist
// page's album-section "Play selected" parks it bottom-right
// inside the art (`selectMarkBottom`).
y: root.selectMarkBottom ? parent.height - height - 8 : 8
width: 24
height: 24
radius: 12
+26 -2
View File
@@ -67,6 +67,14 @@ Rectangle {
/// Host hook: clear the filter when the field closes itself (Esc / X).
property alias searchOpen: searchBox.open
/// Host-owned controls that float between the tab strip and the
/// magnifier — the artist page mounts its "In library" purchases /
/// favourites switches here while that tab is active (#737 round). The
/// slot sizes to its children; empty, it costs nothing and the strip
/// keeps its full width. Same footprint rule as the search slot: the
/// tabs are clipped, never reflowed, when something lives here.
property alias trailing: trailingSlot.data
height: 44
color: root.barBg
topLeftRadius: root.topRadius
@@ -99,6 +107,7 @@ Rectangle {
y: 0
width: Math.max(0, root.width - root.padH
- (searchSlot.visible ? searchSlot.width + 18 : 0)
- (trailingSlot.width > 0 ? trailingSlot.width + 18 : 0)
- x)
height: parent.height
clip: true
@@ -152,6 +161,21 @@ Rectangle {
}
}
// Trailing slot (see `trailing`): sized by its children, parked at the
// FAR right — past the magnifier, not before it: the search field grows
// LEFT over the tabs when it opens, so anything left of the magnifier is
// covered while the user types, and a control that resets the page's
// state must stay reachable exactly then. Hidden items still count toward
// `childrenRect`, so a host that toggles its controls should toggle their
// `visible` AND leave the slot empty-width (`width: visible ? n : 0`).
Item {
id: trailingSlot
width: childrenRect.width
height: parent.height
anchors.right: parent.right
anchors.rightMargin: root.padH
}
// Search slot — keeps the CLOSED footprint; the field itself grows left
// over the tabs, so nothing in the bar reflows when it opens.
Item {
@@ -159,8 +183,8 @@ Rectangle {
visible: root.showSearch
width: searchBox.width
height: searchBox.height
anchors.right: parent.right
anchors.rightMargin: root.padH
anchors.right: trailingSlot.width > 0 ? trailingSlot.left : parent.right
anchors.rightMargin: trailingSlot.width > 0 ? 18 : root.padH
anchors.verticalCenter: parent.verticalCenter
QbzLineEdit {
@@ -0,0 +1,170 @@
// QbzSplitButton — a toolbar button that LOOKS like a select: the body runs
// the primary action, the chevron cell opens a menu of alternatives.
//
// +--------------+-+
// | Play all |v|
// +--------------+-+
//
// Sized and painted like `QbzSelect { sm: true }` so it sits flush beside one
// in a section header (30 tall, r6, elevated fill, no outline, 12px label,
// 14px chevron): the album-section "Play all / Play random / Play selected"
// control on the artist page is the first host. The menu is the shared
// controls/QbzContextMenu opened below-right of the whole control, the
// QbzSelect / sort-menu convention.
//
// `menuItems` is `[{ label, action, icon? }]`; a pick closes the menu and
// emits `picked(action)`. The body emits `clicked()` and never opens the
// menu — the two halves are separate hit zones with their own hover fill,
// and the 1px hairline between them is the split.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property string label: ""
property var menuItems: []
property int menuWidth: 180
property bool btnEnabled: true
signal clicked()
signal picked(string action)
QbzTheme { id: theme }
readonly property int chevronWidth: 24
height: 30
radius: 6
implicitWidth: bodyLabel.implicitWidth + 24 + root.chevronWidth
width: implicitWidth
// Same resting fill as QbzSelect sm (translucent under the dynamic
// background); hover is painted per zone below.
color: theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated
opacity: root.btnEnabled ? 1.0 : 0.4
// --- Body: the primary action ------------------------------------------
Rectangle {
id: body
x: 0
y: 0
width: parent.width - root.chevronWidth
height: parent.height
radius: 6
color: bodyArea.containsMouse && root.btnEnabled ? theme.surfaceHover : "transparent"
// Square the inner corners so the hover fill meets the hairline.
Rectangle {
x: parent.width - 6
y: 0
width: 6
height: parent.height
color: parent.color
}
Text {
id: bodyLabel
anchors.centerIn: parent
text: root.label
color: theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: bodyArea
anchors.fill: parent
enabled: root.btnEnabled
hoverEnabled: true
cursorShape: root.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.clicked()
}
}
// --- The split ----------------------------------------------------------
Rectangle {
x: parent.width - root.chevronWidth
y: 7
width: 1
height: parent.height - 14
color: theme.borderSubtle
}
// --- Chevron: the menu --------------------------------------------------
Rectangle {
id: chevron
x: parent.width - root.chevronWidth
y: 0
width: root.chevronWidth
height: parent.height
radius: 6
color: chevronArea.containsMouse && root.btnEnabled ? theme.surfaceHover : "transparent"
Rectangle {
x: 0
y: 0
width: 6
height: parent.height
color: parent.color
}
QbzIcon {
name: "chevron-down"
width: 14
height: 14
anchors.centerIn: parent
tintName: chevronArea.containsMouse ? "textPrimary" : "secondary"
}
MouseArea {
id: chevronArea
anchors.fill: parent
enabled: root.btnEnabled
hoverEnabled: true
cursorShape: root.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: menu.openBelowRight(root)
}
}
QbzContextMenu {
id: menu
menuWidth: root.menuWidth
Repeater {
model: root.menuItems
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: itemArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon {
visible: (modelData.icon || "") !== ""
name: modelData.icon || ""
width: visible ? 15 : 0
height: 15
anchors.verticalCenter: parent.verticalCenter
tintName: "secondary"
}
Text {
height: parent.height
width: parent.width - 23
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: itemArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
menu.close()
root.picked(modelData.action)
}
}
}
}
}
}
@@ -346,4 +346,19 @@ Column {
label: QbzSession.tr("Flatpak socket access", QbzSession.trRev)
description: QbzSession.tr("Flatpak install and the presence isn't showing? Grant access to Discord's IPC socket, then restart QBZ:\nflatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz", QbzSession.trRev)
}
// ========================= QOBUZ LINKS ===============================
// The OS-level claim on the official client's `qobuzapp://` scheme, as a
// choice (link_handler_qt.rs): on Windows the official client is where
// most users still are, so it is opt-IN there; elsewhere it defaults on.
// `qbz://` (our own scheme) is always registered and is not this switch.
GroupHeader { text: QbzSession.tr("QOBUZ LINKS", QbzSession.trRev) }
SettingRow {
label: QbzSession.tr("Open Qobuz links in QBZ", QbzSession.trRev)
description: QbzSession.tr("Make QBZ the handler for qobuzapp:// links from your browser. Off, the official Qobuz app keeps them; QBZ's own qbz:// links always open here.", QbzSession.trRev)
QbzToggle {
checked: root.doc.qobuzLinksEnabled === true
onToggled: function (v) { QbzBridge.settingsBool("qobuz-links", v) }
}
}
}
+462 -57
View File
@@ -283,6 +283,137 @@ Rectangle {
property string activeJumpTab: "popular-tracks"
property string artistTab: "catalog"
// ---- "In library" tab (#737 round, contract §3/§4) ---------------------
// The rows ride INSIDE the artist document (`artist.libraryItems`,
// artist_qt.rs) — favourites ∪ purchases of this artist, one row per
// entity, the purchase row representative and `isFavorite` meaning "also
// hearted". The two switches below are the `Library > Albums / Tracks`
// source switches with THEIR semantics (LibraryView.qml:566-590: neither
// = union, one = that source, both = intersection) but LOCAL state: the
// Library view's own switches must not reach into this page.
property bool libShowPurchases: false
property bool libShowFavorites: false
/// Preview 10 (Popular Tracks previews 5), "Load more" opens the whole
/// list at once — no paging, the rows are already in hand.
readonly property int libPreview: 10
property bool libTracksExpanded: false
readonly property var libItems: {
var src = artist.libraryItems || []
var out = []
for (var i = 0; i < src.length; i++) {
var it = src[i]
var purchased = it.group === "purchases"
var hearted = it.isFavorite === true
var included
if (root.libShowPurchases && root.libShowFavorites) included = purchased && hearted
else if (root.libShowPurchases) included = purchased
else if (root.libShowFavorites) included = hearted
else included = true
if (included) out.push(it)
}
return out
}
readonly property var libTracks: libItems.filter(function (x) { return x.kind === "track" })
readonly property var libAlbums: libItems.filter(function (x) { return x.kind === "album" })
/// The queue a row click / "Play all" builds from: the WHOLE filtered
/// list in render order, not only the revealed preview — a click on row
/// 3 must be followed by rows 4..n exactly like Popular Tracks queues its
/// full list. Handed to `QbzLibrary.libraryPlayVisible` / `libraryPlayAll`
/// (library_qt::play_from_visible), the source-aware seam LibraryView
/// uses — never `QbzPlayer.playArtistTrack`, whose queue is the POPULAR
/// list and which starts it at row 0 for any id it does not hold (#737).
function libTrackIds() {
var ids = []
for (var i = 0; i < root.libTracks.length; i++) ids.push(String(root.libTracks[i].id))
return ids
}
/// The library Albums section's sort — the release buckets' five keys,
/// seeded from the document (`artist.librarySort`, artist_prefs "library")
/// and persisted through the same invokable the buckets use. Sorted here,
/// not in Rust: the rows already live in QML (`sortReleaseCards` reads
/// `title` / `year`, which a FeedItem carries).
property string libSort: "default"
readonly property var libAlbumsSorted: root.sortReleaseCards(root.libAlbums, root.libSort)
function setLibSort(key) {
root.libSort = key
QbzArtist.setSectionSort("library", key)
}
// ---- Album-section play (the split button) -------------------------------
// Every album section — each catalog bucket and the library Albums grid —
// carries a QbzSplitButton right of its sort: the body plays the whole
// section in its SORT order as one queue (`QbzPlayer.playAlbums`), the
// chevron offers Play random (ONE album of the section, replacing the
// queue) and Play selected, which arms a per-section select mode: the
// cards grow a check bottom-right inside the art, the button reads
// "Play selected (n)" and plays the checked albums in sort order, and the
// menu swaps that row for Cancel selection. One section is armed at a
// time (`albumSelectKey` is the bucket's releaseType or "library").
// No queue options (next / later / queue) on purpose.
property string albumSelectKey: ""
property var albumSelected: ({})
readonly property int albumSelectedCount: Object.keys(root.albumSelected).length
function albumSelectToggle(id) {
var m = Object.assign({}, root.albumSelected)
if (m[id]) delete m[id]
else m[id] = true
root.albumSelected = m
}
function albumSelectClear() {
root.albumSelectKey = ""
root.albumSelected = ({})
}
function albumSectionLabel(key) {
return root.albumSelectKey === key
? QbzSession.tr("Play selected", QbzSession.trRev) + " (" + root.albumSelectedCount + ")"
: QbzSession.tr("Play all", QbzSession.trRev)
}
function albumSectionMenu(key) {
var m = [
{ "label": QbzSession.tr("Play all", QbzSession.trRev), "icon": "play-fill", "action": "play-all" },
{ "label": QbzSession.tr("Play random", QbzSession.trRev), "icon": "shuffle", "action": "play-random" }
]
if (root.albumSelectKey === key)
m.push({ "label": QbzSession.tr("Cancel selection", QbzSession.trRev), "icon": "x", "action": "cancel-selection" })
else
m.push({ "label": QbzSession.tr("Play selected", QbzSession.trRev), "icon": "square-check-big", "action": "play-selected" })
return m
}
function albumSectionIds(cards) {
var ids = []
for (var i = 0; i < cards.length; i++) ids.push(String(cards[i].id))
return ids
}
/// `cards` is the section's list in its CURRENT sort order.
function albumSectionAction(key, cards, action) {
var ids = root.albumSectionIds(cards)
if (action === "play-all") {
root.albumSelectClear()
if (ids.length > 0) QbzPlayer.playAlbums(JSON.stringify(ids))
} else if (action === "play-random") {
root.albumSelectClear()
if (ids.length > 0) QbzPlayer.playAlbum(ids[Math.floor(Math.random() * ids.length)])
} else if (action === "play-selected") {
root.albumSelectKey = key
root.albumSelected = ({})
} else if (action === "cancel-selection") {
root.albumSelectClear()
}
}
/// The button body: Play all, or — armed — the checked albums in sort
/// order, then disarm.
function albumSectionPrimary(key, cards) {
if (root.albumSelectKey !== key) {
root.albumSectionAction(key, cards, "play-all")
return
}
var ids = []
for (var i = 0; i < cards.length; i++)
if (root.albumSelected[cards[i].id] === true) ids.push(String(cards[i].id))
root.albumSelectClear()
if (ids.length > 0) QbzPlayer.playAlbums(JSON.stringify(ids))
}
// ---- Header atmosphere (ArtistPageView.slint:120-147) ----------------
// Same three-line rule as AlbumView (the .slint says "same rule as
// AlbumPageView" at :120). The pref is read LIVE off the settings
@@ -684,6 +815,22 @@ Rectangle {
// does not touch them, so the strip must not reshuffle per keystroke.
readonly property var jumpTabs: {
var tabs = []
// The strip is a SCROLL navigator over one vertical page, so it can
// only list what that page currently draws. In library mode that is
// the Tracks and Albums sections that have rows — the catalog set
// stayed up before and "Popular Tracks" scrolled to a section that
// was not there (owner capture, 2026-09-04).
if (root.artistTab === "library") {
// "About" is the strip's back-to-top, and in library mode it is
// also what keeps the strip MOUNTED when the filter yields nothing
// (purchases ∧ favourites on an artist whose purchases are not
// hearted): the bar carries the two switches, so an empty tab set
// would have hidden the only way to reset them.
tabs.push({ "id": "about", "label": QbzSession.tr("About", QbzSession.trRev) })
if (root.libTracks.length > 0) tabs.push({ "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev) })
if (root.libAlbums.length > 0) tabs.push({ "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev) })
return tabs
}
var rawTop = artist.topTracks || []
var rawSections = artist.releaseSections || []
var rawAppears = artist.appearsOn || []
@@ -740,7 +887,7 @@ Rectangle {
var aid = (artist && artist.id) ? artist.id : ""
if (aid !== "" && key === "artist:" + aid)
root.setToggleState("artist", value)
else if (key.indexOf("track:") === 0)
else if (key.indexOf("track:") === 0 || key.indexOf("album:") === 0)
root.setToggleState(key, value)
}
// Header pin, same seam: the overflow-menu row flips `artistPin`
@@ -807,11 +954,34 @@ Rectangle {
syncArtistState()
invalidatePortraitOverride()
dispatchCovers()
// The last owned row just left (a republish after an un-heart): the
// toggle unmounts, so the page must not stay on an empty tab with no
// way back.
if (artistTab === "library" && (artist.libraryCount || 0) === 0)
artistTab = "catalog"
}
// Cover dispatch keys off the raw document (artist.artUrl etc.), so
// re-fire when the parsed value actually changes (same stale race).
// (the raw document drives the dispatch; onArtistChanged above covers it)
onArtistTabChanged: if (artistTab === "library") dispatchLibCovers()
onArtistTabChanged: {
// A selection is a list of THIS mode's rows; it cannot survive the
// switch. The strip re-anchors on the first section the new mode
// draws (catalog keeps its Popular Tracks default).
setMultiSelect(false)
albumSelectClear()
// The first SECTION, not "About" (back-to-top) when a section exists.
var first = ""
for (var i = 0; i < root.jumpTabs.length; i++) {
first = root.jumpTabs[i].id
if (first !== "about") break
}
root.activeJumpTab = artistTab === "catalog" ? "popular-tracks" : first
if (artistTab === "library") {
// Re-arm the warm, served from cache (main.rs warm_library).
QbzArtist.warmLibrary()
dispatchLibCovers()
}
}
// The document is republished several times per page (stories, then each
// MusicBrainz section). Reset per-artist view state ONLY when the id
@@ -835,14 +1005,23 @@ Rectangle {
/// range can run from the last top track into "Appears on" exactly as it
/// looks on screen — `selectedIdsInOrder` already walks them that way.
SelectionModel { id: sel }
/// The track lists a selection can span, in draw order, for the ACTIVE
/// mode: the catalog's Popular Tracks + Appears On, or the library tab's
/// Tracks. `onArtistTabChanged` clears the selection on a switch, so a
/// range never mixes the two.
function selectableLists() {
return root.artistTab === "library" ? [root.libTracks]
: [root.topTracks, root.appearsOn]
}
function toggleSelected(id, mods) {
root.selected = sel.next(root.selected, id,
root.topTracks.concat(root.appearsOn),
var lists = selectableLists()
var all = lists.length > 1 ? lists[0].concat(lists[1]) : lists[0]
root.selected = sel.next(root.selected, id, all,
mods === undefined ? Qt.NoModifier : mods)
}
function selectedIdsInOrder() {
var out = []
var lists = [root.topTracks, root.appearsOn]
var lists = selectableLists()
for (var l = 0; l < lists.length; l++) {
var rows = lists[l]
for (var i = 0; i < rows.length; i++)
@@ -853,7 +1032,7 @@ Rectangle {
function bulkAction(action) {
if (action === "select-all") {
var m = {}
var lists = [root.topTracks, root.appearsOn]
var lists = selectableLists()
for (var l = 0; l < lists.length; l++)
for (var i = 0; i < lists[l].length; i++) m[lists[l][i].id] = true
root.selected = m
@@ -881,6 +1060,14 @@ Rectangle {
return
loadedArtistId = id
setMultiSelect(false)
// A fresh artist opens on its catalog with the library tab's
// preview and switches at rest.
artistTab = "catalog"
libTracksExpanded = false
libShowPurchases = false
libShowFavorites = false
libSort = artist.librarySort || "default"
albumSelectClear()
// Slint opens a fresh artist on Network, or on Magazine when
// MusicBrainz is off (an empty Network tab is worse than none).
netTab = (artist.network && artist.network.mbAvailable) ? "network" : "magazine"
@@ -909,7 +1096,7 @@ Rectangle {
}
function dispatchLibCovers() {
var items = libraryTab.libItems || []
var items = root.libItems || []
var urls = []
for (var i = 0; i < items.length; i++) if (items[i].imageUrl) urls.push(items[i].imageUrl)
dispatchArtwork(urls)
@@ -1021,13 +1208,21 @@ Rectangle {
function scrollToSection(id) {
root.activeJumpTab = id
for (var i = 0; i < sectionAnchors.children.length; i++) {
var c = sectionAnchors.children[i]
// Anchors resolve against the column of the ACTIVE mode: the catalog
// and library columns are siblings in the page, and a library tab
// resolved against the hidden catalog column would scroll to that
// column's coordinates.
var host = root.artistTab === "library" ? libraryTab : sectionAnchors
for (var i = 0; i < host.children.length; i++) {
var c = host.children[i]
if (c.anchorId === id) {
flick.contentY = sectionAnchors.y + c.y - 48
flick.contentY = host.y + c.y - 48
return
}
}
// "About" is back-to-top: the library column carries no such anchor
// (the bio lives in the header), so it lands on the page top.
if (id === "about") flick.contentY = 0
}
@@ -1047,6 +1242,17 @@ Rectangle {
property var row: ({})
property int rowIndex: 0
property bool showAlbum: true
/// Where a play on this row goes — `function (id)`. Unset, the row
/// plays through `QbzPlayer.playArtistTrack`, whose queue is the
/// POPULAR list (artist_qt TOP_QUEUE). The "In library" tab passes
/// its own handler because a library row is NOT in that queue and
/// `top_queue` starts the popular list at row 0 for any id it does
/// not hold — issue #737. The row never asks which tab it is in.
property var playHandler: null
function play() {
if (popRow.playHandler) popRow.playHandler(String(popRow.row.id))
else QbzPlayer.playArtistTrack(popRow.row.id)
}
// Multi-select (the view's bulk bar): in select mode the whole row
// body is the toggle and the leading cell swaps to a round checkbox
// (TrackRow.slint:173-177, 392-417).
@@ -1248,7 +1454,7 @@ Rectangle {
anchors.fill: parent
enabled: !popRow.selectMode && !popRow.pulledDead
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlayer.playArtistTrack(row.id)
onClicked: popRow.play()
}
}
// Title + artist.
@@ -1539,7 +1745,7 @@ Rectangle {
}
onPicked: function (a) {
var id = popRow.row.id
if (a === "play") QbzPlayer.playArtistTrack(id)
if (a === "play") popRow.play()
else if (a === "next") QbzPlayer.enqueueTrack(id, "next")
else if (a === "later") QbzPlayer.enqueueTrack(id, "later")
else if (a === "queue") QbzPlayer.enqueueTrack(id, "queue")
@@ -1579,7 +1785,7 @@ Rectangle {
// toast, no error. The right-press area below is untouched, so the
// menu still opens.
onDoubleClicked: if (!popRow.selectMode && !popRow.pulledDead)
QbzPlayer.playArtistTrack(row.id)
popRow.play()
onClicked: {
if (popRow.selectMode) popRow.toggleSelect(mouse.modifiers)
else mouse.accepted = false
@@ -1833,7 +2039,7 @@ Rectangle {
width: parent.width
spacing: 12
Text {
width: parent.width - seeAll.width - sortBtn.width - 24
width: parent.width - seeAll.width - sortBtn.width - playBtn.width - 36
anchors.verticalCenter: parent.verticalCenter
text: section.title
color: theme.textPrimary
@@ -1945,6 +2151,21 @@ Rectangle {
: i === 3 ? "title-asc" : i === 4 ? "title-desc" : "default")
}
}
// Play all / Play random / Play selected over THIS bucket, in
// its sort order (`section.cards` is already sorted — Rust sorts
// the bucket and the overlay merge re-sorts).
QbzSplitButton {
id: playBtn
anchors.verticalCenter: parent.verticalCenter
label: root.albumSectionLabel(relSection.section.releaseType || "")
menuItems: root.albumSectionMenu(relSection.section.releaseType || "")
onClicked: root.albumSectionPrimary(relSection.section.releaseType || "",
relSection.section.cards || [])
onPicked: function (a) {
root.albumSectionAction(relSection.section.releaseType || "",
relSection.section.cards || [], a)
}
}
}
Grid {
@@ -1983,6 +2204,11 @@ Rectangle {
// artist_qt::map_release stamps this the same way it
// stamps the pin; false inverted the first click.
isFavorite: modelData.isFavorite === true
// "Play selected" over this bucket (the split button).
selectMode: root.albumSelectKey === (relSection.section.releaseType || "")
selected: root.albumSelected[modelData.id] === true
selectMarkBottom: true
onSelectToggled: root.albumSelectToggle(modelData.id)
// ---- smooth append (owner, 2026-08-02) ---------------
// "que la aparicion de lo que se cargue, sea smooth" —
@@ -2563,7 +2789,10 @@ Rectangle {
id: topMenuBtn
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: function (mouse) { topMenu.openAtCursor(topMenuBtn, mouse.x, mouse.y) }
onClicked: function (mouse) {
topMenu.forLibrary = false
topMenu.openAtCursor(topMenuBtn, mouse.x, mouse.y)
}
}
}
Item { visible: topTracks.length > 0; width: 1; height: 10 }
@@ -2810,67 +3039,191 @@ Rectangle {
}
// ================= In library tab =============================
// The user's OWN rows for this artist (`root.libItems`, off the
// artist document). Two first-class sections — Tracks modelled on
// Popular Tracks (header action cluster, 10-row preview, Load
// more) and the Albums grid — each carrying an `anchorId` so the
// JUMP TO strip resolves against THIS column in library mode.
Column {
id: libraryTab
visible: root.artistTab === "library" && !QbzArtist.artistLoading
width: page.bodyWidth
spacing: 0
readonly property var libItems: {
var out = []
var feed = libraryFeed()
for (var i = 0; i < feed.length; i++) {
if (feed[i].artistId === artist.id && (feed[i].kind === "track" || feed[i].kind === "album"))
out.push(feed[i])
}
return out
}
readonly property var libAlbums: libItems.filter(function (x) { return x.kind === "album" })
readonly property var libTracks: libItems.filter(function (x) { return x.kind === "track" })
Text {
visible: libraryTab.libTracks.length > 0
text: QbzSession.tr("Tracks", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
// Same bulk bar as the catalog column: `bulkAction` routes on
// the active mode's lists (`selectableLists`).
QbzMultiSelectBar {
visible: root.multiSelect
width: parent.width
selectedCount: root.selectedCount
actions: [
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
]
onAction: function (id) { root.bulkAction(id) }
}
Item { visible: libraryTab.libTracks.length > 0; width: 1; height: 10 }
// --- Tracks ---------------------------------------------
// Header + action cluster: the Popular Tracks row above,
// with the actions re-pointed at the library list — Play all
// / Shuffle all through `libraryPlayAll`, the ⋯ menu shared
// with Popular Tracks (`topMenu.forLibrary`).
Row {
property string anchorId: "tracks"
visible: root.libTracks.length > 0
width: parent.width
spacing: 12
Text {
width: parent.width - 44 - 32 - 32 - 3 * 12
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Tracks", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
QbzCircleAction {
primary: true
name: "play-fill"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzLibrary.libraryPlayAll(JSON.stringify(root.libTrackIds()), false)
}
QbzCircleAction {
name: "square-check-big"
active: root.multiSelect
btnEnabled: root.libTracks.length > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: root.setMultiSelect(!root.multiSelect)
}
QbzCircleAction {
id: libMenuBtn
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: function (mouse) {
topMenu.forLibrary = true
topMenu.openAtCursor(libMenuBtn, mouse.x, mouse.y)
}
}
}
Item { visible: root.libTracks.length > 0; width: 1; height: 10 }
Repeater {
model: libraryTab.libTracks
// Numeric model: only the revealed rows are BUILT
// (declaring is building — the Popular Tracks expander's
// rule, feedback_qml_hidden_is_not_unbuilt).
model: root.libTracksExpanded ? root.libTracks.length
: Math.min(root.libTracks.length, root.libPreview)
delegate: PopularTrackRow {
readonly property var item: root.libTracks[index] || ({})
property bool revealed: false
// A cleared heart takes the row out — visually now,
// for real when the document republishes (the heart
// settles, `artist_qt::apply_favorite_change`
// recomputes the rows). A PURCHASE that loses its
// heart stays: it is still library (contract §3).
readonly property bool leaving:
!root.toggleState("track:" + item.id, item.isFavorite === true)
&& item.group !== "purchases"
height: 50
opacity: (revealed && !leaving) ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
Component.onCompleted: revealed = true
row: ({
"id": modelData.id, "number": index + 1, "title": modelData.title,
"artist": modelData.artist, "artistId": modelData.artistId,
"album": modelData.album, "albumId": modelData.albumId,
"duration": modelData.duration, "qualityTier": modelData.qualityTier,
"explicit": modelData.explicit, "artUrl": modelData.imageUrl,
"isFavorite": modelData.isFavorite,
"id": item.id, "number": index + 1, "title": item.title,
"artist": item.artist, "artistId": item.artistId,
"album": item.album, "albumId": item.albumId,
"duration": item.duration, "qualityTier": item.qualityTier,
"explicit": item.explicit, "artUrl": item.imageUrl,
"isFavorite": item.isFavorite === true,
"qobuzUnavailable": item.qobuzUnavailable === true,
"cacheStatus": item.cacheStatus || 0,
})
rowIndex: index
showAlbum: true
selectMode: root.multiSelect
checked: root.selected[row.id] === true
playHandler: function (id) {
QbzLibrary.libraryPlayVisible(JSON.stringify(root.libTrackIds()), id)
}
onToggleSelect: function (mods) { root.toggleSelected(row.id, mods) }
}
}
Item { visible: libraryTab.libAlbums.length > 0; width: 1; height: 24 }
Text {
visible: libraryTab.libAlbums.length > 0
text: QbzSession.tr("Albums", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
Item { visible: root.libTracks.length > root.libPreview; width: 1; height: 4 }
QbzLoadMore {
visible: root.libTracks.length > root.libPreview
width: parent.width
label: root.libTracksExpanded ? QbzSession.tr("View less", QbzSession.trRev) : QbzSession.tr("Load more", QbzSession.trRev)
onClicked: root.libTracksExpanded = !root.libTracksExpanded
}
Item { visible: libraryTab.libAlbums.length > 0; width: 1; height: 10 }
// --- Albums ---------------------------------------------
Item { visible: root.libAlbums.length > 0 && root.libTracks.length > 0; width: 1; height: 24 }
// Header: title, the release buckets' sort select, and the
// split play button — the catalog section header's cluster
// minus "See discography" (there is nothing to page here).
Row {
property string anchorId: "albums"
visible: root.libAlbums.length > 0
width: parent.width
spacing: 12
Text {
width: parent.width - libSortBtn.width - libPlayBtn.width - 24
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Albums", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
QbzSelect {
id: libSortBtn
anchors.verticalCenter: parent.verticalCenter
sm: true
menuWidth: 150
options: [QbzSession.tr("Default", QbzSession.trRev),
QbzSession.tr("Newest", QbzSession.trRev),
QbzSession.tr("Oldest", QbzSession.trRev),
QbzSession.tr("A–Z", QbzSession.trRev),
QbzSession.tr("Z–A", QbzSession.trRev)]
currentIndex: {
var s = root.libSort || "default"
return s === "newest" ? 1 : s === "oldest" ? 2
: s === "title-asc" ? 3 : s === "title-desc" ? 4 : 0
}
onSelected: function (i) {
root.setLibSort(i === 1 ? "newest" : i === 2 ? "oldest"
: i === 3 ? "title-asc" : i === 4 ? "title-desc" : "default")
}
}
QbzSplitButton {
id: libPlayBtn
anchors.verticalCenter: parent.verticalCenter
label: root.albumSectionLabel("library")
menuItems: root.albumSectionMenu("library")
onClicked: root.albumSectionPrimary("library", root.libAlbumsSorted)
onPicked: function (a) { root.albumSectionAction("library", root.libAlbumsSorted, a) }
}
}
Item { visible: root.libAlbums.length > 0; width: 1; height: 10 }
Grid {
visible: libraryTab.libAlbums.length > 0
visible: root.libAlbums.length > 0
width: parent.width
columns: Math.max(1, Math.floor((width + 24) / 224))
columnSpacing: 24
rowSpacing: 24
Repeater {
model: libraryTab.libAlbums
model: root.libAlbumsSorted
delegate: AlbumCard {
// "Play selected" over this section (the split
// button), check bottom-right inside the art.
selectMode: root.albumSelectKey === "library"
selected: root.albumSelected[modelData.id] === true
selectMarkBottom: true
onSelectToggled: root.albumSelectToggle(modelData.id)
albumId: modelData.id
title: modelData.title
artist: modelData.artist
@@ -2886,7 +3239,19 @@ Rectangle {
// row (library_qt `map_album`).
isPinned: modelData.isPinned === true
artworkUrl: modelData.imageUrl || ""
isFavorite: modelData.isFavorite
// The heart goes through the page's optimistic
// store so the card can fade out on the click,
// ahead of the settle that removes the row.
hostFavorite: true
isFavorite: root.toggleState("album:" + modelData.id, modelData.isFavorite === true)
onFavoriteRequested: {
var next = !root.toggleState("album:" + modelData.id, modelData.isFavorite === true)
root.setToggleState("album:" + modelData.id, next)
QbzLibrary.libraryToggleFavorite("album", modelData.id)
}
readonly property bool leaving: !isFavorite && modelData.group !== "purchases"
opacity: leaving ? 0.0 : 1.0
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
}
}
}
@@ -2913,11 +3278,6 @@ Rectangle {
target: flick
}
// Library feed access (the phase-5 document, parsed in LibraryView).
function libraryFeed() {
return JSON.parse(QbzLibrary.libraryJson)
}
// --- Network sidebar (300px, surface-card + 1px left border) ---------
// BOUNDED, not full-height. ArtistPageView.slint:1178-1195:
// natural-top = the live viewport y of the body row + the JUMP TO bar
@@ -2974,6 +3334,31 @@ Rectangle {
tabs: root.jumpTabs
activeTabId: root.activeJumpTab
visible: root.jumpTabs.length > 0
// "In library" source switches — `Library > Albums / Tracks`'s
// purchases / favourites toggles (LibraryToolbar.qml:376-377), on the
// shared QbzToggleButton, floating in the strip ONLY while the library
// tab is active. State is the page's own (`libShowPurchases` /
// `libShowFavorites`), never `QbzLibrary.sessionShow*`.
trailing: Row {
visible: root.artistTab === "library"
width: visible ? implicitWidth : 0
height: 44
spacing: 8
QbzToggleButton {
sm: true
name: "shopping-bag"
active: root.libShowPurchases
anchors.verticalCenter: parent.verticalCenter
onClicked: root.libShowPurchases = !root.libShowPurchases
}
QbzToggleButton {
sm: true
name: "heart"
active: root.libShowFavorites
anchors.verticalCenter: parent.verticalCenter
onClicked: root.libShowFavorites = !root.libShowFavorites
}
}
onTabClicked: function (id) { root.scrollToSection(id) }
onSearchEdited: function (text) { root.searchQuery = text }
}
@@ -3606,6 +3991,10 @@ Rectangle {
QbzContextMenu {
id: topMenu
menuWidth: 224
/// Opened from the library tab's Tracks header: the five rows act on
/// `root.libTrackIds()` through the library seams instead of the
/// TOP_QUEUE ones. Set by whichever ⋯ button opens the menu.
property bool forLibrary: false
Repeater {
model: [
{ "label": QbzSession.tr("Play all next", QbzSession.trRev), "icon": "list-start", "action": "next-all" },
@@ -3643,6 +4032,21 @@ Rectangle {
onClicked: {
topMenu.close()
var a = modelData.action
// Library arm: the same five rows over the
// library tab's list — shuffle through the
// library play-all seam, the three enqueues
// through the bulk funnel with the whole list,
// and the picker over that list (the
// `playlist-all` branch below reads the mode).
if (topMenu.forLibrary && a !== "playlist-all") {
var lib = root.libTrackIds()
if (lib.length === 0) return
if (a === "shuffle-all") QbzLibrary.libraryPlayAll(JSON.stringify(lib), true)
else if (a === "next-all") QbzPlayer.bulkTracksAction(JSON.stringify(lib), "play-next", "artist", String(artist.id || ""))
else if (a === "later-all") QbzPlayer.bulkTracksAction(JSON.stringify(lib), "play-later", "artist", String(artist.id || ""))
else if (a === "queue-all") QbzPlayer.bulkTracksAction(JSON.stringify(lib), "queue", "artist", String(artist.id || ""))
return
}
if (a === "shuffle-all") QbzPlayer.playArtistTop(true)
// "Play all next" INSERTS at the cursor; it does
// not replay the artist. It used to call
@@ -3666,7 +4070,8 @@ Rectangle {
// a Qobuz catalog track (`/artist/page`
// top_tracks), so the catalog arm is correct.
var ids = []
for (var i = 0; i < root.topTracks.length; i++)
if (topMenu.forLibrary) ids = root.libTrackIds()
else for (var i = 0; i < root.topTracks.length; i++)
ids.push(String(root.topTracks[i].id))
if (ids.length > 0)
QbzPlaylistPicker.openForTracks(JSON.stringify(ids))
+11
View File
@@ -97,6 +97,13 @@ pub mod qbz_artist_bridge {
#[qinvokable]
fn image_save_as(self: Pin<&mut QbzArtist>, name: QString, artwork_url: QString);
/// ArtistView "In library" toggle: warm the Library feed if this
/// session never loaded it, else repaint the tab's rows from the
/// cache (`main::warm_library`). `#[auto_cxx_name]` makes the QML
/// name `QbzArtist.warmLibrary`.
#[qinvokable]
fn warm_library(self: Pin<&mut QbzArtist>);
/// ArtistView per-section "Load more" — the next releases page.
#[qinvokable]
fn load_release_section(
@@ -222,6 +229,10 @@ impl qbz_artist_bridge::QbzArtist {
crate::open_artist(artist_id.to_string());
}
pub fn warm_library(self: Pin<&mut Self>) {
crate::warm_library();
}
pub fn load_release_section(
self: Pin<&mut Self>,
artist_id: QString,
+136 -14
View File
@@ -46,6 +46,7 @@ use serde_json::json;
use crate::album_qt::{truncate_words, AlbumCardData, TrackRow};
use crate::home_qt;
use crate::library_qt::FeedItem;
pub const RELEASE_PAGE_SIZE: u32 = 20;
@@ -358,6 +359,22 @@ pub struct ArtistViewData {
pub is_blacklisted: bool,
#[serde(rename = "libraryCount")]
pub library_count: i64,
/// The user's OWN rows for this artist — favourites ∪ purchases, one row
/// per entity (`library_qt::artist_library_items`, contract §3). Published
/// INSIDE this document rather than read off `QbzLibrary.libraryJson` by
/// the view: the view used to `JSON.parse` the whole ~10k-row feed per
/// binding evaluation, and this is also what lets a heart change
/// (`apply_favorite_change`) and a late feed load (`refresh_library_items`)
/// repaint the tab through `publish_patch`, the page's one transport.
#[serde(rename = "libraryItems")]
pub library_items: Vec<FeedItem>,
/// Sort of the "In library" Albums section — the same five keys the
/// release buckets use, persisted by `artist_prefs` under the pseudo
/// release type `library` (`QbzArtist.setSectionSort("library", …)`).
/// The rows are sorted in QML (they live there already); only the seed
/// travels here.
#[serde(rename = "librarySort")]
pub library_sort: String,
#[serde(rename = "topTracks")]
pub top_tracks: Vec<TrackRow>,
#[serde(rename = "appearsOn")]
@@ -835,20 +852,19 @@ fn map_artist(page: PageArtistResponse) -> ArtistViewData {
});
}
// The library COUNT is feed-only by nature (it counts owned items), but the
// follow state must not be: the feed is empty until the Library view is
// opened, so this drew an un-followed heart on artists the user follows —
// and `toggle_favorite` then read the same false and re-added the follow.
// `library_qt::is_favorite` checks the favourite-id cache first.
let library_count = crate::library_qt::with_library(|d| {
d.feed
.iter()
.filter(|i| {
(i.kind == "album" || i.kind == "track") && i.artist_id == page.id.to_string()
})
.count() as i64
})
.unwrap_or(0);
// The library rows are feed-only by nature (they ARE the owned items), but
// the follow state must not be: the feed is empty until the Library view
// is opened, so this drew an un-followed heart on artists the user follows
// — and `toggle_favorite` then read the same false and re-added the
// follow. `library_qt::is_favorite` checks the favourite-id cache first.
//
// The feed's coldness is handled one level up: `main::open_artist` warms
// it in the background and `refresh_library_items` repaints this pair
// when it lands, so a fresh session (or a fresh account) no longer opens
// every artist with an invisible "In library" toggle.
let library_items = crate::library_qt::artist_library_items(&page.id.to_string());
let library_count = library_items.len() as i64;
log_library_membership(&page.id.to_string(), &library_items);
let is_following = crate::library_qt::is_favorite("artist", &page.id.to_string());
ArtistViewData {
@@ -870,6 +886,8 @@ fn map_artist(page: PageArtistResponse) -> ArtistViewData {
is_pinned: crate::sidebar_qt::is_pinned("artist", &page.id.to_string()),
is_blacklisted: crate::artist_blacklist::is_blacklisted(page.id),
library_count,
library_items,
library_sort: crate::artist_prefs::get_sort("library"),
top_tracks,
appears_on,
last_release,
@@ -1097,6 +1115,93 @@ pub(crate) fn stash_is_for(artist_id: &str) -> bool {
}
}
/// `(generation, artist id)` of the page on screen, or `None` when no artist
/// document is stashed.
fn stashed_page() -> Option<(u64, String)> {
let guard = ARTIST_DOC.lock().ok()?;
let (generation, doc) = guard.as_ref()?;
let id = doc.get("id").and_then(|v| v.as_str())?.to_string();
Some((*generation, id))
}
// ==================== "In library" tab: live membership ====================
//
// The tab's rows travel inside the artist document (`ArtistViewData::
// library_items`). Two things move them after the page is built, and both
// come through `publish_patch` so the generation guard applies:
//
// * the Library feed landing AFTER the page opened (`main::open_artist`
// warms it; `main::reload_library` calls `refresh_library_items` when it
// is published), and
// * a heart settling on an album / track (`main::emit_library_favorite` ->
// `apply_favorite_change`), after `library_qt::reconcile_qobuz_favorite`
// has inserted or removed the feed row.
//
// Both recompute the WHOLE list off the feed rather than patching one row:
// the rule (contract §3 — purchases ∪ favourites, one row per entity, the
// purchase row representative) is easier to keep in one place than to
// replay incrementally, and the scan is a single pass over the feed.
/// Recompute the open page's library rows from the cached feed and republish
/// `libraryItems` + `libraryCount`. No-op without an artist on screen or
/// before the Library has ever loaded (the count then stays at what the page
/// was built with — 0 on a cold session — until the feed lands and this runs
/// again).
pub(crate) fn refresh_library_items() {
let Some((generation, artist_id)) = stashed_page() else {
return;
};
if crate::library_qt::with_library(|_| ()).is_none() {
return;
}
let items = crate::library_qt::artist_library_items(&artist_id);
log_library_membership(&artist_id, &items);
let count = items.len() as i64;
let items_json = match serde_json::to_value(&items) {
Ok(v) => v,
Err(e) => {
log::warn!("[qbz-qt] artist library rows serialize failed: {e}");
return;
}
};
publish_patch(generation, move |doc| {
if let Some(obj) = doc.as_object_mut() {
obj.insert("libraryItems".into(), items_json);
obj.insert("libraryCount".into(), json!(count));
}
});
}
/// A heart settled on an album or track: the tab's membership may have
/// changed (a new favourite joins, a cleared one leaves unless it is also a
/// purchase). `main::emit_library_favorite` is the one door, the same fan-out
/// `home_qt` / `search_qt` / `recommendations_qt` hang off; the artist page was
/// listed there as "NOT yet covered".
pub(crate) fn apply_favorite_change(kind: &str, _id: &str, _value: bool) {
if kind == "track" || kind == "album" {
refresh_library_items();
}
}
/// Contract §5 discriminator, kept as `debug` once the round closed: how the
/// open artist's owned rows split by kind and by which id matched. Read it
/// with `RUST_LOG=qbz_qt=debug` when an artist's tab looks short.
fn log_library_membership(artist_id: &str, items: &[FeedItem]) {
if !log::log_enabled!(log::Level::Debug) {
return;
}
let tracks = items.iter().filter(|i| i.kind == "track").count();
let albums = items.len() - tracks;
let by_album_artist = items
.iter()
.filter(|i| i.kind == "track" && i.artist_id != artist_id)
.count();
log::debug!(
"[qbz-qt] artist {artist_id} in-library: {tracks} track(s) ({by_album_artist} via album \
artist), {albums} album(s)"
);
}
/// Artist page per-section sort — the port of `crates/qbz/src/artist.rs:1178-1210`
/// `resort_section` ("Re-sort one release bucket in place […] and persist the
/// choice"), reached from `QbzArtist.setSectionSort` (artist_bridge.rs).
@@ -1830,3 +1935,20 @@ async fn load_mb_discovery(
.collect();
Ok((response.primary_tag, rows))
}
#[cfg(test)]
mod tests {
use super::*;
/// THE #737 CONTRACT: `top_queue` answers for the POPULAR list only. An
/// id it does not hold does not fail — it starts that list at row 0. So
/// no caller may hand it an id from any other list (the "In library" tab
/// routes through `library_qt::play_from_visible` instead).
#[test]
fn top_queue_starts_at_zero_for_a_foreign_id() {
TOP_QUEUE.lock().unwrap().clear();
let (queue, start) = top_queue(Some(424242));
assert!(queue.is_empty());
assert_eq!(start, 0);
}
}
+2 -2
View File
@@ -16,8 +16,8 @@ pub(crate) fn is_qobuz_link(arg: &str) -> bool {
|| arg.starts_with("http://open.qobuz.com/")
}
/// `qbz://` is OUR scheme, registered by the Windows MSI beside
/// `qobuzapp://`. It is rewritten rather than taught downstream: the resolver
/// `qbz://` is OUR scheme, registered by the Windows MSI unconditionally
/// (`qobuzapp://` is a runtime opt-in there — link_handler_qt). It is rewritten rather than taught downstream: the resolver
/// (`qbz-music-link`) knows `qobuzapp://` and the Qobuz web shapes and nothing
/// else, so an accepted `qbz://` would travel the whole way and then be
/// classified "unsupported or invalid music link" -- which is exactly what a
+261 -1
View File
@@ -64,6 +64,14 @@ pub struct FeedItem {
pub album: String,
#[serde(rename = "albumId")]
pub album_id: String,
/// Track rows only: the id of the ALBUM's main artist, beside the
/// performer id in `artist_id`. A favourited track is a member of an
/// artist's "In library" tab through EITHER — `artist_id` alone is the
/// track's performer, which on features, interpreters and re-credited
/// remasters is not the artist whose page is open (issue #737 round,
/// contract §5). Skipped when empty so album/artist rows pay nothing.
#[serde(default, rename = "albumArtistId", skip_serializing_if = "String::is_empty")]
pub album_artist_id: String,
#[serde(rename = "imageUrl")]
pub image_url: String,
/// Stable artwork key (`{kind}:{id}`) — library_all.rs `feed_key`.
@@ -459,6 +467,12 @@ fn map_track(track: Track) -> FeedItem {
.and_then(|a| a.genre.as_ref())
.map(|g| g.name.clone())
.unwrap_or_default();
let album_artist_id = track
.album
.as_ref()
.and_then(|a| a.artist.as_ref())
.map(|x| x.id.to_string())
.unwrap_or_default();
let (artist, artist_id) = track
.performer
.map(|p| (p.name, p.id.to_string()))
@@ -474,6 +488,7 @@ fn map_track(track: Track) -> FeedItem {
artist_id,
album,
album_id,
album_artist_id,
genre,
duration: mmss(track.duration),
quality_tier: home_qt::quality_tier_from_depth(track.maximum_bit_depth).to_string(),
@@ -1330,7 +1345,7 @@ async fn fetch_purchases(
.into_iter()
.map(|t| {
// Track row art: full variant (best()) — the thumbnail down-tier was reverted after the 2026-08-15 owner smoke (contract 04 §3).
let (img, alb, aid) = t
let (img, alb, aid, album_artist_id) = t
.album
.as_ref()
.map(|a| {
@@ -1338,6 +1353,7 @@ async fn fetch_purchases(
a.image.best().cloned().unwrap_or_default(),
a.title.clone(),
a.id.clone(),
a.artist.as_ref().map(|x| x.id.to_string()).unwrap_or_default(),
)
})
.unwrap_or_default();
@@ -1354,6 +1370,7 @@ async fn fetch_purchases(
artist_id: t.performer.id.to_string(),
album: alb,
album_id: aid,
album_artist_id,
image_url: img,
quality_tier: tier.into(),
// See `FeedItem::bit_depth` — a purchased TRACK row is a queue
@@ -1698,6 +1715,186 @@ pub(crate) fn set_feed_favorite(kind: &str, id: &str, value: bool) {
});
}
// ---------------------------------------------------------------------------
// Artist page "In library" membership (#737 round, contract §3)
// ---------------------------------------------------------------------------
//
// The artist page lists what the user OWNS of one artist: favourites ∪
// purchases, with `Library > Albums` / `Library > Tracks` semantics — NOT the
// `All` feed's. Local / Plex rows never enter (owner ruling 2026-09-04).
//
// One representative row per `{kind}:{id}` — the purchase row wins so its
// quality badge survives — and `is_favorite` on that row answers "is it ALSO
// hearted", which is what the tab's purchases/favourites switches evaluate
// (LibraryView.qml:566-590: neither = union, one = that source, both =
// intersection). `group` on the representative row therefore means
// "is purchased" and `is_favorite` means "is hearted"; a row can carry both.
//
// A track belongs to an artist through its performer OR its album's main
// artist (`FeedItem::album_artist_id`): the feed's `artist_id` is the
// performer, and the owner's own screenshot (Megadeth, two hearted Popular
// Tracks, badge 3) showed hearted tracks dropping out under the
// performer-only rule.
fn feed_row_is_artist(item: &FeedItem, artist_id: &str) -> bool {
item.artist_id == artist_id || item.album_artist_id == artist_id
}
/// Every album / track of `artist_id` the user owns, deduplicated per entity,
/// in feed order (newest first — `load_library` merges that way).
pub(crate) fn artist_library_items(artist_id: &str) -> Vec<FeedItem> {
if artist_id.is_empty() {
return Vec::new();
}
with_library(|d| artist_library_items_from(&d.feed, artist_id)).unwrap_or_default()
}
/// The pure rule behind [`artist_library_items`], over any feed slice.
fn artist_library_items_from(feed: &[FeedItem], artist_id: &str) -> Vec<FeedItem> {
let mut order: Vec<String> = Vec::new();
let mut by_key: HashMap<String, FeedItem> = HashMap::new();
for item in feed.iter().filter(|i| {
(i.kind == "track" || i.kind == "album")
&& (i.group == "favorites" || i.group == "purchases")
&& feed_row_is_artist(i, artist_id)
}) {
let key = feed_key(&item.kind, &item.id);
let hearted = item.group == "favorites" || item.is_favorite;
match by_key.get_mut(&key) {
None => {
let mut row = item.clone();
row.is_favorite = hearted;
by_key.insert(key.clone(), row);
order.push(key);
}
Some(row) => {
row.is_favorite |= hearted;
// The purchase row is the representative (quality badge,
// entitlement); keep the heart it just collected.
if item.group == "purchases" && row.group != "purchases" {
let keep_heart = row.is_favorite;
*row = item.clone();
row.is_favorite = keep_heart;
}
}
}
}
order
.into_iter()
.filter_map(|k| by_key.remove(&k))
.collect()
}
/// Reconcile the cached feed with a Qobuz heart that just SETTLED, so a
/// favourite created after `load_library` ran appears in the document and a
/// cleared one leaves it. Returns whether membership changed (the caller then
/// republishes the Library document — the same rule `remove_local_favorite_row`
/// / `insert_local_favorite_row` follow for local hearts).
///
/// Why this exists: `set_feed_favorite` only flips `is_favorite` on rows that
/// are ALREADY in the feed. A track hearted from the artist page had no row,
/// so the artist page's "In library" count never moved until the next
/// session — the reference had the same hole and called it "acceptable v1"
/// (`library_by_artist.rs`); this round closes it.
///
/// - `value == true` and no row: ONE `/track/get` or `/album/get`, mapped by
/// the same producer the full load uses, inserted at the top (newest first).
/// A failed fetch is logged and skipped — the heart itself already landed.
/// - `value == false`: the `favorites` rows leave. A `purchases` row for the
/// same entity STAYS (it is still library) with its heart cleared, which
/// `set_feed_favorite` already did for every row.
///
/// Only Qobuz kinds ride this path; local hearts have their own pair.
pub(crate) async fn reconcile_qobuz_favorite(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
kind: &str,
id: &str,
value: bool,
) -> bool {
if kind != "track" && kind != "album" {
return false;
}
if is_local_feed_id(kind, id) {
return false;
}
// No library snapshot yet: nothing to reconcile, the next load sees the
// server state.
if with_library(|_| ()).is_none() {
return false;
}
if !value {
return with_library_mut(|d| {
let before = d.feed.len();
d.feed
.retain(|i| !(i.group == "favorites" && i.kind == kind && i.id == id));
let removed = before - d.feed.len();
if removed > 0 {
d.counts.all = (d.counts.all - removed as i64).max(0);
match kind {
"track" => d.counts.tracks = (d.counts.tracks - 1).max(0),
"album" => d.counts.albums = (d.counts.albums - 1).max(0),
_ => {}
}
}
removed > 0
})
.unwrap_or(false);
}
let already = with_library(|d| {
d.feed
.iter()
.any(|i| i.group == "favorites" && i.kind == kind && i.id == id)
})
.unwrap_or(false);
if already {
return false;
}
let row = match fetch_favorite_row(runtime, kind, id).await {
Some(row) => row,
None => {
log::warn!("[qbz-qt] library feed: could not fetch {kind}:{id} after favourite");
return false;
}
};
with_library_mut(|d| {
d.feed.insert(0, row);
d.counts.all += 1;
match kind {
"track" => d.counts.tracks += 1,
"album" => d.counts.albums += 1,
_ => {}
}
});
true
}
/// The catalog row for a freshly hearted entity, mapped by the SAME producer
/// the full load uses (`map_track` / `map_album`), so a row inserted live and
/// one fetched at load are indistinguishable downstream.
async fn fetch_favorite_row(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
kind: &str,
id: &str,
) -> Option<FeedItem> {
match kind {
"track" => {
let tid = id.parse::<u64>().ok()?;
let track = runtime.core().get_track(tid).await.ok()?;
let mut row = map_track(track);
row.is_favorite = true;
Some(row)
}
"album" => {
let album = runtime.core().get_album(id).await.ok()?;
let ready = ready_offline_album_track_counts().await;
let mut row = map_album(album, ready.get(id).copied().unwrap_or(0));
row.is_favorite = true;
Some(row)
}
_ => None,
}
}
/// Reconcile a local-favorite membership change with the cached mixed feed.
/// In the `all` local scope the live row stays and only its heart changes; in
/// the `favorites` scope the row itself joins/leaves the document.
@@ -2431,4 +2628,67 @@ mod tests {
let _ = std::fs::remove_dir_all(&guest);
let _ = std::fs::remove_dir_all(&account);
}
// --- Artist page "In library" membership (#737 round, contract §3) ------
fn owned(kind: &str, id: &str, group: &str, artist_id: &str, album_artist_id: &str) -> FeedItem {
FeedItem {
kind: kind.into(),
group: group.into(),
source: "qobuz".into(),
id: id.into(),
title: id.into(),
artist_id: artist_id.into(),
album_artist_id: album_artist_id.into(),
is_favorite: group == "favorites",
..Default::default()
}
}
/// A hearted track joins through EITHER id: the performer (feed
/// `artist_id`) or the album's main artist — the owner's Megadeth rows
/// whose performer was not the page's artist.
#[test]
fn artist_library_matches_performer_or_album_artist() {
let feed = vec![
owned("track", "1", "favorites", "42", ""),
owned("track", "2", "favorites", "7", "42"),
owned("track", "3", "favorites", "7", "7"),
owned("album", "a", "favorites", "42", ""),
];
let ids: Vec<String> = artist_library_items_from(&feed, "42")
.into_iter()
.map(|i| i.id)
.collect();
assert_eq!(ids, vec!["1", "2", "a"]);
}
/// Local / Plex rows and the `following` group never enter — the tab is
/// `Library > Albums / Tracks`, not `Library > All`.
#[test]
fn artist_library_excludes_local_and_following() {
let mut local = owned("track", "9", "local", "42", "");
local.source = "local".into();
let feed = vec![local, owned("artist", "42", "following", "42", "")];
assert!(artist_library_items_from(&feed, "42").is_empty());
}
/// A purchased album that is also hearted collapses to ONE row — the
/// purchase row (its quality badge survives) — carrying the heart; a
/// purchase that is not hearted carries none.
#[test]
fn artist_library_dedups_on_the_purchase_row() {
let feed = vec![
owned("album", "a", "favorites", "42", ""),
owned("album", "a", "purchases", "42", ""),
owned("album", "b", "purchases", "42", ""),
];
let rows = artist_library_items_from(&feed, "42");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].id, "a");
assert_eq!(rows[0].group, "purchases");
assert!(rows[0].is_favorite);
assert_eq!(rows[1].id, "b");
assert!(!rows[1].is_favorite);
}
}
+437
View File
@@ -0,0 +1,437 @@
//! Settings > Integrations > "Open Qobuz links in QBZ" — the OS-level
//! registration of QBZ as the handler for the official client's
//! `qobuzapp://` scheme, as a user choice rather than a side effect of
//! installing.
//!
//! WHY IT IS A SETTING. On Linux there is no official Qobuz desktop client,
//! so the launcher files claiming `x-scheme-handler/qobuzapp` displace
//! nobody. On macOS and Windows the official client exists and registers
//! the same scheme; an install that silently takes it over is the kind of
//! thing a user on a platform QBZ is only starting to reach (Windows) reads
//! as abusive. So `qbz://` — OUR scheme — stays registered by the packages
//! unconditionally, and `qobuzapp://` is claimed only when this toggle is on.
//!
//! DEFAULTS are per platform: on where it costs nobody anything (Linux) or
//! where the user base has already made the switch (macOS), off on Windows.
//! The default is only a DEFAULT: nothing here re-claims the scheme on every
//! launch. The registration moves when the user flips the toggle, and at
//! startup the only reconciliation is the conservative one — a registration
//! that points at THIS executable while the toggle is off is dropped (the
//! 2.0.x Windows MSI registered `qobuzapp` at install time; an upgraded
//! install must not keep a claim the user never made).
//!
//! Per platform:
//! - Windows: `HKCU\Software\Classes\qobuzapp` (per-user, no elevation),
//! the same keys the MSI writes for `qbz`. Off deletes the tree ONLY when
//! its `shell\open\command` names this executable — another app's claim
//! is never touched.
//! - macOS: Launch Services. The bundle keeps declaring the scheme in
//! `CFBundleURLTypes` (LS only lets a declared handler be default); on
//! makes QBZ the default handler, off hands the default to the first
//! OTHER registered handler (the official client when installed) and is
//! a no-op when there is none.
//! - Linux: `xdg-mime default com.blitzfc.qbz.desktop
//! x-scheme-handler/qobuzapp` on; off is a no-op — there is no one to
//! hand the scheme back to, and the desktop file keeps advertising it.
//!
//! The pref lives in the shared `ui_prefs.json` like the other Integrations
//! opt-ins (`discord_rpc_enabled`, `musicbrainz_enabled`).
use crate::settings_qt::{pref_bool, save_pref};
pub const PREF_KEY: &str = "qobuz_links_enabled";
const SCHEME: &str = "qobuzapp";
/// Platform default — see the module docs.
pub fn default_enabled() -> bool {
!cfg!(target_os = "windows")
}
pub fn is_enabled() -> bool {
pref_bool(PREF_KEY, default_enabled())
}
/// The toggle: persist, then move the registration.
pub fn set_enabled(value: bool) -> Result<(), String> {
save_pref(PREF_KEY, serde_json::json!(value));
let result = if value { claim() } else { release() };
match &result {
Ok(()) => log::info!("[qbz-qt] {PREF_KEY} -> {value}"),
Err(e) => log::warn!("[qbz-qt] {PREF_KEY} -> {value}: registration failed: {e}"),
}
result
}
/// Once per session, from shell entry. Conservative on purpose: never
/// claims, only drops a claim that names this executable while the toggle
/// is off (Windows — the 2.0.x MSI wrote it at install time).
pub fn reconcile_at_startup() {
if is_enabled() {
return;
}
#[cfg(target_os = "windows")]
{
if windows::command_names_this_exe() {
match windows::delete_tree() {
Ok(()) => log::info!(
"[qbz-qt] {SCHEME}:// handler pointed at this executable with the setting \
off (2.0.x installer) — dropped"
),
Err(e) => log::warn!("[qbz-qt] could not drop the {SCHEME}:// handler: {e}"),
}
}
}
}
// ---------------------------------------------------------------------------
#[cfg(target_os = "linux")]
fn claim() -> Result<(), String> {
let status = std::process::Command::new("xdg-mime")
.args([
"default",
"com.blitzfc.qbz.desktop",
&format!("x-scheme-handler/{SCHEME}"),
])
.status()
.map_err(|e| format!("xdg-mime: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("xdg-mime exited with {status}"))
}
}
#[cfg(target_os = "linux")]
fn release() -> Result<(), String> {
// Nothing to hand the scheme back to; the desktop entry keeps advertising
// it and the user's mimeapps.list keeps whatever it says.
Ok(())
}
#[cfg(target_os = "windows")]
fn claim() -> Result<(), String> {
windows::write_tree()
}
#[cfg(target_os = "windows")]
fn release() -> Result<(), String> {
if windows::command_names_this_exe() {
windows::delete_tree()
} else {
// Someone else's registration (the official client): leave it.
Ok(())
}
}
#[cfg(target_os = "macos")]
fn claim() -> Result<(), String> {
macos::set_default_handler(macos::BUNDLE_ID)
}
#[cfg(target_os = "macos")]
fn release() -> Result<(), String> {
match macos::other_handler() {
Some(other) => macos::set_default_handler(&other),
None => Ok(()),
}
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
fn claim() -> Result<(), String> {
Ok(())
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
fn release() -> Result<(), String> {
Ok(())
}
// ---------------------------------------------------------------------------
// Windows: HKCU\Software\Classes\qobuzapp
// ---------------------------------------------------------------------------
#[cfg(target_os = "windows")]
mod windows {
use super::SCHEME;
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
RegCloseKey, RegCreateKeyExW, RegDeleteTreeW, RegOpenKeyExW, RegQueryValueExW,
RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_OPTION_NON_VOLATILE,
REG_SZ,
};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
fn key_path() -> String {
format!("Software\\Classes\\{SCHEME}")
}
fn exe_path() -> Result<String, String> {
std::env::current_exe()
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| format!("current_exe: {e}"))
}
/// Create (or open) `subkey` under HKCU and write its default value.
fn write_default(subkey: &str, value: &str) -> Result<HKEY, String> {
let mut key: HKEY = std::ptr::null_mut();
let path = wide(subkey);
// SAFETY: valid null-terminated wide strings; out-pointer to a local.
let rc = unsafe {
RegCreateKeyExW(
HKEY_CURRENT_USER,
path.as_ptr(),
0,
std::ptr::null(),
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
std::ptr::null(),
&mut key,
std::ptr::null_mut(),
)
};
if rc != ERROR_SUCCESS {
return Err(format!("RegCreateKeyExW({subkey}) = {rc}"));
}
set_string(key, None, value)?;
Ok(key)
}
fn set_string(key: HKEY, name: Option<&str>, value: &str) -> Result<(), String> {
let data = wide(value);
let name_w = name.map(wide);
let name_ptr = name_w.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null());
// SAFETY: `data` outlives the call; byte length includes the terminator.
let rc = unsafe {
RegSetValueExW(
key,
name_ptr,
0,
REG_SZ,
data.as_ptr() as *const u8,
(data.len() * 2) as u32,
)
};
if rc != ERROR_SUCCESS {
return Err(format!("RegSetValueExW({}) = {rc}", name.unwrap_or("@")));
}
Ok(())
}
/// The same four entries `packaging/windows/qbz.wxs` writes for `qbz`.
pub(super) fn write_tree() -> Result<(), String> {
let exe = exe_path()?;
let root = key_path();
let key = write_default(&root, "URL:Qobuz App Protocol")?;
let r = set_string(key, Some("URL Protocol"), "");
// SAFETY: key came from RegCreateKeyExW.
unsafe { RegCloseKey(key) };
r?;
let icon = write_default(&format!("{root}\\DefaultIcon"), &format!("\"{exe}\",0"))?;
unsafe { RegCloseKey(icon) };
let cmd = write_default(
&format!("{root}\\shell\\open\\command"),
&format!("\"{exe}\" \"%1\""),
)?;
unsafe { RegCloseKey(cmd) };
Ok(())
}
/// The registered `shell\open\command`, if any.
fn command_value() -> Option<String> {
let mut key: HKEY = std::ptr::null_mut();
let path = wide(&format!("{}\\shell\\open\\command", key_path()));
// SAFETY: valid wide string; out-pointer to a local.
let rc = unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, path.as_ptr(), 0, KEY_READ, &mut key) };
if rc != ERROR_SUCCESS {
return None;
}
let mut len: u32 = 0;
let mut ty: u32 = 0;
// SAFETY: size probe with a null buffer, per the RegQueryValueExW contract.
let rc = unsafe {
RegQueryValueExW(
key,
std::ptr::null(),
std::ptr::null_mut(),
&mut ty,
std::ptr::null_mut(),
&mut len,
)
};
if rc != ERROR_SUCCESS || len == 0 {
unsafe { RegCloseKey(key) };
return None;
}
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
// SAFETY: `buf` has `len` bytes.
let rc = unsafe {
RegQueryValueExW(
key,
std::ptr::null(),
std::ptr::null_mut(),
&mut ty,
buf.as_mut_ptr() as *mut u8,
&mut len,
)
};
unsafe { RegCloseKey(key) };
if rc != ERROR_SUCCESS {
return None;
}
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
Some(String::from_utf16_lossy(&buf[..end]))
}
/// Does the current `qobuzapp` claim point at THIS executable?
pub(super) fn command_names_this_exe() -> bool {
let (Some(cmd), Ok(exe)) = (command_value(), exe_path()) else {
return false;
};
cmd.to_lowercase().contains(&exe.to_lowercase())
}
pub(super) fn delete_tree() -> Result<(), String> {
let path = wide(&key_path());
// SAFETY: valid wide string.
let rc = unsafe { RegDeleteTreeW(HKEY_CURRENT_USER, path.as_ptr()) };
if rc != ERROR_SUCCESS {
return Err(format!("RegDeleteTreeW = {rc}"));
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// macOS: Launch Services
// ---------------------------------------------------------------------------
#[cfg(target_os = "macos")]
mod macos {
use super::SCHEME;
use std::ffi::{c_char, c_void, CString};
pub(super) const BUNDLE_ID: &str = "com.blitzfc.qbz";
type CFTypeRef = *const c_void;
type CFStringRef = *const c_void;
type CFArrayRef = *const c_void;
type CFIndex = isize;
type OSStatus = i32;
const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;
#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
fn CFStringCreateWithCString(
alloc: *const c_void,
cstr: *const c_char,
encoding: u32,
) -> CFStringRef;
fn CFStringGetCString(
s: CFStringRef,
buffer: *mut c_char,
size: CFIndex,
encoding: u32,
) -> u8;
fn CFArrayGetCount(array: CFArrayRef) -> CFIndex;
fn CFArrayGetValueAtIndex(array: CFArrayRef, index: CFIndex) -> *const c_void;
fn CFRelease(cf: CFTypeRef);
}
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSSetDefaultHandlerForURLScheme(scheme: CFStringRef, bundle_id: CFStringRef)
-> OSStatus;
fn LSCopyAllHandlersForURLScheme(scheme: CFStringRef) -> CFArrayRef;
}
struct CfString(CFStringRef);
impl CfString {
fn new(s: &str) -> Result<Self, String> {
let c = CString::new(s).map_err(|e| e.to_string())?;
// SAFETY: `c` is a valid C string for the duration of the call.
let r = unsafe {
CFStringCreateWithCString(std::ptr::null(), c.as_ptr(), K_CF_STRING_ENCODING_UTF8)
};
if r.is_null() {
return Err("CFStringCreateWithCString returned NULL".into());
}
Ok(Self(r))
}
}
impl Drop for CfString {
fn drop(&mut self) {
// SAFETY: created by CFStringCreateWithCString, owned here.
unsafe { CFRelease(self.0) }
}
}
fn cf_to_string(s: CFStringRef) -> Option<String> {
let mut buf = vec![0 as c_char; 512];
// SAFETY: `buf` is `size` bytes long.
let ok = unsafe {
CFStringGetCString(s, buf.as_mut_ptr(), buf.len() as CFIndex, K_CF_STRING_ENCODING_UTF8)
};
if ok == 0 {
return None;
}
let bytes: Vec<u8> = buf
.iter()
.take_while(|&&c| c != 0)
.map(|&c| c as u8)
.collect();
String::from_utf8(bytes).ok()
}
pub(super) fn set_default_handler(bundle_id: &str) -> Result<(), String> {
let scheme = CfString::new(SCHEME)?;
let bundle = CfString::new(bundle_id)?;
// SAFETY: both CFStrings are live for the call.
let status = unsafe { LSSetDefaultHandlerForURLScheme(scheme.0, bundle.0) };
if status != 0 {
return Err(format!("LSSetDefaultHandlerForURLScheme({bundle_id}) = {status}"));
}
Ok(())
}
/// The first registered handler for the scheme that is not QBZ.
pub(super) fn other_handler() -> Option<String> {
let scheme = CfString::new(SCHEME).ok()?;
// SAFETY: the returned array is owned (Copy rule) and released below.
let handlers = unsafe { LSCopyAllHandlersForURLScheme(scheme.0) };
if handlers.is_null() {
return None;
}
let mut found = None;
// SAFETY: `handlers` is a live CFArray of CFStrings.
unsafe {
let n = CFArrayGetCount(handlers);
for i in 0..n {
let item = CFArrayGetValueAtIndex(handlers, i);
if let Some(id) = cf_to_string(item) {
if !id.eq_ignore_ascii_case(BUNDLE_ID) {
found = Some(id);
break;
}
}
}
CFRelease(handlers);
}
found
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The one rule that must never drift: Windows is opt-IN, the rest
/// opt-out.
#[test]
fn windows_defaults_off_others_on() {
assert_eq!(default_enabled(), !cfg!(target_os = "windows"));
}
}
+66 -5
View File
@@ -365,6 +365,7 @@ mod suggestions_qt;
// no #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
mod hotkeys_qt;
mod integrations_qt;
mod link_handler_qt;
mod settings_qt;
mod sidebar_qt;
mod sleep_timer_qt;
@@ -830,6 +831,9 @@ fn on_session_entered() {
// ListenBrainz opt-ins and starts the scrobble-queue flush watcher.
// Strictly opt-in — every one of them is inert until the user connects it.
integrations_qt::start(&app());
// Drop a `qobuzapp://` claim the user never made (2.0.x MSI); never
// claims on its own — see link_handler_qt.
link_handler_qt::reconcile_at_startup();
// System tray (Linux only, ksni — owner ruling K2), the port of
// `init_shell_for_user`'s `tray::init` call (`crates/qbz/src/main.rs:257-263`).
// Suppressed under gamescope by the same predicate the reference uses
@@ -1427,6 +1431,13 @@ pub(crate) fn open_artist(artist_id: String) {
}
nav_qt::record("artist");
*LAST_DETAIL.lock().unwrap() = ("artist".to_string(), artist_id.clone());
// Warm the Library feed in the background: the page's "In library" tab is
// built off it, and on a cold session (or right after an account switch —
// `reset_session_latches`) it was empty until the user happened to visit
// Library, so the toggle never mounted. Idempotent through
// `LIBRARY_LOADED`; with the feed warm this is a no-op, and when it lands
// `reload_library` repaints the open page's rows.
load_library_once();
let runtime = app();
artist_bridge::ui(move |mut b| {
// Same as open_album: stale artist until the fetch lands otherwise.
@@ -1706,6 +1717,21 @@ pub(crate) fn queue_panel_opened() {
}
/// AlbumView header Shuffle.
/// Artist page album-section split button: "Play all" / "Play selected"
/// over a JSON array of album ids, in the section's sort order.
pub(crate) fn play_albums(ids_json: String) {
let ids: Vec<String> = serde_json::from_str(&ids_json).unwrap_or_default();
if ids.is_empty() {
return;
}
let runtime = app();
spawn(async move {
if let Err(e) = playback_qt::play_albums(&runtime, &ids).await {
log::error!("[qbz-qt] play_albums failed: {e}");
}
});
}
pub(crate) fn play_album_shuffled(album_id: String) {
let runtime = app();
spawn(async move {
@@ -2956,6 +2982,16 @@ pub(crate) fn toggle_ambient_background() {
shell_bridge::ui(move |mut b| b.as_mut().set_ambient_mode(mode));
}
/// ArtistView's "In library" toggle: ask for the feed (a fetch only if it has
/// never loaded this session — `load_library_once`), and repaint the open
/// page's rows from the cache when it is already warm. The owner's rule for
/// the toggle: re-arm the warm, but SERVE FROM CACHE — clicking the tab must
/// never refetch a 10k-row feed.
pub(crate) fn warm_library() {
load_library_once();
artist_qt::refresh_library_items();
}
fn load_library_once() {
if *LIBRARY_LOADED.lock().unwrap() {
return;
@@ -3009,6 +3045,9 @@ pub(crate) fn reload_library() {
b.as_mut().set_library_loading(false);
});
log_rss("library published");
// The artist page open right now (if any) was built while the
// feed was cold — hand it its "In library" rows.
artist_qt::refresh_library_items();
}
Err(e) => {
log::warn!("[qbz-qt] library load failed: {e}");
@@ -3140,15 +3179,36 @@ pub(crate) fn library_toggle_favorite(kind: String, id: String) {
let runtime = app();
spawn(async move {
if let Some(value) = library_qt::toggle_favorite(&runtime, &kind, &id).await {
let membership_changed = !value && library_qt::remove_local_favorite_row(&kind, &id);
emit_library_favorite(&kind, &id, value);
if membership_changed {
publish_library_document();
}
settle_favorite(&runtime, &kind, &id, value).await;
}
});
}
/// A heart's write LANDED with `value`: reconcile the cached feed (a fresh
/// Qobuz favourite joins it, a cleared one leaves; local hearts have their
/// own row pair), fan the settled value out through `emit_library_favorite`,
/// and republish the Library document only when a row came or went.
///
/// Reconcile BEFORE emit, deliberately: the fan-out reaches
/// `artist_qt::apply_favorite_change`, which recomputes the open artist's
/// "In library" rows off the feed — it has to see the inserted / removed row
/// or the tab keeps its stale count (the #737 round's "the badge never
/// moves"). Every settle point that owns a Qobuz heart goes through here
/// (`library_toggle_favorite`, the queue panel heart in `queue_qt`).
pub(crate) async fn settle_favorite(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
kind: &str,
id: &str,
value: bool,
) {
let mut membership_changed = !value && library_qt::remove_local_favorite_row(kind, id);
membership_changed |= library_qt::reconcile_qobuz_favorite(runtime, kind, id, value).await;
emit_library_favorite(kind, id, value);
if membership_changed {
publish_library_document();
}
}
/// THE settle point for a heart, wherever the click came from: emit the
/// in-place signal, then fan the settled value into the document caches.
///
@@ -3183,6 +3243,7 @@ pub(crate) fn emit_library_favorite(kind: &str, id: &str, value: bool) {
home_qt::apply_favorite_change(kind, id, value);
recommendations_qt::apply_favorite_change(kind, id, value);
search_qt::apply_favorite_change(kind, id, value);
artist_qt::apply_favorite_change(kind, id, value);
}
/// Dev navigation driver — `QBZ_QT_NAV_BENCH=home,library,home,settings`.
+37
View File
@@ -2237,6 +2237,43 @@ pub async fn play_album(
play_album_from(runtime, album_id, 0).await
}
/// An album SECTION's "Play all" / "Play selected" (artist page split
/// button): every album's tracks, in the order given, as ONE queue that
/// replaces the current one — track 1 of the first album starts. Each album
/// resolves through `fetch_album_queue` like a single-album play, so a
/// purchase preference or a withdrawn row is treated identically; an album
/// that fails to resolve is logged and skipped rather than sinking the whole
/// section. Local / Plex group keys are not a catalog section and are
/// skipped.
pub async fn play_albums(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_ids: &[String],
) -> Result<(), String> {
let mut queue: Vec<QueueTrack> = Vec::new();
for album_id in album_ids {
if is_local_album(album_id) {
log::info!("[qbz-qt] play_albums: skipping local group key {album_id}");
continue;
}
match fetch_album_queue(runtime, album_id).await {
Ok(mut tracks) => queue.append(&mut tracks),
Err(e) => log::warn!("[qbz-qt] play_albums: {album_id} skipped: {e}"),
}
}
if queue.is_empty() {
return Err(format!(
"none of the {} album(s) resolved to playable tracks",
album_ids.len()
));
}
log::info!(
"[qbz-qt] play_albums: queueing {} track(s) from {} album(s)",
queue.len(),
album_ids.len()
);
play_track_list(runtime, queue, 0, false).await
}
/// Play an album starting at track `start_index` (AlbumView row play —
/// Slint's play_album_from semantics: the queue keeps the whole album,
/// the cursor starts at the clicked track).
+9
View File
@@ -200,6 +200,11 @@ pub mod qbz_player {
/// AlbumView header Shuffle.
#[qinvokable]
fn play_album_shuffled(self: Pin<&mut QbzPlayer>, album_id: QString);
/// Artist page album-section split button — a JSON array of album
/// ids in the section's sort order, queued as one list.
/// `#[auto_cxx_name]` makes the QML name `QbzPlayer.playAlbums`.
#[qinvokable]
fn play_albums(self: Pin<&mut QbzPlayer>, ids_json: QString);
/// AlbumView row play: the album starting AT this track.
#[qinvokable]
fn play_album_from(self: Pin<&mut QbzPlayer>, album_id: QString, track_id: QString);
@@ -477,6 +482,10 @@ impl qbz_player::QbzPlayer {
crate::play_album(album_id.to_string());
}
pub fn play_albums(self: Pin<&mut Self>, ids_json: QString) {
crate::play_albums(ids_json.to_string());
}
pub fn play_album_shuffled(self: Pin<&mut Self>, album_id: QString) {
crate::play_album_shuffled(album_id.to_string());
}
+1 -1
View File
@@ -1562,7 +1562,7 @@ pub async fn toggle_favorite(runtime: &Arc<AppRuntime<LoggingAdapter>>, kind: &s
// (Home / Search / Recommendations re-published the pre-toggle heart on
// their next republish). The panel itself still repaints from `publish`.
if let Some(value) = crate::library_qt::toggle_favorite(runtime, kind, id).await {
crate::emit_library_favorite(kind, id, value);
crate::settle_favorite(runtime, kind, id, value).await;
}
publish(runtime).await;
}
+6
View File
@@ -2243,6 +2243,9 @@ pub struct SettingsDoc {
pub integrations_status_kind: i32,
#[serde(rename = "discordEnabled")]
pub discord_enabled: bool,
/// Settings > Integrations > "Open Qobuz links in QBZ" (link_handler_qt).
#[serde(rename = "qobuzLinksEnabled")]
pub qobuz_links_enabled: bool,
// --- Per-section sub-documents (settings_qt/*.rs) ---------------------
/// Settings > Local Library (folders, scan, maintenance, Plex fields).
pub library: library::Snapshot,
@@ -2719,6 +2722,7 @@ pub async fn publish_snapshot() {
integrations_status_text: integ_ui.status_text.clone(),
integrations_status_kind: integ_ui.status_kind,
discord_enabled: crate::integrations_qt::discord_enabled(),
qobuz_links_enabled: crate::link_handler_qt::is_enabled(),
library: library::snapshot(),
offline: offline::snapshot(),
dev: devtools::snapshot(),
@@ -3614,6 +3618,8 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
crate::integrations_qt::set_listenbrainz_enabled(value).map(|_| Apply::None)
}
"discord-rpc" => crate::integrations_qt::set_discord_enabled(value).map(|_| Apply::None),
// The OS-level `qobuzapp://` handler claim (link_handler_qt).
"qobuz-links" => crate::link_handler_qt::set_enabled(value).map(|_| Apply::None),
// --- Offline -------------------------------------------------------
// Induced offline. The engine takes the #279 stream-first snapshot,
// so the audio settings can change under us -> Reload the player.
+1
View File
@@ -773,6 +773,7 @@ mod tests {
},
label: None,
genre: None,
artist: None,
});
let qt = track_to_queue_track(&track);
+9 -9
View File
@@ -31,8 +31,10 @@
* AppUserModelID com.blitzfc.qbz on the Start-menu shortcut: required by
WinRT toasts (W21) and used by SMTC for the app identity (W11).
Needs InstallerVersion >= 500 (MsiShortcutProperty is MSI 5.0+).
* URL protocols qbz:// and qobuzapp:// (the CFBundleURLSchemes analogue);
inert until single-instance + deep links land (W23/W24), harmless before
* URL protocol qbz:// (the CFBundleURLSchemes analogue): inert until
single-instance + deep links land (W23/W24), harmless before.
qobuzapp:// is a runtime opt-in (Settings > Integrations,
src/link_handler_qt.rs), never an installer claim.
* LGPL notices: the workflow copies LICENSE + licenses\*.txt into
dist\qbz\ BEFORE this runs, so the harvest picks them up. No authoring
here. See research/04 §8.
@@ -144,7 +146,7 @@
Type="integer" Value="1" KeyPath="yes" />
</Component>
<!-- qbz:// and qobuzapp:// — analogue of CFBundleURLSchemes in Info.plist -->
<!-- qbz:// — analogue of CFBundleURLSchemes in Info.plist -->
<Component Id="UrlProtocols" Directory="INSTALLFOLDER">
<RegistryKey Root="HKCU" Key="Software\Classes\qbz">
<RegistryValue Type="string" Value="URL:QBZ Protocol" />
@@ -152,12 +154,10 @@
<RegistryValue Key="DefaultIcon" Type="string" Value="&quot;[INSTALLFOLDER]qbz.exe&quot;,0" />
<RegistryValue Key="shell\open\command" Type="string" Value="&quot;[INSTALLFOLDER]qbz.exe&quot; &quot;%1&quot;" />
</RegistryKey>
<RegistryKey Root="HKCU" Key="Software\Classes\qobuzapp">
<RegistryValue Type="string" Value="URL:Qobuz App Protocol" />
<RegistryValue Name="URL Protocol" Type="string" Value="" />
<RegistryValue Key="DefaultIcon" Type="string" Value="&quot;[INSTALLFOLDER]qbz.exe&quot;,0" />
<RegistryValue Key="shell\open\command" Type="string" Value="&quot;[INSTALLFOLDER]qbz.exe&quot; &quot;%1&quot;" />
</RegistryKey>
<!-- qobuzapp:// (the official client's scheme) is NOT registered by the
installer: it is a user choice — Settings > Integrations > "Open
Qobuz links in QBZ" (src/link_handler_qt.rs) writes and removes the
same shape of key at runtime. -->
<RegistryValue Root="HKCU" Key="Software\blitzfc\QBZ" Name="Protocols"
Type="integer" Value="1" KeyPath="yes" />
</Component>