diff --git a/composeApp/Cargo.lock b/composeApp/Cargo.lock index 1e6bd60d..5367c7ae 100644 --- a/composeApp/Cargo.lock +++ b/composeApp/Cargo.lock @@ -41,7 +41,7 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn", + "syn 2.0.119", ] [[package]] @@ -131,6 +131,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" name = "compose-app" version = "0.1.0" dependencies = [ + "discord-rich-presence", "lofty", "uniffi", ] @@ -150,6 +151,21 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "discord-rich-presence" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494" +dependencies = [ + "log", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "thiserror", + "uuid", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -191,6 +207,17 @@ dependencies = [ "autocfg", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -282,7 +309,7 @@ checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -416,7 +443,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -456,7 +483,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -472,6 +499,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -507,6 +545,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -514,7 +563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", @@ -546,7 +595,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -626,7 +675,7 @@ dependencies = [ "indexmap", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -641,7 +690,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.119", "toml", "uniffi_meta", ] @@ -683,6 +732,21 @@ dependencies = [ "weedle2", ] +[[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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "weedle2" version = "5.0.0" diff --git a/composeApp/Cargo.toml b/composeApp/Cargo.toml index 8617b0f7..4357cc68 100644 --- a/composeApp/Cargo.toml +++ b/composeApp/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] uniffi = "0.29.4" lofty = "0.24.0" +discord-rich-presence = "1.1.0" [lib] crate-type = ["cdylib", "staticlib"] diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 8288f014..1d2408ed 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -23,6 +23,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository import dev.krtirtho.spotube.core.db.Database +import dev.krtirtho.spotube.core.discord.DiscordRpcService import dev.krtirtho.spotube.core.navigation.navigationModule import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.server.AlternativeTracksRepository @@ -172,6 +173,8 @@ val sharedModules = module { DeviceAudioPlayerQueue(get(), get(), get(), get()) } + single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() } + factory { (tag: String?) -> if (tag != null) { Logger.withTag(tag) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt new file mode 100644 index 00000000..b31b3d05 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.discord + +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.PlayerState +import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.modules.settings.SettingsProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import uniffi.compose_app.DiscordRpcClient + +class DiscordRpcService( + private val audioPlayer: AudioPlayerInterface, + private val settingsProvider: SettingsProvider, +) : KoinComponent, AutoCloseable { + + private val logger by injectLogger() + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var observerJob: Job? = null + + private val clientId = "1176718791388975124" + private var rpcClient: DiscordRpcClient? = null + + init { + start() + } + + private fun start() { + logger.d { "Starting Discord RPC service" } + observerJob?.cancel() + observerJob = scope.launch { + combine( + settingsProvider.settingsState, + audioPlayer.playerStateFlow, + audioPlayer.currentMediaItemFlow, + audioPlayer.positionFlow, + audioPlayer.durationFlow, + ) { settings, playerState, mediaItem, position, duration -> + DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration) + }.collect { state -> + handleStateChange(state) + } + } + } + + private fun handleStateChange(state: DiscordRpcState) { + if (!state.enabled) { + disconnectClient() + return + } + + when (state.playerState) { + PlayerState.PLAYING -> { + val mediaItem = state.mediaItem ?: return + ensureConnected() + updatePresence( + title = mediaItem.title, + artist = mediaItem.artist, + album = mediaItem.album, + coverUrl = mediaItem.coverURL, + positionMs = state.position.inWholeMilliseconds, + durationMs = state.duration.inWholeMilliseconds, + ) + } + PlayerState.PAUSED -> { + val mediaItem = state.mediaItem ?: return + ensureConnected() + updatePresence( + title = mediaItem.title, + artist = mediaItem.artist, + album = mediaItem.album, + coverUrl = mediaItem.coverURL, + positionMs = state.position.inWholeMilliseconds, + durationMs = state.duration.inWholeMilliseconds, + ) + } + PlayerState.IDLE, PlayerState.COMPLETED -> { + clearPresence() + } + PlayerState.BUFFERING, PlayerState.READY -> { + } + } + } + + private fun ensureConnected() { + if (rpcClient != null) return + try { + val client = DiscordRpcClient(clientId) + client.connect() + rpcClient = client + logger.i { "Connected to Discord RPC" } + } catch (e: Exception) { + logger.e(e) { "Failed to connect to Discord RPC" } + } + } + + private fun updatePresence( + title: String, + artist: String, + album: String, + coverUrl: String, + positionMs: Long, + durationMs: Long, + ) { + try { + rpcClient?.updatePresence( + title = title, + artist = artist, + album = album, + coverUrl = coverUrl, + positionMs = positionMs, + durationMs = durationMs, + ) + } catch (e: Exception) { + logger.e(e) { "Failed to update Discord RPC presence" } + } + } + + private fun clearPresence() { + try { + rpcClient?.clearPresence() + } catch (e: Exception) { + logger.e(e) { "Failed to clear Discord RPC presence" } + } + } + + private fun disconnectClient() { + try { + rpcClient?.disconnect() + rpcClient = null + logger.d { "Disconnected from Discord RPC" } + } catch (e: Exception) { + logger.e(e) { "Failed to disconnect from Discord RPC" } + } + } + + override fun close() { + observerJob?.cancel() + disconnectClient() + } + + private data class DiscordRpcState( + val enabled: Boolean, + val playerState: PlayerState, + val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?, + val position: kotlin.time.Duration, + val duration: kotlin.time.Duration, + ) +} diff --git a/composeApp/src/commonMain/rust/lib.rs b/composeApp/src/commonMain/rust/lib.rs index 8700d014..0e9f342f 100644 --- a/composeApp/src/commonMain/rust/lib.rs +++ b/composeApp/src/commonMain/rust/lib.rs @@ -1,9 +1,13 @@ +use discord_rich_presence::activity::{Activity, ActivityType, Assets, Timestamps}; +use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; use lofty::config::WriteOptions; use lofty::file::{AudioFile, FileType, TaggedFileExt}; use lofty::picture::Picture; use lofty::probe::Probe; use lofty::tag::{Accessor, Tag, TagExt}; use std::path::Path; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; #[derive(uniffi::Record)] pub struct AudioMetadata { @@ -157,4 +161,138 @@ fn write_audio_metadata( Ok(()) } +#[derive(Debug, uniffi::Error)] +pub enum DiscordRpcError { + ConnectionError { reason: String }, + UpdateError { reason: String }, + NotConnected, +} + +impl std::fmt::Display for DiscordRpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DiscordRpcError::ConnectionError { reason } => write!(f, "Connection error: {}", reason), + DiscordRpcError::UpdateError { reason } => write!(f, "Update error: {}", reason), + DiscordRpcError::NotConnected => write!(f, "Not connected"), + } + } +} + +#[derive(uniffi::Object)] +pub struct DiscordRpcClient { + client_id: String, + client: Mutex>, +} + +#[uniffi::export] +impl DiscordRpcClient { + #[uniffi::constructor] + pub fn new(client_id: String) -> Self { + Self { + client_id, + client: Mutex::new(None), + } + } + + pub fn connect(&self) -> Result<(), DiscordRpcError> { + let mut client_guard = self.client.lock().unwrap(); + if client_guard.is_some() { + return Ok(()); + } + + let mut client = DiscordIpcClient::new(&self.client_id); + + client + .connect() + .map_err(|e| DiscordRpcError::ConnectionError { reason: e.to_string() })?; + + *client_guard = Some(client); + Ok(()) + } + + pub fn disconnect(&self) -> Result<(), DiscordRpcError> { + let mut client_guard = self.client.lock().unwrap(); + if let Some(mut client) = client_guard.take() { + client + .close() + .map_err(|e| DiscordRpcError::ConnectionError { reason: e.to_string() })?; + } + Ok(()) + } + + pub fn update_presence( + &self, + title: String, + artist: String, + album: String, + cover_url: String, + position_ms: i64, + duration_ms: i64, + ) -> Result<(), DiscordRpcError> { + let mut client_guard = self.client.lock().unwrap(); + let client = client_guard + .as_mut() + .ok_or(DiscordRpcError::NotConnected)?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let start = now - (position_ms / 1000); + let end = start + (duration_ms / 1000); + + let details = if title.is_empty() { + "Unknown Track".to_string() + } else { + title + }; + + let state = if artist.is_empty() { + "Unknown Artist".to_string() + } else { + format!("by {}", artist) + }; + + let mut activity = Activity::new() + .details(&details) + .state(&state) + .timestamps(Timestamps::new().start(start).end(end)) + .activity_type(ActivityType::Listening); + + if !album.is_empty() { + let mut assets = Assets::new().large_text(&album); + if !cover_url.is_empty() { + assets = assets.large_image(&cover_url); + } + activity = activity.assets(assets); + } else if !cover_url.is_empty() { + activity = activity.assets(Assets::new().large_image(&cover_url)); + } + + client + .set_activity(activity) + .map_err(|e| DiscordRpcError::UpdateError { reason: e.to_string() })?; + + Ok(()) + } + + pub fn clear_presence(&self) -> Result<(), DiscordRpcError> { + let mut client_guard = self.client.lock().unwrap(); + let client = client_guard + .as_mut() + .ok_or(DiscordRpcError::NotConnected)?; + + client + .clear_activity() + .map_err(|e| DiscordRpcError::UpdateError { reason: e.to_string() })?; + + Ok(()) + } + + pub fn is_connected(&self) -> bool { + self.client.lock().unwrap().is_some() + } +} + uniffi::setup_scaffolding!();