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>(emptyMap()) + val presence: StateFlow> = _presence.asStateFlow() + + // ---------- Public API ---------- + + /** One-off connection check used by the settings screen. Returns latency description. */ + suspend fun testConnection(broker: JamBroker): Result { + if (broker.host.isBlank()) return Result.failure(IllegalArgumentException("Broker host is empty")) + val started = kotlin.time.TimeSource.Monotonic.markNow() + return runCatching { + val client = startClient(broker, clientId = "${broker.clientIdPrefix}-test") + try { + withTimeout(broker.connectionTimeoutSeconds.seconds) { + client.awaitConnectivity() + } + "Connected in ${started.elapsedNow().inWholeMilliseconds} ms" + } finally { + runCatching { client.shutdown(sendDisconnect = true, drain = false) } + } + } + } + + /** Connects to [code] on [broker] and starts routing room messages. */ + suspend fun connect( + broker: JamBroker, + code: String, + clientId: String, + displayName: String, + isHost: Boolean, + ): Result { + disconnect() + if (broker.host.isBlank()) { + return Result.failure(IllegalArgumentException("No jam broker configured")) + } + return runCatching { + roomCode = code + localPresence = JamPresence( + clientId = clientId, + displayName = displayName, + isHost = isHost, + left = false, + ) + + val mqtt = startClient(broker, clientId) + client = mqtt + withTimeout(broker.connectionTimeoutSeconds.seconds) { + mqtt.awaitConnectivity() + } + _connectionError.value = null + log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" } + + mqtt.connectionState + .onEach { onConnectionStateChanged(it) } + .launchIn(scope) + Unit + }.onFailure { e -> + log.w(e) { "Failed to connect to jam broker" } + _connectionError.value = e.message ?: "Connection failed" + _isConnected.value = false + runCatching { client?.shutdown(sendDisconnect = true, drain = false) } + client = null + } + } + + suspend fun publishState(state: JamMessage.QueueState) { + val code = roomCode ?: return + publishJson(stateTopic(code), json.encodeToString(JamMessage.QueueState.serializer(), state), retain = true) + } + + suspend fun publishCommand(message: JamMessage) { + val code = roomCode ?: return + publishJson(cmdTopic(code), json.encodeToString(JamMessage.serializer(), message), retain = false) + } + + /** Publishes our own (retained) presence. Re-published after every reconnect. */ + suspend fun publishPresence() { + val code = roomCode ?: return + val presence = localPresence ?: return + publishJson( + presenceTopic(code, presence.clientId), + json.encodeToString(JamPresence.serializer(), presence), + retain = true, + ) + } + + /** Marks us as the host in presence (host takeover). */ + suspend fun claimHost() { + val presence = localPresence ?: return + localPresence = presence.copy(isHost = true) + publishPresence() + } + + /** Graceful leave: publish `left = true` before disconnecting. */ + suspend fun leavePresence() { + val presence = localPresence ?: return + val code = roomCode ?: return + runCatching { + publishJson( + presenceTopic(code, presence.clientId), + json.encodeToString(JamPresence.serializer(), presence.copy(left = true)), + retain = true, + ) + } + } + + suspend fun disconnect() { + roomCode = null + localPresence = null + _isConnected.value = false + _presence.value = emptyMap() + val current = client + client = null + runCatching { current?.shutdown(sendDisconnect = true, drain = false) } + } + + // ---------- Internals ---------- + + private suspend fun startClient(broker: JamBroker, clientId: String): MqttClient { + val connection = MqttConnectionOptions.SocketConnection( + host = broker.host, + port = broker.port, + tlsEnabled = broker.useTls, + connectionTimeout = broker.connectionTimeoutSeconds.seconds, + ) + val code = roomCode ?: "unset" + val will = WillConfig.Enabled( + topic = TopicName.fromOrThrow(presenceTopic(code, clientId)), + payload = json.encodeToString( + JamPresence.serializer(), + JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true), + ).toReadBuffer(Charset.UTF8), + qos = QualityOfService.AT_LEAST_ONCE, + retain = true, + ) + val request = ConnectionRequest( + clientId = clientId, + keepAliveSeconds = broker.keepAliveSeconds, + cleanStart = true, + userName = broker.username, + password = broker.password, + will = will, + ) + val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true) + val brokerRef = persistence.addBroker(connection, request) + return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence) + } + + private fun onConnectionStateChanged(state: ConnectionState) { + when (state) { + is ConnectionState.Connected -> { + _isConnected.value = true + _connectionError.value = null + scope.launch { + // Every connection (initial + reconnects) must re-establish the + // broker-side subscription (clean session) and re-publish our + // retained presence to clear any Last Will. Re-subscribing with + // the same filter replaces the previous dispatcher handler. + subscribeRoom() + publishPresence() + } + } + + ConnectionState.Disconnected, ConnectionState.Handshaking -> { + _isConnected.value = false + } + + else -> { + _isConnected.value = false + _connectionError.value = "Connection lost" + } + } + } + + private suspend fun subscribeRoom() { + val mqtt = client ?: return + val code = roomCode ?: return + val operation = mqtt.subscribe( + roomFilter(code), + OpaquePublishPayloadCodec, + QualityOfService.AT_LEAST_ONCE, + ) { publish, payload -> + route(publish.topic.toString(), payload) + } + runCatching { operation.subAck.await() } + .onFailure { log.w(it) { "Subscribe ack failed for room $code" } } + } + + private fun route(topic: String, payload: com.ditchoom.mqtt.controlpacket.OpaquePublishPayload) { + val text = runCatching { + val buffer = payload.handle.asReadBuffer() + buffer.readString(buffer.remaining(), Charset.UTF8) + }.getOrElse { e -> + log.w(e) { "Failed to read jam payload on $topic" } + return + } + + runCatching { + when { + topic.endsWith("/state") -> { + _state.tryEmit(json.decodeFromString(JamMessage.QueueState.serializer(), text)) + } + + topic.endsWith("/cmd") -> { + _commands.tryEmit(json.decodeFromString(JamMessage.serializer(), text)) + } + + topic.contains("/presence/") -> { + val presence = json.decodeFromString(JamPresence.serializer(), text) + _presence.value = _presence.value + (presence.clientId to presence) + } + } + }.onFailure { e -> + log.w(e) { "Failed to decode jam message on $topic: $text" } + } + } + + private suspend fun publishJson(topic: String, payload: String, retain: Boolean) { + val mqtt = client ?: return + runCatching { + mqtt.publish( + topicName = topic, + qos = QualityOfService.AT_LEAST_ONCE, + payload = payload.toReadBuffer(Charset.UTF8), + retain = retain, + ) + }.onFailure { e -> + log.w(e) { "Failed to publish to $topic" } + } + } + + private fun stateTopic(code: String) = "spotube/jam/$code/state" + private fun cmdTopic(code: String) = "spotube/jam/$code/cmd" + private fun presenceTopic(code: String, clientId: String) = "spotube/jam/$code/presence/$clientId" + private fun roomFilter(code: String) = "spotube/jam/$code/#" +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt new file mode 100644 index 00000000..0a1b891f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt @@ -0,0 +1,47 @@ +/* + * 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 kotlin.random.Random + +/** + * Six-character room codes shared verbally / by text. Codes are opaque keys used + * to namespace the MQTT topics of a jam room — they carry no connection details. + * + * The alphabet excludes look-alike characters (I, O, 0, 1) so codes are easy to + * read aloud and retype. + */ +object JamRoomCode { + const val LENGTH = 6 + private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + fun generate(): String = buildString(LENGTH) { + repeat(LENGTH) { + append(ALPHABET[Random.nextInt(ALPHABET.length)]) + } + } + + /** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */ + fun normalize(input: String): String = input + .uppercase() + .filter { it.isLetterOrDigit() } + .take(LENGTH) + + fun isValid(code: String): Boolean = + code.length == LENGTH && code.all { it in ALPHABET } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt new file mode 100644 index 00000000..f8c15a2b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt @@ -0,0 +1,573 @@ +/* + * 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 dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.PlayerState +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlin.random.Random +import kotlin.time.Clock + +/** + * A jam session over MQTT (star topology, host-authoritative queue). + * + * Sync rules: + * - The queue list and current index are global. [skipNext]/[skipPrevious]/[jumpTo] + * from anyone are applied by the host, then broadcast via the retained state topic. + * - Play/pause, seek, volume and loop are local to each device — never broadcast. + * When the queue moves on, a paused participant stays paused; a playing one + * keeps playing the new current item. + * - Shuffle is host-only; guests mirror the host's shuffle setting. + * - Guests can only add to the queue (suggest); the host applies suggestions. + * - If the host leaves, the participant with the lowest client id takes over. + */ +private data class PlaybackBroadcast( + val queue: List, + val current: QueueEntry?, + val shuffle: Boolean, + val playerState: PlayerState, +) + +class JamRoomService( + private val jamClient: JamRoomClient, + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val settingsRepository: SettingsRepository, +) { + private val log = Logger.withTag("JamRoomService") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _role = MutableStateFlow(null) + val role: StateFlow = _role.asStateFlow() + + private val _participants = MutableStateFlow>(emptyList()) + val participants: StateFlow> = _participants.asStateFlow() + + private val _roomCode = MutableStateFlow(null) + val roomCode: StateFlow = _roomCode.asStateFlow() + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _shuffleEnabled = MutableStateFlow(false) + val shuffleEnabled: StateFlow = _shuffleEnabled.asStateFlow() + + private var localClientId: String = "" + private var localDisplayName: String = "" + + /** Display name of the local participant. */ + val participantDisplayName: String + get() = localDisplayName + + /** Stable id of the local participant (stamped on items this device adds). */ + val participantClientId: String + get() = localClientId + + private var hostBroadcastJob: Job? = null + + /** Guest side: last queue snapshot applied to the local player. */ + private var lastAppliedItems: List = emptyList() + private var lastAppliedIndex = -1 + + /** + * Host side: a song completed, so index changes within this window are + * auto-advance (guests must not follow). The completion event, the player + * state change and the media transition arrive as separate flow emissions, + * so the window covers the whole sequence instead of a single flag. + */ + private var autoAdvanceDeadlineMs = 0L + + /** Guest side: playback has started at least once (local control is the guest's own). */ + private var hasStartedPlayback = false + + /** Host side: client ids banned for this session. */ + private val bannedClientIds = mutableSetOf() + + private var leaving = false + + init { + jamClient.isConnected + .onEach { _isConnected.value = it } + .launchIn(scope) + jamClient.connectionError + .onEach { _connectionError.value = it } + .launchIn(scope) + jamClient.state + .onEach { onRemoteState(it) } + .launchIn(scope) + jamClient.commands + .onEach { onCommand(it) } + .launchIn(scope) + jamClient.presence + .onEach { onPresence(it) } + .launchIn(scope) + // A naturally-completed song means the host's next index change is an + // auto-advance — guests must NOT follow those, only manual skips. + audioPlayer.completionFlow + .onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS } + .launchIn(scope) + // Once a guest has played on its own (or was started by the host), its + // play/pause is its own — the host's play state only starts fresh joiners. + audioPlayer.playerStateFlow + .onEach { state -> + if (_role.value == JamRole.Guest && state == PlayerState.PLAYING) { + hasStartedPlayback = true + } + } + .launchIn(scope) + } + + // ---------- Session lifecycle ---------- + + suspend fun createRoom(): Result { + val broker = settingsRepository.userSettings.value.jamBroker + if (broker.host.isBlank()) { + return Result.failure(IllegalStateException("No jam broker configured")) + } + val code = JamRoomCode.generate() + localClientId = newClientId(broker) + val name = participantName("Host") + localDisplayName = name + + return jamClient.connect( + broker = broker, + code = code, + clientId = localClientId, + displayName = name, + isHost = true, + ).map { + _role.value = JamRole.Host + _roomCode.value = code + _participants.value = listOf(JamParticipant(localClientId, name, isHost = true)) + lastAppliedItems = emptyList() + lastAppliedIndex = -1 + bannedClientIds.clear() + leaving = false + autoAdvanceDeadlineMs = 0L + hasStartedPlayback = false + startHostBroadcast() + persistLastCode(code) + code + } + } + + suspend fun joinRoom(code: String): Result { + val broker = settingsRepository.userSettings.value.jamBroker + if (broker.host.isBlank()) { + return Result.failure(IllegalStateException("No jam broker configured")) + } + val normalized = JamRoomCode.normalize(code) + if (!JamRoomCode.isValid(normalized)) { + return Result.failure(IllegalArgumentException("Invalid room code")) + } + localClientId = newClientId(broker) + val name = participantName("Guest") + localDisplayName = name + + return jamClient.connect( + broker = broker, + code = normalized, + clientId = localClientId, + displayName = name, + isHost = false, + ).map { + _role.value = JamRole.Guest + _roomCode.value = normalized + _participants.value = emptyList() + lastAppliedItems = emptyList() + lastAppliedIndex = -1 + leaving = false + hasStartedPlayback = false + persistLastCode(normalized) + } + } + + suspend fun leaveRoom() { + leaving = true + stopHostBroadcast() + runCatching { jamClient.leavePresence() } + jamClient.disconnect() + _role.value = null + _roomCode.value = null + _participants.value = emptyList() + _shuffleEnabled.value = false + _isConnected.value = false + lastAppliedItems = emptyList() + lastAppliedIndex = -1 + bannedClientIds.clear() + hasStartedPlayback = false + } + + // ---------- Controls (called from the UI) ---------- + + fun skipNext() { + publishCommand(PlaybackCmd.SkipNext) + } + + fun skipPrevious() { + publishCommand(PlaybackCmd.SkipPrevious) + } + + fun jumpTo(index: Int) { + publishCommand(PlaybackCmd.JumpTo(index)) + } + + /** Host-only. Applied locally; the queue broadcast carries the new shuffle flag. */ + fun toggleShuffle() { + if (_role.value != JamRole.Host) return + scope.launch { + runCatching { audioPlayer.shuffle(!_shuffleEnabled.value) } + } + } + + suspend fun suggestTrack(track: MetadataTrack) { + jamClient.publishCommand( + JamMessage.SuggestTrack( + mediaItem = JamMediaItem.fromTrack(track), + addedBy = localClientId, + ) + ) + } + + suspend fun suggestPlaylist(tracks: List) { + if (tracks.isEmpty()) return + jamClient.publishCommand( + JamMessage.SuggestPlaylist( + tracks = tracks.map(JamMediaItem::fromTrack), + addedBy = localClientId, + ) + ) + } + + suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") { + if (_role.value != JamRole.Host) return + jamClient.publishCommand(JamMessage.Kick(participantId, reason)) + } + + suspend fun banParticipant(participantId: String) { + if (_role.value != JamRole.Host) return + bannedClientIds += participantId + kickParticipant(participantId, "banned by host") + } + + // ---------- Host: broadcast ---------- + + private fun startHostBroadcast() { + if (hostBroadcastJob?.isActive == true) return + hostBroadcastJob = scope.launch { + combine( + audioPlayerQueue.queueFlow, + audioPlayerQueue.currentQueueEntryFlow, + audioPlayer.shuffleModeFlow, + audioPlayer.playerStateFlow, + ) { queue, current, shuffle, playerState -> + PlaybackBroadcast(queue, current, shuffle, playerState) + } + .onEach { broadcast -> + if (_role.value != JamRole.Host) return@onEach + val index = if (broadcast.current != null) { + broadcast.queue.indexOfFirst { it.matchesEntry(broadcast.current) } + } else { + -1 + } + _shuffleEnabled.value = broadcast.shuffle + jamClient.publishState( + JamMessage.QueueState( + items = broadcast.queue.map(JamMediaItem::fromQueueEntry), + currentIndex = index.coerceAtLeast(0), + shuffleEnabled = broadcast.shuffle, + follow = now() > autoAdvanceDeadlineMs, + isPlaying = broadcast.playerState == PlayerState.PLAYING, + ) + ) + } + .launchIn(this) + } + } + + private fun stopHostBroadcast() { + hostBroadcastJob?.cancel() + hostBroadcastJob = null + } + + // ---------- Guest: apply remote state ---------- + + private suspend fun onRemoteState(state: JamMessage.QueueState) { + if (_role.value != JamRole.Guest) return + if (leaving) return + + _shuffleEnabled.value = state.shuffleEnabled + runCatching { audioPlayer.shuffle(state.shuffleEnabled) } + + // Late joiner: the host is already playing, so start immediately. Once + // this guest has played on its own, the host's play state is ignored. + if (state.isPlaying && !hasStartedPlayback) { + runCatching { audioPlayer.play() } + .onFailure { log.w(it) { "Failed to start playback on host play state" } } + } + + val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } + val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING + + if (items != lastAppliedItems) { + val previous = lastAppliedItems + lastAppliedItems = items + val guestCurrent = audioPlayerQueue.currentQueueEntryFlow.value + val guestIndex = items.indexOfFirst { it.matchesEntry(guestCurrent) } + + // The host only appended items (e.g. accepted suggestions): merge + // them in without resetting playback or the guest's position. + if (previous.isNotEmpty() && items.size > previous.size && + items.take(previous.size) == previous && guestIndex >= 0 + ) { + lastAppliedIndex = guestIndex + val appended = items.drop(previous.size) + runCatching { + audioPlayerQueue.addAllToQueue(appended.map { it.toQueueEntry() }) + }.onFailure { log.w(it) { "Failed to append jam queue items" } } + return + } + + // Full re-sync. Keep the guest's current track when it still exists + // in the synced queue; otherwise take the host's position. + val startIndex = if (guestIndex >= 0) guestIndex + else state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)) + lastAppliedIndex = startIndex + runCatching { + audioPlayerQueue.load( + entries = items.map { it.toQueueEntry() }, + autoPlay = wasPlaying, + startPosition = startIndex, + ) + }.onFailure { log.w(it) { "Failed to apply jam queue" } } + return + } + + // Same queue content: only follow the host when it moved manually + // (skip/jump). Natural auto-advance keeps everyone where they are. + if (state.follow && state.currentIndex != lastAppliedIndex) { + lastAppliedIndex = state.currentIndex + runCatching { + audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false) + }.onFailure { log.w(it) { "Failed to follow jam queue index" } } + } + } + + // ---------- Incoming commands ---------- + + private suspend fun onCommand(message: JamMessage) { + when (message) { + is JamMessage.PlaybackCommand -> { + if (_role.value != JamRole.Host) return + applyCommand(message.command) + } + + is JamMessage.SuggestTrack -> { + if (_role.value == JamRole.Host) { + acceptSuggestion(listOf(message.mediaItem.copy(addedBy = message.addedBy))) + } + } + + is JamMessage.SuggestPlaylist -> { + if (_role.value == JamRole.Host) { + acceptSuggestion(message.tracks.map { it.copy(addedBy = message.addedBy) }) + } + } + + is JamMessage.Kick -> { + if (_role.value == JamRole.Guest && message.participantId == localClientId) { + log.i { "Kicked from jam room: ${message.reason}" } + leaveRoom() + } + } + + else -> Unit + } + } + + private suspend fun applyCommand(command: PlaybackCmd) { + when (command) { + PlaybackCmd.SkipNext -> runCatching { audioPlayer.skipToNext() } + PlaybackCmd.SkipPrevious -> runCatching { audioPlayer.skipToPrevious() } + is PlaybackCmd.JumpTo -> runCatching { audioPlayer.jumpTo(command.index) } + } + } + + private suspend fun acceptSuggestion(items: List) { + if (items.isEmpty()) return + log.i { "Accepting ${items.size} suggested item(s) into the jam queue" } + runCatching { + audioPlayerQueue.addAllToQueue(items.map { it.toQueueEntry() }) + } + } + + // ---------- Presence & host takeover ---------- + + private fun onPresence(all: Map) { + if (_role.value == null) return + val live = all.values.filter { !it.left } + _participants.value = live + .sortedBy { it.clientId } + .map { JamParticipant(it.clientId, it.displayName, it.isHost) } + + // Host-side: auto-kick banned participants that rejoin. + if (_role.value == JamRole.Host) { + live.filter { it.clientId in bannedClientIds }.forEach { banned -> + scope.launch { kickParticipant(banned.clientId, "banned by host") } + } + return + } + + val host = live.firstOrNull { it.isHost } + if (host != null) return + + // Host left: the lowest client id takes over (deterministic, clock-free). + val candidate = live.minByOrNull { it.clientId } ?: return + if (candidate.clientId != localClientId) return + + scope.launch { + delay(HOST_TAKEOVER_DELAY_MS) + val stillNoHost = jamClient.presence.value.values.none { !it.left && it.isHost } + if (!stillNoHost || leaving || _role.value != JamRole.Guest) return@launch + log.i { "Taking over as jam host (previous host left)" } + _role.value = JamRole.Host + jamClient.claimHost() + startHostBroadcast() + } + } + + // ---------- Helpers ---------- + + private fun publishCommand(command: PlaybackCmd) { + scope.launch { + jamClient.publishCommand(JamMessage.PlaybackCommand(command)) + } + } + + private fun participantName(fallbackPrefix: String): String { + val configured = settingsRepository.userSettings.value.jamParticipantName + return configured.ifBlank { "$fallbackPrefix-${Random.nextInt(1000, 9999)}" } + } + + private fun newClientId(broker: dev.krtirtho.spotube.modules.settings.JamBroker): String { + val suffix = buildString(6) { + val chars = "0123456789abcdef" + repeat(6) { append(chars[Random.nextInt(chars.length)]) } + } + return "${broker.clientIdPrefix.ifBlank { "spotube" }}-$suffix" + } + + private fun persistLastCode(code: String) { + scope.launch { + runCatching { + val settings = settingsRepository.userSettings.value + if (settings.lastJamCode != code) { + settingsRepository.updateSettings(settings.copy(lastJamCode = code)) + } + } + } + } + + private fun JamMediaItem.toQueueEntry(): QueueEntry = when { + trackId.isNotBlank() -> QueueEntry.StreamingTrack( + track = MetadataTrack( + id = trackId, + title = title, + durationMs = durationMs, + trackNumber = null, + discNumber = null, + artists = listOf( + MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null) + ), + album = null, + // Keep the cover art flowing to participants — the wire carries it + // as coverUrl, so mirror it back into the reconstructed thumbnails. + thumbnails = coverUrl.takeIf { it.isNotBlank() } + ?.let { url -> listOf(Thumbnail(url = url, width = 0, height = 0)) }, + explicit = null, + popularity = null, + isrcCode = null, + externalUri = null, + ), + url = "", + protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } + .getOrDefault(StreamProtocol.PROGRESSIVE), + addedBy = addedBy, + ) + + else -> QueueEntry.LocalTrack( + name = title, + artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() }, + duration = durationMs, + album = album.ifBlank { null }, + coverBytes = null, + url = url, + addedBy = addedBy, + ) + } + + private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean { + if (entry == null) return false + return when (entry) { + is QueueEntry.StreamingTrack -> + trackId.isNotBlank() && entry.track.id == trackId + + is QueueEntry.LocalTrack -> + url.isNotBlank() && entry.url == url && entry.name == title + } + } + + private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean = when { + this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> + this.track.id == other.track.id + + this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> + this.url == other.url && this.name == other.name + + else -> false + } + + private fun now(): Long = Clock.System.now().toEpochMilliseconds() + + companion object { + private const val HOST_TAKEOVER_DELAY_MS = 1_500L + private const val AUTO_ADVANCE_WINDOW_MS = 2_000L + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index d048c1d8..bfea9743 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -21,6 +21,9 @@ import androidx.navigation3.runtime.NavKey import dev.krtirtho.spotube.modules.album.AlbumScreen import dev.krtirtho.spotube.modules.artist.ArtistScreen import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen +import dev.krtirtho.spotube.modules.devices.DevicesScreen +import dev.krtirtho.spotube.modules.devices.RemoteControlScreen +import dev.krtirtho.spotube.modules.jam.JamScreen import dev.krtirtho.spotube.modules.home.HomeScreen import dev.krtirtho.spotube.modules.library.LibraryScreen import dev.krtirtho.spotube.modules.lyrics.LyricsScreen @@ -75,6 +78,15 @@ sealed interface Routes : NavKey { @Serializable data object Blacklist : Routes + + @Serializable + data object RemoteControl : Routes + + @Serializable + data object Devices : Routes + + @Serializable + data object Jam : Routes } @OptIn(KoinExperimentalAPI::class) @@ -148,5 +160,16 @@ val navigationModule = module { navigation { BlacklistScreen() } + navigation { + DevicesScreen(navigationCommands = get()) + } + navigation { + RemoteControlScreen( + onDisconnect = { get().pop() } + ) + } + navigation { + JamScreen(navigationCommands = get()) + } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt index 7ef24dec..3f0ced21 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt @@ -61,6 +61,8 @@ val serializersConfig = SavedStateConfiguration { subclass(Routes.Artist::class, Routes.Artist.serializer()) subclass(Routes.Album::class, Routes.Album.serializer()) subclass(Routes.Blacklist::class, Routes.Blacklist.serializer()) + subclass(Routes.Devices::class, Routes.Devices.serializer()) + subclass(Routes.Jam::class, Routes.Jam.serializer()) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt index 81594eda..da55fb38 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt @@ -22,14 +22,17 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.modules.album.AlbumRepository +import dev.krtirtho.spotube.modules.artist.ArtistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import dev.krtirtho.spotube.core.remote.RemoteCollectionType class CollectionPlaybackHelper( private val albumRepository: AlbumRepository, private val playlistRepository: PlaylistRepository, private val savedTracksRepository: SavedTracksRepository, + private val artistRepository: ArtistRepository, private val audioPlayerQueue: AudioPlayerQueue, private val blacklistRepository: BlacklistRepository, ) { @@ -56,6 +59,13 @@ class CollectionPlaybackHelper( } } + suspend fun playAlbumNext(albumId: String) { + val entries = fetchAllAlbumTracks(albumId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + suspend fun playAlbumFromTrack(albumId: String, track: MetadataTrack) { val entries = fetchAllAlbumTracks(albumId) if (entries.isEmpty()) return @@ -104,6 +114,13 @@ class CollectionPlaybackHelper( } } + suspend fun playPlaylistNext(playlistId: String) { + val entries = fetchAllPlaylistTracks(playlistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + suspend fun playPlaylistFromTrack(playlistId: String, track: MetadataTrack) { val entries = fetchAllPlaylistTracks(playlistId) if (entries.isEmpty()) return @@ -142,6 +159,32 @@ class CollectionPlaybackHelper( } } + suspend fun playArtistTopTracks(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + } + + suspend fun addArtistTopTracksToQueue(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllToQueue(entries) + } + } + + suspend fun playArtistTopTracksNext(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + suspend fun addSavedTracksToQueue() { val entries = fetchAllSavedTracks() if (entries.isNotEmpty()) { @@ -222,6 +265,21 @@ class CollectionPlaybackHelper( } } + /** + * Resolves the tracks of a collection without loading them into the local + * queue — used to suggest a collection into a jam session from a guest. + */ + suspend fun resolveCollectionTracks(type: RemoteCollectionType, id: String): List = + when (type) { + RemoteCollectionType.Playlist -> fetchAllPlaylistTracks(id).asTracks() + RemoteCollectionType.Album -> fetchAllAlbumTracks(id).asTracks() + RemoteCollectionType.ArtistTopTracks -> fetchArtistTopTracks(id).asTracks() + RemoteCollectionType.SavedTracks -> fetchAllSavedTracks().asTracks() + } + + private fun List.asTracks(): List = + mapNotNull { (it as? QueueEntry.StreamingTrack)?.track } + private suspend fun fetchAllSavedTracks(): List { val allTracks = mutableListOf() var pagination = savedTracksRepository.getSavedTracks() @@ -243,6 +301,15 @@ class CollectionPlaybackHelper( } } + private suspend fun fetchArtistTopTracks(artistId: String): List { + val tracks = artistRepository.topTracks(artistId).orEmpty() + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + return tracks + .filter { track -> !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + } + private fun isTrackBlacklisted( track: MetadataTrack, blacklistedTrackIds: Set, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt new file mode 100644 index 00000000..cdda53c0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt @@ -0,0 +1,91 @@ +/* + * 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.remote + +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.style.TextAlign +import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.koin.compose.koinInject + +@Composable +fun ConnectionRequestDialogHost() { + val handler: RemoteControlHandler = koinInject() + var pendingRequest by remember { mutableStateOf(null) } + + LaunchedEffect(handler) { + handler.incomingConnectionRequests.collect { request -> + pendingRequest = request + } + } + + pendingRequest?.let { request -> + AdaptiveDialogBottomSheet( + onDismiss = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) + pendingRequest = null + }, + title = { + Text( + text = "Remote Control Request", + style = MaterialTheme.typography.titleLarge, + ) + }, + content = { + Text( + text = "\"${request.deviceName}\" wants to control playback on this device.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + ) + }, + actions = { + Button( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) + pendingRequest = null + }, + ) { + Text("Deny") + } + Button( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow) + pendingRequest = null + }, + ) { + Text("Allow") + } + Button( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways) + pendingRequest = null + }, + ) { + Text("Allow Always") + } + }, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt new file mode 100644 index 00000000..8f2adeea --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt @@ -0,0 +1,200 @@ +/* + * 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.remote + +import co.touchlab.kermit.Logger +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +/** + * WebSocket client for controlling a remote Spotube instance. + * Connects to the remote device's `/control` endpoint and sends commands. + */ +class RemoteControlClient { + private val logger = Logger.withTag("RemoteControlClient") + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val httpClient = HttpClient { + install(WebSockets) + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + requestTimeoutMillis = 30_000 + } + } + + private var session: WebSocketSession? = null + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _latestPlayerState = MutableStateFlow(null) + val latestPlayerState: StateFlow = _latestPlayerState.asStateFlow() + + private val _latestQueue = MutableStateFlow(null) + val latestQueue: StateFlow = _latestQueue.asStateFlow() + + suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) { + if (_connectionState.value is ConnectionState.Connected) { + logger.w { "Already connected" } + return + } + + _connectionState.value = ConnectionState.Connecting + try { + session = httpClient.webSocketSession { + url("ws://$host:$port/control") + header("X-Device-Id", deviceId) + header("X-Device-Name", deviceName) + } + + logger.i { "WebSocket connected to $host:$port, waiting for authorization..." } + + // Start receiving messages in a separate coroutine + scope.launch { + receiveLoop(host, port) + } + } catch (e: Exception) { + logger.e(e) { "Failed to connect to $host:$port" } + _connectionState.value = ConnectionState.Error(e.message ?: "Connection failed") + disconnect() + } + } + + private suspend fun receiveLoop(host: String, port: Int) { + val currentSession = session ?: return + logger.d { "Starting receive loop for $host:$port" } + try { + for (frame in currentSession.incoming) { + when (frame) { + is Frame.Text -> { + val text = frame.readText() + logger.d { "Received frame: $text" } + try { + val event = json.decodeFromString(RemoteControlEvent.serializer(), text) + logger.d { "Parsed event: $event" } + when (event) { + is RemoteControlEvent.Connected -> { + logger.i { "Connection authorized by server" } + _connectionState.value = ConnectionState.Connected(host, port) + } + is RemoteControlEvent.WaitingForPermission -> { + logger.i { "Waiting for permission: ${event.message}" } + // Keep showing connecting state + } + else -> { + // Only store state updates after connection is established + if (_connectionState.value is ConnectionState.Connected) { + when (event) { + is RemoteControlEvent.PlayerState -> { + _latestPlayerState.value = event + } + is RemoteControlEvent.QueueUpdated -> { + _latestQueue.value = event + } + else -> {} + } + } + } + } + } catch (e: Exception) { + logger.w(e) { "Failed to parse message: $text" } + } + } + is Frame.Close -> { + logger.i { "WebSocket closed by server" } + _connectionState.value = ConnectionState.Disconnected + break + } + else -> {} + } + } + logger.d { "Receive loop exited normally" } + } catch (e: Exception) { + logger.e(e) { "Error in receive loop" } + _connectionState.value = ConnectionState.Error(e.message ?: "Connection lost") + } + } + + suspend fun sendCommand(command: RemoteControlCommand) { + val currentSession = session ?: run { + logger.w { "Not connected" } + return + } + + val envelope = CommandEnvelope( + commandId = randomShortId(), + command = command, + ) + + try { + val text = json.encodeToString(CommandEnvelope.serializer(), envelope) + currentSession.send(Frame.Text(text)) + logger.d { "Sent command: $command" } + } catch (e: Exception) { + logger.e(e) { "Failed to send command" } + _connectionState.value = ConnectionState.Error(e.message ?: "Send failed") + } + } + + suspend fun disconnect() { + session?.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) + session = null + _connectionState.value = ConnectionState.Disconnected + logger.i { "Disconnected" } + } + + private fun randomShortId(): String { + val chars = "0123456789abcdef" + return buildString(8) { + repeat(8) { + append(chars[kotlin.random.Random.nextInt(chars.length)]) + } + } + } +} + +sealed interface ConnectionState { + data object Disconnected : ConnectionState + data object Connecting : ConnectionState + data class Connected(val host: String, val port: Int) : ConnectionState + data class Error(val message: String) : ConnectionState +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt new file mode 100644 index 00000000..602fc9f1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -0,0 +1,470 @@ +/* + * 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.remote + +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.LoopState +import dev.krtirtho.spotube.core.audioplayer.PlayerState as AudioPlayerState +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.modules.settings.SettingsRepository +import io.ktor.server.websocket.WebSocketServerSession +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlin.coroutines.resume +import kotlin.time.TimeSource +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.koin.core.component.KoinComponent + +class RemoteControlHandler( + private val settingsRepository: SettingsRepository, + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val collectionPlaybackHelper: CollectionPlaybackHelper, +) : KoinComponent { + val logger by injectLogger() + + val incomingConnectionRequests = MutableSharedFlow( + extraBufferCapacity = 16, + ) + + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val pendingRequestResolutions = mutableMapOf Unit>() + + suspend fun handleConnection(session: WebSocketServerSession) { + val settings = settingsRepository.userSettings.first() + if (!settings.allowRemoteControl) { + logger.w { "Rejecting remote control connection: remote control is disabled" } + session.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Remote control is disabled")) + return + } + + val deviceId = session.call.request.headers["X-Device-Id"] + val deviceName = session.call.request.headers["X-Device-Name"] ?: "Unknown" + + val isAllowed = deviceId != null && deviceId in settings.allowedRemoteDevices + + if (!isAllowed) { + // Send waiting for permission message + val waitingMessage = RemoteControlEvent.WaitingForPermission( + "Waiting for permission from $deviceName..." + ) + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), waitingMessage))) + + val request = ConnectionRequest( + deviceId = deviceId ?: "unknown", + deviceName = deviceName, + sessionId = session.call.request.headers["X-Request-Id"] + ?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}", + ) + incomingConnectionRequests.emit(request) + val response = waitForRequestResolution(request.sessionId) + if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) { + session.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Connection denied")) + return + } + if (response == ConnectionRequestResponse.AllowAlways && deviceId != null) { + settingsRepository.updateSettings( + settings.copy( + allowedRemoteDevices = (settings.allowedRemoteDevices + deviceId).distinct() + ) + ) + logger.i { "Device $deviceId added to always-allowed devices" } + } + } + + // Send connected message + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Connected))) + logger.i { "Remote control connection established from $deviceName ($deviceId)" } + + // Broadcast initial player state so the controller shows current track info + broadcastState(session) + broadcastQueue(session) + + try { + // Periodically push player state so the controller's progress bar + // stays in sync even when no commands are being sent. + coroutineScope { + launch { + while (isActive) { + delay(1_000) + broadcastState(session) + } + } + handleControlLoop(session) + } + } catch (e: Exception) { + logger.w(e) { "Error in remote control session" } + } finally { + logger.d { "Closing session in finally block" } + session.close() + } + } + + fun resolveConnectionRequest(sessionId: String, response: ConnectionRequestResponse) { + pendingRequestResolutions.remove(sessionId)?.invoke(response) + } + + private suspend fun waitForRequestResolution(sessionId: String): ConnectionRequestResponse { + return suspendCancellableCoroutine { continuation -> + pendingRequestResolutions[sessionId] = { response -> + if (continuation.isActive) { + continuation.resume(response) + } + } + continuation.invokeOnCancellation { + pendingRequestResolutions.remove(sessionId) + } + } + } + + private suspend fun handleControlLoop(session: WebSocketServerSession) { + logger.d { "Starting control loop for session" } + for (frame in session.incoming) { + if (frame is Frame.Text) { + val text = frame.readText() + logger.d { "Received command: $text" } + try { + val envelope = json.decodeFromString(CommandEnvelope.serializer(), text) + handleCommand(session, envelope) + } catch (e: Exception) { + logger.w(e) { "Failed to parse remote control command" } + sendError(session, "Invalid command: ${e.message}") + } + } + } + logger.d { "Control loop exited normally" } + } + + private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) { + when (val command = envelope.command) { + is RemoteControlCommand.Play -> { + handleCollectionSource(command.source, RemoteCollectionAction.Play) + } + is RemoteControlCommand.Pause -> { + audioPlayer.pause() + } + is RemoteControlCommand.TogglePlayPause -> { + val isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING + if (isPlaying) { + audioPlayer.pause() + waitForPlaybackState(expectPlaying = false) + } else { + audioPlayer.play() + waitForPlaybackState(expectPlaying = true) + } + } + is RemoteControlCommand.Seek -> { + audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) + } + is RemoteControlCommand.SetVolume -> { + audioPlayer.setVolume(command.volume) + } + is RemoteControlCommand.SkipNext -> { + audioPlayer.skipToNext() + } + is RemoteControlCommand.SkipPrevious -> { + audioPlayer.skipToPrevious() + } + is RemoteControlCommand.SetShuffle -> { + audioPlayer.shuffle(command.enabled) + } + is RemoteControlCommand.SetLoopMode -> { + val loopState = when (command.mode) { + "none" -> LoopState.NONE + "one" -> LoopState.ONE + "all" -> LoopState.ALL + else -> { + sendError(session, "Invalid loop mode: ${command.mode}") + return + } + } + audioPlayer.loop(loopState) + } + is RemoteControlCommand.AddToQueue -> { + handleCollectionSource(command.source, RemoteCollectionAction.AddToQueue) + } + is RemoteControlCommand.PlayNext -> { + handleCollectionSource(command.source, RemoteCollectionAction.PlayNext) + } + is RemoteControlCommand.PlayTrack -> { + audioPlayerQueue.load( + entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")), + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + is RemoteControlCommand.AddTrackToQueue -> { + audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = "")) + } + is RemoteControlCommand.PlayTrackNext -> { + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = command.track, url = ""))) + } + is RemoteControlCommand.AddTracksToQueue -> { + val entries = command.tracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + audioPlayerQueue.addAllToQueue(entries) + } + is RemoteControlCommand.PlayTracksNext -> { + val entries = command.tracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + audioPlayerQueue.addAllAfterCurrent(entries) + } + is RemoteControlCommand.PlayIndex -> { + audioPlayerQueue.jumpTo(command.index) + } + is RemoteControlCommand.RemoveFromQueue -> { + val queue = audioPlayerQueue.queueFlow.value + val entry = queue.firstOrNull { candidate -> + when (candidate) { + is QueueEntry.StreamingTrack -> candidate.track.id == command.mediaUrl + is QueueEntry.LocalTrack -> candidate.url == command.mediaUrl + } + } + if (entry != null) { + audioPlayerQueue.removeFromQueue(entry) + } else { + logger.w { "Remote remove: no matching queue entry for ${command.mediaUrl}" } + } + } + } + sendAck(session, envelope.commandId) + broadcastState(session) + broadcastQueue(session) + } + + private suspend fun sendAck(session: WebSocketServerSession, commandId: String) { + val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Ack(commandId)) + session.send(Frame.Text(text)) + } + + private suspend fun sendError(session: WebSocketServerSession, message: String) { + val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Error(message)) + session.send(Frame.Text(text)) + } + + /** + * Player state changes are applied asynchronously (e.g. ExoPlayer listener + * callbacks posted to the main looper), so after play/pause we poll until + * [playerStateFlow] reflects the expected state before broadcasting it back + * to the controller. Otherwise the client would see a stale (inverted) icon. + */ + private suspend fun waitForPlaybackState(expectPlaying: Boolean, timeoutMs: Long = 1_000) { + val timeoutAt = TimeSource.Monotonic.markNow() + timeoutMs.milliseconds + while (timeoutAt.hasNotPassedNow()) { + if ((audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) == expectPlaying) return + delay(25) + } + } + + /** + * Resolves a `spotube://` collection source URI (playlist/album/artist top + * tracks/saved tracks) and applies the requested action on the remote queue. + */ + private suspend fun handleCollectionSource(source: String, action: RemoteCollectionAction) { + logger.d { "Remote collection $action for source: $source" } + when { + source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> { + val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id) + } + } + + source.startsWith(COLLECTION_ALBUM_PREFIX) -> { + val id = source.removePrefix(COLLECTION_ALBUM_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id) + } + } + + source.startsWith(COLLECTION_ARTIST_TOP_PREFIX) -> { + val id = source.removePrefix(COLLECTION_ARTIST_TOP_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playArtistTopTracks(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(id) + } + } + + source == COLLECTION_SAVED_TRACKS -> { + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks() + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + RemoteCollectionAction.PlayNext -> { + // Saved tracks "play next" is not supported; add to queue instead + collectionPlaybackHelper.addSavedTracksToQueue() + } + } + } + + else -> logger.w { "Unknown remote collection source: $source" } + } + } + + private suspend fun broadcastState(session: WebSocketServerSession) { + val current = audioPlayerQueue.currentQueueEntryFlow.value + val state = RemoteControlEvent.PlayerState( + isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING, + positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds, + durationMs = audioPlayer.durationFlow.value.inWholeMilliseconds, + volume = audioPlayer.volumeFlow.value, + shuffleEnabled = audioPlayer.shuffleModeFlow.value, + loopMode = audioPlayer.loopStateFlow.value.name.lowercase(), + currentTrackId = current?.mediaKey(), + currentTrackTitle = current?.titleOrNull(), + currentTrackArtists = current?.artistsOrNull(), + currentTrackAlbum = current?.albumOrNull(), + currentTrackCoverUrl = current?.coverUrlOrNull(), + ) + val text = json.encodeToString(RemoteControlEvent.serializer(), state) + session.send(Frame.Text(text)) + } + + private suspend fun broadcastQueue(session: WebSocketServerSession) { + val queue = audioPlayerQueue.queueFlow.value + val current = audioPlayerQueue.currentQueueEntryFlow.value + val currentIndex = if (current != null) { + queue.indexOfFirst { it.matchesCurrent(current) } + } else { + -1 + } + val event = RemoteControlEvent.QueueUpdated( + entries = queue.map { it.toRemoteQueueEntry() }, + currentIndex = currentIndex, + ) + val text = json.encodeToString(RemoteControlEvent.serializer(), event) + session.send(Frame.Text(text)) + } + + private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean { + return when { + this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> { + this.track.id == current.track.id + } + + this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> { + this.url == current.url && this.name == current.name + } + + else -> false + } + } + + private fun QueueEntry.toRemoteQueueEntry(): RemoteQueueEntry = when (this) { + is QueueEntry.StreamingTrack -> RemoteQueueEntry( + mediaUrl = track.id, + trackId = track.id, + title = track.title, + artists = track.artists.joinToString(", ") { artist -> artist.name }, + album = track.album?.title, + coverUrl = coverUrlOrNull(), + durationMs = track.durationMs, + ) + + is QueueEntry.LocalTrack -> RemoteQueueEntry( + mediaUrl = url, + trackId = url, + title = name, + artists = artists.joinToString(", "), + album = album, + coverUrl = null, + durationMs = duration, + ) + } + + private fun QueueEntry.mediaKey(): String = when (this) { + is QueueEntry.StreamingTrack -> track.id + is QueueEntry.LocalTrack -> url + } + + private fun QueueEntry.titleOrNull(): String = when (this) { + is QueueEntry.StreamingTrack -> track.title + is QueueEntry.LocalTrack -> name + } + + private fun QueueEntry.artistsOrNull(): String = when (this) { + is QueueEntry.StreamingTrack -> track.artists.joinToString(", ") { artist -> artist.name } + is QueueEntry.LocalTrack -> artists.joinToString(", ") + } + + private fun QueueEntry.albumOrNull(): String? = when (this) { + is QueueEntry.StreamingTrack -> track.album?.title + is QueueEntry.LocalTrack -> album + } + + private fun QueueEntry.coverUrlOrNull(): String? = when (this) { + is QueueEntry.StreamingTrack -> track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url + is QueueEntry.LocalTrack -> null + } +} + +@Serializable +data class CommandEnvelope( + val commandId: String, + val command: RemoteControlCommand, +) + +private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/" +private const val COLLECTION_ALBUM_PREFIX = "spotube://album/" +private const val COLLECTION_ARTIST_TOP_PREFIX = "spotube://artist/" +private const val COLLECTION_SAVED_TRACKS = "spotube://saved_tracks" + +enum class RemoteCollectionAction { + Play, + AddToQueue, + PlayNext, +} + +data class ConnectionRequest( + val deviceId: String, + val deviceName: String, + val sessionId: String, +) + +enum class ConnectionRequestResponse { + Allow, + AllowAlways, + Deny, +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt new file mode 100644 index 00000000..fc51834e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -0,0 +1,150 @@ +/* + * 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.remote + +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class RemoteControlCommand { + @Serializable + @SerialName("play") + data class Play(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("pause") + data object Pause : RemoteControlCommand() + + @Serializable + @SerialName("togglePlayPause") + data object TogglePlayPause : RemoteControlCommand() + + @Serializable + @SerialName("seek") + data class Seek(val positionMs: Long) : RemoteControlCommand() + + @Serializable + @SerialName("setVolume") + data class SetVolume(val volume: Float) : RemoteControlCommand() + + @Serializable + @SerialName("skipNext") + data object SkipNext : RemoteControlCommand() + + @Serializable + @SerialName("skipPrevious") + data object SkipPrevious : RemoteControlCommand() + + @Serializable + @SerialName("setShuffle") + data class SetShuffle(val enabled: Boolean) : RemoteControlCommand() + + @Serializable + @SerialName("setLoopMode") + data class SetLoopMode(val mode: String) : RemoteControlCommand() + + @Serializable + @SerialName("addToQueue") + data class AddToQueue(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("playNext") + data class PlayNext(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("playTrack") + data class PlayTrack(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("addTrackToQueue") + data class AddTrackToQueue(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("playTrackNext") + data class PlayTrackNext(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("addTracksToQueue") + data class AddTracksToQueue(val tracks: List) : RemoteControlCommand() + + @Serializable + @SerialName("playTracksNext") + data class PlayTracksNext(val tracks: List) : RemoteControlCommand() + + @Serializable + @SerialName("playIndex") + data class PlayIndex(val index: Int) : RemoteControlCommand() + + @Serializable + @SerialName("removeFromQueue") + data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand() +} + +@Serializable +sealed class RemoteControlEvent { + @Serializable + @SerialName("connected") + data object Connected : RemoteControlEvent() + + @Serializable + @SerialName("waitingForPermission") + data class WaitingForPermission(val message: String) : RemoteControlEvent() + + @Serializable + @SerialName("playerState") + data class PlayerState( + val isPlaying: Boolean, + val positionMs: Long, + val durationMs: Long, + val volume: Float, + val shuffleEnabled: Boolean, + val loopMode: String, + val currentTrackId: String?, + val currentTrackTitle: String?, + val currentTrackArtists: String?, + val currentTrackAlbum: String?, + val currentTrackCoverUrl: String?, + ) : RemoteControlEvent() + + @Serializable + @SerialName("queueUpdated") + data class QueueUpdated( + val entries: List, + val currentIndex: Int, + ) : RemoteControlEvent() + + @Serializable + @SerialName("ack") + data class Ack(val commandId: String) : RemoteControlEvent() + + @Serializable + @SerialName("error") + data class Error(val message: String) : RemoteControlEvent() +} + +@Serializable +data class RemoteQueueEntry( + val mediaUrl: String, + val trackId: String, + val title: String, + val artists: String, + val album: String?, + val coverUrl: String?, + val durationMs: Long, +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt new file mode 100644 index 00000000..86b920d5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt @@ -0,0 +1,221 @@ +/* + * 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.remote + +import co.touchlab.kermit.Logger +import com.appstractive.dnssd.NetService +import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService +import dev.krtirtho.spotube.core.server.LocalServer +import dev.krtirtho.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.random.Random + +/** + * Advertises this device on the local network via DNS-SD so that other Spotube + * instances can discover and control it. Advertises only while the + * "Allow remote control" setting is enabled and the local playback server is + * listening on the LAN (0.0.0.0). + * + * Registration is retried with backoff: NsdManager is flaky right after a cold + * start, and a single registration attempt is bounded by a short timeout so a + * stalled platform callback can't wedge a dispatcher thread for long. + */ +class RemoteControlService( + private val settingsRepository: SettingsRepository, + private val discoveryService: DeviceDiscoveryService, + private val localServer: LocalServer, +) { + private val log = Logger.withTag("RemoteControlService") + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _localDeviceId = MutableStateFlow("") + val localDeviceId: StateFlow = _localDeviceId.asStateFlow() + + private var advertisedService: NetService? = null + + /** A registration attempt that may have been left pending by the platform. */ + private var pendingService: NetService? = null + + private var registerJob: Job? = null + private var cleanupJob: Job? = null + + init { + // Ensure a stable device id exists and is persisted up front, so discovery + // can reliably filter out this device's own advertisement. + scope.launch { + _localDeviceId.value = resolveDeviceId() + } + scope.launch { + combine( + settingsRepository.userSettings, + localServer.port, + ) { settings, port -> settings to port } + .distinctUntilChanged() + .collect { (settings, port) -> + if (settings.allowRemoteControl && port != null) { + if (registerJob?.isActive != true) { + registerJob = scope.launch { + registerLoop(settings.remoteControlDeviceName, port) + } + } + } else { + registerJob?.cancel() + registerJob = null + stopAdvertising() + } + } + } + } + + /** + * The service name this device advertises under, derived deterministically + * from settings so discovery can match it against the local advertisement. + */ + fun advertisedName(): String { + val deviceId = _localDeviceId.value.ifBlank { + settingsRepository.userSettings.value.remoteControlDeviceId + } + val configured = settingsRepository.userSettings.value.remoteControlDeviceName + return configured.ifBlank { "Spotube-${deviceId.take(6)}" } + } + + /** + * Kicks off (or restarts) the advertising loop. Used when the local-network + * permission is granted at runtime after earlier attempts failed. + */ + fun retryAdvertising() { + val settings = settingsRepository.userSettings.value + val port = localServer.port.value + if (!settings.allowRemoteControl || port == null) return + registerJob?.cancel() + // Clean up any in-flight registration the cancelled job may have leaked. + scheduleCleanup(pendingService) + pendingService = null + registerJob = scope.launch { + registerLoop(settings.remoteControlDeviceName, port) + } + } + + private suspend fun registerLoop(name: String, port: Int) { + val deviceId = resolveDeviceId() + _localDeviceId.value = deviceId + val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" } + + var attempt = 0 + while (advertisedService == null && coroutineContext.isActive) { + attempt++ + // The user may have toggled the setting off during backoff. + if (!settingsRepository.userSettings.value.allowRemoteControl) return + val service = discoveryService.createService( + name = serviceName, + port = port, + deviceId = deviceId, + ) + pendingService = service + try { + service.register(timeoutInMs = REGISTER_TIMEOUT_MS) + pendingService = null + advertisedService = service + log.i { "Advertising remote control service '$serviceName' on port $port (attempt $attempt)" } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + pendingService = null + log.w(e) { "Failed to advertise remote control service (attempt $attempt); retrying in ${retryDelayMs(attempt)}ms" } + // The library leaks the platform registration on timeout. Once the + // platform eventually completes it (success), isRegistered flips + // and unregister() will actually remove it — keep trying until then. + scheduleCleanup(service) + delay(retryDelayMs(attempt)) + } + } + } + + /** + * Repeatedly tries to unregister a service whose registration attempt failed. + * The library's `unregister()` is a no-op while the platform hasn't completed + * the registration, so poll until it has (or give up after a while). + */ + private fun scheduleCleanup(service: NetService?) { + if (service == null) return + cleanupJob?.cancel() + cleanupJob = scope.launch { + repeat(REGISTER_CLEANUP_TRIES) { + delay(1_000) + runCatching { service.unregister() } + } + } + } + + private suspend fun stopAdvertising() { + registerJob?.cancel() + registerJob = null + // Clean up any in-flight registration the cancelled job may have leaked. + scheduleCleanup(pendingService) + pendingService = null + if (advertisedService != null) { + runCatching { advertisedService?.unregister() } + advertisedService = null + log.i { "Stopped advertising remote control service" } + } + } + + private suspend fun resolveDeviceId(): String { + val settings = settingsRepository.userSettings.first() + if (settings.remoteControlDeviceId.isNotBlank()) { + return settings.remoteControlDeviceId + } + val generated = buildString(16) { + val chars = "0123456789abcdef" + repeat(16) { append(chars[Random.nextInt(chars.length)]) } + } + settingsRepository.updateSettings(settings.copy(remoteControlDeviceId = generated)) + return generated + } + + private fun retryDelayMs(attempt: Int): Long = when { + attempt >= 6 -> 5 * 60_000L + attempt >= 3 -> 30_000L + else -> 5_000L + } + + companion object { + // Generous enough that the library's timeout (which leaks the platform + // registration) rarely fires on a working system — registration callbacks + // normally arrive within a second. + private const val REGISTER_TIMEOUT_MS = 10_000L + + // How long to keep polling unregister() on a failed service, in seconds. + private const val REGISTER_CLEANUP_TRIES = 15 + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt new file mode 100644 index 00000000..40602ea9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -0,0 +1,388 @@ +/* + * 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.remote + +import co.touchlab.kermit.Logger +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService +import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +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.launch +import org.koin.core.component.KoinComponent + +enum class PlaybackDestinationAction { + Play, + AddToQueue, + PlayNext, +} + +enum class RemoteCollectionType { + Playlist, + Album, + ArtistTopTracks, + SavedTracks, +} + +/** + * A playback request awaiting a destination choice (local device vs a connected + * remote device). [title] is the content label shown in the picker dialog. + */ +sealed interface PlaybackDestinationRequest { + val title: String + val action: PlaybackDestinationAction + + data class Collection( + override val title: String, + override val action: PlaybackDestinationAction, + val type: RemoteCollectionType, + val id: String, + val startTrack: MetadataTrack? = null, + ) : PlaybackDestinationRequest + + data class Track( + override val title: String, + override val action: PlaybackDestinationAction, + val track: MetadataTrack, + ) : PlaybackDestinationRequest + + data class Tracks( + override val title: String, + override val action: PlaybackDestinationAction, + val tracks: List, + ) : PlaybackDestinationRequest +} + +/** + * Routes playback actions (play / add to queue / play next) to either the local + * device or a connected remote device. When a remote device is connected the + * user is shown a destination picker; otherwise the action runs locally. + */ +class RemotePlaybackController( + private val remoteControlClient: RemoteControlClient, + private val collectionPlaybackHelper: CollectionPlaybackHelper, + private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, + private val jamRoomService: JamRoomService, +) : KoinComponent { + private val logger = Logger.withTag("RemotePlaybackController") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _pendingRequest = MutableStateFlow(null) + val pendingRequest: StateFlow = _pendingRequest.asStateFlow() + + /** One-shot user-facing messages (e.g. "added to jam queue") for a snackbar host. */ + private val _events = MutableSharedFlow(extraBufferCapacity = 8) + val events: SharedFlow = _events.asSharedFlow() + + fun isRemoteConnected(): Boolean { + return remoteControlClient.connectionState.value is ConnectionState.Connected + } + + // ---------- Collection actions ---------- + + fun requestCollectionPlay( + type: RemoteCollectionType, + id: String, + title: String, + startTrack: MetadataTrack? = null, + ) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack)) + } + + fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.AddToQueue, type, id)) + } + + fun requestCollectionPlayNext(type: RemoteCollectionType, id: String, title: String) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.PlayNext, type, id)) + } + + // ---------- Single track actions ---------- + + fun requestTrackAddToQueue(track: MetadataTrack) { + request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.AddToQueue, track)) + } + + fun requestTrackPlayNext(track: MetadataTrack) { + request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.PlayNext, track)) + } + + // ---------- Bulk track actions ---------- + + fun requestTracksAddToQueue(tracks: List, title: String) { + if (tracks.isEmpty()) return + request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.AddToQueue, tracks)) + } + + fun requestTracksPlayNext(tracks: List, title: String) { + if (tracks.isEmpty()) return + request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.PlayNext, tracks)) + } + + // ---------- Picker resolution ---------- + + fun playLocally() { + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + executeLocally(request) + } + + fun playOnRemote() { + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + executeOnRemote(request) + } + + fun dismissPicker() { + _pendingRequest.value = null + } + + // ---------- Jam actions ---------- + + /** + * Adds a single track to the active jam queue. The host applies it to the + * local (shared) queue directly; a guest suggests it to the host over MQTT. + */ + fun addTrackToJam(track: MetadataTrack) { + if (jamRoomService.role.value == null) return + scope.launch { + try { + when (jamRoomService.role.value) { + JamRole.Host -> audioPlayerQueue.addToQueue( + QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantClientId) + ) + + JamRole.Guest -> jamRoomService.suggestTrack(track) + null -> return@launch + } + _events.emit("Added to the jam queue") + } catch (e: Exception) { + logger.e(e) { "Failed to add track to jam session" } + } + } + } + + /** + * Adds multiple tracks to the active jam queue (host applies locally, + * guest suggests to the host). + */ + fun addTracksToJam(tracks: List) { + if (tracks.isEmpty() || jamRoomService.role.value == null) return + scope.launch { + try { + when (jamRoomService.role.value) { + JamRole.Host -> audioPlayerQueue.addAllToQueue( + tracks.map { track -> + QueueEntry.StreamingTrack( + track = track, + url = "", + addedBy = jamRoomService.participantClientId, + ) + } + ) + + JamRole.Guest -> jamRoomService.suggestPlaylist(tracks) + null -> return@launch + } + _events.emit("Added ${tracks.size} to the jam queue") + } catch (e: Exception) { + logger.e(e) { "Failed to add tracks to jam session" } + } + } + } + + // ---------- Internals ---------- + + private fun request(request: PlaybackDestinationRequest) { + if (isRemoteConnected()) { + _pendingRequest.value = request + } else { + executeLocally(request) + } + } + + private fun executeLocally(request: PlaybackDestinationRequest) { + scope.launch { + when (request) { + is PlaybackDestinationRequest.Collection -> { + val startTrack = request.startTrack + when (request.type) { + RemoteCollectionType.Playlist -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playPlaylist(request.id) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id) + } + + RemoteCollectionType.Album -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playAlbum(request.id) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id) + } + + RemoteCollectionType.ArtistTopTracks -> when (request.action) { + PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id) + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id) + } + + RemoteCollectionType.SavedTracks -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) + } else { + collectionPlaybackHelper.playSavedTracks() + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue() + } + } + } + + is PlaybackDestinationRequest.Track -> { + val entry = QueueEntry.StreamingTrack(track = request.track, url = "") + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = listOf(entry), + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addToQueue(entry) + } + + PlaybackDestinationAction.PlayNext -> { + val queue = audioPlayerQueue.getQueue() + queue.find { candidate -> + (candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true + }?.let { audioPlayerQueue.removeFromQueue(it) } + audioPlayerQueue.addAllAfterCurrent(listOf(entry)) + } + } + } + + is PlaybackDestinationRequest.Tracks -> { + val entries = request.tracks + .filter { track -> !isTrackBlacklisted(track) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addAllToQueue(entries) + } + + PlaybackDestinationAction.PlayNext -> { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + } + } + } + } + + private fun executeOnRemote(request: PlaybackDestinationRequest) { + scope.launch { + try { + val command = when (request) { + is PlaybackDestinationRequest.Collection -> { + val source = when (request.type) { + RemoteCollectionType.Playlist -> "spotube://playlist/${request.id}" + RemoteCollectionType.Album -> "spotube://album/${request.id}" + RemoteCollectionType.ArtistTopTracks -> "spotube://artist/${request.id}" + RemoteCollectionType.SavedTracks -> "spotube://saved_tracks" + } + when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source) + } + } + + is PlaybackDestinationRequest.Track -> when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTrack(request.track) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTrackToQueue(request.track) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTrackNext(request.track) + } + + is PlaybackDestinationRequest.Tracks -> when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTracksNext(request.tracks) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTracksToQueue(request.tracks) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTracksNext(request.tracks) + } + } + remoteControlClient.sendCommand(command) + logger.i { "Sent remote ${request.action} for ${request.title}" } + } catch (e: Exception) { + logger.e(e) { "Failed to send remote playback command" } + } + } + } + + private suspend fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val artistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { + if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id + return title == other.title && + durationMs == other.durationMs && + album?.id == other.album?.id && + artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt index 6725f215..e23f9b02 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt @@ -19,10 +19,13 @@ package dev.krtirtho.spotube.core.server import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.core.remote.RemoteControlHandler import dev.krtirtho.spotube.modules.settings.SettingsViewModel import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout import io.ktor.http.HttpMethod import io.ktor.server.application.Application +import io.ktor.server.application.install import io.ktor.server.cio.CIO import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer @@ -30,6 +33,8 @@ import io.ktor.server.response.respondText import io.ktor.server.routing.get import io.ktor.server.routing.head import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.webSocket import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -55,10 +60,18 @@ class LocalServer( private val streamingUrlRepository: StreamingUrlRepository, private val audioPlayerQueue: AudioPlayerQueue, private val cacheManager: CacheManager, + private val remoteControlHandler: RemoteControlHandler, ) : KoinComponent { val logger by injectLogger() - private val httpClient = HttpClient() + private val httpClient = HttpClient { + // A stalled upstream connection must not wedge the CIO dispatcher thread + // forever. Only the connect phase is bounded — the proxy streams long + // audio bodies, so request/socket timeouts would cut playback short. + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + } + } private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serverMutex = Mutex() @@ -70,8 +83,10 @@ class LocalServer( private val activePort = MutableStateFlow(null) val port = activePort.asStateFlow() + private val activeHost = MutableStateFlow(null) + val baseUrl = activePort.map { port -> - port?.let { "http://$HOST:$it" } + port?.let { "http://$HOST_LOCAL:$it" } }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) private val cachedCacheEnabled = MutableStateFlow(false) @@ -89,18 +104,19 @@ class LocalServer( } companion object { - private const val HOST = "127.0.0.1" + private const val HOST_LOCAL = "127.0.0.1" + private const val HOST_LAN = "0.0.0.0" } init { logger.d { "Starting playback proxy port watcher" } portWatcher = scope.launch { settingsViewModel.settingsState - .mapNotNull { it?.playbackProxyServerPort } + .mapNotNull { it?.let { s -> s.playbackProxyServerPort to s.allowRemoteControl } } .distinctUntilChanged() - .collectLatest { port -> - logger.d { "Observed playback proxy port change to $port" } - restartServer(port) + .collectLatest { (port, allowRemoteControl) -> + logger.d { "Observed server config change: port=$port, allowRemoteControl=$allowRemoteControl" } + restartServer(port, allowRemoteControl) } } scope.launch { @@ -129,26 +145,28 @@ class LocalServer( logger.d { "Playback proxy server stopped" } } - private suspend fun restartServer(port: Int) { + private suspend fun restartServer(port: Int, allowRemoteControl: Boolean) { + val host = if (allowRemoteControl) HOST_LAN else HOST_LOCAL serverMutex.withLock { - if (serverState.value != null && activePort.value == port) { - logger.v { "Playback proxy server already running on port $port; skipping restart" } + if (serverState.value != null && activePort.value == port && activeHost.value == host) { + logger.v { "Playback proxy server already running on $host:$port; skipping restart" } return } - logger.d { "Restarting playback proxy server on port $port" } + logger.d { "Restarting playback proxy server on $host:$port (remoteControl=$allowRemoteControl)" } stopServerLocked() serverState.value = embeddedServer( factory = CIO, - host = HOST, + host = host, port = port, module = { configureRoutes() } ).also { engine -> engine.start(wait = false) } activePort.value = port - logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$HOST:$port"}" } + activeHost.value = host + logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$host:$port"}" } } } @@ -165,9 +183,16 @@ class LocalServer( } serverState.value = null activePort.value = null + activeHost.value = null } private fun Application.configureRoutes() { + install(WebSockets) { + pingPeriodMillis = 30_000L + timeoutMillis = 60_000L + maxFrameSize = 10L * 1024 * 1024 + masking = false + } routing { get("/health") { call.respondText("ok") @@ -188,6 +213,10 @@ class LocalServer( get("/segment/{trackId}") { streamProxy.handleSegmentRequest(call) } + + webSocket("/control") { + remoteControlHandler.handleConnection(this) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt index edc24ebb..885f6357 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -48,6 +49,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.shadow @@ -71,6 +73,8 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.User private val BadgeShape = RoundedCornerShape(11.dp) +private const val DisabledContentAlpha = 0.38f + private val ButtonMinHeight = 40.dp private val SquareButtonSize = 40.dp @@ -178,6 +182,7 @@ fun OutlineButton( contentAlignment = Alignment.Center, ) { Row( + modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), content = { @@ -226,6 +231,7 @@ fun PrimaryButton( ) { CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { Row( + modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), content = content, @@ -271,6 +277,7 @@ fun SecondaryButton( ) { CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { Row( + modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), content = content, @@ -488,7 +495,14 @@ fun GroupIconButton( ), contentAlignment = Alignment.Center, ) { - content() + Box( + modifier = Modifier + .fillMaxSize() + .alpha(if (enabled) 1f else DisabledContentAlpha), + contentAlignment = Alignment.Center, + ) { + content() + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt index 2e021980..769b8c43 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt @@ -20,7 +20,6 @@ package dev.krtirtho.spotube.core.ui.component import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -29,8 +28,9 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import kotlinx.coroutines.launch import org.koin.compose.koinInject @Composable @@ -39,9 +39,9 @@ fun AlbumCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject() + navigationCommands: NavigationCommands = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), ) { - val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() PlayableCard( @@ -54,10 +54,18 @@ fun AlbumCard( }, onPlay = { if (currentCollectionEntry?.id == album.id) return@PlayableCard - scope.launch { playbackHelper.playAlbum(album.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Album, + album.id, + album.title, + ) }, onAddToQueue = { - scope.launch { playbackHelper.addAlbumToQueue(album.id) } + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.Album, + album.id, + album.title, + ) }, modifier = modifier.width(160.dp), sharedElementKey = "album_art_${album.id}", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt index 7f4335b5..4b9ec5a9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt @@ -78,6 +78,9 @@ fun CollectionDetails( onShufflePlay: () -> Unit, onAddToQueue: () -> Unit, isPlaying: Boolean = false, + /** Guest in a jam session: play/shuffle are replaced by Add to Jam. */ + isJamGuest: Boolean = false, + onAddToJam: (() -> Unit)? = null, isFollowing: Boolean = false, onFollowClick: () -> Unit = { }, showFollowButton: Boolean = true, @@ -91,39 +94,61 @@ fun CollectionDetails( val animatedVisibilityScope = LocalAnimatedVisibilityScope.current val playPauseButton = @Composable { - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - val modifier = if (isCompact) { - Modifier.weight(1f) - } else { - Modifier + if (isJamGuest) { + if (onAddToJam != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + PrimaryButton( + modifier = if (isCompact) Modifier.weight(1f) else Modifier, + onClick = onAddToJam!!, + ) { + Icon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = "Add to Jam", + ) + TextWithShimmer( + text = "Add to Jam", + modifier = Modifier.padding(start = 6.dp), + ) + } + } } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val modifier = if (isCompact) { + Modifier.weight(1f) + } else { + Modifier + } - PrimaryButton( - modifier = modifier, - onClick = onPlay, - ) { - Icon( - imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, - contentDescription = if (isPlaying) "Pause" else "Play", - ) - TextWithShimmer( - text = if (isPlaying) "Pause" else "Play", - modifier = Modifier.padding(start = 6.dp), - ) - } - OutlineButton( - modifier = modifier, - onClick = onShufflePlay - ) { - Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") - TextWithShimmer( - text = "Shuffle", - modifier = Modifier.padding(start = 6.dp), - ) + PrimaryButton( + modifier = modifier, + onClick = onPlay, + ) { + Icon( + imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, + contentDescription = if (isPlaying) "Pause" else "Play", + ) + TextWithShimmer( + text = if (isPlaying) "Pause" else "Play", + modifier = Modifier.padding(start = 6.dp), + ) + } + OutlineButton( + modifier = modifier, + onClick = onShufflePlay + ) { + Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") + TextWithShimmer( + text = "Shuffle", + modifier = Modifier.padding(start = 6.dp), + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt index 02082d96..e4c9dd31 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt @@ -87,6 +87,10 @@ fun CollectionView( onBulkAddToQueue: (List) -> Unit = {}, onBulkPlayNext: (List) -> Unit = {}, onBulkAddToPlaylist: (List) -> Unit = {}, + onBulkAddToJam: (List) -> Unit = {}, + isInJam: Boolean = false, + isJamGuest: Boolean = false, + onAddToJam: (List) -> Unit = {}, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, footerContent: (@Composable () -> Unit)? = null, trailingContent: @Composable () -> Unit = {}, @@ -114,7 +118,7 @@ fun CollectionView( } else { {} }, - actions = if (isCollapsed) { + actions = if (isCollapsed && !isJamGuest) { { IconButton(onClick = onPlay) { Icon( @@ -159,6 +163,8 @@ fun CollectionView( showFollowButton = showFollowButton, onEdit = onEdit, sharedElementKey = sharedElementKey, + isJamGuest = isJamGuest, + onAddToJam = { onAddToJam(tracks) }, ) } } else { @@ -179,6 +185,8 @@ fun CollectionView( showFollowButton = showFollowButton, onEdit = onEdit, sharedElementKey = sharedElementKey, + isJamGuest = isJamGuest, + onAddToJam = { onAddToJam(tracks) }, ) } }, @@ -200,6 +208,9 @@ fun CollectionView( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, + isJamGuest = isJamGuest, trackOptionsState = trackOptionsState, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt index 1bca2308..cf83ec60 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt @@ -27,6 +27,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -37,7 +39,8 @@ fun PlaylistCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject() + navigationCommands: NavigationCommands = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), ) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() @@ -52,10 +55,18 @@ fun PlaylistCard( }, onPlay = { if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard - scope.launch { playbackHelper.playPlaylist(playlist.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Playlist, + playlist.id, + playlist.title, + ) }, onAddToQueue = { - scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) } + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.Playlist, + playlist.id, + playlist.title, + ) }, modifier = modifier, sharedElementKey = "playlist_art_${playlist.id}", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index 8c75b3f1..a985983a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -143,6 +143,9 @@ fun TrackList( onBulkAddToQueue: (List) -> Unit = {}, onBulkPlayNext: (List) -> Unit = {}, onBulkAddToPlaylist: (List) -> Unit = {}, + onBulkAddToJam: (List) -> Unit = {}, + isInJam: Boolean = false, + isJamGuest: Boolean = false, currentTrackId: String? = null, isCurrentTrackPlaying: Boolean = false, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, @@ -247,6 +250,8 @@ fun TrackList( } else { selectedTrackIds + track.id } + } else if (isJamGuest) { + onTrackOptionsAction(track, TrackOptionsAction.AddToJam) } else { onTrackClick(track) } @@ -274,6 +279,8 @@ fun TrackList( ) }, trackOptionsState = trackOptionsState(track), + isInJam = isInJam, + isJamGuest = isJamGuest, onShowOptionsClick = { selectedTrackForOptions = track }, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, @@ -421,28 +428,48 @@ fun TrackList( val isAll = selectedTrackIds.isEmpty() || trackCount == visibleTracks.size AdaptiveDropdownBottomSheet( - items = listOf( - AdaptiveMenuItem( - icon = Iconsax.IconsaxDirectboxReceive, - label = if (isAll) "Download All" else "Download $trackCount", - onClick = { onBulkDownload(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxAddSquare, - label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", - onClick = { onBulkAddToQueue(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = if (isAll) "Play All Next" else "Play $trackCount Next", - onClick = { onBulkPlayNext(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxMusicPlaylist, - label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", - onClick = { onBulkAddToPlaylist(targetTracks) }, - ), - ), + items = buildList { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxDirectboxReceive, + label = if (isAll) "Download All" else "Download $trackCount", + onClick = { onBulkDownload(targetTracks) }, + ), + ) + if (!isJamGuest) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", + onClick = { onBulkAddToQueue(targetTracks) }, + ), + ) + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = if (isAll) "Play All Next" else "Play $trackCount Next", + onClick = { onBulkPlayNext(targetTracks) }, + ), + ) + } + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicPlaylist, + label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", + onClick = { onBulkAddToPlaylist(targetTracks) }, + ), + ) + } + if (isInJam) { + listOf( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam", + onClick = { onBulkAddToJam(targetTracks) }, + ), + ) + } else { + emptyList() + }, trigger = { onClick -> GroupIconButton( onClick = onClick, @@ -497,6 +524,8 @@ fun TrackList( selectedTrackForOptions = null }, onAlbumClick = { track.album?.let { onAlbumClick(it) } }, + isInJam = isInJam, + isJamGuest = isJamGuest, ) } } @@ -555,6 +584,8 @@ private fun TrackListRow( onSelectionToggle: (Boolean) -> Unit, onTrackOptionsAction: (TrackOptionsAction) -> Unit, trackOptionsState: TrackOptionsState, + isInJam: Boolean, + isJamGuest: Boolean, onShowOptionsClick: () -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, @@ -750,6 +781,8 @@ private fun TrackListRow( state = trackOptionsState, onAction = onTrackOptionsAction, onAlbumClick = { track.album?.let { onAlbumClick(it) } }, + isInJam = isInJam, + isJamGuest = isJamGuest, ) } else { GhostIconButton(onClick = onShowOptionsClick) { @@ -843,6 +876,8 @@ private fun ShimmerTrackListRow( onSelectionToggle = {}, onTrackOptionsAction = {}, trackOptionsState = TrackOptionsState(), + isInJam = false, + isJamGuest = false, onShowOptionsClick = {}, onArtistClick = {}, onAlbumClick = {}, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index c401e631..bccc4fb2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -56,6 +56,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxShare sealed interface TrackOptionsAction { + data object AddToJam : TrackOptionsAction data object StartRadio : TrackOptionsAction data object PlayNext : TrackOptionsAction data object AddToQueue : TrackOptionsAction @@ -98,6 +99,8 @@ fun TrackOptions( onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, modifier: Modifier = Modifier, + isInJam: Boolean = false, + isJamGuest: Boolean = false, ) { AdaptiveDropdownBottomSheet( items = buildTrackMenuItems( @@ -105,6 +108,8 @@ fun TrackOptions( state = state, onAction = onAction, onAlbumClick = onAlbumClick, + isInJam = isInJam, + isJamGuest = isJamGuest, ), trigger = { onClick -> GhostIconButton(onClick = onClick) { @@ -129,6 +134,8 @@ fun TrackOptionsBottomSheet( onDismiss: () -> Unit, onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, + isInJam: Boolean = false, + isJamGuest: Boolean = false, ) { ModalBottomSheet(onDismissRequest = onDismiss) { Column(modifier = Modifier.fillMaxWidth()) { @@ -151,6 +158,8 @@ fun TrackOptionsBottomSheet( onAlbumClick() onDismiss() }, + isInJam = isInJam, + isJamGuest = isJamGuest, ).forEach { item -> Row( modifier = Modifier @@ -241,7 +250,19 @@ private fun buildTrackMenuItems( state: TrackOptionsState, onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, + isInJam: Boolean = false, + isJamGuest: Boolean = false, ): List = buildList { + if (isInJam) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = "Add to Jam", + onClick = { onAction(TrackOptionsAction.AddToJam) }, + ), + ) + } + add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicCircle, @@ -250,40 +271,44 @@ private fun buildTrackMenuItems( ), ) - if (!state.isInQueue && !state.isCurrentlyPlaying) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = "Play next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) - } else if (state.isInQueue && !state.isCurrentlyPlaying) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = "Move to next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) - } + // A guest's queue is the shared jam queue — mutating it locally is not + // allowed, so queue actions are replaced by "Add to Jam". + if (!isJamGuest) { + if (!state.isInQueue && !state.isCurrentlyPlaying) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = "Play next", + onClick = { onAction(TrackOptionsAction.PlayNext) }, + ), + ) + } else if (state.isInQueue && !state.isCurrentlyPlaying) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = "Move to next", + onClick = { onAction(TrackOptionsAction.PlayNext) }, + ), + ) + } - if (!state.isInQueue) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxAddSquare, - label = "Add to queue", - onClick = { onAction(TrackOptionsAction.AddToQueue) }, - ), - ) - } else { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxMusicSquareRemove, - label = "Remove from queue", - onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }, - ), - ) + if (!state.isInQueue) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = "Add to queue", + onClick = { onAction(TrackOptionsAction.AddToQueue) }, + ), + ) + } else { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicSquareRemove, + label = "Remove from queue", + onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }, + ), + ) + } } add( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index 8a91a2c1..db76747f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -20,10 +20,14 @@ package dev.krtirtho.spotube.modules.album import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.map import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker @@ -37,6 +41,11 @@ fun AlbumScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle() @@ -92,6 +101,10 @@ fun AlbumScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, trailingContent = { AddToPlaylistPicker( visible = showAddToPlaylistPicker, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index f705968c..3da95baa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -26,6 +26,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -97,6 +99,7 @@ class AlbumViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -217,15 +220,28 @@ class AlbumViewModel( } fun playAlbum() { - viewModelScope.launch { playbackHelper.playAlbum(albumId) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title) } fun addAlbumToQueue() { - viewModelScope.launch { playbackHelper.addAlbumToQueue(albumId) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title) + } + + fun playAlbumNext() { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Album, albumId, title) } fun playAlbumFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playAlbumFromTrack(albumId, track) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.Album, + id = albumId, + title = title, + startTrack = track, + ) } fun refresh() { @@ -241,14 +257,24 @@ class AlbumViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -311,34 +337,18 @@ class AlbumViewModel( tracks.forEach { track -> downloadManager.enqueue(track) } } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestTracksAddToQueue(tracks, title) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestTracksPlayNext(tracks, title) } fun isTrackBlacklisted(track: MetadataTrack): Boolean { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index 5b76c144..a38c36e6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -63,8 +63,11 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton import dev.krtirtho.spotube.core.ui.base.SecondaryButton @@ -95,6 +98,11 @@ fun ArtistScreen( navigationCommands: NavigationCommands ) { val state by viewModel.state.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() @@ -179,6 +187,9 @@ fun ArtistScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index e6af958c..8c89001a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -27,6 +27,8 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -73,6 +75,7 @@ class ArtistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -247,80 +250,44 @@ class ArtistViewModel( } fun addTopTracksToQueue() { - viewModelScope.launch { - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - audioPlayerQueue.addAllToQueue(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.ArtistTopTracks, + artistId, + artistName, + ) } fun playTopTracks() { - viewModelScope.launch { - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - audioPlayerQueue.load( - entries = entries, - autoPlay = true, - startPosition = 0, - collectionEntry = null, - ) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.ArtistTopTracks, + artistId, + artistName, + ) } fun playTopTracksFromTrack(track: MetadataTrack) { - viewModelScope.launch { - val queue = audioPlayerQueue.getQueue() - val queueIndex = queue.indexOfFirst { entry -> - (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - } - if (queueIndex >= 0) { - audioPlayerQueue.jumpTo(queueIndex) - return@launch - } - - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - - val startPosition = entries.indexOfFirst { entry -> - (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }.coerceAtLeast(0) - - audioPlayerQueue.load( - entries = entries, - autoPlay = true, - startPosition = startPosition, - collectionEntry = null, - ) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.ArtistTopTracks, + id = artistId, + title = artistName, + startTrack = track, + ) } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestTracksAddToQueue(tracks, artistName) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestTracksPlayNext(tracks, artistName) } fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { @@ -329,13 +296,23 @@ class ArtistViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) } is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt new file mode 100644 index 00000000..95366a4a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt @@ -0,0 +1,296 @@ +/* + * 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.modules.devices + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.discovery.DiscoveredDevice +import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen +import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun DevicesScreen( + navigationCommands: NavigationCommands, +) { + val viewModel = koinViewModel() + val devices by viewModel.devices.collectAsStateWithLifecycle() + val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle() + val connectingToDevice by viewModel.connectingToDevice.collectAsStateWithLifecycle() + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() + + DisposableEffect(Unit) { + // Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works. + requestLocalNetworkPermission() + viewModel.startDiscovery() + onDispose { + viewModel.stopDiscovery() + // Don't disconnect here - the connection should persist when navigating to RemoteControlScreen + // The RemoteControlViewModel will manage the connection lifecycle + } + } + + Scaffold( + topBar = { + ApplicationMainBar( + backButton = true, + title = { Text("Devices") }, + actions = { + if (isDiscovering && connectingToDevice == null) { + CircularProgressIndicator( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Iconsax.IconsaxRefreshRight, + contentDescription = "Refresh", + modifier = Modifier + .size(24.dp) + .clickable(enabled = connectingToDevice == null) { + viewModel.startDiscovery() + }, + ) + } + }, + ) + }, + ) { innerPadding -> + val shellBottomInset = LocalAppShellBottomInset.current + + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // Error banner + error?.let { errorMessage -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedButton( + onClick = { viewModel.clearError() }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text("Dismiss") + } + } + } + } + + // Connection status + when (val state = connectionState) { + is ConnectionState.Connected -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column { + Text( + text = "Connected to ${state.host}:${state.port}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + OutlinedButton( + onClick = { viewModel.disconnect() }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text("Disconnect") + } + } + } + } + is ConnectionState.Connecting -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.CenterStart, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + Text( + text = "Connecting...", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + else -> {} + } + + // Device list + if (devices.isEmpty() && connectingToDevice == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (isDiscovering) { + "Searching for devices on the network..." + } else { + "No devices found" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (!isDiscovering) { + Text( + text = "Make sure the other device has \"Allow remote control\" enabled in settings.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp), + ) + } + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 8.dp, + ), + ) { + items(devices.values.toList(), key = { it.key }) { device -> + DeviceRow( + device = device, + isConnecting = connectingToDevice?.key == device.key, + onClick = { viewModel.connectToDevice(device) }, + ) + } + item { + Box(modifier = Modifier.padding(bottom = shellBottomInset)) + } + } + } + } + } +} + +@Composable +private fun DeviceRow( + device: DiscoveredDevice, + isConnecting: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick, enabled = !isConnecting) + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isConnecting) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.name.ifBlank { "Unknown Device" }, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (device.host.isNotBlank() && device.port > 0) { + "${device.host}:${device.port}" + } else { + "Resolving..." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (device.deviceId.isNotBlank()) { + Text( + text = "ID: ${device.deviceId.take(8)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt new file mode 100644 index 00000000..435feaaf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt @@ -0,0 +1,213 @@ +/* + * 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.modules.devices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService +import dev.krtirtho.spotube.core.discovery.DiscoveredDevice +import dev.krtirtho.spotube.core.discovery.DiscoveryState +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.remote.RemoteControlService +import dev.krtirtho.spotube.modules.settings.SettingsProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class DevicesViewModel( + private val navigationCommands: NavigationCommands, +) : ViewModel(), KoinComponent { + private val logger = Logger.withTag("DevicesViewModel") + private val discoveryService: DeviceDiscoveryService by inject() + private val remoteControlClient: RemoteControlClient by inject() + private val remoteControlService: RemoteControlService by inject() + private val settingsProvider: SettingsProvider by inject() + + private val _devices = MutableStateFlow>(emptyMap()) + val devices: StateFlow> = _devices.asStateFlow() + + private val _isDiscovering = MutableStateFlow(false) + val isDiscovering: StateFlow = _isDiscovering.asStateFlow() + + private val _connectingToDevice = MutableStateFlow(null) + val connectingToDevice: StateFlow = _connectingToDevice.asStateFlow() + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private var discoveryJob: Job? = null + + init { + // Observe connection state from the client + viewModelScope.launch { + remoteControlClient.connectionState.collect { state -> + _connectionState.value = state + if (state is ConnectionState.Error) { + _error.value = state.message + _connectingToDevice.value = null + } else if (state is ConnectionState.Disconnected) { + _connectingToDevice.value = null + } else if (state is ConnectionState.Connected) { + // Navigate to remote control screen after successful connection + _connectingToDevice.value = null + navigationCommands.navigateTo(Routes.RemoteControl) + } + } + } + // Whenever the local device id is resolved, drop any of our own + // advertisements that may have been picked up before we knew our id. + viewModelScope.launch { + remoteControlService.localDeviceId.collect { id -> + if (id.isNotBlank()) { + removeSelf() + } + } + } + } + + fun startDiscovery() { + if (discoveryJob?.isActive == true) return + _isDiscovering.value = true + _error.value = null + logger.i { "Starting device discovery" } + // Advertising may have failed before the local-network permission was + // granted; give it another chance now that discovery is being used. + remoteControlService.retryAdvertising() + discoveryJob = viewModelScope.launch { + try { + discoveryService.discover().collect { event -> + logger.d { "Discovery event: $event" } + when (event) { + is DiscoveryState.Discovered -> { + event.resolve() + if (!isSelf(event.device)) { + _devices.update { it + (event.device.key to event.device.copy()) } + } + } + is DiscoveryState.Resolved -> { + if (isSelf(event.device)) { + // Resolved now carries our deviceId in TXT; drop self. + _devices.update { it - event.device.key } + } else { + _devices.update { it + (event.device.key to event.device) } + } + } + is DiscoveryState.Removed -> { + _devices.update { it - event.device.key } + } + } + } + } catch (e: Exception) { + logger.e(e) { "Discovery failed" } + _error.value = "Discovery failed: ${e.message}" + _isDiscovering.value = false + } + } + } + + /** + * True when [device] is this device's own advertisement. On the initial + * `Discovered` event dns-sd hasn't resolved the TXT record yet (deviceId is + * empty), so we match by the name we advertise; once resolved we also have + * the authoritative deviceId. + */ + private fun isSelf(device: DiscoveredDevice): Boolean { + val localId = remoteControlService.localDeviceId.value.ifBlank { + settingsProvider.settingsState.value?.remoteControlDeviceId ?: "" + } + if (localId.isNotBlank() && device.deviceId == localId) return true + // Match by the deterministic advertised name as a fallback for the + // pre-resolution event where deviceId isn't available yet. + return device.name.isNotBlank() && device.name == remoteControlService.advertisedName() + } + + private fun removeSelf() { + val localId = remoteControlService.localDeviceId.value + if (localId.isBlank()) return + _devices.update { map -> + map.filterNot { (_, device) -> device.deviceId == localId } + } + } + + fun stopDiscovery() { + discoveryJob?.cancel() + discoveryJob = null + _isDiscovering.value = false + } + + fun connectToDevice(device: DiscoveredDevice) { + if (_connectingToDevice.value != null) { + logger.w { "Already connecting to a device" } + return + } + if (isSelf(device)) { + logger.w { "Refusing to connect to self: ${device.name}" } + return + } + + _connectingToDevice.value = device + _error.value = null + logger.i { "Connecting to device ${device.name} at ${device.host}:${device.port}" } + + viewModelScope.launch { + try { + val settings = settingsProvider.settingsState.value + val deviceId = settings?.remoteControlDeviceId ?: "" + val deviceName = settings?.remoteControlDeviceName?.ifBlank { "Spotube Controller" } + ?: "Spotube Controller" + + remoteControlClient.connect( + host = device.host, + port = device.port, + deviceId = deviceId, + deviceName = deviceName, + ) + + // Clear connecting state after connection attempt + // The connectionState flow will show the actual connection status + _connectingToDevice.value = null + } catch (e: Exception) { + logger.e(e) { "Failed to connect to device" } + _error.value = "Failed to connect: ${e.message}" + _connectingToDevice.value = null + } + } + } + + fun disconnect() { + viewModelScope.launch { + remoteControlClient.disconnect() + } + } + + fun clearError() { + _error.value = null + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt new file mode 100644 index 00000000..a8b8213e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -0,0 +1,148 @@ +/* + * 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.modules.devices + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction +import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.remote.RemotePlaybackController +import dev.krtirtho.spotube.core.ui.base.ListRowTile +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxCd +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen +import org.koin.compose.koinInject + +/** + * Globally hosted dialog shown when the user tries to play / add to queue / + * play next and there is more than one place it could go (a connected remote + * device and/or an active jam session). Lets the user choose the destination. + */ +@Composable +fun PlayDestinationPickerHost() { + val controller = koinInject() + val remoteControlClient = koinInject() + val request by controller.pendingRequest.collectAsStateWithLifecycle() + val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() + + val pendingRequest = request ?: return + + val remoteDeviceName = when (val state = connectionState) { + is ConnectionState.Connected -> "Remote Device (${state.host})" + else -> "Remote Device" + } + + val actionLabel = when (pendingRequest.action) { + PlaybackDestinationAction.Play -> "Play" + PlaybackDestinationAction.AddToQueue -> "Add to queue" + PlaybackDestinationAction.PlayNext -> "Play next" + } + + ThemedDialog( + onDismissRequest = controller::dismissPicker, + title = { + Text( + text = "Where to $actionLabel?", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + content = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "$actionLabel \"${pendingRequest.title}\" on:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + ListRowTile( + onClick = controller::playLocally, + modifier = Modifier.fillMaxWidth(), + leading = { + Icon( + imageVector = Iconsax.IconsaxCd, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = "This Device", + style = MaterialTheme.typography.bodyLarge, + ) + }, + subtitle = { + Text( + text = "$actionLabel here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + + if (connectionState is ConnectionState.Connected) { + ListRowTile( + onClick = controller::playOnRemote, + modifier = Modifier.fillMaxWidth(), + leading = { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = remoteDeviceName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + subtitle = { + Text( + text = "$actionLabel on the connected device", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } + } + }, + actions = { + TextButton(onClick = controller::dismissPicker) { + Text("Cancel") + } + }, + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt new file mode 100644 index 00000000..31949017 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -0,0 +1,619 @@ +/* + * 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.modules.devices + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteQueueEntry +import dev.krtirtho.spotube.core.ui.base.BaseUITheme +import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.core.ui.base.IconButton +import dev.krtirtho.spotube.core.ui.base.ListRowTile +import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme +import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton +import dev.krtirtho.spotube.core.ui.base.Slider +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.modules.shell.player_queue.QueueSheet +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore +import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove +import dev.krtirtho.spotube.resources.iconsax.IconsaxNext +import dev.krtirtho.spotube.resources.iconsax.IconsaxPause +import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious +import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic +import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle +import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh +import org.koin.compose.viewmodel.koinViewModel +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RemoteControlScreen( + onDisconnect: () -> Unit, +) { + val viewModel = koinViewModel() + val playerState by viewModel.playerState.collectAsStateWithLifecycle() + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val queueState by viewModel.queueState.collectAsStateWithLifecycle() + val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + + // Center the mobile-inspired layout and limit its width so it doesn't + // stretch awkwardly on large screens. + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + Scaffold( + modifier = Modifier + .fillMaxHeight(), + topBar = { + ApplicationMainBar( + title = { Text("Remote Control") }, + backButton = true, + actions = { + GhostIconButton( + onClick = viewModel::toggleQueueVisibility, + ) { + Icon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = "Queue", + tint = if (isQueueVisible) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + GhostIconButton( + onClick = { + viewModel.disconnect() + onDisconnect() + } + ) { + Icon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = "Disconnect", + ) + } + } + ) + } + ) { padding -> + when (connectionState) { + is ConnectionState.Connected -> { + RemoteControlContent( + playerState = playerState, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onSkipPrevious = viewModel::skipPrevious, + onSeek = viewModel::seek, + onSetVolume = viewModel::setVolume, + onToggleShuffle = viewModel::toggleShuffle, + onCycleLoopMode = viewModel::cycleLoopMode, + modifier = Modifier.padding(padding).padding(bottom = shellBottomInset) + ) + } + is ConnectionState.Connecting -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Connecting...") + } + } + is ConnectionState.Disconnected -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Disconnected") + } + } + is ConnectionState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Connection error: ${(connectionState as ConnectionState.Error).message}") + } + } + } + } + } + + // Click-outside scrim for the sliding queue sheet on large screens. + // (The ModalBottomSheet variant has its own built-in scrim.) + if (isQueueVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { viewModel.toggleQueueVisibility() }, + ) + ) + } + + // Keep the sliding sheet above the AppLargePlayer on large screens. + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + ) { + QueueSheet( + isVisible = isQueueVisible, + onDismiss = { viewModel.toggleQueueVisibility() }, + modifier = Modifier.fillMaxSize(), + ) { + RemoteQueueSection( + queueState = queueState, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem, + ) + } + } +} + +@Composable +private fun RemoteControlContent( + playerState: RemotePlayerState, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + onSkipPrevious: () -> Unit, + onSeek: (Long) -> Unit, + onSetVolume: (Float) -> Unit, + onToggleShuffle: () -> Unit, + onCycleLoopMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Box (modifier = Modifier.fillMaxSize()) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .widthIn(max = 480.dp) + .align(Alignment.TopCenter), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(32.dp)) + + // Album art + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + if (playerState.currentTrackCoverUrl != null) { + AsyncImage( + model = playerState.currentTrackCoverUrl, + contentDescription = "Album cover", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Track info + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = playerState.currentTrackTitle ?: "Unknown Track", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = playerState.currentTrackArtists ?: "Unknown Artist", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (playerState.currentTrackAlbum != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = playerState.currentTrackAlbum!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Seek bar + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Slider( + value = playerState.positionMs.toFloat(), + onValueChange = { onSeek(it.toLong()) }, + valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = formatDuration(playerState.positionMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatDuration(playerState.durationMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Playback controls + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + // Shuffle + IconButton( + onClick = onToggleShuffle, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxShuffle, + contentDescription = "Shuffle", + tint = if (playerState.shuffleEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + // Skip previous + IconButton( + onClick = onSkipPrevious, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxPrevious, + contentDescription = "Previous", + modifier = Modifier.size(32.dp), + ) + } + + // Play/Pause + val baseTheme = LocalBaseUITheme.current + val circlePrimaryIconTheme = remember(baseTheme) { + baseTheme.iconButtons.primary.copy( + shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) + ) + } + PrimaryIconButton( + onClick = onTogglePlayPause, + modifier = Modifier.size(72.dp), + theme = circlePrimaryIconTheme, + ) { + Icon( + imageVector = if (playerState.isPlaying) { + Iconsax.IconsaxPause + } else { + Iconsax.IconsaxPlay + }, + contentDescription = if (playerState.isPlaying) "Pause" else "Play", + modifier = Modifier.size(40.dp), + ) + } + + // Skip next + IconButton( + onClick = onSkipNext, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxNext, + contentDescription = "Next", + modifier = Modifier.size(32.dp), + ) + } + + // Loop mode + IconButton( + onClick = onCycleLoopMode, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxRepeateMusic, + contentDescription = "Loop mode", + tint = if (playerState.loopMode != "none") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Volume control + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxVolumeHigh, + contentDescription = "Volume", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + + Slider( + value = playerState.volume, + onValueChange = onSetVolume, + valueRange = 0f..1f, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +private fun RemoteQueueSection( + queueState: RemoteQueueState, + onPlayQueueItem: (Int) -> Unit, + onRemoveQueueItem: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Queue", + style = MaterialTheme.typography.titleLarge, + ) + + if (queueState.entries.isEmpty()) { + Text( + text = "No queue entries", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed( + items = queueState.entries, + key = { index, entry -> "${entry.mediaUrl}@$index" }, + ) { index, entry -> + RemoteQueueItemRow( + entry = entry, + index = index, + isCurrent = index == queueState.currentIndex, + onPlayClick = { onPlayQueueItem(index) }, + onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) }, + ) + } + } + } + } +} + +@Composable +private fun RemoteQueueItemRow( + entry: RemoteQueueEntry, + index: Int, + isCurrent: Boolean, + onPlayClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + var showMenu by remember { mutableStateOf(false) } + + ListRowTile( + onClick = onPlayClick, + selected = isCurrent, + modifier = Modifier, + leading = { + Box( + modifier = Modifier + .size(48.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (entry.coverUrl != null) { + AsyncImage( + model = entry.coverUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text( + text = "${index + 1}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + title = { + Text( + text = entry.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isCurrent) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + }, + subtitle = { + Text( + text = entry.artists, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + trailing = { + Text( + text = formatDuration(entry.durationMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Box { + GhostIconButton( + onClick = { showMenu = true }, + modifier = Modifier.size(36.dp), + ) { + Icon( + Iconsax.Iconsax3DotsMore, + contentDescription = "More options", + modifier = Modifier.size(18.dp), + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Remove from queue") }, + onClick = { + onRemoveClick() + showMenu = false + }, + leadingIcon = { + Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) + }, + ) + } + } + } + ) +} + +private fun formatDuration(ms: Long): String { + val duration = ms.milliseconds + val minutes = duration.inWholeMinutes + val seconds = duration.inWholeSeconds % 60 + return "$minutes:${seconds.toString().padStart(2, '0')}" +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt new file mode 100644 index 00000000..9e329f26 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt @@ -0,0 +1,191 @@ +/* + * 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.modules.devices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.remote.RemoteControlCommand +import dev.krtirtho.spotube.core.remote.RemoteControlEvent +import dev.krtirtho.spotube.core.remote.RemoteQueueEntry +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +data class RemotePlayerState( + val isPlaying: Boolean = false, + val positionMs: Long = 0, + val durationMs: Long = 0, + val volume: Float = 1.0f, + val shuffleEnabled: Boolean = false, + val loopMode: String = "none", + val currentTrackId: String? = null, + val currentTrackTitle: String? = null, + val currentTrackArtists: String? = null, + val currentTrackAlbum: String? = null, + val currentTrackCoverUrl: String? = null, +) + +data class RemoteQueueState( + val entries: List = emptyList(), + val currentIndex: Int = -1, +) + +class RemoteControlViewModel : ViewModel(), KoinComponent { + private val logger = Logger.withTag("RemoteControlViewModel") + private val remoteControlClient: RemoteControlClient by inject() + + private val _playerState = MutableStateFlow(RemotePlayerState()) + val playerState: StateFlow = _playerState.asStateFlow() + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _queueState = MutableStateFlow(RemoteQueueState()) + val queueState: StateFlow = _queueState.asStateFlow() + + private val _isQueueVisible = MutableStateFlow(false) + val isQueueVisible: StateFlow = _isQueueVisible.asStateFlow() + + init { + viewModelScope.launch { + remoteControlClient.connectionState.collect { state -> + _connectionState.value = state + } + } + + viewModelScope.launch { + remoteControlClient.latestPlayerState.collect { event -> + if (event != null) { + handlePlayerState(event) + } + } + } + + viewModelScope.launch { + remoteControlClient.latestQueue.collect { event -> + if (event != null) { + handleQueueUpdated(event) + } + } + } + } + + private fun handlePlayerState(event: RemoteControlEvent.PlayerState) { + _playerState.update { + it.copy( + isPlaying = event.isPlaying, + positionMs = event.positionMs, + durationMs = event.durationMs, + volume = event.volume, + shuffleEnabled = event.shuffleEnabled, + loopMode = event.loopMode, + currentTrackId = event.currentTrackId, + currentTrackTitle = event.currentTrackTitle, + currentTrackArtists = event.currentTrackArtists, + currentTrackAlbum = event.currentTrackAlbum, + currentTrackCoverUrl = event.currentTrackCoverUrl, + ) + } + } + + private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) { + _queueState.value = RemoteQueueState( + entries = event.entries, + currentIndex = event.currentIndex, + ) + } + + fun togglePlayPause() { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause) + } + } + + fun skipNext() { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SkipNext) + } + } + + fun skipPrevious() { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SkipPrevious) + } + } + + fun seek(positionMs: Long) { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.Seek(positionMs)) + } + } + + fun setVolume(volume: Float) { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) + } + } + + fun toggleShuffle() { + viewModelScope.launch { + val newState = !_playerState.value.shuffleEnabled + remoteControlClient.sendCommand(RemoteControlCommand.SetShuffle(newState)) + } + } + + fun cycleLoopMode() { + viewModelScope.launch { + val currentMode = _playerState.value.loopMode + val newMode = when (currentMode) { + "none" -> "one" + "one" -> "all" + else -> "none" + } + remoteControlClient.sendCommand(RemoteControlCommand.SetLoopMode(newMode)) + } + } + + fun playQueueItem(index: Int) { + if (index < 0) return + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.PlayIndex(index)) + } + } + + fun removeQueueItem(mediaUrl: String) { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.RemoveFromQueue(mediaUrl)) + } + } + + fun toggleQueueVisibility() { + _isQueueVisible.update { !it } + } + + fun disconnect() { + viewModelScope.launch { + remoteControlClient.disconnect() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt index c34d53ee..67924aaf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt @@ -31,6 +31,8 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -66,8 +68,14 @@ import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.misc.SkeletonTree +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen +import dev.krtirtho.spotube.resources.iconsax.User +import org.koin.compose.koinInject import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -75,6 +83,7 @@ import kotlinx.coroutines.flow.map @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen(viewModel: HomeScreenViewModel) { + val navigationCommands = koinInject() val platform = getPlatform() val isDesktop = platform.type == PlatformType.Windows || platform.type == PlatformType.Linux || @@ -117,7 +126,23 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { backButton = false, title = { Text("Browse") - } + }, + actions = { + if (!isDesktop) { + IconButton(onClick = { navigationCommands.navigateTo(Routes.Devices) }) { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = "Devices", + ) + } + IconButton(onClick = { navigationCommands.navigateTo(Routes.Jam) }) { + Icon( + imageVector = Iconsax.User, + contentDescription = "Group Jam", + ) + } + } + }, ) }, ) { innerPadding -> @@ -332,7 +357,7 @@ private fun HomeSection( if (!subtitle.isNullOrEmpty()) Text( text = subtitle, style = MaterialTheme.typography.labelMedium.copy( - color = MaterialTheme.colorScheme.secondary, + color = MaterialTheme.colorScheme.secondary, fontWeight = FontWeight.Medium, ), modifier = Modifier.padding(horizontal = 16.dp), diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt new file mode 100644 index 00000000..b7f76b00 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -0,0 +1,347 @@ +/* + * 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.modules.jam + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomCode +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import org.koin.compose.viewmodel.koinViewModel + +/** + * Group Jam session screen. Playback controls and the queue live in the app's + * regular player / queue sheet (the shared jam queue is the local queue), so + * this screen only covers participation and session management. + */ +@Composable +fun JamScreen( + navigationCommands: NavigationCommands, +) { + val viewModel = koinViewModel() + val state by viewModel.uiState.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + + Scaffold( + topBar = { + ApplicationMainBar( + backButton = true, + title = { Text("Group Jam") }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .padding(bottom = shellBottomInset) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + state.error?.let { error -> + ErrorBanner(text = error, onDismiss = viewModel::clearError) + } + + when { + !state.isActive -> CreateOrJoinView( + state = state, + onCreate = viewModel::createSession, + onJoin = viewModel::joinWithCode, + ) + + else -> SessionView( + state = state, + onShareCode = viewModel::shareRoomCode, + onLeave = viewModel::leave, + onKick = viewModel::kickParticipant, + onBan = viewModel::banParticipant, + ) + } + } + } +} + +@Composable +private fun ErrorBanner(text: String, onDismiss: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = text, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedButton(onClick = onDismiss) { + Text("Dismiss") + } + } +} + +@Composable +private fun CreateOrJoinView( + state: JamUiState, + onCreate: () -> Unit, + onJoin: (String) -> Unit, +) { + var tab by rememberSaveable { mutableIntStateOf(0) } + var pasted by rememberSaveable { mutableStateOf("") } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.", + style = MaterialTheme.typography.titleMedium, + ) + + if (!state.brokerConfigured) { + Text( + text = "No jam broker configured — set one up in Settings to host or join a session.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } else { + Text( + text = "Broker: ${state.brokerHost}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + selected = tab == 0, + onClick = { tab = 0 }, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) { Text("Create") } + SegmentedButton( + selected = tab == 1, + onClick = { tab = 1 }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { Text("Join") } + } + + if (tab == 0) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Start a session as the host. You'll get a 6-character room code to " + + "share with friends; you control the queue.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = onCreate, + enabled = state.brokerConfigured, + ) { + Text("Create Session") + } + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Enter the 6-character room code the host shared with you.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = pasted, + onValueChange = { pasted = JamRoomCode.normalize(it) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Room code") }, + placeholder = { Text("ABC123") }, + singleLine = true, + ) + Button( + onClick = { onJoin(pasted) }, + enabled = state.brokerConfigured && JamRoomCode.isValid(pasted), + ) { + Text("Join Session") + } + } + } + } +} + +@Composable +private fun SessionView( + state: JamUiState, + onShareCode: () -> Unit, + onLeave: () -> Unit, + onKick: (String) -> Unit, + onBan: (String) -> Unit, +) { + val isHost = state.role == JamRole.Host + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (!state.isConnected) { + Text( + text = "Connecting to the session…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + ParticipantsSection( + participants = state.participants, + isHost = isHost, + onKick = onKick, + onBan = onBan, + ) + + if (isHost) { + HorizontalDivider() + + Text( + text = "Invite someone", + style = MaterialTheme.typography.titleSmall, + ) + RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode) + } + + HorizontalDivider() + + Text( + text = "The queue and playback controls are in the player at the bottom of the app — " + + "the jam queue is shared with every participant.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + LeaveButton(onLeave) + } +} + +@Composable +private fun ParticipantsSection( + participants: List, + isHost: Boolean, + onKick: (String) -> Unit, + onBan: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Participants (${participants.size})", + style = MaterialTheme.typography.titleSmall, + ) + participants.forEach { participant -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = participant.displayName, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (participant.isHost) { + Text( + text = "Host", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + if (isHost && !participant.isHost) { + OutlinedButton( + onClick = { onKick(participant.id) }, + modifier = Modifier.height(32.dp), + ) { + Text("Kick", style = MaterialTheme.typography.labelSmall) + } + OutlinedButton( + onClick = { onBan(participant.id) }, + modifier = Modifier.height(32.dp), + ) { + Text("Ban", style = MaterialTheme.typography.labelSmall) + } + } + } + } + } +} + +@Composable +private fun RoomCodeBox( + code: String, + onShare: () -> Unit, +) { + val clipboard = LocalClipboardManager.current + val viewModel: JamViewModel = koinViewModel() + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SelectionContainer { + Text( + text = code, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { clipboard.setText(AnnotatedString(code)) }) { + Text("Copy") + } + if (viewModel.supportsNativeShare) { + OutlinedButton(onClick = onShare) { + Text("Share") + } + } + } + } +} + +@Composable +private fun LeaveButton(onLeave: () -> Unit) { + OutlinedButton(onClick = onLeave) { + Text("Leave Session") + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt new file mode 100644 index 00000000..92dcc936 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt @@ -0,0 +1,143 @@ +/* + * 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.modules.jam + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.krtirtho.spotube.PlatformType +import dev.krtirtho.spotube.core.jam.JamParticipant +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomCode +import dev.krtirtho.spotube.core.jam.JamRoomService +import dev.krtirtho.spotube.core.share.ShareService +import dev.krtirtho.spotube.getPlatform +import dev.krtirtho.spotube.modules.settings.SettingsProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class JamUiState( + val isActive: Boolean = false, + val isConnected: Boolean = false, + val role: JamRole? = null, + val participants: List = emptyList(), + val roomCode: String? = null, + val brokerHost: String = "", + val brokerConfigured: Boolean = false, + val error: String? = null, +) + +class JamViewModel( + private val jamRoomService: JamRoomService, + private val shareService: ShareService, + private val settingsProvider: SettingsProvider, +) : ViewModel() { + + private val _localError = MutableStateFlow(null) + + val supportsNativeShare: Boolean = + getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS + + val uiState: StateFlow = combine( + jamRoomService.role, + jamRoomService.participants, + jamRoomService.isConnected, + jamRoomService.roomCode, + jamRoomService.connectionError, + settingsProvider.settingsState, + _localError, + ) { values -> + @Suppress("UNCHECKED_CAST") + val role = values[0] as JamRole? + @Suppress("UNCHECKED_CAST") + val participants = values[1] as List + val isConnected = values[2] as Boolean + val roomCode = values[3] as String? + val connectionError = values[4] as String? + val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings + val localError = values[6] as String? + + JamUiState( + isActive = role != null, + isConnected = isConnected, + role = role, + participants = participants, + roomCode = roomCode, + brokerHost = settings?.jamBroker?.host.orEmpty(), + brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(), + error = localError ?: connectionError, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState()) + + // ---------- Session lifecycle ---------- + + fun createSession() { + viewModelScope.launch { + jamRoomService.createRoom() + .onFailure { e -> + _localError.value = e.message ?: "Failed to create jam session" + } + .onSuccess { _localError.value = null } + } + } + + fun joinWithCode(input: String) { + val code = JamRoomCode.normalize(input) + if (!JamRoomCode.isValid(code)) { + _localError.value = "Room codes are ${JamRoomCode.LENGTH} characters (letters and digits)" + return + } + viewModelScope.launch { + jamRoomService.joinRoom(code) + .onFailure { e -> + _localError.value = e.message ?: "Failed to join jam session" + } + .onSuccess { _localError.value = null } + } + } + + fun shareRoomCode() { + val code = uiState.value.roomCode ?: return + shareService.share("Join my Spotube Jam with code: $code", "Spotube Group Jam") + } + + fun leave() { + viewModelScope.launch { + jamRoomService.leaveRoom() + _localError.value = null + } + } + + fun clearError() { + _localError.value = null + } + + // ---------- Host moderation ---------- + + fun kickParticipant(participantId: String) { + viewModelScope.launch { jamRoomService.kickParticipant(participantId) } + } + + fun banParticipant(participantId: String) { + viewModelScope.launch { jamRoomService.banParticipant(participantId) } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt new file mode 100644 index 00000000..9c1f30c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt @@ -0,0 +1,63 @@ +/* + * 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.modules.jam + +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import dev.krtirtho.spotube.core.ui.base.ThemedDialog + +/** + * Dialog shown when the user tries to play a collection while a jam session is active. + * The user picks between playing locally on their device or suggesting it to the jam session. + */ +@Composable +fun PlayDestinationPicker( + visible: Boolean, + onDismiss: () -> Unit, + onPlayLocally: () -> Unit, + onSuggestToJam: () -> Unit, +) { + if (!visible) return + + ThemedDialog( + onDismissRequest = onDismiss, + title = { + Text("Play Where?", style = MaterialTheme.typography.titleLarge) + }, + content = { + Text( + text = "You have an active jam session. Choose where to play this collection.", + style = MaterialTheme.typography.bodyMedium, + ) + }, + actions = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + TextButton(onClick = onPlayLocally) { + Text("Play here") + } + Button(onClick = onSuggestToJam) { + Text("Suggest to Jam") + } + }, + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index 7a5c0d58..b48f1253 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -31,10 +31,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.map import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.CollectionView @@ -53,6 +57,11 @@ fun PlaylistScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle() @@ -127,6 +136,10 @@ fun PlaylistScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, footerContent = footerContent, trailingContent = { val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index 7cd51cbb..d1ef1088 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -26,6 +26,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -98,6 +100,7 @@ class PlaylistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -223,15 +226,28 @@ class PlaylistViewModel( } fun playPlaylist() { - viewModelScope.launch { playbackHelper.playPlaylist(playlistId) } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title) } fun addPlaylistToQueue() { - viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title) + } + + fun playPlaylistNext() { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Playlist, playlistId, title) } fun playPlaylistFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.Playlist, + id = playlistId, + title = title, + startTrack = track, + ) } fun refresh() { @@ -274,14 +290,24 @@ class PlaylistViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -356,34 +382,18 @@ class PlaylistViewModel( } } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestTracksAddToQueue(tracks, title) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestTracksPlayNext(tracks, title) } val savedTrackIds diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index 045a0380..191923a0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -20,11 +20,15 @@ package dev.krtirtho.spotube.modules.saved_tracks import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.map import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker @@ -39,6 +43,11 @@ fun SavedTracksScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() @@ -85,6 +94,10 @@ fun SavedTracksScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, trailingContent = { AddToPlaylistPicker( visible = showAddToPlaylistPicker, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 41fa1bc7..1025bdbd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -34,6 +34,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -97,6 +99,7 @@ class SavedTracksViewModel( private val libraryRepository: LibraryRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -200,15 +203,20 @@ class SavedTracksViewModel( } fun playSavedTracks() { - viewModelScope.launch { playbackHelper.playSavedTracks() } + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") } fun addSavedTracksToQueue() { - viewModelScope.launch { playbackHelper.addSavedTracksToQueue() } + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") } fun playSavedTracksFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playSavedTracksFromTrack(track) } + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.SavedTracks, + id = SAVED_TRACKS_COLLECTION_ID, + title = "Saved Tracks", + startTrack = track, + ) } fun refresh() { @@ -224,14 +232,24 @@ class SavedTracksViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -298,34 +316,16 @@ class SavedTracksViewModel( } } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks") } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + remotePlaybackController.requestTracksPlayNext(tracks, "Saved Tracks") } suspend fun isSavedTracks(trackIds: List): List { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index 0428244f..2b2bb588 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -86,6 +86,10 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField import dev.krtirtho.spotube.core.ui.base.ChipTab @@ -121,11 +125,17 @@ private val GridMinCellSize = 180.dp fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val audioPlayerQueue: AudioPlayerQueue = koinInject() val shareService: ShareService = koinInject() + val remotePlaybackController: RemotePlaybackController = koinInject() val downloadsViewModel: DownloadsViewModel = koinViewModel() val libraryRepository: LibraryRepository = koinInject() val blacklistRepository: BlacklistRepository = koinInject() val navigationCommands: NavigationCommands = koinInject() val state by viewModel.state.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val selectedType = state.selectedSearchType val scope = rememberCoroutineScope() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() @@ -171,14 +181,23 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.id == track.id - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) + } + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -237,33 +256,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { } fun bulkAddToQueue(tracks: List) { - scope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + remotePlaybackController.requestTracksAddToQueue(tracks, "Search results") + } + + fun bulkAddToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) } fun bulkPlayNext(tracks: List) { - scope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + remotePlaybackController.requestTracksPlayNext(tracks, "Search results") } Scaffold( @@ -341,6 +342,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { tracksToAddToPlaylist = tracks showAddToPlaylistPicker = true }, + onBulkAddToJam = ::bulkAddToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, onArtistClick = { artist -> navigationCommands.navigateTo(Routes.Artist(artist.id)) }, @@ -370,6 +374,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { tracksToAddToPlaylist = tracks showAddToPlaylistPicker = true }, + onBulkAddToJam = ::bulkAddToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, onArtistClick = { artist -> navigationCommands.navigateTo(Routes.Artist(artist.id)) }, @@ -651,6 +658,9 @@ private fun SearchAllTab( onBulkAddToQueue: (List) -> Unit, onBulkPlayNext: (List) -> Unit, onBulkAddToPlaylist: (List) -> Unit, + onBulkAddToJam: (List) -> Unit, + isInJam: Boolean, + isJamGuest: Boolean, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, @@ -708,6 +718,9 @@ private fun SearchAllTab( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, + isJamGuest = isJamGuest, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, @@ -795,6 +808,9 @@ private fun SearchTracksTab( onBulkAddToQueue: (List) -> Unit, onBulkPlayNext: (List) -> Unit, onBulkAddToPlaylist: (List) -> Unit, + onBulkAddToJam: (List) -> Unit, + isInJam: Boolean, + isJamGuest: Boolean, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, @@ -824,6 +840,9 @@ private fun SearchTracksTab( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, + isJamGuest = isJamGuest, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt index cfd8ed3e..3a6792ff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt @@ -55,6 +55,17 @@ data class UserSettings( val enableConnect: Boolean = false, val playbackProxyServerPort: Int = 14769, + // Remote Control (LAN) + val allowRemoteControl: Boolean = false, + val allowedRemoteDevices: List = emptyList(), + val remoteControlDeviceName: String = "", + val remoteControlDeviceId: String = "", + + // Group Jam (MQTT) + val jamParticipantName: String = "", + val jamBroker: JamBroker = JamBroker(), + val lastJamCode: String = "", + // Downloads val overloadedDownloadFolder: String? = null, // When null, uses default music folder val localMediaFolders: List = emptyList(), @@ -77,3 +88,20 @@ data class UserSettings( // Updates val autoCheckForUpdates: Boolean = true, ) + +/** + * Configuration for the MQTT broker used by Group Jam. The host is a placeholder + * until a real broker is configured; users can self-host and point the app at it. + */ +@Serializable +data class JamBroker( + val name: String = "", + val host: String = "test.mosquitto.org", + val port: Int = 1883, + val useTls: Boolean = false, + val username: String? = null, + val password: String? = null, + val clientIdPrefix: String = "spotube", + val keepAliveSeconds: Int = 30, + val connectionTimeoutSeconds: Int = 10, +) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index e420009a..2321ecef 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.getPlatform +import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import spotube.composeapp.generated.resources.* @@ -42,6 +43,7 @@ import dev.krtirtho.spotube.modules.settings.sections.appearanceSection import dev.krtirtho.spotube.modules.settings.sections.cacheSection import dev.krtirtho.spotube.modules.settings.sections.desktopSection import dev.krtirtho.spotube.modules.settings.sections.downloadsSection +import dev.krtirtho.spotube.modules.settings.sections.jamSection import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection import dev.krtirtho.spotube.modules.settings.sections.playbackSection import dev.krtirtho.spotube.modules.settings.sections.pluginsSection @@ -62,6 +64,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { platformType == PlatformType.MacOS val shellBottomInset = LocalAppShellBottomInset.current + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() val contentPadding = remember(shellBottomInset) { PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) } @@ -106,6 +109,12 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { settings = settingsState!!, settingsViewModel = settingsViewModel, navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, + ) + if (settingsState != null) + jamSection( + settings = settingsState!!, + settingsViewModel = settingsViewModel, ) if (settingsState != null) cacheSection( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt new file mode 100644 index 00000000..a15326da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt @@ -0,0 +1,208 @@ +/* + * 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.modules.settings.sections + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.core.jam.JamRoomClient +import dev.krtirtho.spotube.modules.settings.SettingsViewModel +import dev.krtirtho.spotube.modules.settings.UserSettings +import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard +import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard +import dev.krtirtho.spotube.resources.iconsax.CustomServer +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_jam_broker_client_id +import spotube.composeapp.generated.resources.settings_jam_broker_host +import spotube.composeapp.generated.resources.settings_jam_broker_host_subtitle +import spotube.composeapp.generated.resources.settings_jam_broker_password +import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note +import spotube.composeapp.generated.resources.settings_jam_broker_port +import spotube.composeapp.generated.resources.settings_jam_broker_test +import spotube.composeapp.generated.resources.settings_jam_broker_test_fail +import spotube.composeapp.generated.resources.settings_jam_broker_test_ok +import spotube.composeapp.generated.resources.settings_jam_broker_testing +import spotube.composeapp.generated.resources.settings_jam_broker_tls +import spotube.composeapp.generated.resources.settings_jam_broker_username +import spotube.composeapp.generated.resources.settings_section_jam + +internal fun LazyListScope.jamSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, +) { + val broker = settings.jamBroker + + settingsSectionHeader(Res.string.settings_section_jam) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_host), + subtitle = stringResource( + Res.string.settings_jam_broker_host_subtitle, + broker.host.ifBlank { "—" }, + broker.port, + ), + value = broker.host, + onValueSaved = { host -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(host = host)) + } + }, + placeholder = "broker.example.com", + icon = { + SettingsItemIcon( + Iconsax.CustomServer, + stringResource(Res.string.settings_jam_broker_host), + ) + }, + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_port), + value = broker.port.toString(), + onValueSaved = { port -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(port = port.toIntOrNull() ?: 1883)) + } + }, + placeholder = "1883", + normalize = { it.filter { c -> c.isDigit() }.take(5) }, + validate = { input -> + val port = input.toIntOrNull() + if (port == null || port !in 1..65535) "Invalid port" else null + }, + ) + }, + { + SwitchSettingCard( + title = stringResource(Res.string.settings_jam_broker_tls), + checked = broker.useTls, + onCheckedChange = { tls -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(useTls = tls)) + } + }, + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_username), + value = broker.username.orEmpty(), + onValueSaved = { username -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(username = username.ifBlank { null })) + } + }, + placeholder = "anonymous", + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_password), + value = broker.password.orEmpty(), + onValueSaved = { password -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(password = password.ifBlank { null })) + } + }, + placeholder = "••••••••", + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_client_id), + value = broker.clientIdPrefix, + onValueSaved = { prefix -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" })) + } + }, + placeholder = "spotube", + ) + }, + { + val jamClient = koinInject() + val scope = rememberCoroutineScope() + var testing by remember { mutableStateOf(false) } + var result by remember { mutableStateOf(null) } + + Box(modifier = Modifier.fillMaxWidth().padding(16.dp, 8.dp)) { + Button( + onClick = { + testing = true + result = null + scope.launch { + val outcome = jamClient.testConnection(broker) + result = outcome.fold( + onSuccess = { ok -> "OK: $ok" }, + onFailure = { e -> "ERR: ${e.message ?: "unknown"}" }, + ) + testing = false + } + }, + enabled = broker.host.isNotBlank() && !testing, + ) { + Text( + text = if (testing) { + stringResource(Res.string.settings_jam_broker_testing) + } else { + stringResource(Res.string.settings_jam_broker_test) + }, + ) + } + result?.let { message -> + val ok = message.startsWith("OK:") + Text( + text = message.removePrefix("OK:").removePrefix("ERR:"), + style = MaterialTheme.typography.bodySmall, + color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + }, + { + Text( + text = stringResource(Res.string.settings_jam_broker_placeholder_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) + }, + ) + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt index 4dee2d85..52d0a190 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt @@ -38,6 +38,7 @@ import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard import dev.krtirtho.spotube.resources.iconsax.CustomServer import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay @@ -49,6 +50,7 @@ internal fun LazyListScope.playbackSection( settings: UserSettings, settingsViewModel: SettingsViewModel, navigatorCommands: NavigationCommands, + requestLocalNetworkPermission: () -> Unit, ) { val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) val streamingQualities = availableAudioQualities( @@ -135,6 +137,51 @@ internal fun LazyListScope.playbackSection( } ) }, + { + SwitchSettingCard( + title = stringResource(Res.string.settings_allow_remote_control_title), + subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxMirroringScreen, + stringResource(Res.string.settings_allow_remote_control_title) + ) + }, + checked = settings.allowRemoteControl, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(allowRemoteControl = enabled) + } + // Request the local network permission when enabling remote control + // so that DNS-SD registration can succeed on Android 16+ + if (enabled) { + requestLocalNetworkPermission() + } + } + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_remote_device_name_title), + subtitle = stringResource( + Res.string.settings_remote_device_name_subtitle, + settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) } + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title)) + }, + value = settings.remoteControlDeviceName, + dialogDescription = stringResource(Res.string.settings_remote_device_name_description), + placeholder = stringResource(Res.string.settings_remote_device_name_placeholder), + normalize = { it.trim() }, + validate = { _ -> null }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(remoteControlDeviceName = value) + } + } + ) + }, { val error_whole_number = stringResource(Res.string.settings_error_whole_number) val error_port_range = stringResource(Res.string.settings_error_port_range) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index c43ef433..96172f80 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -82,6 +82,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.BaseUITheme @@ -117,6 +119,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import dev.krtirtho.spotube.resources.iconsax.InconsaxClock import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -158,6 +161,10 @@ fun AppExpandedPlayer( ), ) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) + val jamRoomService: JamRoomService = koinInject() + val isJamGuest by jamRoomService.role + .map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val scope = rememberCoroutineScope() val downloadsViewModel: DownloadsViewModel = koinViewModel() val navigationCommands: NavigationCommands = koinInject() @@ -198,18 +205,22 @@ fun AppExpandedPlayer( } fun onSkipPrevious() { + if (isJamGuest) return scope.launch { audioPlayer.skipToPrevious() } } fun onSkipNext() { + if (isJamGuest) return scope.launch { audioPlayer.skipToNext() } } fun onShuffleToggle() { + if (isJamGuest) return scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } } fun onLoopToggle() { + if (isJamGuest) return scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } } @@ -517,7 +528,7 @@ fun AppExpandedPlayer( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - GhostIconButton(onClick = ::onShuffleToggle) { + GhostIconButton(onClick = ::onShuffleToggle, enabled = !isJamGuest) { Icon( Iconsax.IconsaxShuffle, contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", @@ -528,7 +539,7 @@ fun AppExpandedPlayer( } ) } - GhostIconButton(onClick = ::onSkipPrevious) { + GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) { Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") } IconButton( @@ -543,10 +554,10 @@ fun AppExpandedPlayer( modifier = Modifier.size(30.dp), ) } - GhostIconButton(onClick = ::onSkipNext) { + GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) { Icon(Iconsax.IconsaxNext, contentDescription = "Next") } - GhostIconButton(onClick = ::onLoopToggle) { + GhostIconButton(onClick = ::onLoopToggle, enabled = !isJamGuest) { Icon( imageVector = when (playerUiState.loopState) { LoopState.NONE -> Iconsax.IconsaxRepeateMusic diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index 9d7ffbd1..a6e09477 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -65,6 +65,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.Slider @@ -93,6 +95,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -125,6 +128,10 @@ fun AppLargePlayer( ), ) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) + val jamRoomService: JamRoomService = koinInject() + val isJamGuest by jamRoomService.role + .map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val scope = rememberCoroutineScope() val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() var isSeeking by remember { mutableStateOf(false) } @@ -155,18 +162,22 @@ fun AppLargePlayer( } fun onSkipPrevious() { + if (isJamGuest) return scope.launch { audioPlayer.skipToPrevious() } } fun onSkipNext() { + if (isJamGuest) return scope.launch { audioPlayer.skipToNext() } } fun onShuffleToggle() { + if (isJamGuest) return scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } } fun onLoopToggle() { + if (isJamGuest) return scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } } @@ -294,6 +305,7 @@ fun AppLargePlayer( ) { VariableIconButton( onClick = ::onShuffleToggle, + enabled = !isJamGuest, variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost ) { Icon( @@ -306,7 +318,7 @@ fun AppLargePlayer( } ) } - GhostIconButton(onClick = ::onSkipPrevious) { + GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) { Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") } IconButton( @@ -320,11 +332,12 @@ fun AppLargePlayer( contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", ) } - GhostIconButton(onClick = ::onSkipNext) { + GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) { Icon(Iconsax.IconsaxNext, contentDescription = "Next") } VariableIconButton( onClick = ::onLoopToggle, + enabled = !isJamGuest, variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline ) { Icon( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index a3f7e1c3..b044e3a4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape @@ -41,6 +42,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.material3.rememberBottomSheetScaffoldState @@ -69,6 +72,9 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost +import dev.krtirtho.spotube.core.remote.RemotePlaybackController +import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel @@ -93,6 +99,13 @@ fun AppShell( content: @Composable () -> Unit, ) { val navigatorCommands: NavigationCommands = koinInject() + val remotePlaybackController: RemotePlaybackController = koinInject() + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(remotePlaybackController) { + remotePlaybackController.events.collect { message -> + snackbarHostState.showSnackbar(message) + } + } val isQueueVisible by queueViewModel.isQueueVisible.collectAsState() val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState() val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState() @@ -116,6 +129,9 @@ fun AppShell( } } + ConnectionRequestDialogHost() + PlayDestinationPickerHost() + Box(modifier = Modifier.fillMaxSize()) { val useSidebar = viewModel.useSidebar() val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar) @@ -258,6 +274,15 @@ fun AppShell( } } } + + // Drawn last so it floats above the players/sheets, just above the + // bottom overlay (large player or compact player + bottombar). + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = bottomOverlayInset + 12.dp), + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index b6e82e55..0c4a3c78 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -68,8 +68,10 @@ import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken +import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.tabs import org.jetbrains.compose.resources.Font import org.koin.compose.koinInject @@ -103,6 +105,7 @@ fun AppSidebar( }, horizontalAlignment = Alignment.CenterHorizontally ) { + Column(modifier = Modifier.weight(1f)) { Row( modifier = Modifier .fillMaxWidth() @@ -175,6 +178,24 @@ fun AppSidebar( ) } } + } + + SidebarItem( + label = "Devices", + activeIcon = Iconsax.IconsaxMirroringScreen, + onClick = { navigator.navigate(Routes.Devices) }, + selected = false, + expanded = expanded, + ) + SidebarItem( + label = "Group Jam", + activeIcon = Iconsax.User, + onClick = { navigator.navigate(Routes.Jam) }, + selected = false, + expanded = expanded, + ) + + Spacer(modifier = Modifier.height(120.dp)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 997da78f..0042df8d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -33,8 +34,12 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -52,6 +57,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import dev.krtirtho.spotube.core.jam.JamParticipant import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton @@ -59,12 +65,15 @@ import dev.krtirtho.spotube.core.ui.base.ListRowTile import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.copyShape +import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch +import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove import org.koin.compose.viewmodel.koinViewModel import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -78,12 +87,14 @@ fun PlayerQueueContent( val displayItems = state.displayItems val filterQuery = state.filterQuery val isFiltered = state.isFiltered + val isReadOnly = state.isReadOnly + var selectedParticipant by remember { mutableStateOf(null) } val lazyListState = rememberLazyListState() val reorderableLazyListState = rememberReorderableLazyListState( lazyListState, onMove = { from, to -> - if (isFiltered) return@rememberReorderableLazyListState + if (isFiltered || isReadOnly) return@rememberReorderableLazyListState viewModel.onMove(from.index, to.index) }, ) @@ -118,11 +129,13 @@ fun PlayerQueueContent( singleLine = true, modifier = Modifier.weight(1f), ) - IconButton( - onClick = viewModel::clearQueue, - theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small), - ) { - Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") + if (!isReadOnly) { + IconButton( + onClick = viewModel::clearQueue, + theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small), + ) { + Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") + } } } @@ -144,21 +157,141 @@ fun PlayerQueueContent( val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) QueueItemRow( item = item, - reorderScope = if (isFiltered) null else this, + reorderScope = if (isFiltered || isReadOnly) null else this, onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, onDragStarted = { viewModel.onDragStart() }, onDragStopped = { viewModel.onDragStop() }, + showOptions = !isReadOnly, + enabled = !isReadOnly, + onParticipantClick = { selectedParticipant = it }, ) } } } } } + + selectedParticipant?.let { participant -> + ParticipantDialog( + participant = participant, + isJamHost = state.isJamHost, + onDismiss = { selectedParticipant = null }, + onKick = { viewModel.kickParticipant(participant.id) }, + onBan = { viewModel.banParticipant(participant.id) }, + onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) }, + ) + } } } } +@Composable +private fun ParticipantDialog( + participant: JamParticipant, + isJamHost: Boolean, + onDismiss: () -> Unit, + onKick: () -> Unit, + onBan: () -> Unit, + onRemoveSuggestions: () -> Unit, +) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { Text(participant.displayName, style = MaterialTheme.typography.titleLarge) }, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(vertical = 8.dp), + ) { + ParticipantAvatar(participant, size = 40) + Text( + text = participant.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + if (participant.isHost) { + Text( + text = "Host", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + if (isJamHost && !participant.isHost) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ListRowTile( + onClick = { + onKick() + onDismiss() + }, + leading = { + Icon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { Text("Kick") }, + subtitle = { Text("Remove them from the session") }, + ) + ListRowTile( + onClick = { + onBan() + onDismiss() + }, + leading = { + Icon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { Text("Ban") }, + subtitle = { Text("Kick and prevent them from rejoining") }, + ) + ListRowTile( + onClick = { + onRemoveSuggestions() + onDismiss() + }, + leading = { + Icon( + imageVector = Iconsax.IconsaxMusicSquareRemove, + contentDescription = null, + ) + }, + title = { Text("Remove suggestions") }, + subtitle = { Text("Remove every track they added to the queue") }, + ) + } + } + } +} + +@Composable +private fun ParticipantAvatar(participant: JamParticipant, size: Int) { + Box( + modifier = Modifier + .size(size.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +} + @Composable private fun QueueItemRow( item: QueueItemUi, @@ -167,11 +300,15 @@ private fun QueueItemRow( onRemoveClick: () -> Unit, onDragStarted: () -> Unit, onDragStopped: () -> Unit, + showOptions: Boolean = true, + enabled: Boolean = true, + onParticipantClick: (JamParticipant) -> Unit = {}, ) { var showMenu by remember { mutableStateOf(false) } ListRowTile( onClick = onPlayClick, + enabled = enabled, selected = item.isCurrent, modifier = Modifier, leading = { @@ -255,31 +392,51 @@ private fun QueueItemRow( Spacer(modifier = Modifier.width(4.dp)) - Box { - GhostIconButton( - onClick = { showMenu = true }, - modifier = Modifier.size(36.dp), + item.addedByParticipant?.let { participant -> + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { onParticipantClick(participant) }, + contentAlignment = Alignment.Center, ) { - Icon( - Iconsax.Iconsax3DotsMore, - contentDescription = "More options", - modifier = Modifier.size(18.dp), + Text( + text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Remove from queue") }, - onClick = { - onRemoveClick() - showMenu = false - }, - leadingIcon = { - Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) - }, - ) + Spacer(modifier = Modifier.width(4.dp)) + } + + if (showOptions) { + Box { + GhostIconButton( + onClick = { showMenu = true }, + modifier = Modifier.size(36.dp), + ) { + Icon( + Iconsax.Iconsax3DotsMore, + contentDescription = "More options", + modifier = Modifier.size(18.dp), + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Remove from queue") }, + onClick = { + onRemoveClick() + showMenu = false + }, + leadingIcon = { + Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) + }, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index 71faa53b..a426a839 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -21,6 +21,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamParticipant +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -38,16 +41,23 @@ data class QueueItemUi( val isCurrent: Boolean, val imageUrl: String?, val originalIndex: Int, + /** Participant who added this item to the jam queue, if any. */ + val addedByParticipant: JamParticipant? = null, ) data class QueueContentUiState( val filterQuery: String = "", val displayItems: List = emptyList(), val isFiltered: Boolean = false, + /** Guests cannot reorder/remove/clear the shared jam queue. */ + val isReadOnly: Boolean = false, + val isJamHost: Boolean = false, + val participants: List = emptyList(), ) class PlayerQueueContentViewModel( private val audioPlayerQueue: AudioPlayerQueue, + private val jamRoomService: JamRoomService, ) : ViewModel() { private val queueVisibilityFlow = MutableStateFlow(false) private val queueFilterFlow = MutableStateFlow("") @@ -60,7 +70,8 @@ class PlayerQueueContentViewModel( private val computedItems: StateFlow> = combine( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, - ) { queue, currentEntry -> + jamRoomService.participants, + ) { queue, currentEntry, participants -> val currentIndex = if (currentEntry != null) { queue.indexOfFirst { it.matchesCurrent(currentEntry) } } else { @@ -68,7 +79,7 @@ class PlayerQueueContentViewModel( } queue.mapIndexed { index, entry -> val title: String - val subtitle: String + var subtitle: String val durationMs: Long val imageUrl: String? @@ -89,6 +100,11 @@ class PlayerQueueContentViewModel( } } + val addedByParticipant = participants.firstOrNull { it.id == entry.addedBy } + if (addedByParticipant != null) { + subtitle = "$subtitle • Added by ${addedByParticipant.displayName}" + } + QueueItemUi( id = "${entry.url}@$index", title = title, @@ -97,6 +113,7 @@ class PlayerQueueContentViewModel( isCurrent = index == currentIndex, imageUrl = imageUrl, originalIndex = index, + addedByParticipant = addedByParticipant, ) } }.stateIn( @@ -109,7 +126,9 @@ class PlayerQueueContentViewModel( computedItems, reorderBuffer, queueFilterFlow, - ) { items, buffer, filterQuery -> + jamRoomService.role, + jamRoomService.participants, + ) { items, buffer, filterQuery, role, participants -> val normalizedFilter = filterQuery.trim().lowercase() val isFiltered = normalizedFilter.isNotBlank() val filtered = if (isFiltered) { @@ -124,6 +143,9 @@ class PlayerQueueContentViewModel( filterQuery = filterQuery, displayItems = buffer ?: filtered, isFiltered = isFiltered, + isReadOnly = role == JamRole.Guest, + isJamHost = role == JamRole.Host, + participants = participants, ) }.stateIn( scope = viewModelScope, @@ -144,14 +166,14 @@ class PlayerQueueContentViewModel( } fun playQueueItem(index: Int) { - if (index < 0) return + if (index < 0 || queueContentUiState.value.isReadOnly) return viewModelScope.launch { audioPlayerQueue.jumpTo(index) } } fun removeQueueItem(index: Int) { - if (index < 0) return + if (index < 0 || queueContentUiState.value.isReadOnly) return viewModelScope.launch { val currentQueue = audioPlayerQueue.queueFlow.value if (index < currentQueue.size) { @@ -162,24 +184,49 @@ class PlayerQueueContentViewModel( fun moveQueueItem(fromIndex: Int, toIndex: Int) { if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return + if (queueContentUiState.value.isReadOnly) return viewModelScope.launch { audioPlayerQueue.move(fromIndex, toIndex) } } fun clearQueue() { + if (queueContentUiState.value.isReadOnly) return viewModelScope.launch { audioPlayerQueue.clear() } } + // ---------- Jam participant moderation (host only) ---------- + + fun kickParticipant(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { jamRoomService.kickParticipant(participantId) } + } + + fun banParticipant(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { jamRoomService.banParticipant(participantId) } + } + + /** Removes every queue item that the given participant suggested. */ + fun removeParticipantTracks(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { + val entries = audioPlayerQueue.queueFlow.value.filter { it.addedBy == participantId } + entries.forEach { audioPlayerQueue.removeFromQueue(it) } + } + } + fun onDragStart() { if (reorderBuffer.value != null) return + if (queueContentUiState.value.isReadOnly) return val currentItems = queueContentUiState.value.displayItems reorderBuffer.value = currentItems.toList() } fun onMove(from: Int, to: Int) { + if (queueContentUiState.value.isReadOnly) return val buffer = reorderBuffer.value ?: return if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return val item = buffer[from] diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt index a0e8220c..47206bab 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset private val SlidingSheetBreakpoint = 840.dp @@ -68,7 +69,11 @@ private fun SlidingQueueSheet( visible = isVisible, modifier = Modifier .align(Alignment.TopEnd) - .padding(top = 12.dp, end = 12.dp, bottom = 12.dp), + .padding( + top = 12.dp, + end = 12.dp, + bottom = 12.dp + LocalAppShellBottomInset.current, + ), enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(), exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(), ) { diff --git a/composeApp/src/commonMain/rust/lib.rs b/composeApp/src/commonMain/rust/lib.rs index b5ab652c..f8e0e84e 100644 --- a/composeApp/src/commonMain/rust/lib.rs +++ b/composeApp/src/commonMain/rust/lib.rs @@ -4,4 +4,4 @@ mod discord_rpc; pub use metadata::*; pub use discord_rpc::*; -uniffi::setup_scaffolding!(); +uniffi::setup_scaffolding!(); \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt new file mode 100644 index 00000000..4b02635f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt @@ -0,0 +1,24 @@ +/* + * 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 + +/** No runtime local-network permission needed on iOS. */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt new file mode 100644 index 00000000..68522b82 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt @@ -0,0 +1,24 @@ +/* + * 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 + +/** No runtime local-network permission needed on the JVM. */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt index 3e597a48..171f215b 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.window.rememberWindowState import com.sun.jna.Library import com.sun.jna.Native import dev.krtirtho.spotube.core.di.initKoin +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.core.systemtray.SystemTray @@ -81,9 +82,28 @@ private fun disableWebKitGpuCompositing() { LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1) } +/** + * Routes `spotube://` deep links into [ExternalUriHandler]. + * macOS delivers them through the open-URI handler; on Linux/Windows they arrive + * as command line arguments (scheme registration is handled by the distribution + * packaging, e.g. the `.desktop` file's `Exec %u`). + */ +private fun handleStartupDeepLinks(args: Array) { + runCatching { + if (java.awt.Desktop.isDesktopSupported()) { + java.awt.Desktop.getDesktop().setOpenURIHandler { event -> + ExternalUriHandler.onNewUri(event.uri.toString()) + } + } + } + args.firstOrNull { it.startsWith("spotube:", ignoreCase = true) } + ?.let(ExternalUriHandler::onNewUri) +} + @OptIn(ExperimentalComposeUiApi::class) -fun main() { +fun main(args: Array) { disableWebKitGpuCompositing() + handleStartupDeepLinks(args) FileKit.init(appId = "dev.krtirtho.spotube") initKoin() NewPipeDownloader.init(KoinPathsProvider.paths) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04084c5a..d0951e49 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ androidx-activity = "1.13.0" androidx-appcompat = "1.7.1" androidx-core = "1.19.0" androidx-espresso = "3.7.0" -androidx-lifecycle = "2.11.0" +androidx-lifecycle = "2.10.0" androidx-testExt = "1.3.0" appdirs = "1.5.0" cache4k = "0.14.0" @@ -44,6 +44,8 @@ kotlinx-io = "0.9.1" material3 = "1.10.0-alpha05" kotlinx-serialization-json = "1.11.0" materialKolor = "4.1.1" +mqttClient = "2.1.1" +mqttBuffer = "6.30.8" murmurhash = "0.4.2" newpipeextractor = "v0.26.2" newpipeExtractorKmp = "1.3.0" @@ -53,6 +55,7 @@ vlcj = "4.12.1" vlcjNative = "4.12.0" ziplineVersion = "1.27.0" ktor = "3.5.1" +dnssdkt = "1.1.0" kotlinStdlib = "2.4.10" runner = "1.7.0" core = "1.7.0" @@ -61,8 +64,8 @@ compose-webview = "1.0.1" composeNativeTray = "2.0.3" koin = "4.2.2" multiplatform-nav3-ui = "1.1.1" -compose-multiplatform-adaptive = "1.3.0-beta02" -compose-multiplatform-lifecycle = "2.11.0" +compose-multiplatform-adaptive = "1.3.0-alpha05" +compose-multiplatform-lifecycle = "2.10.0" feather-icons = "1.1.1" material3-window-size = "1.9.0" kotlinx-datetime = "0.8.0" @@ -85,6 +88,7 @@ androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } compose-placeholder-material3 = { module = "com.eygraber:compose-placeholder-material3", version.ref = "composePlaceholderMaterial3" } compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } +dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" } haze-blur = { module = "dev.chrisbanes.haze:haze-blur", version.ref = "haze" } @@ -117,7 +121,13 @@ kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-s kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } +ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } +mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClient" } +mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" } +mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" } +mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 11845e1d..a07e2e68 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,5 +4,23 @@ CADisableMinimumFrameDurationOnPhone + CFBundleURLTypes + + + CFBundleURLName + dev.krtirtho.spotube + CFBundleURLSchemes + + spotube + + + + NSLocalNetworkUsageDescription + Required to discover local network devices + NSBonjourServices + + _http._tcp + _spotube-ctrl._tcp + diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 08b58d47..f0058387 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -20,6 +20,9 @@ struct iOSApp: App { var body: some Scene { WindowGroup { ContentView() + .onOpenURL { url in + ExternalUriHandler.shared.onNewUri(uri: url.absoluteString) + } } } } \ No newline at end of file