diff --git a/composeApp/Cargo.lock b/composeApp/Cargo.lock
index 5367c7ae..d8199d57 100644
--- a/composeApp/Cargo.lock
+++ b/composeApp/Cargo.lock
@@ -56,6 +56,19 @@ dependencies = [
"winnow",
]
+[[package]]
+name = "async-compat"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "once_cell",
+ "pin-project-lite",
+ "tokio",
+]
+
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -133,6 +146,10 @@ version = "0.1.0"
dependencies = [
"discord-rich-presence",
"lofty",
+ "log",
+ "parking_lot",
+ "thiserror",
+ "tokio",
"uniffi",
]
@@ -207,6 +224,18 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -286,6 +315,15 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
[[package]]
name = "lofty"
version = "0.24.0"
@@ -365,6 +403,29 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
[[package]]
name = "paste"
version = "1.0.15"
@@ -377,6 +438,12 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
[[package]]
name = "plain"
version = "0.2.3"
@@ -407,6 +474,15 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -426,6 +502,12 @@ dependencies = [
"windows-sys",
]
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
[[package]]
name = "scroll"
version = "0.12.0"
@@ -522,6 +604,12 @@ version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
[[package]]
name = "smawk"
version = "0.3.3"
@@ -598,6 +686,27 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "pin-project-lite",
+ "tokio-macros",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.2",
+]
+
[[package]]
name = "toml"
version = "0.5.11"
@@ -660,6 +769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98"
dependencies = [
"anyhow",
+ "async-compat",
"bytes",
"once_cell",
"static_assertions",
diff --git a/composeApp/Cargo.toml b/composeApp/Cargo.toml
index 4357cc68..8a06d501 100644
--- a/composeApp/Cargo.toml
+++ b/composeApp/Cargo.toml
@@ -4,9 +4,13 @@ version = "0.1.0"
edition = "2024"
[dependencies]
-uniffi = "0.29.4"
+uniffi = { version = "0.29.4", features = ["tokio"] }
lofty = "0.24.0"
discord-rich-presence = "1.1.0"
+thiserror = "2.0"
+parking_lot = "0.12"
+log = "0.4"
+tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
[lib]
crate-type = ["cdylib", "staticlib"]
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 34b20dd2..cdca27ed 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -86,6 +86,7 @@ kotlin {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
+ implementation(libs.dns.sd.kt)
// Navigation
implementation(libs.jetbrains.navigation3.ui)
@@ -104,8 +105,10 @@ kotlin {
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.cio)
+ implementation(libs.ktor.client.websockets)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio)
+ implementation(libs.ktor.server.websockets)
// Zipline
api(libs.zipline.core)
implementation(libs.zipline.loader)
@@ -160,6 +163,15 @@ kotlin {
// Shimmer effect
implementation(libs.compose.shimmer)
implementation(libs.compose.placeholder.material3)
+
+ // DLNA
+ implementation(libs.dns.sd.kt)
+
+ // mqtt client for jam-session
+ implementation(libs.mqtt.client)
+ implementation(libs.mqtt.x.models)
+ implementation(libs.mqtt.buffer)
+ implementation(libs.mqtt.buffer.codec)
}
}
commonTest.dependencies {
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index 1ee6b8ae..9e832592 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -16,13 +16,19 @@
~ along with this program. If not, see .
-->
-
+
+
+
diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt
index f6c099fc..55ab3202 100644
--- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt
@@ -17,12 +17,16 @@
package dev.krtirtho.spotube
+import android.content.Intent
+import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
import dev.krtirtho.spotube.core.paths.Paths
+import dev.krtirtho.spotube.media.PlaybackService
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.dialogs.init
@@ -31,9 +35,24 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
FileKit.init(this)
- NewPipeDownloader.init(Paths(this))
+ NewPipeDownloader.init(Paths())
+
+ // Start PlaybackService from Activity context (allowed on Android 12+)
+ val serviceIntent = Intent(this, PlaybackService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(serviceIntent)
+ } else {
+ startService(serviceIntent)
+ }
+
+ intent?.dataString?.let(ExternalUriHandler::onNewUri)
setContent {
App()
}
}
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ intent.dataString?.let(ExternalUriHandler::onNewUri)
+ }
}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt
index 306e4433..d93242aa 100644
--- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt
+++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt
@@ -18,10 +18,8 @@
package dev.krtirtho.spotube
import android.app.Application
-import android.content.Intent
-import android.os.Build
import dev.krtirtho.spotube.core.di.initKoin
-import dev.krtirtho.spotube.media.PlaybackService
+import dev.krtirtho.spotube.core.paths.Paths
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -34,15 +32,10 @@ class MyApplication : Application(), KoinComponent {
override fun onCreate() {
super.onCreate()
+ Paths.init(this)
initKoin {
androidContext(this@MyApplication)
}
- val intent = Intent(this, PlaybackService::class.java)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- startForegroundService(intent)
- } else {
- startService(intent)
- }
}
override fun onTerminate() {
diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt
index 899cd58f..63f7f49b 100644
--- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt
+++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt
@@ -20,6 +20,7 @@ package dev.krtirtho.spotube.core.audioplayer
import android.content.Context
import android.content.Intent
import android.os.Build
+import android.util.Log
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaMetadata
@@ -51,11 +52,19 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private val appContext: Context = (context as Context).applicationContext
private fun ensureServiceStarted() {
- val intent = Intent(appContext, PlaybackService::class.java)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- appContext.startForegroundService(intent)
- } else {
- appContext.startService(intent)
+ // On Android 12+ starting a foreground service from the background throws
+ // (ForegroundServiceStartNotAllowedException) — e.g. when a jam session or
+ // remote control applies playback while the app is backgrounded. Never let
+ // that crash the app; playback itself runs in-process without the service.
+ try {
+ val intent = Intent(appContext, PlaybackService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ appContext.startForegroundService(intent)
+ } else {
+ appContext.startService(intent)
+ }
+ } catch (e: Exception) {
+ Log.w("AudioPlayer", "Failed to start playback service", e)
}
}
@@ -226,7 +235,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
actual override suspend fun seekTo(position: Duration) {
withContext(Dispatchers.Main) {
- val targetMs = position.inWholeMilliseconds.coerceIn(0, exoPlayer.duration)
+ val duration = exoPlayer.duration
+ val targetMs = if (duration > 0) {
+ position.inWholeMilliseconds.coerceIn(0, duration)
+ } else {
+ position.inWholeMilliseconds.coerceAtLeast(0)
+ }
exoPlayer.seekTo(targetMs)
_position.tryEmit(exoPlayer.currentPosition.milliseconds)
}
diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt
index 1ee43348..be29dba7 100644
--- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt
+++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt
@@ -29,7 +29,7 @@ import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscove
import org.koin.dsl.module
actual val platformModules = module {
- single { Paths(get()) }
+ single { Paths() }
single { AudioPlayer(get()) }
single { AndroidLocalMediaDiscoveryService(get()) }
single { AndroidShareService(get()) }
diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt
new file mode 100644
index 00000000..961a6b7c
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.discovery
+
+import android.Manifest
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+
+/**
+ * On Android 13+ (and especially 16+ where it became a runtime permission),
+ * mDNS/NSD discovery requires `NEARBY_WIFI_DEVICES`. Requests it when the
+ * returned lambda is invoked; the caller decides when (e.g. first visit to the
+ * Devices screen).
+ */
+@Composable
+actual fun rememberLocalNetworkPermissionRequester(): () -> Unit {
+ val context = LocalContext.current
+ val launcher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { /* result is picked up by discovery/advertising retry loops */ }
+
+ return {
+ val granted = ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.NEARBY_WIFI_DEVICES,
+ ) == PackageManager.PERMISSION_GRANTED
+ if (!granted) {
+ launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt
index e8f172ce..20c187a0 100644
--- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt
+++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt
@@ -20,9 +20,10 @@ package dev.krtirtho.spotube.core.paths
import android.content.Context
import android.os.Environment
-actual class Paths(
- val context: Context
-) {
+actual class Paths {
+ private val context: Context
+ get() = requireNotNull(appContext) { "Paths.init(context) must be called before use" }
+
actual fun getApplicationCacheDirPath(): String {
return context.cacheDir.absolutePath
}
@@ -38,4 +39,13 @@ actual class Paths(
actual fun getMusicCacheDirPath(): String {
return context.cacheDir.absolutePath + "/music_cache"
}
+
+ companion object {
+ @Volatile
+ private var appContext: Context? = null
+
+ fun init(context: Context) {
+ appContext = context.applicationContext
+ }
+ }
}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt
index 6db62be6..3c49b84c 100644
--- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt
+++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt
@@ -86,7 +86,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
.setOngoing(true)
.build()
- startForeground(NOTIFICATION_ID, notification)
+ // On Android 15+ a service created from the background (e.g. by a jam
+ // sync or queue restoration) hits this in startForeground instead of at
+ // the startForegroundService call site. Don't crash: playback keeps
+ // running in-process, just without the notification until the app is
+ // foregrounded and the service can start properly.
+ try {
+ startForeground(NOTIFICATION_ID, notification)
+ } catch (e: Exception) {
+ Log.w(TAG, "startForeground not allowed; continuing without notification", e)
+ }
librarySession =
MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 010d4259..9341f547 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -96,6 +96,13 @@
Enable Connect
Expose remote playback controls through the Spotube Connect feature.
Playback proxy server port
+ Allow remote control
+ Let other devices on the same network control playback when they connect.
+ Remote control device name
+ The name other devices see when discovering this one. Current: %1$s
+ Choose a friendly name to show to other devices on your network when they discover this Spotube instance.
+ e.g. Living Room Spotube
+ device hostname
Port used by the playback proxy server. Current: %1$d
Choose a port between 1 and 65535.
14769
@@ -166,5 +173,19 @@
Send and receive data over the internet
WebView
Display web content inside the app
+ Group Jam
+ MQTT Broker
+ Broker host
+ %1$s:%2$d
+ Broker port
+ Use TLS
+ Username (optional)
+ Password (optional)
+ Client ID prefix
+ Test connection
+ Testing…
+ Connected in %1$d ms
+ Failed: %1$s
+ Placeholder broker — configure your own to self-host
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
index 66d14649..ee165e9b 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt
index b2864360..1562a2e1 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt
@@ -25,6 +25,7 @@ import kotlinx.serialization.Serializable
@Serializable
sealed interface QueueEntry {
val url: String
+ val addedBy: String
@Serializable
@SerialName("streaming")
@@ -32,6 +33,7 @@ sealed interface QueueEntry {
val track: MetadataTrack,
override val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
+ override val addedBy: String = "",
) : QueueEntry
@Serializable
@@ -42,7 +44,8 @@ sealed interface QueueEntry {
val duration: Long,
val album: String?,
val coverBytes: ByteArray?,
- override val url: String
+ override val url: String,
+ override val addedBy: String = "",
) : QueueEntry {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -56,6 +59,7 @@ sealed interface QueueEntry {
if (album != other.album) return false
if (!coverBytes.contentEquals(other.coverBytes)) return false
if (url != other.url) return false
+ if (addedBy != other.addedBy) return false
return true
}
@@ -67,6 +71,7 @@ sealed interface QueueEntry {
result = 31 * result + (album?.hashCode() ?: 0)
result = 31 * result + (coverBytes?.contentHashCode() ?: 0)
result = 31 * result + url.hashCode()
+ result = 31 * result + addedBy.hashCode()
return result
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt
new file mode 100644
index 00000000..00deb04d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.deeplink
+
+/**
+ * Cross-platform receiver for URIs handed to the app by the operating system
+ * (deep links). Platform entry points (Android activity intents, desktop command
+ * line / open-URI handler, iOS `onOpenURL`) call [onNewUri]; the main composable
+ * installs a [listener] once composition starts.
+ *
+ * Follows the Compose Multiplatform deep linking docs pattern: URIs arriving
+ * before a listener is installed are cached and delivered as soon as one is set.
+ */
+object ExternalUriHandler {
+ private var cached: String? = null
+
+ var listener: ((uri: String) -> Unit)? = null
+ set(value) {
+ field = value
+ if (value != null) {
+ cached?.let(value::invoke)
+ cached = null
+ }
+ }
+
+ fun onNewUri(uri: String) {
+ if (uri.isBlank()) return
+ val currentListener = listener
+ if (currentListener != null) {
+ currentListener(uri)
+ } else {
+ cached = uri
+ }
+ }
+}
\ No newline at end of file
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 665f351b..a130729f 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,8 +23,15 @@ 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.discovery.DeviceDiscoveryService
import dev.krtirtho.spotube.core.discord.DiscordRpcService
+import dev.krtirtho.spotube.core.jam.JamRoomClient
+import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.navigation.navigationModule
+import dev.krtirtho.spotube.core.remote.RemoteControlClient
+import dev.krtirtho.spotube.core.remote.RemoteControlHandler
+import dev.krtirtho.spotube.core.remote.RemoteControlService
+import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
import dev.krtirtho.spotube.core.server.CacheManager
@@ -38,6 +45,9 @@ import dev.krtirtho.spotube.modules.artist.ArtistRepository
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel
+import dev.krtirtho.spotube.modules.devices.DevicesViewModel
+import dev.krtirtho.spotube.modules.devices.RemoteControlViewModel
+import dev.krtirtho.spotube.modules.jam.JamViewModel
import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
@@ -133,6 +143,7 @@ val sharedModules = module {
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
+ remotePlaybackController = get(),
)
}
@@ -147,6 +158,7 @@ val sharedModules = module {
libraryRepository = get(),
shareService = get(),
downloadManager = get(),
+ remotePlaybackController = get(),
)
}
@@ -162,12 +174,22 @@ val sharedModules = module {
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
+ remotePlaybackController = get(),
)
}
// Blacklist
singleOf(::BlacklistRepository)
viewModelOf(::BlacklistViewModel)
+ viewModel { DevicesViewModel(get()) }
+ viewModelOf(::RemoteControlViewModel)
+ viewModel {
+ JamViewModel(
+ jamRoomService = get(),
+ shareService = get(),
+ settingsProvider = get(),
+ )
+ }
// Album
singleOf(::AlbumRepository)
@@ -182,6 +204,7 @@ val sharedModules = module {
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
+ remotePlaybackController = get(),
)
}
@@ -194,6 +217,7 @@ val sharedModules = module {
albumRepository = get(),
playlistRepository = get(),
savedTracksRepository = get(),
+ artistRepository = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
)
@@ -205,6 +229,15 @@ val sharedModules = module {
singleOf(::LocalServer) withOptions {
createdAtStart()
}
+ single { RemoteControlHandler(get(), get(), get(), get()) }
+ single { RemoteControlClient() }
+ singleOf(::DeviceDiscoveryService)
+ single { RemoteControlService(get(), get(), get()) } withOptions {
+ createdAtStart()
+ }
+ single { RemotePlaybackController(get(), get(), get(), get(), get()) }
+ singleOf(::JamRoomClient)
+ single { JamRoomService(get(), get(), get(), get()) }
singleOf(::AudioPlayerQueueRepository) { bind() }
single {
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt
new file mode 100644
index 00000000..25537c3f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt
@@ -0,0 +1,108 @@
+/*
+ * 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.discovery
+
+import com.appstractive.dnssd.DiscoveryEvent
+import com.appstractive.dnssd.NetService
+import com.appstractive.dnssd.createNetService
+import com.appstractive.dnssd.discoverServices
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlin.text.decodeToString
+
+data class DiscoveredDevice(
+ val name: String,
+ val type: String,
+ val host: String,
+ val port: Int,
+ val deviceId: String,
+) {
+ val key: String get() = "$name$type".replace(".", "")
+}
+
+sealed interface DiscoveryState {
+ data class Discovered(val device: DiscoveredDevice, val resolve: () -> Unit) : DiscoveryState
+ data class Resolved(val device: DiscoveredDevice) : DiscoveryState
+ data class Removed(val device: DiscoveredDevice) : DiscoveryState
+}
+
+class DeviceDiscoveryService {
+ companion object {
+ const val SERVICE_TYPE = "_spotube-ctrl._tcp"
+ const val TXT_DEVICE_ID = "deviceId"
+ }
+
+ fun discover(): Flow = discoverServices(SERVICE_TYPE).map { event ->
+ when (event) {
+ is DiscoveryEvent.Discovered -> {
+ val device = DiscoveredDevice(
+ name = event.service.name,
+ type = event.service.type,
+ host = event.service.host,
+ port = event.service.port,
+ deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(),
+ )
+ DiscoveryState.Discovered(device = device, resolve = event.resolve)
+ }
+
+ is DiscoveryEvent.Resolved -> {
+ val device = DiscoveredDevice(
+ name = event.service.name,
+ type = event.service.type,
+ host = event.service.host,
+ port = event.service.port,
+ deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(),
+ )
+ DiscoveryState.Resolved(device = device)
+ }
+
+ is DiscoveryEvent.Removed -> {
+ val device = DiscoveredDevice(
+ name = event.service.name,
+ type = event.service.type,
+ host = event.service.host,
+ port = event.service.port,
+ deviceId = "",
+ )
+ DiscoveryState.Removed(device = device)
+ }
+ }
+ }
+
+ suspend fun advertise(
+ name: String,
+ port: Int,
+ deviceId: String,
+ registerTimeoutMs: Long = 5_000,
+ ): NetService {
+ val service = createService(name, port, deviceId)
+ service.register(timeoutInMs = registerTimeoutMs)
+ return service
+ }
+
+ fun createService(
+ name: String,
+ port: Int,
+ deviceId: String,
+ ): NetService = createNetService(
+ type = SERVICE_TYPE,
+ name = name,
+ port = port,
+ txt = mapOf(TXT_DEVICE_ID to deviceId),
+ )
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt
new file mode 100644
index 00000000..404c2b0d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.discovery
+
+import androidx.compose.runtime.Composable
+
+/**
+ * Returns a lambda that requests the OS permission needed for local network
+ * discovery (mDNS/NSD). No-op on platforms where such a permission doesn't
+ * exist or is granted implicitly.
+ */
+@Composable
+expect fun rememberLocalNetworkPermissionRequester(): () -> Unit
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt
new file mode 100644
index 00000000..a51b8cb5
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt
@@ -0,0 +1,198 @@
+/*
+ * 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.jam
+
+import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
+import dev.krtirtho.spotube.core.audioplayer.MediaItem
+import dev.krtirtho.spotube.core.audioplayer.QueueEntry
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * Jam messages exchanged over MQTT.
+ *
+ * - `state` topic: [QueueState] (retained, host -> everyone)
+ * - `cmd` topic: [PlaybackCommand], [Kick], [SuggestTrack], [SuggestPlaylist]
+ * (anyone -> host, except Kick which is host -> guest)
+ * - `presence/{clientId}` topic: [JamPresence] (retained, one per participant)
+ */
+@Serializable
+sealed class JamMessage {
+ @Serializable
+ @SerialName("queueState")
+ data class QueueState(
+ val items: List,
+ val currentIndex: Int,
+ val shuffleEnabled: Boolean = false,
+ /**
+ * Whether guests should follow the host's current index. True when the
+ * host manually skipped/jumped or loaded a queue; false when the host
+ * merely auto-advanced because its song ended (guests stay put).
+ */
+ val follow: Boolean = false,
+ /** Host's live play state — late-joining guests start with it. */
+ val isPlaying: Boolean = false,
+ ) : JamMessage()
+
+ @Serializable
+ @SerialName("playbackCommand")
+ data class PlaybackCommand(val command: PlaybackCmd) : JamMessage()
+
+ @Serializable
+ @SerialName("suggestTrack")
+ data class SuggestTrack(
+ val mediaItem: JamMediaItem,
+ val addedBy: String = "",
+ ) : JamMessage()
+
+ @Serializable
+ @SerialName("suggestPlaylist")
+ data class SuggestPlaylist(
+ val tracks: List,
+ val addedBy: String = "",
+ ) : JamMessage()
+
+ @Serializable
+ @SerialName("kick")
+ data class Kick(
+ val participantId: String,
+ val reason: String = "kicked",
+ ) : JamMessage()
+
+ @Serializable
+ @SerialName("leave")
+ data class Leave(val reason: String = "user_left") : JamMessage()
+}
+
+/**
+ * Playback commands. Only queue navigation is global — play/pause, seek,
+ * volume, shuffle and loop are local to each participant.
+ */
+@Serializable
+sealed class PlaybackCmd {
+ @Serializable
+ @SerialName("skipNext")
+ data object SkipNext : PlaybackCmd()
+
+ @Serializable
+ @SerialName("skipPrevious")
+ data object SkipPrevious : PlaybackCmd()
+
+ @Serializable
+ @SerialName("jumpTo")
+ data class JumpTo(val index: Int) : PlaybackCmd()
+}
+
+/** Retained per-participant presence entry (with an MQTT Last Will for leave). */
+@Serializable
+data class JamPresence(
+ val clientId: String,
+ val displayName: String,
+ val isHost: Boolean,
+ val left: Boolean = false,
+)
+
+@Serializable
+data class JamMediaItem(
+ val url: String,
+ val trackId: String = "",
+ val title: String,
+ val artist: String,
+ val album: String,
+ val durationMs: Long,
+ val coverUrl: String,
+ val protocol: String,
+ val addedBy: String = "",
+) {
+ companion object {
+ fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
+ is QueueEntry.StreamingTrack -> JamMediaItem(
+ url = "",
+ trackId = entry.track.id,
+ title = entry.track.title,
+ artist = entry.track.artists.joinToString(", ") { it.name },
+ album = entry.track.album?.title.orEmpty(),
+ durationMs = entry.track.durationMs,
+ coverUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url
+ ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
+ .orEmpty(),
+ protocol = entry.protocol.name,
+ addedBy = entry.addedBy,
+ )
+
+ is QueueEntry.LocalTrack -> JamMediaItem(
+ url = entry.url,
+ trackId = "",
+ title = entry.name,
+ artist = entry.artists.joinToString(", "),
+ album = entry.album.orEmpty(),
+ durationMs = entry.duration,
+ coverUrl = "",
+ protocol = "PROGRESSIVE",
+ addedBy = entry.addedBy,
+ )
+ }
+
+ fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem(
+ url = "",
+ trackId = track.id,
+ title = track.title,
+ artist = track.artists.joinToString(", ") { it.name },
+ album = track.album?.title.orEmpty(),
+ durationMs = track.durationMs,
+ coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
+ ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
+ .orEmpty(),
+ protocol = "PROGRESSIVE",
+ )
+
+ fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
+ url = item.url,
+ title = item.title,
+ artist = item.artist,
+ album = item.album,
+ durationMs = item.duration.inWholeMilliseconds,
+ coverUrl = item.coverURL,
+ protocol = item.protocol.name,
+ )
+
+ fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem(
+ title = item.title,
+ artist = item.artist,
+ album = item.album,
+ duration = kotlin.time.Duration.parse("${item.durationMs}ms"),
+ coverURL = item.coverUrl,
+ url = item.url,
+ protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
+ .valueOf(item.protocol.ifBlank { "PROGRESSIVE" }),
+ )
+ }
+}
+
+@Serializable
+data class JamParticipant(
+ val id: String,
+ val displayName: String,
+ val isHost: Boolean,
+)
+
+@Serializable
+enum class JamRole {
+ Host,
+ Guest,
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt
new file mode 100644
index 00000000..83531f19
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt
@@ -0,0 +1,323 @@
+/*
+ * 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.jam
+
+import co.touchlab.kermit.Logger
+import com.ditchoom.buffer.Charset
+import com.ditchoom.buffer.codec.asReadBuffer
+import com.ditchoom.buffer.toReadBuffer
+import com.ditchoom.mqtt.client.ConnectionState
+import com.ditchoom.mqtt.client.MqttClient
+import com.ditchoom.mqtt.connection.MqttConnectionOptions
+import com.ditchoom.mqtt.controlpacket.OpaquePublishPayloadCodec
+import com.ditchoom.mqtt.controlpacket.QualityOfService
+import com.ditchoom.mqtt.controlpacket.TopicName
+import com.ditchoom.mqtt.controlpacket.WillConfig
+import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
+import dev.krtirtho.spotube.modules.settings.JamBroker
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
+import kotlinx.serialization.json.Json
+import org.koin.core.component.KoinComponent
+
+/**
+ * Thin facade over the Ditchoom MQTT 5 client for one jam room.
+ *
+ * Topics (room code `C`):
+ * - `spotube/jam/{C}/state` retained — [JamMessage.QueueState] (host -> everyone)
+ * - `spotube/jam/{C}/cmd` volatile — commands/suggestions (everyone -> host, host -> guest)
+ * - `spotube/jam/{C}/presence/{clientId}` retained — [JamPresence], with a Last Will
+ * (`left = true`) so a dropped client disappears from the room automatically.
+ *
+ * The library keeps the connection alive (auto-reconnect + backoff); this class only
+ * re-establishes the room subscription and re-publishes presence after a reconnect.
+ */
+class JamRoomClient : KoinComponent {
+ private val log = Logger.withTag("JamRoomClient")
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private val json = Json {
+ ignoreUnknownKeys = true
+ classDiscriminator = "type"
+ encodeDefaults = true
+ }
+
+ private var client: MqttClient? = null
+
+ private var roomCode: String? = null
+ private var localPresence: JamPresence? = null
+
+ private val _isConnected = MutableStateFlow(false)
+ val isConnected: StateFlow = _isConnected.asStateFlow()
+
+ private val _connectionError = MutableStateFlow(null)
+ val connectionError: StateFlow = _connectionError.asStateFlow()
+
+ private val _state = MutableSharedFlow(replay = 1, extraBufferCapacity = 8)
+ val state: SharedFlow = _state.asSharedFlow()
+
+ private val _commands = MutableSharedFlow(extraBufferCapacity = 32)
+ val commands: SharedFlow = _commands.asSharedFlow()
+
+ private val _presence = MutableStateFlow