This commit is contained in:
Kingkor Roy Tirtho 2026-09-12 04:09:11 +00:00 committed by GitHub
commit 8787dbc420
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 6600 additions and 371 deletions

View File

@ -221,6 +221,67 @@ jobs:
with:
limit-access-to-actor: true
build-linux-arm64:
name: Linux Desktop (aarch64)
needs: prepare-deps
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Download maven local
uses: actions/download-artifact@v4
with:
name: maven-local
path: ~/.m2/repository/
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Make gradlew executable
run: chmod +x gradlew
- name: Package Linux
run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm
- name: Rename Linux artifacts
run: |
DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1)
RPM_FILE=$(find composeApp/build -name "*.rpm" -type f | head -1)
cp "$DEB_FILE" Spotube-linux-aarch64.deb
cp "$RPM_FILE" Spotube-linux-aarch64.rpm
- name: Upload DEB
uses: actions/upload-artifact@v4
with:
name: linux-deb-arm64
path: Spotube-linux-aarch64.deb
- name: Upload RPM
uses: actions/upload-artifact@v4
with:
name: linux-rpm-arm64
path: Spotube-linux-aarch64.rpm
- name: Setup tmate session on failure
if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
build-windows:
name: Windows Desktop
needs: prepare-deps
@ -321,6 +382,7 @@ jobs:
needs:
- build-android
- build-linux
- build-linux-arm64
- build-windows
- build-macos
runs-on: ubuntu-latest
@ -352,7 +414,7 @@ jobs:
run: |
echo "## Downloads" > release-notes.md
echo "" >> release-notes.md
for dir in android-apk android-aab linux-deb linux-rpm linux-appimage windows-msi windows-exe macos-dmg; do
for dir in android-apk android-aab linux-deb linux-rpm linux-appimage linux-deb-arm64 linux-rpm-arm64 windows-msi windows-exe macos-dmg; do
if [ -d "$dir" ]; then
echo "### $dir" >> release-notes.md
for f in "$dir"/*; do
@ -376,6 +438,8 @@ jobs:
linux-deb/*.deb
linux-rpm/*.rpm
linux-appimage/*.AppImage
linux-deb-arm64/*.deb
linux-rpm-arm64/*.rpm
windows-msi/*.msi
windows-exe/*.exe
macos-dmg/*.dmg

110
composeApp/Cargo.lock generated
View File

@ -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",

View File

@ -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"]

View File

@ -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 {

View File

@ -16,13 +16,19 @@
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="33" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />

View File

@ -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)
}
}

View File

@ -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() {

View File

@ -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,12 +52,20 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private val appContext: Context = (context as Context).applicationContext
private fun ensureServiceStarted() {
// 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)
}
}
private val exoPlayer: ExoPlayer = ExoPlayer.Builder(appContext)
@ -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)
}

View File

@ -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<AudioPlayerInterface> { AudioPlayer(get<Context>()) }
single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) }
single<ShareService> { AndroidShareService(get()) }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}

View File

@ -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
}
}
}

View File

@ -86,7 +86,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
.setOngoing(true)
.build()
// 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())

View File

@ -96,6 +96,13 @@
<string name="settings_enable_connect_title">Enable Connect</string>
<string name="settings_enable_connect_subtitle">Expose remote playback controls through the Spotube Connect feature.</string>
<string name="settings_playback_port_title">Playback proxy server port</string>
<string name="settings_allow_remote_control_title">Allow remote control</string>
<string name="settings_allow_remote_control_subtitle">Let other devices on the same network control playback when they connect.</string>
<string name="settings_remote_device_name_title">Remote control device name</string>
<string name="settings_remote_device_name_subtitle">The name other devices see when discovering this one. Current: %1$s</string>
<string name="settings_remote_device_name_description">Choose a friendly name to show to other devices on your network when they discover this Spotube instance.</string>
<string name="settings_remote_device_name_placeholder">e.g. Living Room Spotube</string>
<string name="settings_remote_device_name_default">device hostname</string>
<string name="settings_playback_port_subtitle_current">Port used by the playback proxy server. Current: %1$d</string>
<string name="settings_playback_port_description">Choose a port between 1 and 65535.</string>
<string name="settings_playback_port_placeholder">14769</string>
@ -166,5 +173,19 @@
<string name="plugin_permissions_capability_network_desc">Send and receive data over the internet</string>
<string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Display web content inside the app</string>
<string name="settings_section_jam">Group Jam</string>
<string name="settings_jam_broker_title">MQTT Broker</string>
<string name="settings_jam_broker_host">Broker host</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Broker port</string>
<string name="settings_jam_broker_tls">Use TLS</string>
<string name="settings_jam_broker_username">Username (optional)</string>
<string name="settings_jam_broker_password">Password (optional)</string>
<string name="settings_jam_broker_client_id">Client ID prefix</string>
<string name="settings_jam_broker_test">Test connection</string>
<string name="settings_jam_broker_testing">Testing…</string>
<string name="settings_jam_broker_test_ok">Connected in %1$d ms</string>
<string name="settings_jam_broker_test_fail">Failed: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Placeholder broker — configure your own to self-host</string>
</resources>

View File

@ -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

View File

@ -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
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}

View File

@ -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<QueueStateRepository>() }
single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<DiscoveryState> = 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),
)
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<JamMediaItem>,
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<JamMediaItem>,
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,
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Boolean> = _isConnected.asStateFlow()
private val _connectionError = MutableStateFlow<String?>(null)
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
private val _state = MutableSharedFlow<JamMessage.QueueState>(replay = 1, extraBufferCapacity = 8)
val state: SharedFlow<JamMessage.QueueState> = _state.asSharedFlow()
private val _commands = MutableSharedFlow<JamMessage>(extraBufferCapacity = 32)
val commands: SharedFlow<JamMessage> = _commands.asSharedFlow()
private val _presence = MutableStateFlow<Map<String, JamPresence>>(emptyMap())
val presence: StateFlow<Map<String, JamPresence>> = _presence.asStateFlow()
// ---------- Public API ----------
/** One-off connection check used by the settings screen. Returns latency description. */
suspend fun testConnection(broker: JamBroker): Result<String> {
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<Unit> {
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/#"
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 }
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<QueueEntry>,
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<JamRole?>(null)
val role: StateFlow<JamRole?> = _role.asStateFlow()
private val _participants = MutableStateFlow<List<JamParticipant>>(emptyList())
val participants: StateFlow<List<JamParticipant>> = _participants.asStateFlow()
private val _roomCode = MutableStateFlow<String?>(null)
val roomCode: StateFlow<String?> = _roomCode.asStateFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _connectionError = MutableStateFlow<String?>(null)
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
private val _shuffleEnabled = MutableStateFlow(false)
val shuffleEnabled: StateFlow<Boolean> = _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<JamMediaItem> = 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<String>()
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<String> {
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<Unit> {
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<MetadataTrack>) {
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<JamMediaItem>) {
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<String, JamPresence>) {
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
}
}

View File

@ -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<Routes.Blacklist> {
BlacklistScreen()
}
navigation<Routes.Devices> {
DevicesScreen(navigationCommands = get())
}
navigation<Routes.RemoteControl> {
RemoteControlScreen(
onDisconnect = { get<NavigationCommands>().pop() }
)
}
navigation<Routes.Jam> {
JamScreen(navigationCommands = get())
}
}

