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/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 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1ea727381..bbad30e6d 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" @@ -3709,9 +3722,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", @@ -4965,7 +4978,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "uuid", + "uuid 1.23.1", "walkdir", ] @@ -4994,6 +5007,7 @@ dependencies = [ "base64 0.22.1", "chrono", "dirs", + "discord-rich-presence", "dotenvy", "ebur128", "env_logger", @@ -5056,7 +5070,7 @@ dependencies = [ "tokio-stream", "tower-http 0.5.2", "urlencoding", - "uuid", + "uuid 1.23.1", "walkdir", ] @@ -5113,7 +5127,7 @@ dependencies = [ "serde", "sha2 0.10.9", "thiserror 2.0.18", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -5129,7 +5143,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -5150,7 +5164,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -5800,9 +5814,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", @@ -5917,7 +5931,7 @@ dependencies = [ "serde", "serde_json", "url", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -6912,7 +6926,7 @@ dependencies = [ "thiserror 2.0.18", "time", "url", - "uuid", + "uuid 1.23.1", "walkdir", ] @@ -6964,9 +6978,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", @@ -7027,9 +7041,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", @@ -7209,7 +7223,7 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", "url", "urlpattern", - "uuid", + "uuid 1.23.1", "walkdir", ] @@ -7393,9 +7407,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", @@ -7927,6 +7941,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" @@ -9310,7 +9333,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"] } 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") diff --git a/src/lib/components/TrackRow.svelte b/src/lib/components/TrackRow.svelte index 4ca28c2c4..5e6fe0230 100644 --- a/src/lib/components/TrackRow.svelte +++ b/src/lib/components/TrackRow.svelte @@ -274,25 +274,26 @@ tabindex={isBlacklisted ? -1 : 0} onkeydown={(e) => e.key === 'Enter' && !isBlacklisted && (selectable ? onToggleSelect?.(e as unknown as MouseEvent) : onPlay?.())} > - - {#if selectable} - -
{ - e.stopPropagation(); - onToggleSelect?.(e); - }} - > - -
- {/if} + + +
{ + if (!selectable) return; + e.stopPropagation(); + onToggleSelect?.(e); + }} + > + +
@@ -523,11 +524,21 @@ } .track-checkbox { - width: 24px; + width: 0; display: flex; align-items: center; justify-content: center; 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'] { diff --git a/src/lib/components/views/AlbumDetailView.svelte b/src/lib/components/views/AlbumDetailView.svelte index 092864683..962dad77e 100644 --- a/src/lib/components/views/AlbumDetailView.svelte +++ b/src/lib/components/views/AlbumDetailView.svelte @@ -41,6 +41,7 @@ subscribe as subscribeAppearance, isAlbumHeaderGradientEnabled, } from '$lib/stores/appearancePreferencesStore'; + import { getCachedImageUrl } from '$lib/services/imageCacheService'; interface Track { id: number; @@ -231,20 +232,28 @@ // Header gradient driven by extracted artwork palette. let gradientEnabled = $state(isAlbumHeaderGradientEnabled()); let artworkPalette = $state(null); + let resolvedHeaderImageUrl = $state(null); $effect(() => { const url = coverOverride ?? album.artwork ?? null; artworkPalette = null; + resolvedHeaderImageUrl = null; if (!url || !gradientEnabled) return; + const stillCurrent = () => (coverOverride ?? album.artwork ?? null) === url; + getCachedImageUrl(url).then((resolved) => { + if (stillCurrent()) resolvedHeaderImageUrl = resolved; + }).catch(() => {}); extractPalette(url).then((p) => { - const current = coverOverride ?? album.artwork ?? null; - if (current === url) artworkPalette = 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; - 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 @@ -750,18 +759,6 @@ {/if} - +
@@ -821,17 +830,16 @@
- {#if multiSelectMode} -
- -
- {/if} +
+ +
#
{$t('tracklist.title')}
{$t('tracklist.duration')}
@@ -1094,19 +1102,44 @@ 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 { content: ''; position: absolute; top: 0; left: 0; right: 0; - height: 320px; - background: - linear-gradient(180deg, rgba(0, 0, 0, var(--art-scrim, 0)) 0%, rgba(0, 0, 0, 0) 100%), - linear-gradient(180deg, var(--art-bg, transparent) 0%, var(--art-bg, transparent) 30%, transparent 100%); + height: 280px; + 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.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; pointer-events: none; - transition: background 320ms ease; } .album-detail > * { @@ -1486,7 +1519,7 @@ .table-header { width: 100%; height: 40px; - padding: 0 16px; + padding: 0 16px 0 4px; display: flex; flex-direction: row; align-items: center; @@ -1498,23 +1531,44 @@ 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 { - width: 32px; + width: 0; display: flex; align-items: 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"] { - width: 16px; - height: 16px; + width: 15px; + height: 15px; accent-color: var(--accent-primary); cursor: pointer; } .col-number { width: 48px; - text-align: center; + text-align: left; } .col-title { diff --git a/src/lib/components/views/ArtistDetailView.svelte b/src/lib/components/views/ArtistDetailView.svelte index e34d485a5..e3694c163 100644 --- a/src/lib/components/views/ArtistDetailView.svelte +++ b/src/lib/components/views/ArtistDetailView.svelte @@ -19,6 +19,12 @@ import { openAddToMixtape } from '$lib/stores/addToMixtapeModalStore'; import type { ArtistDetail, QobuzArtist, PageArtistTrack, PageArtistSimilarItem } from '$lib/types'; 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 TrackMenu from '../TrackMenu.svelte'; import BulkActionBar from '../BulkActionBar.svelte'; @@ -205,6 +211,36 @@ let hasCustomImage = $state(false); let imageOverride = $state(null); let customFullImage = $state(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(null); + let resolvedHeaderImageUrl = $state(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([]); let tracksLoading = $state(false); let isFavorite = $state(false); @@ -269,6 +305,22 @@ // Load custom image status 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 requestAnimationFrame(() => { const saved = getSavedScrollPosition('artist', artist.id); @@ -278,10 +330,15 @@ }); }); + let unsubscribeAppearance: (() => void) | null = null; + let jumpNavObserver: IntersectionObserver | null = null; + onDestroy(() => { unsubscribeSidebar?.(); unsubscribeBlacklist?.(); unsubscribeTrackFavorites?.(); + unsubscribeAppearance?.(); + jumpNavObserver?.disconnect(); // Close the network sidebar when leaving artist view closeContentSidebar('network'); clearPreviousContentSidebar(); @@ -1702,7 +1759,7 @@ }); -
saveScrollPosition('artist', (e.target as HTMLElement).scrollTop, artist.id)}> +
saveScrollPosition('artist', (e.target as HTMLElement).scrollTop, artist.id)}>
- {#if isCustomOrderMode} -
-
- {#if selectedTrackKeys.size > 0} - {selectedTrackKeys.size} selected - - {:else} - - {/if} -
- {#if selectedTrackKeys.size > 0} -
- - -
- {/if} -
- {/if} -
- {#if multiSelectMode} -
- -
- {:else if isCustomOrderMode} -
- {/if} +
+ +
#
{$t('tracklist.title')}
@@ -2718,7 +2587,16 @@
- {#each visibleDisplayTracks as track, loopIdx (`${visibleTrackRange.start + loopIdx}-${track.id}`)} + + {#each visibleDisplayTracks as track, loopIdx (`${track.isLocal ? 'l' : 'q'}-${track.id}-${track.number ?? 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 isActiveTrack = ( @@ -2749,17 +2627,28 @@ ondragend={handleDragEnd} ondrop={(e) => handleDrop(e, idx)} > + + +
{ + if (!multiSelectMode) return; + e.stopPropagation(); + toggleMultiSelect(track, idx, e); + }} + > + +
{#if isCustomOrderMode} - {@const trackKey = getTrackKey(track, idx)} - -
+ +
+
+ {$t('settings.integrations.discordRpc.label')} + + {$t('settings.integrations.discordRpc.description')} + +
+ +
+ +
+
+ {$t('settings.integrations.discordRpc.overrideHeader')} +
+

{$t('settings.integrations.discordRpc.flatpakNote')}

+
flatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz
+

{$t('settings.integrations.discordRpc.snapNote')}

+
+
+
+
{$t('settings.integrations.plexConnection')} @@ -6934,6 +6980,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; diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index e9ee68d61..44c99896d 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -2425,6 +2425,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 d966a39ff..5c6385170 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -2431,6 +2431,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 7a1abf4bb..36fc3bd3c 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -2431,6 +2431,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 ef8c6a099..3879e3812 100644 --- a/src/lib/i18n/locales/fr.json +++ b/src/lib/i18n/locales/fr.json @@ -2425,6 +2425,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 176a6d29c..e358a9be3 100644 --- a/src/lib/i18n/locales/pt.json +++ b/src/lib/i18n/locales/pt.json @@ -2425,6 +2425,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", 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. + } +} 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, 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();