Merge branch 'pre-release' into feature/external/macos-exclusive-mode

This commit is contained in:
Vudgekek authored and GitHub committed 2026-05-07 15:34:44 -05:00
commit 5cd76f53d4
20 files changed
+853 -333

No files matched your search

+21 -21
View File
@@ -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
View File
@@ -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"
+3
View File
@@ -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
+43 -20
View File
@@ -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",
+3
View File
@@ -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"] }
+122
View File
@@ -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(())
}
+5
View File
@@ -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")
+31 -20
View File
@@ -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'] {
+90 -36
View File
@@ -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;
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+113
View File
@@ -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.
}
}
+19 -1
View File
@@ -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,
+3
View File
@@ -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();