Claiming the official client's `qobuzapp://` scheme was a side effect of installing. On Linux that displaces nobody; on macOS and Windows the official client registers the same scheme, and on Windows — where QBZ is only starting to be adopted — taking the browser's links over at install time is not a reasonable default. `qbz://` (our own scheme) stays registered unconditionally. Settings > Integrations > "Open Qobuz links in QBZ" (link_handler_qt, pref `qobuz_links_enabled` beside the other Integrations opt-ins), default on for Linux/macOS and OFF on Windows. The registration moves only when the toggle is flipped; the one startup reconciliation is conservative — a claim pointing at this executable while the setting is off (the 2.0.x MSI wrote one) is dropped, nothing is ever claimed on its own. Windows writes/removes HKCU\Software\Classes\qobuzapp (deleting only a tree whose command names this exe); macOS uses Launch Services and hands the default back to the first other handler on off; Linux runs xdg-mime on. The MSI no longer registers qobuzapp and the workflow's protocol checks cover qbz only. The Windows and macOS arms build on their platform gates, not on this box. cc
365 lines
17 KiB
QML
365 lines
17 KiB
QML
// Settings > Integrations — the QML port of
|
|
// crates/qbz-ui/ui/settings/IntegrationsSettings.slint. Row order, group
|
|
// headers and auth-gating mirror the Slint 1:1; all state rides the single
|
|
// settingsJson document (integrations_qt.rs over the SAME scrobbler /
|
|
// discover / ui_prefs / offline-queue stores the Slint app uses).
|
|
//
|
|
// Every integration here is STRICTLY OPT-IN: nothing connects, fires or
|
|
// queues until the user signs the service in and its toggles are on.
|
|
//
|
|
// NOTE: the Last.fm connect flow opens the system browser (open::that) — the
|
|
// offscreen smoke never clicks it.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../theme"
|
|
|
|
Column {
|
|
id: root
|
|
|
|
property var doc: ({})
|
|
// Password drafts never enter settingsJson. Keep the live token inside
|
|
// this component until the user explicitly submits it.
|
|
property string listenbrainzTokenDraft: ""
|
|
/// The view-level SettingsConfirmHost (SettingsView.qml), for the
|
|
/// destructive "Clear listening history" row. Null in previews.
|
|
property var confirmHost: null
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
spacing: 4
|
|
|
|
function submitListenBrainzToken() {
|
|
if (root.doc.listenbrainzBusy === true)
|
|
return
|
|
QbzBridge.settingsString("listenbrainz-token", root.listenbrainzTokenDraft)
|
|
}
|
|
|
|
// ======================= RECOMMENDATIONS =============================
|
|
GroupHeader { text: QbzSession.tr("RECOMMENDATIONS", QbzSession.trRev) }
|
|
SettingRow {
|
|
label: QbzSession.tr("Show Recommendations in Discover", QbzSession.trRev)
|
|
description: QbzSession.tr("A personalized Discover tab built from your listening. For the best results connect Last.fm and ListenBrainz below — MusicBrainz is used automatically to match releases.", QbzSession.trRev)
|
|
QbzToggle {
|
|
checked: root.doc.showRecommendations === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("show-recommendations", v) }
|
|
}
|
|
}
|
|
|
|
SettingsSpacer { }
|
|
|
|
// =========================== METADATA ================================
|
|
GroupHeader { text: QbzSession.tr("METADATA", QbzSession.trRev) }
|
|
SettingRow {
|
|
label: QbzSession.tr("MusicBrainz", QbzSession.trRev)
|
|
description: QbzSession.tr("Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections.", QbzSession.trRev)
|
|
QbzToggle {
|
|
checked: root.doc.musicbrainzEnabled === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("musicbrainz", v) }
|
|
}
|
|
}
|
|
|
|
SettingsSpacer { }
|
|
|
|
// ============================ PRIVACY ================================
|
|
// The local listen log (qbz_app::listen_log): one row per play, on this
|
|
// disk only, per account. Default ON. "Clear" is DELETE + VACUUM. The
|
|
// toggle's state comes from the per-user store (`listen_meta.paused`),
|
|
// not from a pref, so it follows the account.
|
|
GroupHeader { text: QbzSession.tr("PRIVACY", QbzSession.trRev) }
|
|
SettingRow {
|
|
label: QbzSession.tr("Listening history", QbzSession.trRev)
|
|
description: QbzSession.tr("Keep a local log of what you play: track, source, how much of it you heard and when. It stays on this machine, per account, and never leaves it unless you enable a scrobbler.", QbzSession.trRev)
|
|
QbzToggle {
|
|
checked: root.doc.listenHistoryEnabled !== false
|
|
onToggled: function (v) { QbzBridge.settingsBool("listen-history", v) }
|
|
}
|
|
}
|
|
SettingRow {
|
|
label: QbzSession.tr("Clear listening history", QbzSession.trRev)
|
|
description: QbzSession.tr("Deletes every play recorded on this machine for this account. Scrobbles already sent are not affected.", QbzSession.trRev)
|
|
SettingsButton {
|
|
danger: true
|
|
text: QbzSession.tr("Clear", QbzSession.trRev)
|
|
onClicked: {
|
|
if (!root.confirmHost) {
|
|
QbzBridge.integrationsAction("listen-history-clear")
|
|
return
|
|
}
|
|
root.confirmHost.ask(
|
|
QbzSession.tr("Clear listening history", QbzSession.trRev),
|
|
QbzSession.tr("Deletes every play recorded on this machine for this account. Scrobbles already sent are not affected.", QbzSession.trRev),
|
|
QbzSession.tr("Clear", QbzSession.trRev),
|
|
function () { QbzBridge.integrationsAction("listen-history-clear") })
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsSpacer { }
|
|
|
|
// ========================== SCROBBLERS ===============================
|
|
// Master header row (toggle + collapse chevron), then the body gated
|
|
// on enabled && !collapsed (IntegrationsSettings.slint:113-169).
|
|
Item {
|
|
width: parent.width
|
|
height: 64
|
|
Column {
|
|
anchors.left: parent.left
|
|
anchors.right: scrobbleControl.left
|
|
anchors.rightMargin: 24
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 3
|
|
Text {
|
|
width: parent.width
|
|
text: QbzSession.tr("SCROBBLERS", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
font.letterSpacing: 1.5
|
|
font.weight: theme.weightSemibold
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
text: QbzSession.tr("Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks. This switch also decides whether Recommendations may read your listening history from those services.", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
Row {
|
|
id: scrobbleControl
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 8
|
|
// Collapse chevron (only when the master toggle is on).
|
|
Rectangle {
|
|
id: collapseButton
|
|
visible: root.doc.scrobbleEnabled === true
|
|
width: 28
|
|
height: 28
|
|
radius: theme.radiusSm
|
|
color: colArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
activeFocusOnTab: visible && enabled
|
|
border.width: activeFocus ? 2 : 0
|
|
border.color: theme.accent
|
|
Accessible.role: Accessible.Button
|
|
Accessible.name: QbzSession.tr("Scrobbling", QbzSession.trRev)
|
|
Accessible.onPressAction: collapseButton.activate()
|
|
function activate() {
|
|
QbzBridge.settingsBool(
|
|
"scrobble-collapse", root.doc.scrobbleUiCollapsed !== true)
|
|
}
|
|
Keys.onPressed: function (event) {
|
|
if (!event.isAutoRepeat
|
|
&& (event.key === Qt.Key_Space
|
|
|| event.key === Qt.Key_Return
|
|
|| event.key === Qt.Key_Enter)) {
|
|
collapseButton.activate()
|
|
event.accepted = true
|
|
}
|
|
}
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
// 28px hit-box / 18px glyph (IntegrationsSettings.slint:129).
|
|
name: root.doc.scrobbleUiCollapsed === true ? "chevron-right" : "chevron-down"
|
|
width: 18
|
|
height: 18
|
|
tintName: "secondary"
|
|
}
|
|
MouseArea {
|
|
id: colArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onPressed: collapseButton.forceActiveFocus()
|
|
onClicked: collapseButton.activate()
|
|
}
|
|
}
|
|
QbzToggle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: root.doc.scrobbleEnabled === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("scrobble-enable", v) }
|
|
}
|
|
}
|
|
}
|
|
|
|
Column {
|
|
visible: root.doc.scrobbleEnabled === true && root.doc.scrobbleUiCollapsed !== true
|
|
width: parent.width
|
|
spacing: 4
|
|
|
|
SettingRow {
|
|
label: QbzSession.tr("Scrobble while logged out", QbzSession.trRev)
|
|
description: QbzSession.tr("Continue sending plays to connected scrobblers without a Qobuz session.", QbzSession.trRev)
|
|
QbzToggle {
|
|
checked: root.doc.allowLoggedOutScrobbling !== false
|
|
onToggled: function (v) {
|
|
QbzBridge.settingsBool("scrobble-logged-out", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsSpacer { }
|
|
|
|
// ------------------------- LAST.FM ------------------------------
|
|
GroupHeader { text: QbzSession.tr("LAST.FM", QbzSession.trRev) }
|
|
SettingRow {
|
|
label: QbzSession.tr("Scrobble to Last.fm", QbzSession.trRev)
|
|
description: root.doc.lastfmAuthed === true
|
|
? QbzSession.tr("Signed in as {}.", QbzSession.trRev).replace("{}", root.doc.lastfmUsername || "")
|
|
: QbzSession.tr("Connect your Last.fm account to enable scrobbling.", QbzSession.trRev)
|
|
QbzToggle {
|
|
checked: root.doc.lastfmEnabled === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("lastfm-enable", v) }
|
|
}
|
|
}
|
|
SettingRow {
|
|
visible: root.doc.lastfmAuthed !== true
|
|
label: QbzSession.tr("Connect", QbzSession.trRev)
|
|
description: QbzSession.tr("Authorize QBZ in your browser, then click Finish.", QbzSession.trRev)
|
|
Row {
|
|
spacing: 8
|
|
SettingsButton {
|
|
text: root.doc.lastfmBusy === true ? QbzSession.tr("Working...", QbzSession.trRev) : QbzSession.tr("Connect Last.fm", QbzSession.trRev)
|
|
enabled: root.doc.lastfmBusy !== true
|
|
onClicked: QbzBridge.integrationsAction("lastfm-connect")
|
|
}
|
|
SettingsButton {
|
|
visible: (root.doc.lastfmAuthUrl || "") !== ""
|
|
text: QbzSession.tr("Open authorize page", QbzSession.trRev)
|
|
onClicked: QbzBridge.integrationsAction("lastfm-open-auth-url")
|
|
}
|
|
SettingsButton {
|
|
visible: (root.doc.lastfmAuthUrl || "") !== ""
|
|
text: root.doc.lastfmBusy === true
|
|
? QbzSession.tr("Working...", QbzSession.trRev)
|
|
: QbzSession.tr("Finish", QbzSession.trRev)
|
|
enabled: root.doc.lastfmBusy !== true
|
|
onClicked: QbzBridge.integrationsAction("lastfm-finish")
|
|
}
|
|
}
|
|
}
|
|
SettingRow {
|
|
visible: root.doc.lastfmAuthed === true
|
|
label: QbzSession.tr("Disconnect Last.fm", QbzSession.trRev)
|
|
description: QbzSession.tr("Sign out of Last.fm.", QbzSession.trRev)
|
|
SettingsButton {
|
|
danger: true
|
|
text: QbzSession.tr("Disconnect", QbzSession.trRev)
|
|
onClicked: QbzBridge.integrationsAction("lastfm-disconnect")
|
|
}
|
|
}
|
|
|
|
SettingsSpacer { }
|
|
|
|
// ----------------------- LISTENBRAINZ ----------------------------
|
|
GroupHeader { text: QbzSession.tr("LISTENBRAINZ", QbzSession.trRev) }
|
|
SettingRow {
|
|
label: QbzSession.tr("Scrobble to ListenBrainz", QbzSession.trRev)
|
|
description: root.doc.listenbrainzBusy === true
|
|
? QbzSession.tr("Validating...", QbzSession.trRev)
|
|
: root.doc.listenbrainzAuthed === true
|
|
? QbzSession.tr("Signed in as {}.", QbzSession.trRev).replace("{}", root.doc.listenbrainzUsername || "")
|
|
: QbzSession.tr("Paste your ListenBrainz user token to enable scrobbling.", QbzSession.trRev)
|
|
QbzToggle {
|
|
checked: root.doc.listenbrainzEnabled === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("listenbrainz-enable", v) }
|
|
}
|
|
}
|
|
SettingRow {
|
|
visible: root.doc.listenbrainzAuthed !== true
|
|
label: QbzSession.tr("User token", QbzSession.trRev)
|
|
description: QbzSession.tr("From listenbrainz.org/settings.", QbzSession.trRev)
|
|
QbzLineEdit {
|
|
id: lbTokenField
|
|
isPassword: true
|
|
enabled: root.doc.listenbrainzBusy !== true
|
|
placeholder: QbzSession.tr("ListenBrainz token", QbzSession.trRev)
|
|
// Typing only updates the local draft. Enter submits once;
|
|
// focus loss merely settles the draft, so a button click can
|
|
// never race an implicit blur validation.
|
|
onEdited: function (v) { root.listenbrainzTokenDraft = v }
|
|
onCommitted: function (v) { root.listenbrainzTokenDraft = v }
|
|
onAccepted: function (v) {
|
|
root.listenbrainzTokenDraft = v
|
|
root.submitListenBrainzToken()
|
|
}
|
|
// The row hides once the sign-in lands; drop the typed token
|
|
// with it (Slint resets listenbrainz-token-input on success).
|
|
onVisibleChanged: if (!visible) {
|
|
text = ""
|
|
root.listenbrainzTokenDraft = ""
|
|
}
|
|
}
|
|
}
|
|
SettingRow {
|
|
visible: root.doc.listenbrainzAuthed !== true
|
|
label: QbzSession.tr("Save token", QbzSession.trRev)
|
|
description: QbzSession.tr("Validates the token against ListenBrainz.", QbzSession.trRev)
|
|
SettingsButton {
|
|
text: root.doc.listenbrainzBusy === true
|
|
? QbzSession.tr("Validating...", QbzSession.trRev)
|
|
: QbzSession.tr("Connect ListenBrainz", QbzSession.trRev)
|
|
enabled: root.doc.listenbrainzBusy !== true
|
|
&& root.listenbrainzTokenDraft.trim().length > 0
|
|
onClicked: root.submitListenBrainzToken()
|
|
}
|
|
}
|
|
SettingRow {
|
|
visible: root.doc.listenbrainzAuthed === true
|
|
label: QbzSession.tr("Disconnect ListenBrainz", QbzSession.trRev)
|
|
description: QbzSession.tr("Sign out of ListenBrainz.", QbzSession.trRev)
|
|
SettingsButton {
|
|
danger: true
|
|
text: QbzSession.tr("Disconnect", QbzSession.trRev)
|
|
onClicked: QbzBridge.integrationsAction("listenbrainz-disconnect")
|
|
}
|
|
}
|
|
|
|
// Status line (scrobble.rs set_status; kind 1 info / 2 ok / 3 error).
|
|
Text {
|
|
visible: (root.doc.integrationsStatusText || "") !== ""
|
|
width: parent.width
|
|
text: root.doc.integrationsStatusText || ""
|
|
color: root.doc.integrationsStatusKind === 2 ? theme.success
|
|
: root.doc.integrationsStatusKind === 3 ? theme.danger
|
|
: theme.textMuted
|
|
font.pixelSize: 12
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
|
|
SettingsSpacer { }
|
|
|
|
// =========================== DISCORD =================================
|
|
GroupHeader { text: QbzSession.tr("DISCORD", QbzSession.trRev) }
|
|
SettingRow {
|
|
label: QbzSession.tr("Discord Rich Presence", QbzSession.trRev)
|
|
description: QbzSession.tr("Show what you're listening to as your Discord status. Opt-in — Discord must be running.", QbzSession.trRev)
|
|
QbzToggle {
|
|
checked: root.doc.discordEnabled === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("discord-rpc", v) }
|
|
}
|
|
}
|
|
SettingRow {
|
|
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) }
|
|
}
|
|
}
|
|
}
|