feat(discord-rpc): backend module and Tauri commands

Adds src-tauri/src/discord_rpc.rs with DiscordRpcState (Mutex<Option<client>>
+ AtomicBool enabled) and three V2 commands:
- v2_discord_rpc_set_enabled
- v2_discord_rpc_update
- v2_discord_rpc_clear

Lazy connect on first update; silent degrade when Discord is unavailable.
Hardcoded Application ID 1501835855587708988 (public identifier).

Refs #336.

cc
This commit is contained in:
vicrodh committed 2026-05-07 01:29:03 -06:00
1 parent 2b5366eb06
commit 82453b8fee
2 files changed
+127

No files matched your search

+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 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<bool>) {
.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<bool>) {
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")