@@ -3099,6 +3157,84 @@
position: relative;
}
+ /* Artwork-derived backdrop. ::before renders the actual image heavily
+ blurred (Qobuz/Feishin-style) with a mask that fades it into the
+ theme bg toward the bottom; ::after lays a scrim on top for text
+ contrast on light artwork. The cost is one-shot — once the image
+ is rasterized into a layer the GPU just composites it. */
+ .artist-detail.has-art-bg::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ /* Visual extent ends around 380px once `scale(1.1)` is applied
+ (340 × 1.1 = 374), placing the fade comfortably above the
+ JUMP TO bar which starts ~y=410. */
+ height: 340px;
+ background-color: var(--art-bg, transparent);
+ background-image: var(--art-image-url, none);
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+ filter: blur(70px) saturate(1.3);
+ transform: scale(1.1);
+ transform-origin: center top;
+ z-index: 0;
+ pointer-events: none;
+ -webkit-mask-image: linear-gradient(180deg, #000 0%, #000 55%, transparent 100%);
+ mask-image: linear-gradient(180deg, #000 0%, #000 55%, transparent 100%);
+ transition: background-color 320ms ease;
+ will-change: transform, filter;
+ }
+
+ .artist-detail.has-art-bg::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 340px;
+ background: linear-gradient(180deg, rgba(0, 0, 0, var(--art-scrim, 0.3)) 0%, rgba(0, 0, 0, 0) 80%);
+ z-index: 0;
+ pointer-events: none;
+ }
+
+ .artist-detail > * {
+ position: relative;
+ z-index: 1;
+ }
+
+ /* Lift secondary text contrast over the colored backdrop. */
+ .artist-detail.has-art-bg .back-btn,
+ .artist-detail.has-art-bg .bio-text,
+ .artist-detail.has-art-bg .bio-toggle {
+ color: rgba(255, 255, 255, 0.78);
+ }
+
+ .artist-detail.has-art-bg .back-btn:hover,
+ .artist-detail.has-art-bg .bio-toggle:hover {
+ color: #fff;
+ }
+
+ /* Action buttons reactive to gradient backdrop. */
+ .artist-detail.has-art-bg :global(.action-btn-circle) {
+ color: rgba(255, 255, 255, 0.78);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
+ }
+
+ .artist-detail.has-art-bg :global(.action-btn-circle:hover:not(:disabled)) {
+ color: #fff;
+ background: rgba(255, 255, 255, 0.12);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.7);
+ }
+
+ .artist-detail.has-art-bg :global(.action-btn-circle.is-active) {
+ color: #fff;
+ background: rgba(255, 255, 255, 0.18);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
+ }
+
.artist-body {
display: flex;
align-items: stretch;
@@ -3923,6 +4059,14 @@
font-weight: 300;
}
+ /* 1px sentinel above .jump-nav so an IntersectionObserver can detect
+ when the nav becomes pinned (sentinel out of viewport = stuck). */
+ .jump-nav-sentinel {
+ height: 1px;
+ margin: 0 -8px 0 -24px;
+ pointer-events: none;
+ }
+
.jump-nav {
position: sticky;
/* `top: 0` pins to the scrollport's padding-box top. Now that
@@ -3936,13 +4080,20 @@
align-items: center;
gap: 10px;
padding: 10px 24px;
- background: var(--bg-primary);
- border-bottom: 1px solid var(--alpha-6);
- box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.5);
+ background: transparent;
+ border-bottom: 1px solid transparent;
+ box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0);
margin: 0 -8px 24px -24px;
width: calc(100% + 32px);
will-change: transform;
transform: translateZ(0);
+ transition: background-color 220ms ease, border-color 220ms ease, box-shadow 220ms ease;
+ }
+
+ .jump-nav.stuck {
+ background: var(--bg-primary);
+ border-bottom-color: var(--alpha-6);
+ box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.5);
}
.jump-nav-left {
diff --git a/src/lib/utils/artworkPalette.ts b/src/lib/utils/artworkPalette.ts
index 60849b1dd..75dbd10da 100644
--- a/src/lib/utils/artworkPalette.ts
+++ b/src/lib/utils/artworkPalette.ts
@@ -1,4 +1,5 @@
import { Vibrant } from 'node-vibrant/browser';
+import { getCachedImageUrl } from '$lib/services/imageCacheService';
export interface ArtworkSwatch {
hex: string;
@@ -47,7 +48,24 @@ export async function extractPalette(url: string | null | undefined): Promise
{
try {
- const palette = await Vibrant.from(url).getPalette();
+ // Resolve through the local image cache so Vibrant works on a
+ // file:// / asset:// URL instead of going to the remote CDN.
+ // Qobuz CDN sends Access-Control-Allow-Origin: * for album covers
+ // but NOT for artist images, which taints the canvas Vibrant uses
+ // and makes pixel reads fail. Routing through the cache also
+ // primes the on-disk store for subsequent visits.
+ let resolvedUrl = url;
+ if (
+ url.startsWith('http://') ||
+ url.startsWith('https://')
+ ) {
+ try {
+ resolvedUrl = await getCachedImageUrl(url);
+ } catch {
+ resolvedUrl = url;
+ }
+ }
+ const palette = await Vibrant.from(resolvedUrl).getPalette();
const all = [
palette.Vibrant,
palette.Muted,
From 9bab69310baef0aec0925ba0dea91ca1278634be Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Thu, 7 May 2026 00:19:45 -0600
Subject: [PATCH 04/11] chore(deps): batch bump minor/patch versions
Manual batch instead of merging the 10 dependabot PRs individually so
Cargo.lock and package-lock.json each see a single coherent update.
Rust (src-tauri/Cargo.lock):
- tokio 1.52.1 -> 1.52.2 (#401)
- rustls 0.23.37 -> 0.23.40 (#400)
- tauri-plugin-opener 2.5.3 -> 2.5.4 (#399)
- tauri-plugin-deep-link 2.4.8 -> 2.4.9 (#397)
- notify-rust 4.16.1 -> 4.17.0 (#394)
npm (package.json + package-lock.json):
- @sveltejs/kit 2.58.0 -> 2.59.1 (#398)
- @tauri-apps/plugin-dialog 2.7.0 -> 2.7.1 (#396)
- jsdom 29.1.0 -> 29.1.1 (#395)
- @tauri-apps/api 2.10.1 -> 2.11.0 (#393)
- svelte-check 4.4.6 -> 4.4.8 (#392)
cargo check + npm run check both clean.
cc
---
package-lock.json | 42 +++++++++++++++++++++---------------------
package.json | 10 +++++-----
src-tauri/Cargo.lock | 30 +++++++++++++++---------------
3 files changed, 41 insertions(+), 41 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 5b753747f..195920873 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,9 +11,9 @@
"dependencies": {
"@icons-pack/svelte-simple-icons": "^7.2.0",
"@kawarp/core": "^1.1.1",
- "@tauri-apps/api": "^2",
+ "@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2",
- "@tauri-apps/plugin-dialog": "^2.7.0",
+ "@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
@@ -24,13 +24,13 @@
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
- "@sveltejs/kit": "^2.58.0",
+ "@sveltejs/kit": "^2.59.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tauri-apps/cli": "^2",
"@testing-library/svelte": "^5.3.1",
- "jsdom": "^29.1.0",
+ "jsdom": "^29.1.1",
"svelte": "^5.55.5",
- "svelte-check": "^4.4.6",
+ "svelte-check": "^4.4.8",
"typescript": "~6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5"
@@ -1291,9 +1291,9 @@
}
},
"node_modules/@sveltejs/kit": {
- "version": "2.58.0",
- "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.58.0.tgz",
- "integrity": "sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA==",
+ "version": "2.59.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz",
+ "integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -1351,9 +1351,9 @@
}
},
"node_modules/@tauri-apps/api": {
- "version": "2.10.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
- "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
+ "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -1587,12 +1587,12 @@
}
},
"node_modules/@tauri-apps/plugin-dialog": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz",
- "integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==",
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
+ "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
- "@tauri-apps/api": "^2.10.1"
+ "@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
@@ -2711,9 +2711,9 @@
"license": "MIT"
},
"node_modules/jsdom": {
- "version": "29.1.0",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz",
- "integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==",
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3645,9 +3645,9 @@
}
},
"node_modules/svelte-check": {
- "version": "4.4.6",
- "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz",
- "integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==",
+ "version": "4.4.8",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz",
+ "integrity": "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 9e7270ba8..bd988dde1 100644
--- a/package.json
+++ b/package.json
@@ -28,9 +28,9 @@
"dependencies": {
"@icons-pack/svelte-simple-icons": "^7.2.0",
"@kawarp/core": "^1.1.1",
- "@tauri-apps/api": "^2",
+ "@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2",
- "@tauri-apps/plugin-dialog": "^2.7.0",
+ "@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
@@ -41,13 +41,13 @@
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
- "@sveltejs/kit": "^2.58.0",
+ "@sveltejs/kit": "^2.59.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tauri-apps/cli": "^2",
"@testing-library/svelte": "^5.3.1",
- "jsdom": "^29.1.0",
+ "jsdom": "^29.1.1",
"svelte": "^5.55.5",
- "svelte-check": "^4.4.6",
+ "svelte-check": "^4.4.8",
"typescript": "~6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5"
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index cd6ae47ce..5f23c1448 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -2729,7 +2729,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2 0.6.3",
+ "socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -3709,9 +3709,9 @@ dependencies = [
[[package]]
name = "notify-rust"
-version = "4.16.1"
+version = "4.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3bdaf6120b9df005d37e58f6b75329be6255450453fbeba9ce4192324f921fb9"
+checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00"
dependencies = [
"futures-lite",
"log",
@@ -4242,7 +4242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.48.0",
]
[[package]]
@@ -5219,7 +5219,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
- "socket2 0.6.3",
+ "socket2 0.5.10",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -5256,7 +5256,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2 0.6.3",
+ "socket2 0.5.10",
"tracing",
"windows-sys 0.60.2",
]
@@ -5797,9 +5797,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.37"
+version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"log",
@@ -6961,9 +6961,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-deep-link"
-version = "2.4.8"
+version = "2.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3db49816aee496a9b200d55b55ab6ae73fd50847c79f2fabc7ee20871fa75c95"
+checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
dependencies = [
"dunce",
"plist",
@@ -7024,9 +7024,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-opener"
-version = "2.5.3"
+version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f"
+checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
dependencies = [
"dunce",
"glob",
@@ -7390,9 +7390,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.52.1"
+version = "1.52.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
+checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386"
dependencies = [
"bytes",
"libc",
@@ -8379,7 +8379,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.48.0",
]
[[package]]
From 2b5366eb0654f87a043c894a95f14847c65b911d Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Thu, 7 May 2026 01:20:27 -0600
Subject: [PATCH 05/11] chore(deps): add discord-rich-presence 0.2.4
For Discord Rich Presence integration (#336).
cc
---
src-tauri/Cargo.lock | 53 +++++++++++++++++++++++++++++++-------------
src-tauri/Cargo.toml | 3 +++
2 files changed, 41 insertions(+), 15 deletions(-)
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 5f23c1448..380166476 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -812,7 +812,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
- "uuid",
+ "uuid 1.23.1",
]
[[package]]
@@ -1479,6 +1479,19 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "discord-rich-presence"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "serde_repr",
+ "uuid 0.8.2",
+]
+
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -2729,7 +2742,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2 0.5.10",
+ "socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@@ -4242,7 +4255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4962,7 +4975,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
- "uuid",
+ "uuid 1.23.1",
"walkdir",
]
@@ -4991,6 +5004,7 @@ dependencies = [
"base64 0.22.1",
"chrono",
"dirs",
+ "discord-rich-presence",
"dotenvy",
"ebur128",
"env_logger",
@@ -5053,7 +5067,7 @@ dependencies = [
"tokio-stream",
"tower-http 0.5.2",
"urlencoding",
- "uuid",
+ "uuid 1.23.1",
"walkdir",
]
@@ -5110,7 +5124,7 @@ dependencies = [
"serde",
"sha2 0.10.9",
"thiserror 2.0.18",
- "uuid",
+ "uuid 1.23.1",
]
[[package]]
@@ -5126,7 +5140,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.18",
"tokio",
- "uuid",
+ "uuid 1.23.1",
]
[[package]]
@@ -5147,7 +5161,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
- "uuid",
+ "uuid 1.23.1",
]
[[package]]
@@ -5219,7 +5233,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
- "socket2 0.5.10",
+ "socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -5256,7 +5270,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2 0.5.10",
+ "socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
@@ -5914,7 +5928,7 @@ dependencies = [
"serde",
"serde_json",
"url",
- "uuid",
+ "uuid 1.23.1",
]
[[package]]
@@ -6909,7 +6923,7 @@ dependencies = [
"thiserror 2.0.18",
"time",
"url",
- "uuid",
+ "uuid 1.23.1",
"walkdir",
]
@@ -7206,7 +7220,7 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
"url",
"urlpattern",
- "uuid",
+ "uuid 1.23.1",
"walkdir",
]
@@ -7924,6 +7938,15 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "uuid"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
[[package]]
name = "uuid"
version = "1.23.1"
@@ -8379,7 +8402,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -9307,7 +9330,7 @@ dependencies = [
"tokio",
"tracing",
"uds_windows",
- "uuid",
+ "uuid 1.23.1",
"windows-sys 0.61.2",
"winnow 0.7.14",
"zbus_macros 5.14.0",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index eb4a4f4f4..bd4545a4d 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -97,6 +97,9 @@ rand = "0.10"
# Fuzzy string similarity (for track-mix dedup)
strsim = "0.11"
+# Discord Rich Presence (#336)
+discord-rich-presence = "0.2.4"
+
# Audio playback
rodio = { version = "0.22", features = ["symphonia-all"] }
symphonia = { version = "0.5", features = ["all"] }
From 82453b8fee4cccf5bd0b8ea3ec15b0822f97ddbf Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Thu, 7 May 2026 01:29:03 -0600
Subject: [PATCH 06/11] feat(discord-rpc): backend module and Tauri commands
Adds src-tauri/src/discord_rpc.rs with DiscordRpcState (Mutex>
+ AtomicBool enabled) and three V2 commands:
- v2_discord_rpc_set_enabled
- v2_discord_rpc_update
- v2_discord_rpc_clear
Lazy connect on first update; silent degrade when Discord is unavailable.
Hardcoded Application ID 1501835855587708988 (public identifier).
Refs #336.
cc
---
src-tauri/src/discord_rpc.rs | 122 +++++++++++++++++++++++++++++++++++
src-tauri/src/lib.rs | 5 ++
2 files changed, 127 insertions(+)
create mode 100644 src-tauri/src/discord_rpc.rs
diff --git a/src-tauri/src/discord_rpc.rs b/src-tauri/src/discord_rpc.rs
new file mode 100644
index 000000000..2e409e0e1
--- /dev/null
+++ b/src-tauri/src/discord_rpc.rs
@@ -0,0 +1,122 @@
+//! Discord Rich Presence integration.
+//!
+//! Wraps `discord-rich-presence` behind three Tauri V2 commands. Connection
+//! is lazy: the IPC client is created on the first `update` call when the
+//! feature is enabled, and dropped if any operation fails. Failure is silent
+//! — when Discord is not running, when the sandbox blocks the IPC socket,
+//! or when the user has not opted in, the activity simply does not appear.
+
+use std::sync::Mutex;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
+use tauri::State;
+
+/// Public Discord Application ID for QBZ. Public identifier, not a secret.
+const DISCORD_APP_ID: &str = "1501835855587708988";
+const QBZ_HOMEPAGE: &str = "https://qbz.lol";
+
+#[derive(Default)]
+pub struct DiscordRpcState {
+ client: Mutex >,
+ enabled: AtomicBool,
+}
+
+#[tauri::command]
+pub fn v2_discord_rpc_set_enabled(
+ state: State<'_, DiscordRpcState>,
+ enabled: bool,
+) -> Result<(), String> {
+ state.enabled.store(enabled, Ordering::SeqCst);
+ if !enabled {
+ let mut guard = state.client.lock().map_err(|e| e.to_string())?;
+ if let Some(client) = guard.as_mut() {
+ let _ = client.clear_activity();
+ let _ = client.close();
+ }
+ *guard = None;
+ }
+ Ok(())
+}
+
+#[tauri::command]
+pub fn v2_discord_rpc_clear(state: State<'_, DiscordRpcState>) -> Result<(), String> {
+ let mut guard = state.client.lock().map_err(|e| e.to_string())?;
+ if let Some(client) = guard.as_mut() {
+ let _ = client.clear_activity();
+ let _ = client.close();
+ }
+ *guard = None;
+ Ok(())
+}
+
+#[tauri::command]
+pub fn v2_discord_rpc_update(
+ state: State<'_, DiscordRpcState>,
+ title: String,
+ artist: String,
+ album: String,
+ is_playing: bool,
+ current_time: f64,
+ duration: f64,
+ cover_url: Option,
+) -> Result<(), String> {
+ if !state.enabled.load(Ordering::SeqCst) {
+ return Ok(());
+ }
+
+ let mut guard = state.client.lock().map_err(|e| e.to_string())?;
+
+ if guard.is_none() {
+ if let Ok(mut client) = DiscordIpcClient::new(DISCORD_APP_ID) {
+ if client.connect().is_ok() {
+ *guard = Some(client);
+ }
+ }
+ }
+
+ let Some(client) = guard.as_mut() else {
+ return Ok(());
+ };
+
+ let state_str = format!("by {}", artist);
+ let small_text = if is_playing {
+ "Playing".to_string()
+ } else {
+ let mins = (current_time / 60.0).floor() as u32;
+ let secs = (current_time % 60.0).floor() as u32;
+ format!("Paused at {:02}:{:02}", mins, secs)
+ };
+ let large_image = cover_url.unwrap_or_else(|| "cover".to_string());
+
+ let mut act = activity::Activity::new()
+ .details(&title)
+ .state(&state_str)
+ .activity_type(activity::ActivityType::Listening)
+ .assets(
+ activity::Assets::new()
+ .large_image(&large_image)
+ .small_image("icon")
+ .large_text(&album)
+ .small_text(&small_text),
+ )
+ .buttons(vec![activity::Button::new("Get QBZ", QBZ_HOMEPAGE)]);
+
+ if is_playing {
+ if let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) {
+ let start_time = now.as_secs() as i64 - current_time as i64;
+ let mut ts = activity::Timestamps::new().start(start_time);
+ if duration > 0.0 {
+ ts = ts.end(start_time + duration as i64);
+ }
+ act = act.timestamps(ts);
+ }
+ }
+
+ if client.set_activity(act).is_err() {
+ *guard = None;
+ }
+
+ Ok(())
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 9da2aaf87..0af2d4e4f 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -38,6 +38,7 @@ pub mod cast;
pub mod config;
pub mod credentials;
pub mod discogs;
+pub mod discord_rpc;
pub mod flatpak;
#[cfg(target_os = "linux")]
pub mod idle_inhibit;
@@ -1376,6 +1377,7 @@ pub fn run(qconnect_cli_override: Option) {
.manage(listenbrainz_v2_state)
.manage(musicbrainz_v2_state)
.manage(lastfm_v2_state)
+ .manage(discord_rpc::DiscordRpcState::default())
.invoke_handler(tauri::generate_handler![
commands_v2::runtime_get_status,
commands_v2::runtime_bootstrap,
@@ -1949,6 +1951,9 @@ pub fn run(qconnect_cli_override: Option) {
commands_v2::v2_collection_shuffle_tracks,
commands_v2::v2_skip_to_next_item,
commands_v2::v2_skip_to_previous_item,
+ discord_rpc::v2_discord_rpc_set_enabled,
+ discord_rpc::v2_discord_rpc_update,
+ discord_rpc::v2_discord_rpc_clear,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
From 7531c2f092d38774f3cf802bcf5e54af28178fc8 Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Thu, 7 May 2026 01:32:07 -0600
Subject: [PATCH 07/11] feat(discord-rpc): frontend service with localStorage
toggle and dedupe
Creates discordRpcService that:
- persists the user toggle in localStorage (qbz-discord-rpc-enabled, off by default)
- on enable, subscribes to playerStore and forwards (track_id, is_playing) transitions
- dedupes by (track_id, is_playing) so currentTime ticks don't spam the backend
- silently ignores backend errors (headless, sandbox-blocked, etc.)
Refs #336.
cc
---
src/lib/services/discordRpcService.ts | 113 ++++++++++++++++++++++++++
1 file changed, 113 insertions(+)
create mode 100644 src/lib/services/discordRpcService.ts
diff --git a/src/lib/services/discordRpcService.ts b/src/lib/services/discordRpcService.ts
new file mode 100644
index 000000000..2b14bc633
--- /dev/null
+++ b/src/lib/services/discordRpcService.ts
@@ -0,0 +1,113 @@
+/**
+ * Discord Rich Presence Service
+ *
+ * Pushes "now listening" activity to Discord via the Tauri backend.
+ * Opt-in: persisted in localStorage as `qbz-discord-rpc-enabled`, off by
+ * default. When the toggle is enabled, this module subscribes to playerStore
+ * and forwards (track_id, is_playing) transitions to the backend, dropping
+ * intermediate currentTime ticks so Discord is not spammed.
+ */
+
+import { invoke } from '@tauri-apps/api/core';
+import { getUserItem, setUserItem } from '$lib/utils/userStorage';
+import {
+ subscribe as subscribePlayer,
+ getCurrentTrack,
+ getIsPlaying,
+ getCurrentTime,
+ getDuration
+} from '$lib/stores/playerStore';
+
+const STORAGE_KEY = 'qbz-discord-rpc-enabled';
+
+let unsubscribePlayer: (() => void) | null = null;
+let lastTrackId: number | null = null;
+let lastIsPlaying: boolean | null = null;
+
+export function isDiscordRpcEnabled(): boolean {
+ return getUserItem(STORAGE_KEY) === 'true';
+}
+
+export async function setDiscordRpcEnabled(enabled: boolean): Promise {
+ setUserItem(STORAGE_KEY, enabled ? 'true' : 'false');
+ try {
+ await invoke('v2_discord_rpc_set_enabled', { enabled });
+ } catch {
+ // Backend unavailable (headless / remote). Toggle still persists in localStorage.
+ }
+ if (enabled) {
+ startSubscription();
+ await pushCurrentState();
+ } else {
+ stopSubscription();
+ try {
+ await invoke('v2_discord_rpc_clear');
+ } catch {
+ // ignore
+ }
+ }
+}
+
+/**
+ * Called once on app boot. Brings backend state in sync with the persisted
+ * toggle and starts the playerStore subscription if enabled.
+ */
+export async function initDiscordRpc(): Promise {
+ const enabled = isDiscordRpcEnabled();
+ try {
+ await invoke('v2_discord_rpc_set_enabled', { enabled });
+ } catch {
+ // ignore
+ }
+ if (enabled) {
+ startSubscription();
+ await pushCurrentState();
+ }
+}
+
+function startSubscription(): void {
+ if (unsubscribePlayer) return;
+ unsubscribePlayer = subscribePlayer(() => {
+ const track = getCurrentTrack();
+ const isPlaying = getIsPlaying();
+ const trackId = track?.id ?? null;
+ if (trackId === lastTrackId && isPlaying === lastIsPlaying) return;
+ lastTrackId = trackId;
+ lastIsPlaying = isPlaying;
+ void pushCurrentState();
+ });
+}
+
+function stopSubscription(): void {
+ if (unsubscribePlayer) {
+ unsubscribePlayer();
+ unsubscribePlayer = null;
+ }
+ lastTrackId = null;
+ lastIsPlaying = null;
+}
+
+async function pushCurrentState(): Promise {
+ const track = getCurrentTrack();
+ if (!track) {
+ try {
+ await invoke('v2_discord_rpc_clear');
+ } catch {
+ // ignore
+ }
+ return;
+ }
+ try {
+ await invoke('v2_discord_rpc_update', {
+ title: track.title,
+ artist: track.artist,
+ album: track.album,
+ isPlaying: getIsPlaying(),
+ currentTime: getCurrentTime(),
+ duration: getDuration(),
+ coverUrl: track.artwork || null
+ });
+ } catch {
+ // Backend unavailable; silent.
+ }
+}
From 232276f4fa0c3eaf339c6a3558a00de654819cad Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Thu, 7 May 2026 01:35:19 -0600
Subject: [PATCH 08/11] feat(discord-rpc): wire initDiscordRpc into app boot
Calls discordRpcService.initDiscordRpc() once from +page.svelte onMount so
the backend state matches the persisted toggle and the playerStore subscriber
is registered when the user has opted in.
Refs #336.
cc
---
src/routes/+page.svelte | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 45bfb6831..99a15915c 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -517,6 +517,7 @@
resetLaunchFlow,
} from '$lib/services/updatesService';
import type { AutoUpdateProgress } from '$lib/services/updatesService';
+ import { initDiscordRpc } from '$lib/services/discordRpcService';
import UpdateProgressModal from '$lib/components/updates/UpdateProgressModal.svelte';
// Offline state
@@ -4603,6 +4604,8 @@
// Bootstrap app (theme, mouse nav, Last.fm restore)
const { cleanup: cleanupBootstrap } = bootstrapApp();
+ void initDiscordRpc();
+
// Window-title preference: load from localStorage and subscribe so that
// toggling the setting updates the OS title bar immediately.
initWindowTitleStore();
From 6b08cf68486364f022b16ceba05b5936d15f9811 Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Thu, 7 May 2026 01:38:04 -0600
Subject: [PATCH 09/11] i18n(discord-rpc): add translation keys for 5 locales
Adds settings.integrations.discordRpc keys (label, description, overrideHeader,
flatpakNote, snapNote) to en/es/de/fr/pt for the Discord Rich Presence settings
toggle and sandbox override block.
Refs #336.
cc
---
src/lib/i18n/locales/de.json | 7 +++++++
src/lib/i18n/locales/en.json | 7 +++++++
src/lib/i18n/locales/es.json | 7 +++++++
src/lib/i18n/locales/fr.json | 7 +++++++
src/lib/i18n/locales/pt.json | 7 +++++++
5 files changed, 35 insertions(+)
diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json
index aa6953426..d179fa9e5 100644
--- a/src/lib/i18n/locales/de.json
+++ b/src/lib/i18n/locales/de.json
@@ -2420,6 +2420,13 @@
},
"integrations": {
"title": "Integrationen",
+ "discordRpc": {
+ "label": "Discord-Aktivität anzeigen",
+ "description": "Während du in QBZ Musik hörst, wird der aktuelle Titel in deinem Discord-Status angezeigt. Standardmäßig aus.",
+ "overrideHeader": "Falls die Aktivität in Discord nicht erscheint (Sandbox-Installationen)",
+ "flatpakNote": "Flatpak: einmal ausführen und QBZ neu starten.",
+ "snapNote": "Snap: nicht unterstützt. Verwende das .deb-, .rpm- oder .AppImage-Paket von GitHub Releases."
+ },
"lastfm": "Last.fm",
"spotify": "Spotify",
"tidal": "Tidal",
diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json
index 65c5ef9e8..7a5241f49 100644
--- a/src/lib/i18n/locales/en.json
+++ b/src/lib/i18n/locales/en.json
@@ -2426,6 +2426,13 @@
},
"integrations": {
"title": "Integrations",
+ "discordRpc": {
+ "label": "Show Discord activity",
+ "description": "When playing music in QBZ, show the current track in your Discord status. Off by default — toggle on to enable.",
+ "overrideHeader": "If activity doesn't show in Discord (sandboxed installs)",
+ "flatpakNote": "Flatpak: run this once, then restart QBZ.",
+ "snapNote": "Snap: not supported. Install the .deb, .rpm, or .AppImage from GitHub Releases."
+ },
"lastfm": "Last.fm",
"spotify": "Spotify",
"tidal": "Tidal",
diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json
index 6d6d0855e..86010307e 100644
--- a/src/lib/i18n/locales/es.json
+++ b/src/lib/i18n/locales/es.json
@@ -2426,6 +2426,13 @@
},
"integrations": {
"title": "Integraciones",
+ "discordRpc": {
+ "label": "Mostrar actividad en Discord",
+ "description": "Cuando reproduces música en QBZ, muestra la pista actual en tu estado de Discord. Desactivado por defecto.",
+ "overrideHeader": "Si la actividad no aparece en Discord (instalaciones con sandbox)",
+ "flatpakNote": "Flatpak: ejecuta este comando una vez y reinicia QBZ.",
+ "snapNote": "Snap: no compatible. Instala el .deb, .rpm o .AppImage desde GitHub Releases."
+ },
"lastfm": "Last.fm",
"spotify": "Spotify",
"tidal": "Tidal",
diff --git a/src/lib/i18n/locales/fr.json b/src/lib/i18n/locales/fr.json
index 17e3e2f0b..b679dd763 100644
--- a/src/lib/i18n/locales/fr.json
+++ b/src/lib/i18n/locales/fr.json
@@ -2420,6 +2420,13 @@
},
"integrations": {
"title": "Intégrations",
+ "discordRpc": {
+ "label": "Afficher l'activité Discord",
+ "description": "Lorsque vous écoutez de la musique dans QBZ, le titre en cours s'affiche dans votre statut Discord. Désactivé par défaut.",
+ "overrideHeader": "Si l'activité n'apparaît pas dans Discord (installations en sandbox)",
+ "flatpakNote": "Flatpak: exécutez cette commande une fois, puis redémarrez QBZ.",
+ "snapNote": "Snap: non pris en charge. Installez le .deb, .rpm ou .AppImage depuis GitHub Releases."
+ },
"lastfm": "Last.fm",
"spotify": "Spotify",
"tidal": "Tidal",
diff --git a/src/lib/i18n/locales/pt.json b/src/lib/i18n/locales/pt.json
index 9c3c9cfba..a14e14a48 100644
--- a/src/lib/i18n/locales/pt.json
+++ b/src/lib/i18n/locales/pt.json
@@ -2420,6 +2420,13 @@
},
"integrations": {
"title": "Integrações",
+ "discordRpc": {
+ "label": "Mostrar atividade no Discord",
+ "description": "Ao reproduzir música no QBZ, mostra a faixa atual no seu status do Discord. Desativado por padrão.",
+ "overrideHeader": "Se a atividade não aparecer no Discord (instalações em sandbox)",
+ "flatpakNote": "Flatpak: execute este comando uma vez e reinicie o QBZ.",
+ "snapNote": "Snap: sem suporte. Instale o .deb, .rpm ou .AppImage do GitHub Releases."
+ },
"lastfm": "Last.fm",
"spotify": "Spotify",
"tidal": "Tidal",
From f2b0ce707107543286fcbe7bbd80d06275e3e5aa Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Thu, 7 May 2026 01:42:42 -0600
Subject: [PATCH 10/11] feat(discord-rpc): settings UI with toggle and sandbox
override block
Adds a Discord Rich Presence section to Settings -> Integrations:
- Toggle wired to discordRpcService (off by default, persisted in localStorage)
- Collapsible override block under the toggle with the flatpak override
command and a Snap fallback note. Always shown - runtime detection isn't
worth the complexity for a help block.
Refs #336.
cc
---
src/lib/components/views/SettingsView.svelte | 109 +++++++++++++++++++
1 file changed, 109 insertions(+)
diff --git a/src/lib/components/views/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte
index 7916058f0..a83fb680d 100644
--- a/src/lib/components/views/SettingsView.svelte
+++ b/src/lib/components/views/SettingsView.svelte
@@ -38,6 +38,10 @@
setSystemNotificationsEnabled,
loadSystemNotificationsPreference
} from '$lib/services/playbackService';
+ import {
+ isDiscordRpcEnabled,
+ setDiscordRpcEnabled
+ } from '$lib/services/discordRpcService';
import { getIsPlaying } from '$lib/stores/playerStore';
import { setLocale, locale, t } from '$lib/i18n';
import { get } from 'svelte/store';
@@ -1390,6 +1394,9 @@
// MusicBrainz integration state
let musicbrainzEnabled = $state(true);
+ // Discord Rich Presence integration state
+ let discordRpcEnabled = $state(false);
+
// ListenBrainz integration state
let listenbrainzConnected = $state(false);
let listenbrainzUsername = $state('');
@@ -1653,6 +1660,9 @@
// Load ListenBrainz state
loadListenBrainzState();
+ // Load Discord Rich Presence state
+ loadDiscordRpcState();
+
// Load remote control status
loadRemoteControlStatus();
@@ -1940,6 +1950,20 @@
}
}
+ // Discord Rich Presence
+ function loadDiscordRpcState() {
+ discordRpcEnabled = isDiscordRpcEnabled();
+ }
+
+ async function handleDiscordRpcToggle(value: boolean) {
+ discordRpcEnabled = value;
+ try {
+ await setDiscordRpcEnabled(value);
+ } catch (err) {
+ console.error('Failed to update Discord RPC setting:', err);
+ }
+ }
+
// ListenBrainz functions
async function loadListenBrainzState() {
try {
@@ -5563,6 +5587,28 @@
{$t('settings.integrations.plexConnection')}
@@ -6915,6 +6961,69 @@ flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz
color: var(--text-muted);
}
+ .setting-row.discord-rpc-override-row {
+ height: auto;
+ align-items: flex-start;
+ padding: 8px 0;
+ }
+
+ .discord-rpc-override {
+ width: 100%;
+ }
+
+ .discord-rpc-override > summary {
+ cursor: pointer;
+ color: var(--text-secondary);
+ font-size: 13px;
+ list-style: none;
+ user-select: none;
+ }
+
+ .discord-rpc-override > summary::-webkit-details-marker {
+ display: none;
+ }
+
+ .discord-rpc-override > summary::before {
+ content: '▸ ';
+ display: inline-block;
+ width: 14px;
+ color: var(--text-muted);
+ }
+
+ .discord-rpc-override[open] > summary::before {
+ content: '▾ ';
+ }
+
+ .discord-rpc-override-body {
+ margin-top: 8px;
+ padding-left: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--text-muted);
+ }
+
+ .discord-rpc-override-body p {
+ margin: 0;
+ }
+
+ .discord-rpc-override-body pre {
+ margin: 0;
+ padding: 8px 10px;
+ background: var(--bg-tertiary);
+ border-radius: 4px;
+ overflow-x: auto;
+ }
+
+ .discord-rpc-override-body pre code {
+ font-family: var(--font-mono, monospace);
+ font-size: 11px;
+ color: var(--text-primary);
+ background: transparent;
+ padding: 0;
+ }
+
.setting-row {
display: flex;
align-items: center;
From 3a0c2ab6ce0aeed60e9afc01f4e950596ae24c4c Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Thu, 7 May 2026 01:47:54 -0600
Subject: [PATCH 11/11] build(flatpak): expose discord-ipc-0 socket for RPC
Adds --filesystem=xdg-run/discord-ipc-0 to the self-hosted Flatpak manifest
so Discord Rich Presence can reach the IPC socket from inside the sandbox.
The Flathub manifest is updated separately at release time.
Refs #336.
cc
---
packaging/flatpak/com.blitzfc.qbz.yml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/packaging/flatpak/com.blitzfc.qbz.yml b/packaging/flatpak/com.blitzfc.qbz.yml
index e01137f3f..3f5d55d5f 100644
--- a/packaging/flatpak/com.blitzfc.qbz.yml
+++ b/packaging/flatpak/com.blitzfc.qbz.yml
@@ -18,6 +18,9 @@ finish-args:
# Native PipeWire access
- --filesystem=xdg-run/pipewire-0:ro
+ # Discord IPC socket for Rich Presence (#336)
+ - --filesystem=xdg-run/discord-ipc-0
+
# User music library
- --filesystem=xdg-music