View File

@ -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())
}
}
}

View File

@ -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<MetadataTrack> =
when (type) {
RemoteCollectionType.Playlist -> fetchAllPlaylistTracks(id).asTracks()
RemoteCollectionType.Album -> fetchAllAlbumTracks(id).asTracks()
RemoteCollectionType.ArtistTopTracks -> fetchArtistTopTracks(id).asTracks()
RemoteCollectionType.SavedTracks -> fetchAllSavedTracks().asTracks()
}
private fun List<QueueEntry>.asTracks(): List<MetadataTrack> =
mapNotNull { (it as? QueueEntry.StreamingTrack)?.track }
private suspend fun fetchAllSavedTracks(): List<QueueEntry> {
val allTracks = mutableListOf<MetadataTrack>()
var pagination = savedTracksRepository.getSavedTracks()
@ -243,6 +301,15 @@ class CollectionPlaybackHelper(
}
}
private suspend fun fetchArtistTopTracks(artistId: String): List<QueueEntry> {
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<String>,

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<ConnectionRequest?>(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")
}
},
)
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _latestPlayerState = MutableStateFlow<RemoteControlEvent.PlayerState?>(null)
val latestPlayerState: StateFlow<RemoteControlEvent.PlayerState?> = _latestPlayerState.asStateFlow()
private val _latestQueue = MutableStateFlow<RemoteControlEvent.QueueUpdated?>(null)
val latestQueue: StateFlow<RemoteControlEvent.QueueUpdated?> = _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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<RemoteControlHandler>()
val incomingConnectionRequests = MutableSharedFlow<ConnectionRequest>(
extraBufferCapacity = 16,
)
private val json = Json {
ignoreUnknownKeys = true
classDiscriminator = "type"
encodeDefaults = true
}
private val pendingRequestResolutions = mutableMapOf<String, (ConnectionRequestResponse) -> 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,
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<MetadataTrack>) : RemoteControlCommand()
@Serializable
@SerialName("playTracksNext")
data class PlayTracksNext(val tracks: List<MetadataTrack>) : 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<RemoteQueueEntry>,
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,
)

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> = _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
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<MetadataTrack>,
) : 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<PlaybackDestinationRequest?>(null)
val pendingRequest: StateFlow<PlaybackDestinationRequest?> = _pendingRequest.asStateFlow()
/** One-shot user-facing messages (e.g. "added to jam queue") for a snackbar host. */
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 8)
val events: SharedFlow<String> = _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<MetadataTrack>, title: String) {
if (tracks.isEmpty()) return
request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.AddToQueue, tracks))
}
fun requestTracksPlayNext(tracks: List<MetadataTrack>, 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<MetadataTrack>) {
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 } }
}
}

