Merge branch 'pre-release' into feature/external/macos-exclusive-mode
This commit is contained in:
20 files changed
+853
-333
No files matched your search
Generated
+21
-21
@@ -11,9 +11,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@icons-pack/svelte-simple-icons": "^7.2.0",
|
"@icons-pack/svelte-simple-icons": "^7.2.0",
|
||||||
"@kawarp/core": "^1.1.1",
|
"@kawarp/core": "^1.1.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2",
|
"@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-opener": "^2",
|
||||||
"@tauri-apps/plugin-os": "^2",
|
"@tauri-apps/plugin-os": "^2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
@@ -24,13 +24,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.58.0",
|
"@sveltejs/kit": "^2.59.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"jsdom": "^29.1.0",
|
"jsdom": "^29.1.1",
|
||||||
"svelte": "^5.55.5",
|
"svelte": "^5.55.5",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.8",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"vite": "^8.0.10",
|
"vite": "^8.0.10",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
@@ -1291,9 +1291,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/kit": {
|
"node_modules/@sveltejs/kit": {
|
||||||
"version": "2.58.0",
|
"version": "2.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.58.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz",
|
||||||
"integrity": "sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA==",
|
"integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
@@ -1351,9 +1351,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/api": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.10.1",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
|
||||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1587,12 +1587,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-dialog": {
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||||
"integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==",
|
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.10.1"
|
"@tauri-apps/api": "^2.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-opener": {
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
@@ -2711,9 +2711,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "29.1.0",
|
"version": "29.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||||
"integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==",
|
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3645,9 +3645,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/svelte-check": {
|
"node_modules/svelte-check": {
|
||||||
"version": "4.4.6",
|
"version": "4.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz",
|
||||||
"integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==",
|
"integrity": "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+5
-5
@@ -28,9 +28,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@icons-pack/svelte-simple-icons": "^7.2.0",
|
"@icons-pack/svelte-simple-icons": "^7.2.0",
|
||||||
"@kawarp/core": "^1.1.1",
|
"@kawarp/core": "^1.1.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2",
|
"@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-opener": "^2",
|
||||||
"@tauri-apps/plugin-os": "^2",
|
"@tauri-apps/plugin-os": "^2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
@@ -41,13 +41,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.58.0",
|
"@sveltejs/kit": "^2.59.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"jsdom": "^29.1.0",
|
"jsdom": "^29.1.1",
|
||||||
"svelte": "^5.55.5",
|
"svelte": "^5.55.5",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.8",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"vite": "^8.0.10",
|
"vite": "^8.0.10",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ finish-args:
|
|||||||
# Native PipeWire access
|
# Native PipeWire access
|
||||||
- --filesystem=xdg-run/pipewire-0:ro
|
- --filesystem=xdg-run/pipewire-0:ro
|
||||||
|
|
||||||
|
# Discord IPC socket for Rich Presence (#336)
|
||||||
|
- --filesystem=xdg-run/discord-ipc-0
|
||||||
|
|
||||||
# User music library
|
# User music library
|
||||||
- --filesystem=xdg-music
|
- --filesystem=xdg-music
|
||||||
|
|
||||||
|
|||||||
Generated
+43
-20
@@ -812,7 +812,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"fnv",
|
"fnv",
|
||||||
"uuid",
|
"uuid 1.23.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1479,6 +1479,19 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "dispatch"
|
name = "dispatch"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3709,9 +3722,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify-rust"
|
name = "notify-rust"
|
||||||
version = "4.16.1"
|
version = "4.17.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3bdaf6120b9df005d37e58f6b75329be6255450453fbeba9ce4192324f921fb9"
|
checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"log",
|
"log",
|
||||||
@@ -4965,7 +4978,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"uuid",
|
"uuid 1.23.1",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4994,6 +5007,7 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"discord-rich-presence",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"ebur128",
|
"ebur128",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -5056,7 +5070,7 @@ dependencies = [
|
|||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tower-http 0.5.2",
|
"tower-http 0.5.2",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid 1.23.1",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5113,7 +5127,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"uuid",
|
"uuid 1.23.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5129,7 +5143,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid 1.23.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5150,7 +5164,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"uuid",
|
"uuid 1.23.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5800,9 +5814,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.37"
|
version = "0.23.40"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"log",
|
"log",
|
||||||
@@ -5917,7 +5931,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid 1.23.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6912,7 +6926,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid 1.23.1",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6964,9 +6978,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-deep-link"
|
name = "tauri-plugin-deep-link"
|
||||||
version = "2.4.8"
|
version = "2.4.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3db49816aee496a9b200d55b55ab6ae73fd50847c79f2fabc7ee20871fa75c95"
|
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dunce",
|
"dunce",
|
||||||
"plist",
|
"plist",
|
||||||
@@ -7027,9 +7041,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.3"
|
version = "2.5.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f"
|
checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dunce",
|
"dunce",
|
||||||
"glob",
|
"glob",
|
||||||
@@ -7209,7 +7223,7 @@ dependencies = [
|
|||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
"uuid",
|
"uuid 1.23.1",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7393,9 +7407,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.52.1"
|
version = "1.52.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -7927,6 +7941,15 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
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]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.1"
|
version = "1.23.1"
|
||||||
@@ -9310,7 +9333,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uds_windows",
|
"uds_windows",
|
||||||
"uuid",
|
"uuid 1.23.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"winnow 0.7.14",
|
"winnow 0.7.14",
|
||||||
"zbus_macros 5.14.0",
|
"zbus_macros 5.14.0",
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ rand = "0.10"
|
|||||||
# Fuzzy string similarity (for track-mix dedup)
|
# Fuzzy string similarity (for track-mix dedup)
|
||||||
strsim = "0.11"
|
strsim = "0.11"
|
||||||
|
|
||||||
|
# Discord Rich Presence (#336)
|
||||||
|
discord-rich-presence = "0.2.4"
|
||||||
|
|
||||||
# Audio playback
|
# Audio playback
|
||||||
rodio = { version = "0.22", features = ["symphonia-all"] }
|
rodio = { version = "0.22", features = ["symphonia-all"] }
|
||||||
symphonia = { version = "0.5", features = ["all"] }
|
symphonia = { version = "0.5", features = ["all"] }
|
||||||
|
|||||||
@@ -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<Option<DiscordIpcClient>>,
|
||||||
|
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<String>,
|
||||||
|
) -> 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(())
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ pub mod cast;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod credentials;
|
pub mod credentials;
|
||||||
pub mod discogs;
|
pub mod discogs;
|
||||||
|
pub mod discord_rpc;
|
||||||
pub mod flatpak;
|
pub mod flatpak;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod idle_inhibit;
|
pub mod idle_inhibit;
|
||||||
@@ -1376,6 +1377,7 @@ pub fn run(qconnect_cli_override: Option<bool>) {
|
|||||||
.manage(listenbrainz_v2_state)
|
.manage(listenbrainz_v2_state)
|
||||||
.manage(musicbrainz_v2_state)
|
.manage(musicbrainz_v2_state)
|
||||||
.manage(lastfm_v2_state)
|
.manage(lastfm_v2_state)
|
||||||
|
.manage(discord_rpc::DiscordRpcState::default())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands_v2::runtime_get_status,
|
commands_v2::runtime_get_status,
|
||||||
commands_v2::runtime_bootstrap,
|
commands_v2::runtime_bootstrap,
|
||||||
@@ -1949,6 +1951,9 @@ pub fn run(qconnect_cli_override: Option<bool>) {
|
|||||||
commands_v2::v2_collection_shuffle_tracks,
|
commands_v2::v2_collection_shuffle_tracks,
|
||||||
commands_v2::v2_skip_to_next_item,
|
commands_v2::v2_skip_to_next_item,
|
||||||
commands_v2::v2_skip_to_previous_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!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
|
|||||||
@@ -274,25 +274,26 @@
|
|||||||
tabindex={isBlacklisted ? -1 : 0}
|
tabindex={isBlacklisted ? -1 : 0}
|
||||||
onkeydown={(e) => e.key === 'Enter' && !isBlacklisted && (selectable ? onToggleSelect?.(e as unknown as MouseEvent) : onPlay?.())}
|
onkeydown={(e) => e.key === 'Enter' && !isBlacklisted && (selectable ? onToggleSelect?.(e as unknown as MouseEvent) : onPlay?.())}
|
||||||
>
|
>
|
||||||
<!-- Checkbox (select mode) -->
|
<!-- Checkbox (select mode) — always rendered, animates in/out so the
|
||||||
{#if selectable}
|
column doesn't pop when select mode toggles. -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="track-checkbox"
|
class="track-checkbox"
|
||||||
onclick={(e) => {
|
class:active={selectable}
|
||||||
e.stopPropagation();
|
onclick={(e) => {
|
||||||
onToggleSelect?.(e);
|
if (!selectable) return;
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
onToggleSelect?.(e);
|
||||||
<input
|
}}
|
||||||
type="checkbox"
|
>
|
||||||
checked={selected}
|
<input
|
||||||
tabindex={-1}
|
type="checkbox"
|
||||||
aria-label="Select track"
|
checked={selected}
|
||||||
style="pointer-events: none;"
|
tabindex={-1}
|
||||||
/>
|
aria-label="Select track"
|
||||||
</div>
|
style="pointer-events: none;"
|
||||||
{/if}
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Track Number / Play Button / Unavailable Indicator -->
|
<!-- Track Number / Play Button / Unavailable Indicator -->
|
||||||
<div class="track-number" class:unavailable={isUnavailable} class:blacklisted={isBlacklisted}>
|
<div class="track-number" class:unavailable={isUnavailable} class:blacklisted={isBlacklisted}>
|
||||||
@@ -523,11 +524,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.track-checkbox {
|
.track-checkbox {
|
||||||
width: 24px;
|
width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: width 180ms ease, opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-checkbox.active {
|
||||||
|
width: 24px;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-checkbox input[type='checkbox'] {
|
.track-checkbox input[type='checkbox'] {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
subscribe as subscribeAppearance,
|
subscribe as subscribeAppearance,
|
||||||
isAlbumHeaderGradientEnabled,
|
isAlbumHeaderGradientEnabled,
|
||||||
} from '$lib/stores/appearancePreferencesStore';
|
} from '$lib/stores/appearancePreferencesStore';
|
||||||
|
import { getCachedImageUrl } from '$lib/services/imageCacheService';
|
||||||
|
|
||||||
interface Track {
|
interface Track {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -231,20 +232,28 @@
|
|||||||
// Header gradient driven by extracted artwork palette.
|
// Header gradient driven by extracted artwork palette.
|
||||||
let gradientEnabled = $state(isAlbumHeaderGradientEnabled());
|
let gradientEnabled = $state(isAlbumHeaderGradientEnabled());
|
||||||
let artworkPalette = $state<ArtworkPalette | null>(null);
|
let artworkPalette = $state<ArtworkPalette | null>(null);
|
||||||
|
let resolvedHeaderImageUrl = $state<string | null>(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const url = coverOverride ?? album.artwork ?? null;
|
const url = coverOverride ?? album.artwork ?? null;
|
||||||
artworkPalette = null;
|
artworkPalette = null;
|
||||||
|
resolvedHeaderImageUrl = null;
|
||||||
if (!url || !gradientEnabled) return;
|
if (!url || !gradientEnabled) return;
|
||||||
|
const stillCurrent = () => (coverOverride ?? album.artwork ?? null) === url;
|
||||||
|
getCachedImageUrl(url).then((resolved) => {
|
||||||
|
if (stillCurrent()) resolvedHeaderImageUrl = resolved;
|
||||||
|
}).catch(() => {});
|
||||||
extractPalette(url).then((p) => {
|
extractPalette(url).then((p) => {
|
||||||
const current = coverOverride ?? album.artwork ?? null;
|
if (stillCurrent()) artworkPalette = p;
|
||||||
if (current === url) artworkPalette = p;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const headerColor = $derived(gradientEnabled ? pickHeaderColor(artworkPalette) : null);
|
const headerColor = $derived(gradientEnabled ? pickHeaderColor(artworkPalette) : null);
|
||||||
const headerStyle = $derived.by(() => {
|
const headerStyle = $derived.by(() => {
|
||||||
if (!headerColor) return '';
|
if (!headerColor) return '';
|
||||||
const needsScrim = headerColor.luminance > 0.6;
|
const needsScrim = headerColor.luminance > 0.6;
|
||||||
return `--art-bg: ${headerColor.hex}; --art-scrim: ${needsScrim ? '0.35' : '0'};`;
|
const imageRule = resolvedHeaderImageUrl
|
||||||
|
? `--art-image-url: url("${resolvedHeaderImageUrl.replace(/"/g, '\\"')}");`
|
||||||
|
: '';
|
||||||
|
return `--art-bg: ${headerColor.hex}; --art-scrim: ${needsScrim ? '0.55' : '0.3'}; ${imageRule}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Multi-select
|
// Multi-select
|
||||||
@@ -750,18 +759,6 @@
|
|||||||
<BookOpen size={18} />
|
<BookOpen size={18} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<AlbumMenu
|
|
||||||
onPlayNext={onPlayAllNext}
|
|
||||||
onPlayLater={onPlayAllLater}
|
|
||||||
onAddToPlaylist={onAddAlbumToPlaylist ? handleAddAlbumToPlaylist : undefined}
|
|
||||||
onAddToMixtape={handleAddToMixtape}
|
|
||||||
onShareQobuz={onShareAlbumQobuz}
|
|
||||||
onShareSonglink={onShareAlbumSonglink}
|
|
||||||
onDownload={onDownloadAlbum}
|
|
||||||
isAlbumFullyDownloaded={albumFullyDownloaded}
|
|
||||||
onOpenContainingFolder={onOpenAlbumFolder}
|
|
||||||
onReDownloadAlbum={onReDownloadAlbum}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
class="action-btn-circle"
|
class="action-btn-circle"
|
||||||
onclick={handleAddToMixtape}
|
onclick={handleAddToMixtape}
|
||||||
@@ -777,6 +774,18 @@
|
|||||||
>
|
>
|
||||||
<SquareCheckBig size={18} />
|
<SquareCheckBig size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
<AlbumMenu
|
||||||
|
onPlayNext={onPlayAllNext}
|
||||||
|
onPlayLater={onPlayAllLater}
|
||||||
|
onAddToPlaylist={onAddAlbumToPlaylist ? handleAddAlbumToPlaylist : undefined}
|
||||||
|
onAddToMixtape={handleAddToMixtape}
|
||||||
|
onShareQobuz={onShareAlbumQobuz}
|
||||||
|
onShareSonglink={onShareAlbumSonglink}
|
||||||
|
onDownload={onDownloadAlbum}
|
||||||
|
isAlbumFullyDownloaded={albumFullyDownloaded}
|
||||||
|
onOpenContainingFolder={onOpenAlbumFolder}
|
||||||
|
onReDownloadAlbum={onReDownloadAlbum}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -821,17 +830,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-header">
|
<div class="table-header">
|
||||||
{#if multiSelectMode}
|
<div class="col-select-all" class:active={multiSelectMode}>
|
||||||
<div class="col-select-all">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
checked={selectAllState === 'all'}
|
||||||
checked={selectAllState === 'all'}
|
indeterminate={selectAllState === 'partial'}
|
||||||
indeterminate={selectAllState === 'partial'}
|
onchange={toggleSelectAll}
|
||||||
onchange={toggleSelectAll}
|
title={$t('actions.selectAll')}
|
||||||
title={$t('actions.selectAll')}
|
tabindex={multiSelectMode ? 0 : -1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
<div class="col-number">#</div>
|
<div class="col-number">#</div>
|
||||||
<div class="col-title">{$t('tracklist.title')}</div>
|
<div class="col-title">{$t('tracklist.title')}</div>
|
||||||
<div class="col-duration">{$t('tracklist.duration')}</div>
|
<div class="col-duration">{$t('tracklist.duration')}</div>
|
||||||
@@ -1094,19 +1102,44 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Artwork-derived backdrop. ::before renders the actual cover 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. */
|
||||||
.album-detail.has-art-bg::before {
|
.album-detail.has-art-bg::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 320px;
|
height: 280px;
|
||||||
background:
|
background-color: var(--art-bg, transparent);
|
||||||
linear-gradient(180deg, rgba(0, 0, 0, var(--art-scrim, 0)) 0%, rgba(0, 0, 0, 0) 100%),
|
background-image: var(--art-image-url, none);
|
||||||
linear-gradient(180deg, var(--art-bg, transparent) 0%, var(--art-bg, transparent) 30%, transparent 100%);
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
filter: blur(70px) saturate(1.3);
|
||||||
|
transform: scale(1.15);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-detail.has-art-bg::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 280px;
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, var(--art-scrim, 0.3)) 0%, rgba(0, 0, 0, 0) 80%);
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: background 320ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-detail > * {
|
.album-detail > * {
|
||||||
@@ -1486,7 +1519,7 @@
|
|||||||
.table-header {
|
.table-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 0 16px;
|
padding: 0 16px 0 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1498,23 +1531,44 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Match the track-row's left padding (overridden below) so the # column
|
||||||
|
and the quality badge above sit at the same x. */
|
||||||
|
.track-list :global(.track-row) {
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list :global(.track-row .track-number) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.col-select-all {
|
.col-select-all {
|
||||||
width: 32px;
|
width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: width 180ms ease, opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-select-all.active {
|
||||||
|
width: 24px;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-select-all input[type="checkbox"] {
|
.col-select-all input[type="checkbox"] {
|
||||||
width: 16px;
|
width: 15px;
|
||||||
height: 16px;
|
height: 15px;
|
||||||
accent-color: var(--accent-primary);
|
accent-color: var(--accent-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-number {
|
.col-number {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-title {
|
.col-title {
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
import { openAddToMixtape } from '$lib/stores/addToMixtapeModalStore';
|
import { openAddToMixtape } from '$lib/stores/addToMixtapeModalStore';
|
||||||
import type { ArtistDetail, QobuzArtist, PageArtistTrack, PageArtistSimilarItem } from '$lib/types';
|
import type { ArtistDetail, QobuzArtist, PageArtistTrack, PageArtistSimilarItem } from '$lib/types';
|
||||||
import { applyShiftRange, isSelectAllShortcut } from '$lib/utils/multiSelect';
|
import { applyShiftRange, isSelectAllShortcut } from '$lib/utils/multiSelect';
|
||||||
|
import { extractPalette, pickHeaderColor, type ArtworkPalette } from '$lib/utils/artworkPalette';
|
||||||
|
import {
|
||||||
|
subscribe as subscribeAppearance,
|
||||||
|
isAlbumHeaderGradientEnabled,
|
||||||
|
} from '$lib/stores/appearancePreferencesStore';
|
||||||
|
import { getCachedImageUrl } from '$lib/services/imageCacheService';
|
||||||
import AlbumCard from '../AlbumCard.svelte';
|
import AlbumCard from '../AlbumCard.svelte';
|
||||||
import TrackMenu from '../TrackMenu.svelte';
|
import TrackMenu from '../TrackMenu.svelte';
|
||||||
import BulkActionBar from '../BulkActionBar.svelte';
|
import BulkActionBar from '../BulkActionBar.svelte';
|
||||||
@@ -205,6 +211,36 @@
|
|||||||
let hasCustomImage = $state(false);
|
let hasCustomImage = $state(false);
|
||||||
let imageOverride = $state<string | null>(null);
|
let imageOverride = $state<string | null>(null);
|
||||||
let customFullImage = $state<string | null>(null);
|
let customFullImage = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Header gradient driven by extracted artwork palette (shared toggle with
|
||||||
|
// album detail under Settings > Appearance > Album header gradient).
|
||||||
|
let gradientEnabled = $state(isAlbumHeaderGradientEnabled());
|
||||||
|
let artworkPalette = $state<ArtworkPalette | null>(null);
|
||||||
|
let resolvedHeaderImageUrl = $state<string | null>(null);
|
||||||
|
let jumpNavSentinelEl: HTMLDivElement | null = $state(null);
|
||||||
|
let jumpNavStuck = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
const url = imageOverride ?? artist.image ?? null;
|
||||||
|
artworkPalette = null;
|
||||||
|
resolvedHeaderImageUrl = null;
|
||||||
|
if (!url || !gradientEnabled) return;
|
||||||
|
const stillCurrent = () => (imageOverride ?? artist.image ?? null) === url;
|
||||||
|
getCachedImageUrl(url).then((resolved) => {
|
||||||
|
if (stillCurrent()) resolvedHeaderImageUrl = resolved;
|
||||||
|
}).catch(() => {});
|
||||||
|
extractPalette(url).then((p) => {
|
||||||
|
if (stillCurrent()) artworkPalette = p;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const headerColor = $derived(gradientEnabled ? pickHeaderColor(artworkPalette) : null);
|
||||||
|
const headerStyle = $derived.by(() => {
|
||||||
|
if (!headerColor) return '';
|
||||||
|
const needsScrim = headerColor.luminance > 0.6;
|
||||||
|
const imageRule = resolvedHeaderImageUrl
|
||||||
|
? `--art-image-url: url("${resolvedHeaderImageUrl.replace(/"/g, '\\"')}");`
|
||||||
|
: '';
|
||||||
|
return `--art-bg: ${headerColor.hex}; --art-scrim: ${needsScrim ? '0.55' : '0.3'}; ${imageRule}`;
|
||||||
|
});
|
||||||
let topTracks = $state<Track[]>([]);
|
let topTracks = $state<Track[]>([]);
|
||||||
let tracksLoading = $state(false);
|
let tracksLoading = $state(false);
|
||||||
let isFavorite = $state(false);
|
let isFavorite = $state(false);
|
||||||
@@ -269,6 +305,22 @@
|
|||||||
// Load custom image status
|
// Load custom image status
|
||||||
loadCustomImageStatus();
|
loadCustomImageStatus();
|
||||||
|
|
||||||
|
// Subscribe to header gradient toggle
|
||||||
|
unsubscribeAppearance = subscribeAppearance(() => {
|
||||||
|
gradientEnabled = isAlbumHeaderGradientEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detect when the jump-nav becomes "stuck" so the bg can fade in only
|
||||||
|
// while it's pinned. A 1px sentinel right above it goes out of the
|
||||||
|
// viewport when the nav reaches the top.
|
||||||
|
if (jumpNavSentinelEl) {
|
||||||
|
jumpNavObserver = new IntersectionObserver(
|
||||||
|
([entry]) => { jumpNavStuck = !entry.isIntersecting; },
|
||||||
|
{ threshold: 0 }
|
||||||
|
);
|
||||||
|
jumpNavObserver.observe(jumpNavSentinelEl);
|
||||||
|
}
|
||||||
|
|
||||||
// Restore scroll position
|
// Restore scroll position
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const saved = getSavedScrollPosition('artist', artist.id);
|
const saved = getSavedScrollPosition('artist', artist.id);
|
||||||
@@ -278,10 +330,15 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let unsubscribeAppearance: (() => void) | null = null;
|
||||||
|
let jumpNavObserver: IntersectionObserver | null = null;
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unsubscribeSidebar?.();
|
unsubscribeSidebar?.();
|
||||||
unsubscribeBlacklist?.();
|
unsubscribeBlacklist?.();
|
||||||
unsubscribeTrackFavorites?.();
|
unsubscribeTrackFavorites?.();
|
||||||
|
unsubscribeAppearance?.();
|
||||||
|
jumpNavObserver?.disconnect();
|
||||||
// Close the network sidebar when leaving artist view
|
// Close the network sidebar when leaving artist view
|
||||||
closeContentSidebar('network');
|
closeContentSidebar('network');
|
||||||
clearPreviousContentSidebar();
|
clearPreviousContentSidebar();
|
||||||
@@ -1702,7 +1759,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="artist-detail" bind:this={artistDetailEl} onscroll={(e) => saveScrollPosition('artist', (e.target as HTMLElement).scrollTop, artist.id)}>
|
<div class="artist-detail" class:has-art-bg={!!headerColor} style={headerStyle} bind:this={artistDetailEl} onscroll={(e) => saveScrollPosition('artist', (e.target as HTMLElement).scrollTop, artist.id)}>
|
||||||
<!-- Back Navigation -->
|
<!-- Back Navigation -->
|
||||||
<button class="back-btn" onclick={onBack}>
|
<button class="back-btn" onclick={onBack}>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
@@ -1977,7 +2034,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showJumpNav}
|
{#if showJumpNav}
|
||||||
<div class="jump-nav">
|
<div bind:this={jumpNavSentinelEl} class="jump-nav-sentinel" aria-hidden="true"></div>
|
||||||
|
<div class="jump-nav" class:stuck={jumpNavStuck}>
|
||||||
<div class="jump-nav-left">
|
<div class="jump-nav-left">
|
||||||
<div class="jump-label">{$t('artist.jumpTo')}</div>
|
<div class="jump-label">{$t('artist.jumpTo')}</div>
|
||||||
<div class="jump-links">
|
<div class="jump-links">
|
||||||
@@ -3099,6 +3157,84 @@
|
|||||||
position: relative;
|
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 {
|
.artist-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -3923,6 +4059,14 @@
|
|||||||
font-weight: 300;
|
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 {
|
.jump-nav {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
/* `top: 0` pins to the scrollport's padding-box top. Now that
|
/* `top: 0` pins to the scrollport's padding-box top. Now that
|
||||||
@@ -3936,13 +4080,20 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
background: var(--bg-primary);
|
background: transparent;
|
||||||
border-bottom: 1px solid var(--alpha-6);
|
border-bottom: 1px solid transparent;
|
||||||
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0);
|
||||||
margin: 0 -8px 24px -24px;
|
margin: 0 -8px 24px -24px;
|
||||||
width: calc(100% + 32px);
|
width: calc(100% + 32px);
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
transform: translateZ(0);
|
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 {
|
.jump-nav-left {
|
||||||
|
|||||||
@@ -355,16 +355,19 @@
|
|||||||
let customOrderMap = $state<Map<string, number>>(new Map()); // "trackId:isLocal" -> position
|
let customOrderMap = $state<Map<string, number>>(new Map()); // "trackId:isLocal" -> position
|
||||||
let customOrderLoading = $state(false);
|
let customOrderLoading = $state(false);
|
||||||
let isCustomOrderMode = $derived(sortBy === 'custom');
|
let isCustomOrderMode = $derived(sortBy === 'custom');
|
||||||
|
// Reorder while in Custom mode is handled by drag-and-drop on each row
|
||||||
|
// (single tracks) and by drag-and-drop on multi-selected tracks (group
|
||||||
|
// moves via dragTrackIds). The previous "selection mode" with its own
|
||||||
|
// checkbox column and floating up/down buttons was a parallel system
|
||||||
|
// that competed with the toolbar's multi-select; consolidating onto
|
||||||
|
// multiSelectMode keeps one selection model across the view.
|
||||||
|
|
||||||
// Drag and drop state
|
// Drag and drop state
|
||||||
let draggedTrackIdx = $state<number | null>(null);
|
let draggedTrackIdx = $state<number | null>(null);
|
||||||
let dragOverIdx = $state<number | null>(null);
|
let dragOverIdx = $state<number | null>(null);
|
||||||
|
|
||||||
// Batch selection state (for custom order mode)
|
// Multi-select state (bulk actions; works in every sort mode including
|
||||||
let selectedTrackKeys = $state<Set<string>>(new Set()); // Set of "trackId:isLocal" keys
|
// Custom — drag-drop on selected rows reorders the group as a unit).
|
||||||
let isSelectionMode = $derived(isCustomOrderMode && selectedTrackKeys.size > 0);
|
|
||||||
|
|
||||||
// Multi-select state (bulk actions, works in all sort modes)
|
|
||||||
let multiSelectMode = $state(false);
|
let multiSelectMode = $state(false);
|
||||||
let multiSelectedKeys = $state(new Set<string>());
|
let multiSelectedKeys = $state(new Set<string>());
|
||||||
let lastSelectedIndex = $state<number | null>(null);
|
let lastSelectedIndex = $state<number | null>(null);
|
||||||
@@ -1320,29 +1323,6 @@
|
|||||||
return `${trackId}:${isLocal}:${index}`;
|
return `${trackId}:${isLocal}:${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTrackSelection(track: DisplayTrack, index: number) {
|
|
||||||
const key = getTrackKey(track, index);
|
|
||||||
const newSet = new Set(selectedTrackKeys);
|
|
||||||
if (newSet.has(key)) {
|
|
||||||
newSet.delete(key);
|
|
||||||
} else {
|
|
||||||
newSet.add(key);
|
|
||||||
}
|
|
||||||
selectedTrackKeys = newSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSelection() {
|
|
||||||
selectedTrackKeys = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectAllTracks() {
|
|
||||||
const newSet = new Set<string>();
|
|
||||||
displayTracks.forEach((track, index) => {
|
|
||||||
newSet.add(getTrackKey(track, index));
|
|
||||||
});
|
|
||||||
selectedTrackKeys = newSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMultiSelectMode() {
|
function toggleMultiSelectMode() {
|
||||||
multiSelectMode = !multiSelectMode;
|
multiSelectMode = !multiSelectMode;
|
||||||
if (!multiSelectMode) {
|
if (!multiSelectMode) {
|
||||||
@@ -1450,91 +1430,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Move all selected tracks up one position (as a group)
|
// Move all selected tracks up one position (as a group)
|
||||||
async function moveSelectedUp() {
|
|
||||||
if (selectedTrackKeys.size === 0) return;
|
|
||||||
|
|
||||||
// Get indices of selected tracks (sorted)
|
|
||||||
const selectedIndices: number[] = [];
|
|
||||||
displayTracks.forEach((track, idx) => {
|
|
||||||
if (selectedTrackKeys.has(getTrackKey(track, idx))) {
|
|
||||||
selectedIndices.push(idx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
selectedIndices.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
// Can't move up if first selected is already at top
|
|
||||||
if (selectedIndices[0] === 0) return;
|
|
||||||
|
|
||||||
// Build new order: swap each selected with the one above
|
|
||||||
const currentOrder = displayTracks.map(trk => ({
|
|
||||||
id: trk.isLocal ? Math.abs(trk.id) : trk.id,
|
|
||||||
isLocal: trk.isLocal ?? false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Move from top to bottom to avoid conflicts
|
|
||||||
for (const idx of selectedIndices) {
|
|
||||||
const newIdx = idx - 1;
|
|
||||||
[currentOrder[newIdx], currentOrder[idx]] = [currentOrder[idx], currentOrder[newIdx]];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save new order
|
|
||||||
const orders: [number, boolean, number][] = currentOrder.map((item, pos) => [item.id, item.isLocal, pos]);
|
|
||||||
try {
|
|
||||||
await invoke('v2_playlist_set_custom_order', { playlistId, orders });
|
|
||||||
// Update local map
|
|
||||||
const newMap = new Map<string, number>();
|
|
||||||
orders.forEach(([id, isLocal, pos]) => {
|
|
||||||
newMap.set(`${id}:${isLocal}`, pos);
|
|
||||||
});
|
|
||||||
customOrderMap = newMap;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to move selected tracks:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move all selected tracks down one position (as a group)
|
|
||||||
async function moveSelectedDown() {
|
|
||||||
if (selectedTrackKeys.size === 0) return;
|
|
||||||
|
|
||||||
// Get indices of selected tracks (sorted descending for moving down)
|
|
||||||
const selectedIndices: number[] = [];
|
|
||||||
displayTracks.forEach((track, idx) => {
|
|
||||||
if (selectedTrackKeys.has(getTrackKey(track, idx))) {
|
|
||||||
selectedIndices.push(idx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
selectedIndices.sort((a, b) => b - a); // Descending
|
|
||||||
|
|
||||||
// Can't move down if last selected is already at bottom
|
|
||||||
if (selectedIndices[0] === displayTracks.length - 1) return;
|
|
||||||
|
|
||||||
// Build new order: swap each selected with the one below
|
|
||||||
const currentOrder = displayTracks.map(trk => ({
|
|
||||||
id: trk.isLocal ? Math.abs(trk.id) : trk.id,
|
|
||||||
isLocal: trk.isLocal ?? false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Move from bottom to top to avoid conflicts
|
|
||||||
for (const idx of selectedIndices) {
|
|
||||||
const newIdx = idx + 1;
|
|
||||||
[currentOrder[idx], currentOrder[newIdx]] = [currentOrder[newIdx], currentOrder[idx]];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save new order
|
|
||||||
const orders: [number, boolean, number][] = currentOrder.map((item, pos) => [item.id, item.isLocal, pos]);
|
|
||||||
try {
|
|
||||||
await invoke('v2_playlist_set_custom_order', { playlistId, orders });
|
|
||||||
// Update local map
|
|
||||||
const newMap = new Map<string, number>();
|
|
||||||
orders.forEach(([id, isLocal, pos]) => {
|
|
||||||
newMap.set(`${id}:${isLocal}`, pos);
|
|
||||||
});
|
|
||||||
customOrderMap = newMap;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to move selected tracks:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectCustomArtwork() {
|
async function selectCustomArtwork() {
|
||||||
try {
|
try {
|
||||||
const selected = await open({
|
const selected = await open({
|
||||||
@@ -2668,43 +2563,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isCustomOrderMode}
|
|
||||||
<div class="batch-controls">
|
|
||||||
<div class="batch-left">
|
|
||||||
{#if selectedTrackKeys.size > 0}
|
|
||||||
<span class="selection-count">{selectedTrackKeys.size} selected</span>
|
|
||||||
<button class="batch-btn" onclick={clearSelection}>{ $t('actions.clear') }</button>
|
|
||||||
{:else}
|
|
||||||
<button class="batch-btn" onclick={selectAllTracks}>{ $t('actions.selectAll') }</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if selectedTrackKeys.size > 0}
|
|
||||||
<div class="batch-right">
|
|
||||||
<button class="batch-btn" onclick={moveSelectedUp} title="Move selected up">
|
|
||||||
<ChevronUp size={14} /> { $t('favorites.moveUp') }
|
|
||||||
</button>
|
|
||||||
<button class="batch-btn" onclick={moveSelectedDown} title="Move selected down">
|
|
||||||
<ChevronDown size={14} /> { $t('favorites.moveDown') }
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="track-list-header">
|
<div class="track-list-header">
|
||||||
{#if multiSelectMode}
|
<div class="col-select-all" class:active={multiSelectMode}>
|
||||||
<div class="col-select-all">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
checked={selectAllState === 'all'}
|
||||||
checked={selectAllState === 'all'}
|
indeterminate={selectAllState === 'partial'}
|
||||||
indeterminate={selectAllState === 'partial'}
|
onchange={toggleSelectAll}
|
||||||
onchange={toggleSelectAll}
|
title={$t('actions.selectAll')}
|
||||||
title={$t('actions.selectAll')}
|
tabindex={multiSelectMode ? 0 : -1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if isCustomOrderMode}
|
|
||||||
<div class="col-checkbox"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="col-number">#</div>
|
<div class="col-number">#</div>
|
||||||
<div class="col-artwork"></div>
|
<div class="col-artwork"></div>
|
||||||
<div class="col-title">{$t('tracklist.title')}</div>
|
<div class="col-title">{$t('tracklist.title')}</div>
|
||||||
@@ -2718,7 +2587,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="virtual-track-content" style="height: {trackListTotalHeight}px;">
|
<div class="virtual-track-content" style="height: {trackListTotalHeight}px;">
|
||||||
{#each visibleDisplayTracks as track, loopIdx (`${visibleTrackRange.start + loopIdx}-${track.id}`)}
|
<!-- Each-key is built from track identity ONLY (id + namespace + the
|
||||||
|
`number` field that displayTracks assigns during construction).
|
||||||
|
Earlier this used `${visibleTrackRange.start + loopIdx}-${track.id}`,
|
||||||
|
which mixed scroll state into the key — when a re-render shifted
|
||||||
|
`visibleTrackRange.start` mid-frame (e.g. the multi-select toggle
|
||||||
|
reflowed the sticky header and bumped trackListScrollTop), Svelte
|
||||||
|
reused virtualized row instances against new data and we ended up
|
||||||
|
with rows showing # 18 / "Sombras en Tiempos Perdidos" stamped
|
||||||
|
onto the visual position of row 2. -->
|
||||||
|
{#each visibleDisplayTracks as track, loopIdx (`${track.isLocal ? 'l' : 'q'}-${track.id}-${track.number ?? loopIdx}`)}
|
||||||
{@const idx = visibleTrackRange.start + loopIdx}
|
{@const idx = visibleTrackRange.start + loopIdx}
|
||||||
{@const downloadInfo = track.isLocal ? { status: 'none' as const, progress: 0 } : (getTrackOfflineCacheStatus?.(track.id) ?? { status: 'none' as const, progress: 0 })}
|
{@const downloadInfo = track.isLocal ? { status: 'none' as const, progress: 0 } : (getTrackOfflineCacheStatus?.(track.id) ?? { status: 'none' as const, progress: 0 })}
|
||||||
{@const isActiveTrack = (
|
{@const isActiveTrack = (
|
||||||
@@ -2749,17 +2627,28 @@
|
|||||||
ondragend={handleDragEnd}
|
ondragend={handleDragEnd}
|
||||||
ondrop={(e) => handleDrop(e, idx)}
|
ondrop={(e) => handleDrop(e, idx)}
|
||||||
>
|
>
|
||||||
|
<!-- External multi-select checkbox — first column. TrackRow's
|
||||||
|
internal checkbox is suppressed via :global so the wrapper
|
||||||
|
owns ordering: [checkbox][reorder-controls?][TrackRow]. -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="row-checkbox"
|
||||||
|
class:active={multiSelectMode}
|
||||||
|
onclick={(e) => {
|
||||||
|
if (!multiSelectMode) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleMultiSelect(track, idx, e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={multiSelectedKeys.has(getTrackKey(track, idx))}
|
||||||
|
tabindex={-1}
|
||||||
|
aria-label={$t('actions.select')}
|
||||||
|
style="pointer-events: none;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{#if isCustomOrderMode}
|
{#if isCustomOrderMode}
|
||||||
{@const trackKey = getTrackKey(track, idx)}
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
|
||||||
<label class="track-checkbox" onclick={(e: MouseEvent) => e.stopPropagation()}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedTrackKeys.has(trackKey)}
|
|
||||||
onchange={() => toggleTrackSelection(track, idx)}
|
|
||||||
aria-label={$t('actions.select')}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div class="reorder-controls">
|
<div class="reorder-controls">
|
||||||
<button
|
<button
|
||||||
class="reorder-btn"
|
class="reorder-btn"
|
||||||
@@ -2804,7 +2693,7 @@
|
|||||||
? $t('player.trackUnavailable')
|
? $t('player.trackUnavailable')
|
||||||
: (!available ? $t('offline.trackNotAvailable') : undefined)}
|
: (!available ? $t('offline.trackNotAvailable') : undefined)}
|
||||||
isBlacklisted={trackBlacklisted}
|
isBlacklisted={trackBlacklisted}
|
||||||
selectable={multiSelectMode && !isCustomOrderMode}
|
selectable={multiSelectMode}
|
||||||
selected={multiSelectedKeys.has(getTrackKey(track, idx))}
|
selected={multiSelectedKeys.has(getTrackKey(track, idx))}
|
||||||
onToggleSelect={(e) => toggleMultiSelect(track, idx, e)}
|
onToggleSelect={(e) => toggleMultiSelect(track, idx, e)}
|
||||||
dragTrackIds={multiSelectMode && multiSelectedKeys.has(getTrackKey(track, idx))
|
dragTrackIds={multiSelectMode && multiSelectedKeys.has(getTrackKey(track, idx))
|
||||||
@@ -3292,15 +3181,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.col-select-all {
|
.col-select-all {
|
||||||
width: 32px;
|
width: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-select-all.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-select-all input[type="checkbox"] {
|
.col-select-all input[type="checkbox"] {
|
||||||
width: 16px;
|
width: 15px;
|
||||||
height: 16px;
|
height: 15px;
|
||||||
accent-color: var(--accent-primary);
|
accent-color: var(--accent-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -3576,6 +3474,39 @@
|
|||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* External multi-select checkbox living in the row wrapper. Width is
|
||||||
|
ALWAYS reserved (24px) so toggling select mode never reflows columns
|
||||||
|
to the right of it; we only fade the checkbox itself in/out. The
|
||||||
|
internal TrackRow checkbox is hidden via the :global rule below
|
||||||
|
(TrackRow still receives selectable=true so its click + keyboard
|
||||||
|
handlers stay wired up). */
|
||||||
|
.row-checkbox {
|
||||||
|
width: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-checkbox.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-checkbox input[type="checkbox"] {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list :global(.track-row > .track-checkbox) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.reorder-controls {
|
.reorder-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -3640,67 +3571,6 @@
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Batch selection controls */
|
|
||||||
.batch-controls {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.batch-left, .batch-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-count {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.batch-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.batch-btn:hover {
|
|
||||||
background: var(--hover-bg, rgba(255, 255, 255, 0.1));
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-checkbox {
|
|
||||||
width: 24px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-checkbox {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-checkbox input[type="checkbox"] {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
accent-color: var(--accent-color, #6366f1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestions-manual-launch {
|
.suggestions-manual-launch {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -38,6 +38,10 @@
|
|||||||
setSystemNotificationsEnabled,
|
setSystemNotificationsEnabled,
|
||||||
loadSystemNotificationsPreference
|
loadSystemNotificationsPreference
|
||||||
} from '$lib/services/playbackService';
|
} from '$lib/services/playbackService';
|
||||||
|
import {
|
||||||
|
isDiscordRpcEnabled,
|
||||||
|
setDiscordRpcEnabled
|
||||||
|
} from '$lib/services/discordRpcService';
|
||||||
import { getIsPlaying } from '$lib/stores/playerStore';
|
import { getIsPlaying } from '$lib/stores/playerStore';
|
||||||
import { setLocale, locale, t } from '$lib/i18n';
|
import { setLocale, locale, t } from '$lib/i18n';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
@@ -1406,6 +1410,9 @@
|
|||||||
// MusicBrainz integration state
|
// MusicBrainz integration state
|
||||||
let musicbrainzEnabled = $state(true);
|
let musicbrainzEnabled = $state(true);
|
||||||
|
|
||||||
|
// Discord Rich Presence integration state
|
||||||
|
let discordRpcEnabled = $state(false);
|
||||||
|
|
||||||
// ListenBrainz integration state
|
// ListenBrainz integration state
|
||||||
let listenbrainzConnected = $state(false);
|
let listenbrainzConnected = $state(false);
|
||||||
let listenbrainzUsername = $state('');
|
let listenbrainzUsername = $state('');
|
||||||
@@ -1669,6 +1676,9 @@
|
|||||||
// Load ListenBrainz state
|
// Load ListenBrainz state
|
||||||
loadListenBrainzState();
|
loadListenBrainzState();
|
||||||
|
|
||||||
|
// Load Discord Rich Presence state
|
||||||
|
loadDiscordRpcState();
|
||||||
|
|
||||||
// Load remote control status
|
// Load remote control status
|
||||||
loadRemoteControlStatus();
|
loadRemoteControlStatus();
|
||||||
|
|
||||||
@@ -1956,6 +1966,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
|
// ListenBrainz functions
|
||||||
async function loadListenBrainzState() {
|
async function loadListenBrainzState() {
|
||||||
try {
|
try {
|
||||||
@@ -5582,6 +5606,28 @@
|
|||||||
<Toggle enabled={musicbrainzEnabled} onchange={handleMusicBrainzChange} />
|
<Toggle enabled={musicbrainzEnabled} onchange={handleMusicBrainzChange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Discord Rich Presence Integration -->
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">{$t('settings.integrations.discordRpc.label')}</span>
|
||||||
|
<small class="setting-note">
|
||||||
|
{$t('settings.integrations.discordRpc.description')}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<Toggle enabled={discordRpcEnabled} onchange={handleDiscordRpcToggle} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row discord-rpc-override-row">
|
||||||
|
<details class="discord-rpc-override">
|
||||||
|
<summary>{$t('settings.integrations.discordRpc.overrideHeader')}</summary>
|
||||||
|
<div class="discord-rpc-override-body">
|
||||||
|
<p>{$t('settings.integrations.discordRpc.flatpakNote')}</p>
|
||||||
|
<pre><code>flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz</code></pre>
|
||||||
|
<p>{$t('settings.integrations.discordRpc.snapNote')}</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">{$t('settings.integrations.plexConnection')}</span>
|
<span class="setting-label">{$t('settings.integrations.plexConnection')}</span>
|
||||||
@@ -6934,6 +6980,69 @@ flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
|
|||||||
color: var(--text-muted);
|
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 {
|
.setting-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2425,6 +2425,13 @@
|
|||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"title": "Integrationen",
|
"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",
|
"lastfm": "Last.fm",
|
||||||
"spotify": "Spotify",
|
"spotify": "Spotify",
|
||||||
"tidal": "Tidal",
|
"tidal": "Tidal",
|
||||||
|
|||||||
@@ -2431,6 +2431,13 @@
|
|||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"title": "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",
|
"lastfm": "Last.fm",
|
||||||
"spotify": "Spotify",
|
"spotify": "Spotify",
|
||||||
"tidal": "Tidal",
|
"tidal": "Tidal",
|
||||||
|
|||||||
@@ -2431,6 +2431,13 @@
|
|||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"title": "Integraciones",
|
"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",
|
"lastfm": "Last.fm",
|
||||||
"spotify": "Spotify",
|
"spotify": "Spotify",
|
||||||
"tidal": "Tidal",
|
"tidal": "Tidal",
|
||||||
|
|||||||
@@ -2425,6 +2425,13 @@
|
|||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"title": "Intégrations",
|
"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",
|
"lastfm": "Last.fm",
|
||||||
"spotify": "Spotify",
|
"spotify": "Spotify",
|
||||||
"tidal": "Tidal",
|
"tidal": "Tidal",
|
||||||
|
|||||||
@@ -2425,6 +2425,13 @@
|
|||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"title": "Integrações",
|
"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",
|
"lastfm": "Last.fm",
|
||||||
"spotify": "Spotify",
|
"spotify": "Spotify",
|
||||||
"tidal": "Tidal",
|
"tidal": "Tidal",
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Vibrant } from 'node-vibrant/browser';
|
import { Vibrant } from 'node-vibrant/browser';
|
||||||
|
import { getCachedImageUrl } from '$lib/services/imageCacheService';
|
||||||
|
|
||||||
export interface ArtworkSwatch {
|
export interface ArtworkSwatch {
|
||||||
hex: string;
|
hex: string;
|
||||||
@@ -47,7 +48,24 @@ export async function extractPalette(url: string | null | undefined): Promise<Ar
|
|||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
try {
|
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 = [
|
const all = [
|
||||||
palette.Vibrant,
|
palette.Vibrant,
|
||||||
palette.Muted,
|
palette.Muted,
|
||||||
|
|||||||
@@ -517,6 +517,7 @@
|
|||||||
resetLaunchFlow,
|
resetLaunchFlow,
|
||||||
} from '$lib/services/updatesService';
|
} from '$lib/services/updatesService';
|
||||||
import type { AutoUpdateProgress } 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';
|
import UpdateProgressModal from '$lib/components/updates/UpdateProgressModal.svelte';
|
||||||
|
|
||||||
// Offline state
|
// Offline state
|
||||||
@@ -4603,6 +4604,8 @@
|
|||||||
// Bootstrap app (theme, mouse nav, Last.fm restore)
|
// Bootstrap app (theme, mouse nav, Last.fm restore)
|
||||||
const { cleanup: cleanupBootstrap } = bootstrapApp();
|
const { cleanup: cleanupBootstrap } = bootstrapApp();
|
||||||
|
|
||||||
|
void initDiscordRpc();
|
||||||
|
|
||||||
// Window-title preference: load from localStorage and subscribe so that
|
// Window-title preference: load from localStorage and subscribe so that
|
||||||
// toggling the setting updates the OS title bar immediately.
|
// toggling the setting updates the OS title bar immediately.
|
||||||
initWindowTitleStore();
|
initWindowTitleStore();
|
||||||
|
|||||||
Reference in new issue
Block a user