merge: integrate artist library fixes into release branch
cx
This commit is contained in:
31 files changed
+1805
-85
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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:// всегда открываются здесь."
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" })
|
||||
|
||||
// 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) }
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
visible: libraryTab.libTracks.length > 0
|
||||
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
|
||||
}
|
||||
Item { visible: libraryTab.libTracks.length > 0; width: 1; height: 10 }
|
||||
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 }
|
||||
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
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
visible: libraryTab.libAlbums.length > 0
|
||||
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
|
||||
}
|
||||
Item { visible: libraryTab.libAlbums.length > 0; width: 1; height: 10 }
|
||||
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))
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,14 +3179,35 @@ 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);
|
||||
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`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -773,6 +773,7 @@ mod tests {
|
||||
},
|
||||
label: None,
|
||||
genre: None,
|
||||
artist: None,
|
||||
});
|
||||
|
||||
let qt = track_to_queue_track(&track);
|
||||
|
||||
@@ -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=""[INSTALLFOLDER]qbz.exe",0" />
|
||||
<RegistryValue Key="shell\open\command" Type="string" Value=""[INSTALLFOLDER]qbz.exe" "%1"" />
|
||||
</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=""[INSTALLFOLDER]qbz.exe",0" />
|
||||
<RegistryValue Key="shell\open\command" Type="string" Value=""[INSTALLFOLDER]qbz.exe" "%1"" />
|
||||
</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>
|
||||
|
||||
Reference in new issue
Block a user