View File

@ -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<LocalServer>()
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<Int?>(null)
val port = activePort.asStateFlow()
private val activeHost = MutableStateFlow<String?>(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)
}
}
}
}

View File

@ -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,
@ -487,10 +494,17 @@ fun GroupIconButton(
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxSize()
.alpha(if (enabled) 1f else DisabledContentAlpha),
contentAlignment = Alignment.Center,
) {
content()
}
}
}
@Composable
fun ButtonGroup(

View File

@ -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}",

View File

@ -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,7 +94,28 @@ fun CollectionDetails(
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
val playPauseButton = @Composable {
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),
@ -127,6 +151,7 @@ fun CollectionDetails(
}
}
}
}
val actions = @Composable {
ButtonGroup {

View File

@ -87,6 +87,10 @@ fun CollectionView(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
isInJam: Boolean = false,
isJamGuest: Boolean = false,
onAddToJam: (List<MetadataTrack>) -> 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,
)
}

View File

@ -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}",

View File

@ -143,6 +143,9 @@ fun TrackList(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
onBulkAddToJam: (List<MetadataTrack>) -> 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(
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 = {},

View File

@ -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<AdaptiveMenuItem> = buildList {
if (isInJam) {
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare,
label = "Add to Jam",
onClick = { onAction(TrackOptionsAction.AddToJam) },
),
)
}
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicCircle,
@ -250,6 +271,9 @@ private fun buildTrackMenuItems(
),
)
// 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(
@ -285,6 +309,7 @@ private fun buildTrackMenuItems(
),
)
}
}
add(
AdaptiveMenuItem(

View File

@ -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,

View File

@ -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<AlbumViewModel>()
@ -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) }
}
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<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
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<MetadataTrack>) {
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 {

View File

@ -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,
)
}

View File

