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