@ -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<ArtistViewModel>()
@ -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<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
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<MetadataTrack>) {
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) }
}
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()

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<DevicesViewModel>()
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,
)
}
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Map<String, DiscoveredDevice>>(emptyMap())
val devices: StateFlow<Map<String, DiscoveredDevice>> = _devices.asStateFlow()
private val _isDiscovering = MutableStateFlow(false)
val isDiscovering: StateFlow<Boolean> = _isDiscovering.asStateFlow()
private val _connectingToDevice = MutableStateFlow<DiscoveredDevice?>(null)
val connectingToDevice: StateFlow<DiscoveredDevice?> = _connectingToDevice.asStateFlow()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _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
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<RemotePlaybackController>()
val remoteControlClient = koinInject<RemoteControlClient>()
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")
}
},
)
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<RemoteControlViewModel>()
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')}"
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<RemoteQueueEntry> = 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<RemotePlayerState> = _playerState.asStateFlow()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _queueState = MutableStateFlow(RemoteQueueState())
val queueState: StateFlow<RemoteQueueState> = _queueState.asStateFlow()
private val _isQueueVisible = MutableStateFlow(false)
val isQueueVisible: StateFlow<Boolean> = _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()
}
}
}

View File

@ -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<NavigationCommands>()
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 ->

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<JamViewModel>()
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<dev.krtirtho.spotube.core.jam.JamParticipant>,
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")
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<JamParticipant> = 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<String?>(null)
val supportsNativeShare: Boolean =
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
val uiState: StateFlow<JamUiState> = 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<JamParticipant>
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) }
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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")
}
},
)
}

View File

@ -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

View File

@ -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<PlaylistViewModel>()
@ -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) }
}
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<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
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<MetadataTrack>) {
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

View File

@ -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,

View File

@ -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<SavedTracksViewModel>()
@ -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) }
}
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<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
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<MetadataTrack>) {
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<String>): List<Boolean> {

View File

@ -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) }
}
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<MetadataTrack>) {
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 }
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results")
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllToQueue(entries)
}
fun bulkAddToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun bulkPlayNext(tracks: List<MetadataTrack>) {
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<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> 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<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> 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,

View File

@ -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<String> = 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<String> = 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,
)

View File

@ -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(

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<JamRoomClient>()
val scope = rememberCoroutineScope()
var testing by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<String?>(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),
)
},
)
)
}

View File

@ -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)

View File

@ -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

View File

@ -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(

View File

@ -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),
)
}
}

View File

@ -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()
@ -176,6 +179,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))
}
}
@Composable

View File

@ -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<JamParticipant?>(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,6 +129,7 @@ fun PlayerQueueContent(
singleLine = true,
modifier = Modifier.weight(1f),
)
if (!isReadOnly) {
IconButton(
onClick = viewModel::clearQueue,
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
@ -125,6 +137,7 @@ fun PlayerQueueContent(
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
}
}
}
if (displayItems.isEmpty()) {
Text(
@ -144,20 +157,140 @@ 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(
@ -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,6 +392,25 @@ private fun QueueItemRow(
Spacer(modifier = Modifier.width(4.dp))
item.addedByParticipant?.let { participant ->
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { onParticipantClick(participant) },
contentAlignment = Alignment.Center,
) {
Text(
text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(4.dp))
}
if (showOptions) {
Box {
GhostIconButton(
onClick = { showMenu = true },
@ -283,5 +439,6 @@ private fun QueueItemRow(
}
}
}
}
)
}

View File

@ -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<QueueItemUi> = 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<JamParticipant> = 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<List<QueueItemUi>> = 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]

View File

@ -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(),
) {

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.discovery
import androidx.compose.runtime.Composable
/** No runtime local-network permission needed on iOS. */
@Composable
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 = {}

View File

@ -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<String>) {
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<String>) {
disableWebKitGpuCompositing()
handleStartupDeepLinks(args)
FileKit.init(appId = "dev.krtirtho.spotube")
initKoin()
NewPipeDownloader.init(KoinPathsProvider.paths)

View File

@ -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" }

View File

@ -4,5 +4,23 @@
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>dev.krtirtho.spotube</string>
<key>CFBundleURLSchemes</key>
<array>
<string>spotube</string>
</array>
</dict>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Required to discover local network devices</string>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
<string>_spotube-ctrl._tcp</string>
</array>
</dict>
</plist>

View File

@ -20,6 +20,9 @@ struct iOSApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
ExternalUriHandler.shared.onNewUri(uri: url.absoluteString)
}
}
}
}