feat(jam-session): integrate MQTT broker configuration and enhance jam session settings

This commit is contained in:
Kingkor Roy Tirtho 2026-09-11 19:35:54 +06:00
parent 571aea8d38
commit a2b9f4178c
28 changed files with 1361 additions and 4485 deletions

2157
composeApp/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,10 +10,6 @@ discord-rich-presence = "1.1.0"
thiserror = "2.0"
parking_lot = "0.12"
log = "0.4"
webrtc = "0.20.4"
rtc = "0.20.4"
async-trait = "0.1"
bytes = "1"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
[lib]

View File

@ -166,6 +166,12 @@ kotlin {
// 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

@ -55,17 +55,6 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Group Jam invite/answer deep links: spotube://jam/... -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="jam"
android:scheme="spotube" />
</intent-filter>
</activity>
<service
android:name=".media.PlaybackService"

View File

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

@ -33,8 +33,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay
import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler
import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
@ -106,12 +104,6 @@ fun App(
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
val jamDeepLinks: JamDeepLinkService = koinInject()
DisposableEffect(Unit) {
ExternalUriHandler.listener = { uri -> jamDeepLinks.handleUri(uri) }
onDispose { ExternalUriHandler.listener = null }
}
val navigationState = rememberNavigationState(
startRoute = Routes.Home,
topLevelRoutes = TOP_LEVEL_ROUTES

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

@ -1,50 +0,0 @@
/*
* 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
import dev.krtirtho.spotube.core.jam.JamInviteCodec
import dev.krtirtho.spotube.core.jam.JamInviteLink
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Parses incoming `spotude://jam/...` deep links, exposes them to the Jam UI,
* and navigates to [Routes.Jam] so the user lands where the link is handled.
*/
class JamDeepLinkService(
private val navigationCommands: NavigationCommands,
) {
private val _pendingLink = MutableStateFlow<JamInviteLink?>(null)
val pendingLink: StateFlow<JamInviteLink?> = _pendingLink.asStateFlow()
fun handleUri(uri: String) {
val link = JamInviteCodec.parse(uri) ?: return
_pendingLink.value = link
navigationCommands.navigateTo(Routes.Jam)
}
/** Consumes the currently pending link (if any). */
fun consume(): JamInviteLink? = _pendingLink.value.also { _pendingLink.value = null }
fun clear() {
_pendingLink.value = null
}
}

View File

@ -23,10 +23,10 @@ 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.deeplink.JamDeepLinkService
import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService
import dev.krtirtho.spotube.core.discord.DiscordRpcService
import dev.krtirtho.spotube.core.jam.JamSessionService
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
@ -185,12 +185,9 @@ val sharedModules = module {
viewModelOf(::RemoteControlViewModel)
viewModel {
JamViewModel(
jamSession = get(),
deepLinks = get(),
jamRoomService = get(),
shareService = get(),
settingsProvider = get(),
audioPlayer = get(),
audioPlayerQueue = get(),
)
}
@ -239,8 +236,8 @@ val sharedModules = module {
createdAtStart()
}
single { RemotePlaybackController(get(), get(), get(), get(), get()) }
single { JamSessionService(get(), get(), get()) }
singleOf(::JamDeepLinkService)
singleOf(::JamRoomClient)
single { JamRoomService(get(), get(), get(), get()) }
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())

View File

@ -1,105 +0,0 @@
/*
* 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 io.ktor.http.decodeURLQueryComponent
import io.ktor.http.encodeURLParameter
/**
* SDP payloads exchanged between jam peers are wrapped into `spotube://` deep links
* so they can be shared through any messaging medium. The SDP blob is percent-encoded
* as a query parameter.
*
* Host invite : `spotube://jam/invite?name=<host name>&sdp=<offer sdp>`
* Guest answer : `spotube://jam/answer?name=<guest name>&sdp=<answer sdp>`
*/
sealed interface JamInviteLink {
val peerName: String
val sdp: String
data class HostInvite(
override val peerName: String,
override val sdp: String,
) : JamInviteLink
data class GuestAnswer(
override val peerName: String,
override val sdp: String,
) : JamInviteLink
}
object JamInviteCodec {
const val SCHEME = "spotube"
const val INVITE_PATH = "jam/invite"
const val ANSWER_PATH = "jam/answer"
fun buildHostInvite(hostName: String, offerSdp: String): String =
buildLink(INVITE_PATH, hostName, offerSdp)
fun buildGuestAnswer(guestName: String, answerSdp: String): String =
buildLink(ANSWER_PATH, guestName, answerSdp)
private fun buildLink(path: String, peerName: String, sdp: String): String =
"$SCHEME://$path?name=${peerName.encodeURLParameter()}" +
"&sdp=${sdp.encodeURLParameter()}"
/**
* Parses a `spotude://jam/...` link. Returns null for foreign or malformed URIs.
* Parsing is done manually generic URI parsers normalize unknown schemes in
* ways that mangle percent-encoded multi-line payloads.
*/
fun parse(rawUri: String): JamInviteLink? {
val uri = rawUri.trim()
if (!uri.startsWith("$SCHEME://", ignoreCase = true)) return null
val withoutScheme = uri.substring(SCHEME.length + 3)
val queryStart = withoutScheme.indexOf('?')
if (queryStart < 0) return null
val path = withoutScheme.take(queryStart).trim('/').lowercase()
val params = withoutScheme.substring(queryStart + 1)
.split('&')
.mapNotNull { pair ->
val separator = pair.indexOf('=')
if (separator <= 0) return@mapNotNull null
pair.take(separator) to pair.substring(separator + 1)
}
.toMap()
val sdp = params["sdp"]?.decodeURLQueryComponent()?.takeIf { it.isNotBlank() }
?: return null
val peerName = params["name"]?.decodeURLQueryComponent().orEmpty()
return when (path) {
INVITE_PATH -> JamInviteLink.HostInvite(peerName, sdp)
ANSWER_PATH -> JamInviteLink.GuestAnswer(peerName, sdp)
else -> null
}
}
/**
* Extracts an SDP payload from user input which may either be a full
* `spotube://` deep link or a raw SDP body pasted by hand.
*/
fun extractSdp(rawInput: String): String? {
val input = rawInput.trim()
parse(input)?.let { return it.sdp }
// Heuristic for raw SDP: first line is the session description header
return if (input.startsWith("v=", ignoreCase = false)) input else null
}
}

View File

@ -18,61 +18,46 @@
package dev.krtirtho.spotube.core.jam
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.LoopState
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("hello")
data class Hello(
val displayName: String,
val deviceId: String,
) : JamMessage()
@Serializable
@SerialName("welcome")
data class Welcome(
val hostName: String,
val participantId: String,
) : JamMessage()
@Serializable
@SerialName("queueState")
data class QueueState(
val items: List<JamMediaItem>,
val currentIndex: Int,
val isPlaying: Boolean,
val positionMs: Long,
val shuffleEnabled: Boolean = false,
) : JamMessage()
@Serializable
@SerialName("playbackCommand")
data class PlaybackCommand(
val command: PlaybackCmd,
) : JamMessage()
data class PlaybackCommand(val command: PlaybackCmd) : JamMessage()
@Serializable
@SerialName("suggestTrack")
data class SuggestTrack(val mediaItem: JamMediaItem) : JamMessage()
@Serializable
@SerialName("suggestPlaylist")
data class SuggestPlaylist(val tracks: List<JamMediaItem>) : JamMessage()
@Serializable
@SerialName("chat")
data class Chat(
val fromName: String,
val text: String,
data class SuggestTrack(
val mediaItem: JamMediaItem,
val addedBy: String = "",
) : JamMessage()
@Serializable
@SerialName("participantList")
data class ParticipantList(val participants: List<JamParticipant>) : JamMessage()
@SerialName("suggestPlaylist")
data class SuggestPlaylist(
val tracks: List<JamMediaItem>,
val addedBy: String = "",
) : JamMessage()
@Serializable
@SerialName("kick")
@ -86,24 +71,12 @@ sealed class JamMessage {
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("play")
data object Play : PlaybackCmd()
@Serializable
@SerialName("pause")
data object Pause : PlaybackCmd()
@Serializable
@SerialName("toggle")
data object Toggle : PlaybackCmd()
@Serializable
@SerialName("seek")
data class Seek(val positionMs: Long) : PlaybackCmd()
@Serializable
@SerialName("skipNext")
data object SkipNext : PlaybackCmd()
@ -112,23 +85,20 @@ sealed class PlaybackCmd {
@SerialName("skipPrevious")
data object SkipPrevious : PlaybackCmd()
@Serializable
@SerialName("setVolume")
data class SetVolume(val volume: Float) : PlaybackCmd()
@Serializable
@SerialName("setLoop")
data class SetLoop(val loop: String) : PlaybackCmd()
@Serializable
@SerialName("setShuffle")
data class SetShuffle(val enabled: Boolean) : 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,
@ -139,6 +109,7 @@ data class JamMediaItem(
val durationMs: Long,
val coverUrl: String,
val protocol: String,
val addedBy: String = "",
) {
companion object {
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
@ -153,6 +124,7 @@ data class JamMediaItem(
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
.orEmpty(),
protocol = entry.protocol.name,
addedBy = entry.addedBy,
)
is QueueEntry.LocalTrack -> JamMediaItem(
@ -164,6 +136,7 @@ data class JamMediaItem(
durationMs = entry.duration,
coverUrl = "",
protocol = "PROGRESSIVE",
addedBy = entry.addedBy,
)
}
@ -215,13 +188,3 @@ enum class JamRole {
Host,
Guest,
}
object JamLoopMapping {
fun toString(state: LoopState): String = state.name.lowercase()
fun fromString(value: String): LoopState = when (value.lowercase()) {
"none" -> LoopState.NONE
"one" -> LoopState.ONE
"all" -> LoopState.ALL
else -> LoopState.NONE
}
}

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,475 @@
/*
* 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
/**
* 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.
*/
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 = ""
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: 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)
}
// ---------- 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
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
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()
}
// ---------- 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 = localDisplayName,
)
)
}
suspend fun suggestPlaylist(tracks: List<MetadataTrack>) {
if (tracks.isEmpty()) return
jamClient.publishCommand(
JamMessage.SuggestPlaylist(
tracks = tracks.map(JamMediaItem::fromTrack),
addedBy = localDisplayName,
)
)
}
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,
) { queue, current, shuffle -> Triple(queue, current, shuffle) }
.onEach { (queue, current, shuffle) ->
if (_role.value != JamRole.Host) return@onEach
val index = if (current != null) {
queue.indexOfFirst { it.matchesEntry(current) }
} else {
-1
}
_shuffleEnabled.value = shuffle
jamClient.publishState(
JamMessage.QueueState(
items = queue.map(JamMediaItem::fromQueueEntry),
currentIndex = index.coerceAtLeast(0),
shuffleEnabled = shuffle,
)
)
}
.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) }
val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING
if (items != lastAppliedItems) {
lastAppliedItems = items
lastAppliedIndex = state.currentIndex
runCatching {
audioPlayerQueue.load(
entries = items.map { it.toQueueEntry() },
autoPlay = wasPlaying,
startPosition = state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)),
)
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
return
}
if (state.currentIndex != lastAppliedIndex) {
lastAppliedIndex = state.currentIndex
// Queue moved on: follow it, but keep this device's play/pause state.
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))
}
is JamMessage.SuggestPlaylist -> {
if (_role.value == JamRole.Host) acceptSuggestion(message.tracks)
}
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 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
}
companion object {
private const val HOST_TAKEOVER_DELAY_MS = 1_500L
}
}

View File

@ -1,587 +0,0 @@
/*
* 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.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent
import uniffi.compose_app.IceServerConfig
import uniffi.compose_app.WebrtcEventHandler
import uniffi.compose_app.WebrtcPeerConnection
import uniffi.compose_app.createWebrtcPeerConnection
/**
* An invite generated by the host for one guest slot. The [sdp] offer is shared
* via a deep link; once the guest's answer comes back, [JamSessionService.acceptAnswer]
* completes the handshake for that slot.
*/
data class JamInvite(
val id: String,
val sdp: String,
)
/**
* Owns the peer connections of a jam session (star topology: host relays state
* to all guests) and the hello/welcome handshake, participant bookkeeping and
* kick/ban. Playback & queue synchronization itself is delegated to
* [QueueSyncManager], which runs while a session is active.
*/
class JamSessionService(
private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue,
private val settingsProvider: SettingsProvider,
) : KoinComponent {
val logger by injectLogger<JamSessionService>()
private val log = Logger.withTag("JamSessionService")
/**
* Playback/queue synchronization. Owned by this service (not a Koin bean) so
* the two don't form a circular dependency; it's started/stopped with the
* session lifecycle.
*/
private val queueSyncManager = QueueSyncManager(
audioPlayer = audioPlayer,
audioPlayerQueue = audioPlayerQueue,
jamSession = this,
)
private val json = Json {
ignoreUnknownKeys = true
classDiscriminator = "type"
encodeDefaults = true
}
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 _isActive = MutableStateFlow(false)
val isActive: StateFlow<Boolean> = _isActive.asStateFlow()
private val _localParticipantId = MutableStateFlow<String?>(null)
val localParticipantId: StateFlow<String?> = _localParticipantId.asStateFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _incomingMessages = MutableSharedFlow<JamMessage>(extraBufferCapacity = 64)
val incomingMessages = _incomingMessages.asSharedFlow()
private val _incomingSuggestions = MutableSharedFlow<JamMessage>(extraBufferCapacity = 32)
val incomingSuggestions = _incomingSuggestions.asSharedFlow()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/** Host side: invites whose answers have not arrived yet. */
private val pendingInvites = mutableMapOf<String, WebrtcPeerConnection>()
/** Host side: guests whose handshake completed. Keyed by invite id. */
private val connectedGuests = mutableMapOf<String, WebrtcPeerConnection>()
/** Host side: guest device ids, used for bans. */
private val guestDeviceIds = mutableMapOf<String, String>()
/** Host side: latest RTCPeerConnection state per guest ("connecting", "connected", "failed"...). */
private val guestConnectionStates = mutableMapOf<String, String>()
/** Host side: device ids banned for this session. */
private val bannedDeviceIds = mutableSetOf<String>()
/** Guest side: the single connection to the host. */
private var hostConnection: WebrtcPeerConnection? = null
private var hostDisplayName: String = "Host"
private var guestDisplayName: String = "Guest"
suspend fun createSession(): String {
log.i { "Creating jam session" }
hostDisplayName = resolveParticipantName(defaultPrefix = "Host")
_role.value = JamRole.Host
_localParticipantId.value = "host"
_participants.value = listOf(
JamParticipant(
id = "host",
displayName = hostDisplayName,
isHost = true,
)
)
_isActive.value = true
queueSyncManager.start()
return generateInvite().sdp
}
/**
* Generates a new invite (peer connection + SDP offer with bundled ICE candidates).
* Each invite admits exactly one guest.
*/
suspend fun generateInvite(): JamInvite {
if (_role.value != JamRole.Host) {
error("generateInvite can only be called by the host")
}
val inviteId = "guest-${randomShortId()}"
log.i { "Generating invite $inviteId" }
val pc = createWebrtcPeerConnection(
iceServers = defaultIceServers(),
handler = guestEventHandler(inviteId),
)
pc.createDataChannel(CHANNEL_LABEL)
val offer = pc.createOffer()
pendingInvites[inviteId] = pc
return JamInvite(id = inviteId, sdp = offer)
}
/**
* Completes a guest's handshake: applies their SDP answer to the peer connection
* created for [inviteId] and adds them to the participant list.
*
* When [inviteId] is null, the oldest still-pending invite is used convenient
* when an answer deep link arrives out of band.
*
* @param answerSdp raw SDP answer body (not a deep link).
* @param peerName display name of the guest, taken from their answer link if available.
*/
suspend fun acceptAnswer(inviteId: String?, answerSdp: String, peerName: String): Boolean {
if (_role.value != JamRole.Host) {
log.w { "acceptAnswer ignored: not hosting" }
return false
}
val resolvedId = inviteId ?: pendingInvites.keys.firstOrNull()
if (resolvedId == null) {
log.w { "acceptAnswer: no pending invite" }
return false
}
val pc = pendingInvites.remove(resolvedId)
if (pc == null) {
log.w { "acceptAnswer: no pending invite '$resolvedId'" }
return false
}
runCatching { pc.setRemoteAnswer(answerSdp) }
.onFailure { e ->
log.w(e) { "Failed to apply answer for $inviteId" }
scope.launch { runCatching { pc.shutdown() } }
return false
}
connectedGuests[resolvedId] = pc
_participants.update { current ->
current + JamParticipant(
id = resolvedId,
displayName = peerName.ifBlank { "Guest-${resolvedId.takeLast(4)}" },
isHost = false,
)
}
log.i { "Guest $resolvedId ($peerName) joined" }
broadcastParticipantList()
return true
}
suspend fun joinSession(offerSdp: String, hostName: String? = null): String {
log.i { "Joining jam session" }
guestDisplayName = resolveParticipantName(defaultPrefix = "Guest")
val pc = createWebrtcPeerConnection(
iceServers = defaultIceServers(),
handler = eventHandler,
)
hostConnection = pc
_role.value = JamRole.Guest
_localParticipantId.value = null
_participants.value = listOf(
JamParticipant(
id = "host",
displayName = hostName?.ifBlank { null } ?: "Host",
isHost = true,
)
)
_isActive.value = true
queueSyncManager.start()
// The data channel arrives in-band from the host's offer via on_data_channel;
// we only answer here.
pc.setRemoteOffer(offerSdp)
val answer = pc.createAnswer()
log.i { "Generated SDP answer (length=${answer.length})" }
return answer
}
suspend fun sendMessage(message: JamMessage, guestId: String? = null) {
val payload = json.encodeToString(JamMessage.serializer(), message)
when (_role.value) {
JamRole.Host -> {
if (guestId != null) {
val pc = connectedGuests[guestId] ?: return
runCatching { pc.sendData(CHANNEL_LABEL, payload) }
.onFailure { e ->
log.w(e) { "Failed to send to guest $guestId" }
onSendFailure(guestId)
}
} else {
val dead = mutableListOf<String>()
connectedGuests.forEach { (id, pc) ->
runCatching { pc.sendData(CHANNEL_LABEL, payload) }
.onFailure { e ->
log.w(e) { "Failed to send to guest $id" }
dead += id
}
}
dead.forEach { id -> onSendFailure(id) }
}
}
JamRole.Guest -> {
runCatching { hostConnection?.sendData(CHANNEL_LABEL, payload) }
.onFailure { e ->
log.w(e) { "Failed to send to host" }
}
}
null -> log.w { "sendMessage called while no session is active" }
}
}
/**
* A send to a guest failed. If that guest's connection has already given up
* (failed/closed), drop them from the session; while the connection is merely
* "connecting" the channel may simply not be open yet, so keep them.
*/
private fun onSendFailure(guestId: String) {
val state = guestConnectionStates[guestId]
if (state == "failed" || state == "closed" || state == "disconnected") {
scope.launch { removeGuest(guestId) }
}
}
suspend fun leave() {
log.i { "Leaving jam session" }
queueSyncManager.stop()
runCatching { sendMessage(JamMessage.Leave()) }
shutdownAll()
_role.value = null
_participants.value = emptyList()
_isActive.value = false
_isConnected.value = false
_localParticipantId.value = null
guestDeviceIds.clear()
guestConnectionStates.clear()
bannedDeviceIds.clear()
}
suspend fun broadcastPlaybackCommand(command: PlaybackCmd) {
if (_role.value != JamRole.Host) return
sendMessage(JamMessage.PlaybackCommand(command))
}
suspend fun broadcastQueueState(
items: List<JamMediaItem>,
currentIndex: Int,
isPlaying: Boolean,
positionMs: Long,
) {
if (_role.value != JamRole.Host) return
sendMessage(JamMessage.QueueState(items, currentIndex, isPlaying, positionMs))
}
suspend fun suggestTrack(mediaItem: JamMediaItem) {
if (_role.value != JamRole.Guest) return
sendMessage(JamMessage.SuggestTrack(mediaItem))
}
suspend fun suggestPlaylist(tracks: List<JamMediaItem>) {
if (_role.value != JamRole.Guest) return
sendMessage(JamMessage.SuggestPlaylist(tracks))
}
// ---------- Host moderation ----------
suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") {
if (_role.value != JamRole.Host) return
log.i { "Kicking participant $participantId" }
sendMessage(JamMessage.Kick(participantId, reason), guestId = participantId)
removeGuest(participantId)
}
suspend fun banParticipant(participantId: String) {
if (_role.value != JamRole.Host) return
val deviceId = guestDeviceIds[participantId]
if (deviceId != null) {
bannedDeviceIds += deviceId
log.i { "Banning device $deviceId (participant $participantId)" }
}
kickParticipant(participantId, "banned by host")
}
private suspend fun removeGuest(guestId: String) {
val pc = connectedGuests.remove(guestId)
runCatching { pc?.shutdown() }
guestDeviceIds.remove(guestId)
guestConnectionStates.remove(guestId)
_participants.update { current ->
current.filterNot { it.id == guestId }
}
broadcastParticipantList()
}
private suspend fun broadcastParticipantList() {
if (_role.value != JamRole.Host) return
sendMessage(JamMessage.ParticipantList(_participants.value))
}
/**
* ICE servers for global peer-to-peer jam sessions: multiple STUN servers for
* NAT traversal plus a TURN relay for symmetric NATs and strict firewalls.
*/
private fun defaultIceServers(): List<IceServerConfig> = listOf(
IceServerConfig(
urls = listOf(
"stun:stun.cloudflare.com:3478",
"stun:stun1.l.google.com:19302",
"stun:stun.l.google.com:19302",
),
username = "",
credential = "",
),
IceServerConfig(
urls = listOf("turn:openrelay.metered.ca:80"),
username = "openrelayproject",
credential = "openrelayproject",
),
)
private suspend fun resolveParticipantName(defaultPrefix: String): String {
val settings = settingsProvider.settingsState.first()
return settings?.jamParticipantName?.ifBlank { "$defaultPrefix-${randomShortId()}" }
?: "$defaultPrefix-${randomShortId()}"
}
private fun localDeviceId(): String {
return settingsProvider.settingsState.value?.remoteControlDeviceId
?: "device-${randomShortId()}"
}
/**
* Per-guest handler so messages received on a guest's connection can be
* attributed back to that guest (needed for kick-on-leave and targeted sends).
*/
private fun guestEventHandler(guestId: String) = object : WebrtcEventHandler {
override fun onIceCandidate(candidate: String) {
log.i { "[$guestId] ICE candidate: $candidate" }
}
override fun onIceGatheringStateChange(state: String) {
log.i { "[$guestId] ICE gathering state: $state" }
}
override fun onConnectionStateChange(state: String) {
log.i { "[$guestId] Connection state: $state" }
guestConnectionStates[guestId] = state
if (state == "failed" || state == "closed") {
scope.launch { removeGuest(guestId) }
}
}
override fun onDataChannelOpen(label: String) {
log.i { "[$guestId] Data channel '$label' open" }
_isConnected.value = true
}
override fun onDataChannelMessage(label: String, data: String) {
handleIncomingMessage(data, fromGuestId = guestId)
}
override fun onDataChannelClose(label: String) {
log.i { "[$guestId] Data channel closed" }
if (_role.value == JamRole.Host) {
scope.launch { removeGuest(guestId) }
}
}
}
private val eventHandler = object : WebrtcEventHandler {
override fun onIceCandidate(candidate: String) {
log.i { "ICE candidate: $candidate" }
}
override fun onIceGatheringStateChange(state: String) {
log.i { "ICE gathering state: $state" }
}
override fun onConnectionStateChange(state: String) {
log.i { "Connection state: $state" }
if (state == "failed" || state == "closed") {
scope.launch { leave() }
}
}
override fun onDataChannelOpen(label: String) {
log.i { "Data channel '$label' open" }
_isConnected.value = true
// Introduce ourselves so the host can fill in our name and hand us
// our participant id.
scope.launch {
sendMessage(JamMessage.Hello(guestDisplayName, localDeviceId()))
}
}
override fun onDataChannelMessage(label: String, data: String) {
handleIncomingMessage(data, fromGuestId = null)
}
override fun onDataChannelClose(label: String) {
log.i { "Data channel closed" }
scope.launch { leave() }
}
}
private fun handleIncomingMessage(data: String, fromGuestId: String?) {
try {
val message = json.decodeFromString(JamMessage.serializer(), data)
_incomingMessages.tryEmit(message)
when (message) {
is JamMessage.SuggestTrack, is JamMessage.SuggestPlaylist -> {
_incomingSuggestions.tryEmit(message)
}
is JamMessage.Hello -> {
if (_role.value == JamRole.Host && fromGuestId != null) {
handleHello(fromGuestId, message)
}
}
is JamMessage.Welcome -> {
if (_role.value == JamRole.Guest) {
_localParticipantId.value = message.participantId
_participants.update { current ->
current.map { participant ->
if (participant.isHost) {
participant.copy(displayName = message.hostName.ifBlank { participant.displayName })
} else {
participant
}
}
}
log.i { "Welcome: joined as ${message.participantId}" }
}
}
is JamMessage.ParticipantList -> {
if (_role.value == JamRole.Guest) {
_participants.value = message.participants
}
}
is JamMessage.Kick -> {
if (_role.value == JamRole.Guest) {
log.i { "Kicked by host: ${message.reason}" }
scope.launch { leave() }
}
}
is JamMessage.Leave -> {
if (_role.value == JamRole.Host && fromGuestId != null) {
scope.launch { removeGuest(fromGuestId) }
} else if (_role.value == JamRole.Guest) {
scope.launch { leave() }
}
}
else -> Unit
}
} catch (e: Exception) {
log.w(e) { "Failed to parse jam message" }
}
}
private fun handleHello(guestId: String, hello: JamMessage.Hello) {
val deviceId = hello.deviceId
if (deviceId in bannedDeviceIds) {
log.w { "Rejecting banned device $deviceId" }
scope.launch {
sendMessage(
JamMessage.Kick(guestId, "banned by host"),
guestId = guestId,
)
removeGuest(guestId)
}
return
}
guestDeviceIds[guestId] = deviceId
_participants.update { current ->
current.map { participant ->
if (participant.id == guestId) {
participant.copy(displayName = hello.displayName.ifBlank { participant.displayName })
} else {
participant
}
}
}
scope.launch {
sendMessage(
JamMessage.Welcome(hostDisplayName, guestId),
guestId = guestId,
)
broadcastParticipantList()
// Give the newly joined guest the current queue + playback state.
queueSyncManager.broadcastNow()
}
}
private suspend fun shutdownAll() {
pendingInvites.values.forEach { runCatching { it.shutdown() } }
connectedGuests.values.forEach { runCatching { it.shutdown() } }
runCatching { hostConnection?.shutdown() }
pendingInvites.clear()
connectedGuests.clear()
hostConnection = null
}
}
private const val CHANNEL_LABEL = "jam"
private fun <T> MutableStateFlow<T>.update(transform: (T) -> T) {
value = transform(value)
}
private fun randomShortId(): String {
val chars = "0123456789abcdef"
return buildString(8) {
repeat(8) {
append(chars[kotlin.random.Random.nextInt(chars.length)])
}
}
}

View File

@ -1,316 +0,0 @@
/*
* 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.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 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.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
/**
* Keeps playback in sync across a jam session (star topology).
*
* On the **host**: applies incoming playback commands and guest suggestions to the
* host's player, and broadcasts the current queue + playback state to all guests
* (on queue changes and periodically, so play/pause/seek/position propagate).
*
* On the **guest**: mirrors the host's queue into the local player and applies
* playback commands. The guest's queue is read-only the host has authority.
*/
class QueueSyncManager(
private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue,
private val jamSession: JamSessionService,
) {
private val log = Logger.withTag("QueueSyncManager")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _isSyncing = MutableStateFlow(false)
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
private var hostBroadcastJob: Job? = null
private var hostCommandJob: Job? = null
private var guestApplyJob: Job? = null
private var guestCommandJob: Job? = null
/** Guest side: the last applied queue snapshot, used to detect real queue changes. */
private var lastAppliedItems: List<JamMediaItem> = emptyList()
/** Guest side: tracks the player was told to start playing from. */
private var lastAppliedCurrentIndex = -1
fun start() {
if (_isSyncing.value) return
_isSyncing.value = true
when (jamSession.role.value) {
JamRole.Host -> startHostSync()
JamRole.Guest -> startGuestSync()
null -> {
_isSyncing.value = false
return
}
}
}
fun stop() {
_isSyncing.value = false
hostBroadcastJob?.cancel()
hostCommandJob?.cancel()
guestApplyJob?.cancel()
guestCommandJob?.cancel()
hostBroadcastJob = null
hostCommandJob = null
guestApplyJob = null
guestCommandJob = null
lastAppliedItems = emptyList()
lastAppliedCurrentIndex = -1
}
// ---------- Host side ----------
private fun startHostSync() {
// Apply commands/suggestions coming from guests.
hostCommandJob = scope.launch {
jamSession.role.first { it != null }
if (jamSession.role.value != JamRole.Host) return@launch
jamSession.incomingMessages.collect { message ->
when (message) {
is JamMessage.PlaybackCommand -> applyPlaybackCommand(message.command)
is JamMessage.SuggestTrack -> acceptSuggestion(listOf(message.mediaItem))
is JamMessage.SuggestPlaylist -> acceptSuggestion(message.tracks)
else -> {}
}
}
}
// Broadcast state on queue changes and periodically.
hostBroadcastJob = scope.launch {
jamSession.role.first { it != null }
if (jamSession.role.value != JamRole.Host) return@launch
// Queue changes (separate coroutine — collect() never returns).
launch {
audioPlayerQueue.queueFlow.collect {
broadcastCurrentState()
}
}
// Periodic tick so play/pause/seek/position propagate to guests.
while (isActive) {
delay(2_000)
broadcastCurrentState()
}
}
}
/** Immediately pushes the current queue + playback state to all guests. */
suspend fun broadcastNow() {
if (jamSession.role.value == JamRole.Host) {
broadcastCurrentState()
}
}
private suspend fun broadcastCurrentState() {
val queue = audioPlayerQueue.getQueue()
val current = audioPlayerQueue.getCurrentQueueEntry()
val currentIndex = if (current != null) {
queue.indexOfFirst { it.matchesEntry(current) }
} else {
-1
}
jamSession.broadcastQueueState(
items = queue.map(JamMediaItem::fromQueueEntry),
currentIndex = currentIndex.coerceAtLeast(0),
isPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING,
positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds,
)
}
private suspend fun acceptSuggestion(items: List<JamMediaItem>) {
if (items.isEmpty()) return
val entries = items.map { it.toQueueEntry() }
log.i { "Accepting ${entries.size} suggested item(s) into the jam queue" }
audioPlayerQueue.addAllToQueue(entries)
}
// ---------- Guest side ----------
private fun startGuestSync() {
guestApplyJob = scope.launch {
jamSession.incomingMessages.collect { message ->
if (message !is JamMessage.QueueState) return@collect
applyQueueState(message)
}
}
guestCommandJob = scope.launch {
jamSession.incomingMessages.collect { message ->
if (message !is JamMessage.PlaybackCommand) return@collect
applyPlaybackCommand(message.command)
}
}
}
private suspend fun applyQueueState(state: JamMessage.QueueState) {
log.d { "Applying queue state: ${state.items.size} items, current=${state.currentIndex}" }
// Items that carry neither a track id nor a usable URL can't be played
// on this device — skip them instead of crashing the player.
val playableItems = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
val queueChanged = playableItems != lastAppliedItems
if (queueChanged) {
lastAppliedItems = playableItems
lastAppliedCurrentIndex = state.currentIndex
val entries = playableItems.map { it.toQueueEntry() }
runCatching {
// Load through the queue repository (like the host does) so the
// stream proxy can resolve the tracks — it only knows tracks in
// queueFlow.
audioPlayerQueue.load(
entries = entries,
autoPlay = state.isPlaying,
startPosition = state.currentIndex.coerceIn(0, entries.lastIndex.coerceAtLeast(0)),
)
}.onFailure { e ->
log.e(e) { "Failed to apply jam queue to local player" }
}
return
}
// Same queue: just sync playback state. Avoid seeking on every tick unless
// the drift is meaningful.
if (state.currentIndex != lastAppliedCurrentIndex) {
lastAppliedCurrentIndex = state.currentIndex
runCatching { audioPlayer.jumpTo(state.currentIndex.coerceAtLeast(0)) }
.onFailure { e -> log.w(e) { "Failed to jump to index ${state.currentIndex}" } }
}
val currentState = audioPlayer.playerStateFlow.value
if (state.isPlaying && currentState != PlayerState.PLAYING) {
audioPlayer.play()
} else if (!state.isPlaying && currentState == PlayerState.PLAYING) {
audioPlayer.pause()
}
val driftMs = kotlin.math.abs(
audioPlayer.positionFlow.value.inWholeMilliseconds - state.positionMs
)
if (driftMs > POSITION_SYNC_THRESHOLD_MS) {
runCatching { audioPlayer.seekTo(kotlin.time.Duration.parse("${state.positionMs}ms")) }
.onFailure { e -> log.w(e) { "Failed to sync position" } }
}
}
private suspend fun applyPlaybackCommand(command: PlaybackCmd) {
log.d { "Applying playback command: $command" }
runCatching {
when (command) {
PlaybackCmd.Play -> audioPlayer.play()
PlaybackCmd.Pause -> audioPlayer.pause()
PlaybackCmd.Toggle -> {
if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) {
audioPlayer.pause()
} else {
audioPlayer.play()
}
}
is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms"))
PlaybackCmd.SkipNext -> audioPlayer.skipToNext()
PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious()
is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume)
is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop))
is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled)
is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index)
}
}.onFailure { e ->
log.w(e) { "Failed to apply playback command: $command" }
}
}
/**
* Build a queue entry from a jam media item. Streaming tracks carry their id
* so the device's own queue/stream proxy can resolve a playable URL later.
*/
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,
thumbnails = null,
explicit = null,
popularity = null,
isrcCode = null,
externalUri = null,
),
url = "",
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
.getOrDefault(StreamProtocol.PROGRESSIVE),
)
else -> QueueEntry.LocalTrack(
name = title,
artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() },
duration = durationMs,
album = album.ifBlank { null },
coverBytes = null,
url = url,
)
}
private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean {
return 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
}
}
companion object {
/** Seek the guest only when its position drifts more than this from the host. */
private const val POSITION_SYNC_THRESHOLD_MS = 3_000L
}
}

View File

@ -21,16 +21,18 @@ 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.JamMediaItem
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamSessionService
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
@ -87,7 +89,7 @@ class RemotePlaybackController(
private val collectionPlaybackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
private val jamSession: JamSessionService,
private val jamRoomService: JamRoomService,
) : KoinComponent {
private val logger = Logger.withTag("RemotePlaybackController")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -95,6 +97,10 @@ class RemotePlaybackController(
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
}
@ -168,34 +174,41 @@ class RemotePlaybackController(
_pendingRequest.value = null
scope.launch {
try {
when (jamSession.role.value) {
when (jamRoomService.role.value) {
JamRole.Host -> executeLocally(request)
JamRole.Guest -> suggestToJam(request)
null -> {}
null -> return@launch
}
_events.emit(confirmationMessage(request))
} catch (e: Exception) {
logger.e(e) { "Failed to send content to jam session" }
}
}
}
private fun confirmationMessage(request: PlaybackDestinationRequest): String = when (request.action) {
PlaybackDestinationAction.Play -> "Playing on the jam queue"
PlaybackDestinationAction.AddToQueue -> "Added to the jam queue"
PlaybackDestinationAction.PlayNext -> "Added to play next in the jam queue"
}
private suspend fun suggestToJam(request: PlaybackDestinationRequest) {
when (request) {
is PlaybackDestinationRequest.Collection -> {
val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id)
if (tracks.isNotEmpty()) {
jamSession.suggestPlaylist(tracks.map { it.toJamMediaItem() })
jamRoomService.suggestPlaylist(tracks)
logger.i { "Suggested ${tracks.size} track(s) to the jam session" }
}
}
is PlaybackDestinationRequest.Track -> {
jamSession.suggestTrack(request.track.toJamMediaItem())
jamRoomService.suggestTrack(request.track)
}
is PlaybackDestinationRequest.Tracks -> {
if (request.tracks.isNotEmpty()) {
jamSession.suggestPlaylist(request.tracks.map { it.toJamMediaItem() })
jamRoomService.suggestPlaylist(request.tracks)
}
}
}
@ -204,7 +217,9 @@ class RemotePlaybackController(
// ---------- Internals ----------
private fun request(request: PlaybackDestinationRequest) {
if (isRemoteConnected()) {
// The picker offers "This Device", a connected remote device, and an
// active jam session — show it whenever more than one destination exists.
if (isRemoteConnected() || jamRoomService.role.value != null) {
_pendingRequest.value = request
} else {
executeLocally(request)
@ -369,5 +384,3 @@ class RemotePlaybackController(
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
}
}
private fun MetadataTrack.toJamMediaItem(): JamMediaItem = JamMediaItem.fromTrack(this)

View File

@ -30,7 +30,7 @@ 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.jam.JamSessionService
import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.remote.ConnectionState
import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction
import dev.krtirtho.spotube.core.remote.RemoteControlClient
@ -41,6 +41,7 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
import kotlinx.coroutines.flow.map
import org.koin.compose.koinInject
/**
@ -52,10 +53,11 @@ import org.koin.compose.koinInject
fun PlayDestinationPickerHost() {
val controller = koinInject<RemotePlaybackController>()
val remoteControlClient = koinInject<RemoteControlClient>()
val jamSession = koinInject<JamSessionService>()
val jamRoomService = koinInject<JamRoomService>()
val request by controller.pendingRequest.collectAsStateWithLifecycle()
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
val jamActive by jamSession.isActive.collectAsStateWithLifecycle()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val pendingRequest = request ?: return

View File

@ -17,25 +17,18 @@
package dev.krtirtho.spotube.modules.jam
import androidx.compose.foundation.clickable
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@ -48,38 +41,28 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
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 coil3.compose.AsyncImage
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.base.IconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.copyShape
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.IconsaxArrowDown4
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 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,
@ -110,48 +93,19 @@ fun JamScreen(
}
when {
!state.isActive && state.incomingOfferSdp != null -> IncomingInviteView(
hostName = state.incomingHostName.orEmpty(),
onJoin = viewModel::joinWithIncomingInvite,
onDismiss = viewModel::dismissIncomingInvite,
)
!state.isActive -> CreateOrJoinView(
state = state,
onCreate = viewModel::createSession,
onJoin = viewModel::joinWithPasted,
onJoin = viewModel::joinWithCode,
)
state.role == JamRole.Host -> HostSessionView(
else -> SessionView(
state = state,
playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value,
onNewInvite = viewModel::generateNewInvite,
onSubmitAnswer = viewModel::submitAnswerPasted,
onShare = viewModel::share,
onShareCode = viewModel::shareRoomCode,
onLeave = viewModel::leave,
onTogglePlayPause = viewModel::togglePlayPause,
onSkipNext = viewModel::skipNext,
onSkipPrevious = viewModel::skipPrevious,
onSeek = viewModel::seek,
onJumpTo = viewModel::jumpTo,
onToggleShuffle = viewModel::toggleShuffle,
onCycleLoop = viewModel::cycleLoopMode,
onKick = viewModel::kickParticipant,
onBan = viewModel::banParticipant,
)
else -> GuestSessionView(
state = state,
playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value,
onShare = viewModel::share,
onLeave = viewModel::leave,
onTogglePlayPause = viewModel::togglePlayPause,
onSkipNext = viewModel::skipNext,
onSkipPrevious = viewModel::skipPrevious,
onSeek = viewModel::seek,
onJumpTo = viewModel::jumpTo,
onToggleShuffle = viewModel::toggleShuffle,
onCycleLoop = viewModel::cycleLoopMode,
)
}
}
}
@ -173,18 +127,33 @@ private fun ErrorBanner(text: String, onDismiss: () -> Unit) {
@Composable
private fun CreateOrJoinView(
state: JamUiState,
onCreate: () -> Unit,
onJoin: (String) -> Unit,
) {
var tab by remember { mutableIntStateOf(0) }
var tab by rememberSaveable { mutableIntStateOf(0) }
var pasted by rememberSaveable { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = "Listen together with friends over a peer-to-peer connection.",
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,
@ -201,34 +170,36 @@ private fun CreateOrJoinView(
if (tab == 0) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Start a session as the host. You'll get a shareable invite link " +
"to send to friends; when they accept, they appear here.",
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) {
Button(
onClick = onCreate,
enabled = state.brokerConfigured,
) {
Text("Create Session")
}
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Paste the invite link the host shared with you.",
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 = it },
onValueChange = { pasted = JamRoomCode.normalize(it) },
modifier = Modifier.fillMaxWidth(),
label = { Text("Invite link") },
placeholder = { Text("spotube://jam/invite?...") },
minLines = 2,
maxLines = 6,
label = { Text("Room code") },
placeholder = { Text("ABC123") },
singleLine = true,
)
Button(
onClick = { onJoin(pasted) },
enabled = pasted.isNotBlank(),
enabled = state.brokerConfigured && JamRoomCode.isValid(pasted),
) {
Text("Join Session")
}
@ -238,401 +209,54 @@ private fun CreateOrJoinView(
}
@Composable
private fun IncomingInviteView(
hostName: String,
onJoin: () -> Unit,
onDismiss: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "$hostName invited you to a jam session",
style = MaterialTheme.typography.titleMedium,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onJoin) {
Text("Join")
}
OutlinedButton(onClick = onDismiss) {
Text("Ignore")
}
}
}
}
@Composable
private fun HostSessionView(
private fun SessionView(
state: JamUiState,
playerState: JamPlayerUiState,
onNewInvite: () -> Unit,
onSubmitAnswer: (String) -> Unit,
onShare: (String) -> Unit,
onShareCode: () -> Unit,
onLeave: () -> Unit,
onTogglePlayPause: () -> Unit,
onSkipNext: () -> Unit,
onSkipPrevious: () -> Unit,
onSeek: (Long) -> Unit,
onJumpTo: (Int) -> Unit,
onToggleShuffle: () -> Unit,
onCycleLoop: () -> Unit,
onKick: (String) -> Unit,
onBan: (String) -> Unit,
) {
val clipboard = LocalClipboardManager.current
var pastedAnswer by rememberSaveable { mutableStateOf("") }
val isHost = state.role == JamRole.Host
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ParticipantsSection(state.participants, isHost = true, onKick = onKick, onBan = onBan)
JamNowPlayingView(
playerState = playerState,
onTogglePlayPause = onTogglePlayPause,
onSkipNext = onSkipNext,
onSkipPrevious = onSkipPrevious,
onToggleShuffle = onToggleShuffle,
onCycleLoop = onCycleLoop,
)
HorizontalDivider()
Text(
text = "Invite someone",
style = MaterialTheme.typography.titleSmall,
)
val inviteLink = state.inviteLink
if (inviteLink != null) {
ShareableLinkBox(
label = "Invite link",
link = inviteLink,
onCopy = { clipboard.setText(AnnotatedString(inviteLink)) },
onShare = { onShare(inviteLink) },
if (!state.isConnected) {
Text(
text = "Connecting to the session…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(onClick = onNewInvite) {
Text("Generate new invite")
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 = "Accept a guest's answer",
style = MaterialTheme.typography.titleSmall,
)
Text(
text = "When your guest sends back their answer link, paste it below.",
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,
)
OutlinedTextField(
value = pastedAnswer,
onValueChange = { pastedAnswer = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Answer link or SDP") },
minLines = 2,
maxLines = 6,
)
Button(
onClick = {
onSubmitAnswer(pastedAnswer)
pastedAnswer = ""
},
enabled = pastedAnswer.isNotBlank(),
) {
Text("Accept Answer")
}
HorizontalDivider()
JamQueueView(
queue = playerState.queue,
onJumpTo = onJumpTo,
)
LeaveButton(onLeave)
}
}
@Composable
private fun GuestSessionView(
state: JamUiState,
playerState: JamPlayerUiState,
onShare: (String) -> Unit,
onLeave: () -> Unit,
onTogglePlayPause: () -> Unit,
onSkipNext: () -> Unit,
onSkipPrevious: () -> Unit,
onSeek: (Long) -> Unit,
onJumpTo: (Int) -> Unit,
onToggleShuffle: () -> Unit,
onCycleLoop: () -> Unit,
) {
val clipboard = LocalClipboardManager.current
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ParticipantsSection(state.participants, isHost = false, onKick = {}, onBan = {})
val answerLink = state.answerLink
when {
state.isConnected -> {
JamNowPlayingView(
playerState = playerState,
onTogglePlayPause = onTogglePlayPause,
onSkipNext = onSkipNext,
onSkipPrevious = onSkipPrevious,
onToggleShuffle = onToggleShuffle,
onCycleLoop = onCycleLoop,
)
JamQueueView(
queue = playerState.queue,
onJumpTo = onJumpTo,
)
}
answerLink == null -> {
Text(
text = "Connecting to the session...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {
Text(
text = "Almost there! Send your answer back to the host:",
style = MaterialTheme.typography.titleSmall,
)
ShareableLinkBox(
label = "Answer link",
link = answerLink,
onCopy = { clipboard.setText(AnnotatedString(answerLink)) },
onShare = { onShare(answerLink) },
)
}
}
LeaveButton(onLeave)
}
}
@Composable
private fun JamNowPlayingView(
playerState: JamPlayerUiState,
onTogglePlayPause: () -> Unit,
onSkipNext: () -> Unit,
onSkipPrevious: () -> Unit,
onToggleShuffle: () -> Unit,
onCycleLoop: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(
model = playerState.currentCoverUrl?.takeIf { it.isNotBlank() },
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(64.dp)
.clip(MaterialTheme.shapes.medium),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = playerState.currentTitle ?: "Nothing playing",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = playerState.currentArtist ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = formatJamDuration(playerState.positionMs),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = formatJamDuration(playerState.durationMs),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onToggleShuffle,
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
) {
Icon(
imageVector = Iconsax.IconsaxShuffle,
contentDescription = "Shuffle",
tint = if (playerState.shuffleEnabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
IconButton(
onClick = onSkipPrevious,
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
) {
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
}
IconButton(
onClick = onTogglePlayPause,
theme = LocalBaseUITheme.current.iconButtons.primary.copyShape(CircleShape),
modifier = Modifier.size(64.dp),
) {
Icon(
imageVector = if (playerState.isPlaying) {
Iconsax.IconsaxPause
} else {
Iconsax.IconsaxPlay
},
contentDescription = if (playerState.isPlaying) "Pause" else "Play",
modifier = Modifier.size(32.dp),
)
}
IconButton(
onClick = onSkipNext,
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
) {
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
}
IconButton(
onClick = onCycleLoop,
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
) {
Icon(
imageVector = Iconsax.IconsaxRepeateMusic,
contentDescription = "Loop mode",
tint = if (playerState.loopMode != "none") {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
@Composable
private fun JamQueueView(
queue: List<JamQueueUiItem>,
onJumpTo: (Int) -> Unit,
) {
var expanded by rememberSaveable { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Queue (${queue.size})",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = Iconsax.IconsaxArrowDown4,
contentDescription = if (expanded) "Collapse queue" else "Expand queue",
modifier = Modifier
.size(20.dp)
.graphicsLayer { rotationZ = if (expanded) 180f else 0f },
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (queue.isEmpty()) {
Text(
text = "The queue is empty. Add tracks from anywhere in the app — the jam queue is shared.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (expanded) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 280.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
itemsIndexed(queue) { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onJumpTo(index) }
.padding(vertical = 6.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
AsyncImage(
model = item.coverUrl.takeIf { it.isNotBlank() },
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(40.dp)
.clip(MaterialTheme.shapes.small),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (item.isCurrent) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
Text(
text = item.artist,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = formatJamDuration(item.durationMs),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else {
Text(
text = "Tap to view the shared queue.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun ParticipantsSection(
participants: List<dev.krtirtho.spotube.core.jam.JamParticipant>,
@ -684,35 +308,26 @@ private fun ParticipantsSection(
}
}
private fun formatJamDuration(ms: Long): String {
val totalSeconds = (ms / 1000).coerceAtLeast(0)
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
}
@Composable
private fun ShareableLinkBox(
label: String,
link: String,
onCopy: () -> Unit,
private fun RoomCodeBox(
code: String,
onShare: () -> Unit,
) {
val clipboard = LocalClipboardManager.current
val viewModel: JamViewModel = koinViewModel()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SelectionContainer {
OutlinedTextField(
value = link,
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth(),
label = { Text(label) },
minLines = 2,
maxLines = 6,
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 = onCopy) {
Button(onClick = { clipboard.setText(AnnotatedString(code)) }) {
Text("Copy")
}
if (viewModel.supportsNativeShare) {

View File

@ -20,22 +20,10 @@ package dev.krtirtho.spotube.modules.jam
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.PlatformType
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.MediaItem
import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService
import dev.krtirtho.spotube.core.jam.JamInviteCodec
import dev.krtirtho.spotube.core.jam.JamInviteLink
import dev.krtirtho.spotube.core.jam.JamLoopMapping
import dev.krtirtho.spotube.core.jam.JamMediaItem
import dev.krtirtho.spotube.core.jam.JamMessage
import dev.krtirtho.spotube.core.jam.JamParticipant
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamSessionService
import dev.krtirtho.spotube.core.jam.PlaybackCmd
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
@ -45,7 +33,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class JamUiState(
@ -53,377 +40,104 @@ data class JamUiState(
val isConnected: Boolean = false,
val role: JamRole? = null,
val participants: List<JamParticipant> = emptyList(),
/** Host: deep link containing this session's SDP offer, ready to share. */
val inviteLink: String? = null,
/** Guest: deep link containing our SDP answer, to send back to the host. */
val answerLink: String? = null,
/** Guest: offer received via deep link (or paste), waiting for confirmation. */
val incomingHostName: String? = null,
val incomingOfferSdp: String? = null,
val roomCode: String? = null,
val brokerHost: String = "",
val brokerConfigured: Boolean = false,
val error: String? = null,
)
data class JamQueueUiItem(
val id: String,
val title: String,
val artist: String,
val album: String,
val durationMs: Long,
val coverUrl: String,
val isCurrent: Boolean,
)
data class JamPlayerUiState(
val queue: List<JamQueueUiItem> = emptyList(),
val currentIndex: Int = -1,
val currentTitle: String? = null,
val currentArtist: String? = null,
val currentCoverUrl: String? = null,
val isPlaying: Boolean = false,
val positionMs: Long = 0,
val durationMs: Long = 0,
val shuffleEnabled: Boolean = false,
val loopMode: String = "none",
)
class JamViewModel(
private val jamSession: JamSessionService,
private val deepLinks: JamDeepLinkService,
private val jamRoomService: JamRoomService,
private val shareService: ShareService,
private val settingsProvider: SettingsProvider,
private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue,
) : ViewModel() {
private val _uiState = MutableStateFlow(JamUiState())
val uiState: StateFlow<JamUiState> = _uiState.asStateFlow()
private val _localError = MutableStateFlow<String?>(null)
val supportsNativeShare: Boolean =
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
init {
viewModelScope.launch {
// Mirror live session state into the UI state.
jamSession.isActive.collect { active ->
_uiState.update {
it.copy(
isActive = active,
isConnected = jamSession.isConnected.value,
role = jamSession.role.value,
participants = jamSession.participants.value,
inviteLink = if (!active) null else it.inviteLink,
answerLink = if (!active) null else it.answerLink,
incomingOfferSdp = if (!active) it.incomingOfferSdp else null,
incomingHostName = if (!active) it.incomingHostName else null,
)
}
}
}
viewModelScope.launch {
jamSession.participants.collect { participants ->
_uiState.update { it.copy(participants = participants) }
}
}
viewModelScope.launch {
jamSession.isConnected.collect { connected ->
_uiState.update { it.copy(isConnected = connected) }
}
}
viewModelScope.launch {
deepLinks.pendingLink.collect { link ->
handleDeepLink(link)
}
}
}
/**
* The jam player state: the shared queue + current playback, built from the
* local player (the host's queue IS the jam queue; on guests the synced
* mirror lives in the local player).
*/
val jamPlayerState: StateFlow<JamPlayerUiState> = combine(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
audioPlayer.playlistFlow,
audioPlayer.currentMediaItemFlow,
audioPlayer.playerStateFlow,
audioPlayer.positionFlow,
audioPlayer.durationFlow,
audioPlayer.loopStateFlow,
audioPlayer.shuffleModeFlow,
val uiState: StateFlow<JamUiState> = combine(
jamRoomService.role,
jamRoomService.participants,
jamRoomService.isConnected,
jamRoomService.roomCode,
jamRoomService.connectionError,
settingsProvider.settingsState,
_localError,
) { values ->
val queue: List<QueueEntry> = values[0] as List<QueueEntry>
val currentEntry: QueueEntry? = values[1] as QueueEntry?
val playlist: List<MediaItem> = values[2] as List<MediaItem>
val currentItem: MediaItem? = values[3] as MediaItem?
val playerState: PlayerState = values[4] as PlayerState
val position: kotlin.time.Duration = values[5] as kotlin.time.Duration
val duration: kotlin.time.Duration = values[6] as kotlin.time.Duration
val loop: LoopState = values[7] as LoopState
val shuffle: Boolean = values[8] as Boolean
@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?
val isHost = jamSession.role.value == JamRole.Host
val items: List<JamQueueUiItem>
val currentIndex: Int
val currentTitle: String?
val currentArtist: String?
val currentCoverUrl: String?
if (isHost) {
val queueItems = queue.map { JamMediaItem.fromQueueEntry(it) }
val index = if (currentEntry != null) {
queue.indexOfFirst { entry -> entry.matchesQueueEntry(currentEntry) }
} else {
-1
}
items = queueItems.mapIndexed { i, item ->
item.toUiItem(i == index)
}
currentIndex = index
currentTitle = queueItems.getOrNull(index)?.title
currentArtist = queueItems.getOrNull(index)?.artist
currentCoverUrl = queueItems.getOrNull(index)?.coverUrl
} else {
val index = playlist.indexOf(currentItem)
items = playlist.mapIndexed { i, item ->
JamMediaItem.fromMediaItem(item).toUiItem(i == index)
}
currentIndex = index
currentTitle = currentItem?.title
currentArtist = currentItem?.artist
currentCoverUrl = currentItem?.coverURL
}
JamPlayerUiState(
queue = items,
currentIndex = currentIndex,
currentTitle = currentTitle,
currentArtist = currentArtist,
currentCoverUrl = currentCoverUrl,
isPlaying = playerState == PlayerState.PLAYING,
positionMs = position.inWholeMilliseconds,
durationMs = duration.inWholeMilliseconds,
shuffleEnabled = shuffle,
loopMode = loop.name.lowercase(),
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), JamPlayerUiState())
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState())
// ---------- Playback controls ----------
// ---------- Session lifecycle ----------
fun togglePlayPause() = sendOrApply(PlaybackCmd.Toggle)
fun skipNext() = sendOrApply(PlaybackCmd.SkipNext)
fun skipPrevious() = sendOrApply(PlaybackCmd.SkipPrevious)
fun seek(positionMs: Long) = sendOrApply(PlaybackCmd.Seek(positionMs))
fun jumpTo(index: Int) = sendOrApply(PlaybackCmd.JumpTo(index))
fun toggleShuffle() = sendOrApply(PlaybackCmd.SetShuffle(!jamPlayerState.value.shuffleEnabled))
fun cycleLoopMode() {
val next = when (jamPlayerState.value.loopMode) {
"none" -> "one"
"one" -> "all"
else -> "none"
}
sendOrApply(PlaybackCmd.SetLoop(next))
}
private fun sendOrApply(command: PlaybackCmd) {
fun createSession() {
viewModelScope.launch {
if (jamSession.role.value == JamRole.Host) {
applyCommandLocally(command)
} else {
jamSession.sendMessage(JamMessage.PlaybackCommand(command))
}
jamRoomService.createRoom()
.onFailure { e ->
_localError.value = e.message ?: "Failed to create jam session"
}
.onSuccess { _localError.value = null }
}
}
private suspend fun applyCommandLocally(command: PlaybackCmd) {
when (command) {
PlaybackCmd.Play -> audioPlayer.play()
PlaybackCmd.Pause -> audioPlayer.pause()
PlaybackCmd.Toggle -> {
if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) {
audioPlayer.pause()
} else {
audioPlayer.play()
}
}
is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms"))
PlaybackCmd.SkipNext -> audioPlayer.skipToNext()
PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious()
is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume)
is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop))
is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled)
is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index)
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 { jamSession.kickParticipant(participantId) }
viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
}
fun banParticipant(participantId: String) {
viewModelScope.launch { jamSession.banParticipant(participantId) }
}
fun createSession() {
viewModelScope.launch {
runCatching {
val offer = jamSession.createSession()
JamInviteCodec.buildHostInvite(localName(), offer)
}.onSuccess { link ->
_uiState.update { it.copy(inviteLink = link, error = null) }
}.onFailure { e ->
_uiState.update { it.copy(error = "Failed to create session: ${e.message}") }
}
}
}
fun generateNewInvite() {
viewModelScope.launch {
runCatching {
val invite = jamSession.generateInvite()
JamInviteCodec.buildHostInvite(localName(), invite.sdp)
}.onSuccess { link ->
_uiState.update { it.copy(inviteLink = link, error = null) }
}.onFailure { e ->
_uiState.update { it.copy(error = "Failed to generate invite: ${e.message}") }
}
}
}
fun joinWithIncomingInvite() {
val sdp = _uiState.value.incomingOfferSdp ?: return
join(sdp, _uiState.value.incomingHostName)
}
fun joinWithPasted(input: String) {
val parsed = JamInviteCodec.parse(input)
val sdp = parsed?.sdp ?: JamInviteCodec.extractSdp(input)
if (sdp == null) {
_uiState.update { it.copy(error = "That doesn't look like a valid jam invite.") }
return
}
join(sdp, (parsed as? JamInviteLink.HostInvite)?.peerName)
}
/**
* Host side: accepts an answer pasted as raw SDP or as a full `spotube://jam/answer` link.
*/
fun submitAnswerPasted(input: String) {
when (val parsed = JamInviteCodec.parse(input.trim())) {
is JamInviteLink.GuestAnswer -> acceptAnswerInternal(parsed.sdp, parsed.peerName)
else -> {
val sdp = JamInviteCodec.extractSdp(input)
if (sdp == null) {
_uiState.update { it.copy(error = "That doesn't look like a valid SDP answer.") }
} else {
acceptAnswerInternal(sdp, "")
}
}
}
}
fun share(text: String) {
shareService.share(text, "Spotube Group Jam")
}
fun leave() {
viewModelScope.launch {
jamSession.leave()
deepLinks.clear()
_uiState.update {
JamUiState(incomingOfferSdp = it.incomingOfferSdp, incomingHostName = it.incomingHostName)
}
}
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
fun dismissIncomingInvite() {
deepLinks.clear()
_uiState.update { it.copy(incomingOfferSdp = null, incomingHostName = null) }
}
private fun join(offerSdp: String, hostName: String? = null) {
viewModelScope.launch {
runCatching {
val answer = jamSession.joinSession(offerSdp, hostName)
JamInviteCodec.buildGuestAnswer(localName(), answer)
}.onSuccess { link ->
_uiState.update {
it.copy(answerLink = link, incomingOfferSdp = null, incomingHostName = null, error = null)
}
}.onFailure { e ->
_uiState.update { it.copy(error = "Failed to join session: ${e.message}") }
}
}
}
private fun acceptAnswerInternal(answerSdp: String, peerName: String) {
viewModelScope.launch {
val accepted = runCatching { jamSession.acceptAnswer(null, answerSdp, peerName) }
.getOrDefault(false)
if (!accepted) {
_uiState.update { it.copy(error = "Couldn't accept that answer — no pending invite matched.") }
} else {
_uiState.update { it.copy(error = null) }
}
}
}
private suspend fun handleDeepLink(link: JamInviteLink?) {
when (link) {
is JamInviteLink.HostInvite -> {
if (!jamSession.isActive.value) {
_uiState.update {
it.copy(incomingHostName = link.peerName.ifBlank { "Someone" }, incomingOfferSdp = link.sdp)
}
}
}
is JamInviteLink.GuestAnswer -> {
if (jamSession.role.value == JamRole.Host) {
acceptAnswerInternal(link.sdp, link.peerName)
}
}
null -> Unit
}
}
private fun localName(): String =
settingsProvider.settingsState.value?.jamParticipantName.orEmpty()
}
private fun JamMediaItem.toUiItem(isCurrent: Boolean): JamQueueUiItem = JamQueueUiItem(
id = if (trackId.isNotBlank()) trackId else url,
title = title,
artist = artist,
album = album,
durationMs = durationMs,
coverUrl = coverUrl,
isCurrent = isCurrent,
)
private fun QueueEntry.matchesQueueEntry(other: QueueEntry): Boolean {
return 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
viewModelScope.launch { jamRoomService.banParticipant(participantId) }
}
}

View File

@ -61,8 +61,10 @@ data class UserSettings(
val remoteControlDeviceName: String = "",
val remoteControlDeviceId: String = "",
// Group Jam (P2P)
// Group Jam (MQTT)
val jamParticipantName: String = "",
val jamBroker: JamBroker = JamBroker(),
val lastJamCode: String = "",
// Downloads
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
@ -86,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

@ -43,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
@ -110,6 +111,11 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
navigatorCommands = navigatorCommands,
requestLocalNetworkPermission = requestLocalNetworkPermission,
)
if (settingsState != null)
jamSection(
settings = settingsState!!,
settingsViewModel = settingsViewModel,
)
if (settingsState != null)
cacheSection(
settings = settingsState!!,

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

@ -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
@ -70,6 +73,7 @@ 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
@ -95,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()
@ -122,6 +133,13 @@ fun AppShell(
PlayDestinationPickerHost()
Box(modifier = Modifier.fillMaxSize()) {
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 96.dp),
)
val useSidebar = viewModel.useSidebar()
val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar)

View File

@ -68,7 +68,7 @@ class PlayerQueueContentViewModel(
}
queue.mapIndexed { index, entry ->
val title: String
val subtitle: String
var subtitle: String
val durationMs: Long
val imageUrl: String?
@ -89,6 +89,11 @@ class PlayerQueueContentViewModel(
}
}
val addedBy = entry.addedBy
if (addedBy.isNotBlank()) {
subtitle = "$subtitle • Added by $addedBy"
}
QueueItemUi(
id = "${entry.url}@$index",
title = title,

View File

@ -1,9 +1,7 @@
mod metadata;
mod discord_rpc;
mod webrtc_p2p;
pub use metadata::*;
pub use discord_rpc::*;
pub use webrtc_p2p::*;
uniffi::setup_scaffolding!();

View File

@ -1,340 +0,0 @@
/*
* 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/>.
*/
use std::sync::Arc;
use std::time::Duration;
use parking_lot::Mutex;
use rtc::ice::mdns::MulticastDnsMode;
use rtc::peer_connection::configuration::interceptor_registry::register_default_interceptors;
use rtc::peer_connection::configuration::setting_engine::SettingEngine;
use webrtc::data_channel::{DataChannel, DataChannelEvent, RTCDataChannelInit};
use webrtc::peer_connection::{
MediaEngine, PeerConnection, PeerConnectionBuilder, PeerConnectionEventHandler,
RTCConfigurationBuilder, RTCIceGatheringState, RTCIceServer, RTCPeerConnectionIceEvent,
RTCPeerConnectionState, RTCSessionDescription, Registry,
};
use webrtc::runtime::channel;
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum WebrtcError {
#[error("SDP error: {reason}")]
SdpError { reason: String },
#[error("Connection error: {reason}")]
ConnectionError { reason: String },
#[error("Data channel error: {reason}")]
DataChannelError { reason: String },
#[error("Invalid state: {reason}")]
InvalidState { reason: String },
#[error("Internal error: {reason}")]
Internal { reason: String },
}
impl From<webrtc::error::Error> for WebrtcError {
fn from(e: webrtc::error::Error) -> Self {
WebrtcError::Internal {
reason: format!("{e:?}"),
}
}
}
#[derive(uniffi::Record)]
pub struct IceServerConfig {
pub urls: Vec<String>,
pub username: String,
pub credential: String,
}
#[uniffi::export(callback_interface)]
pub trait WebrtcEventHandler: Send + Sync + 'static {
fn on_ice_candidate(&self, candidate: String);
fn on_ice_gathering_state_change(&self, state: String);
fn on_connection_state_change(&self, state: String);
fn on_data_channel_open(&self, label: String);
fn on_data_channel_message(&self, label: String, data: String);
fn on_data_channel_close(&self, label: String);
}
struct DataChannelEntry {
dc: Arc<dyn DataChannel>,
label: String,
}
#[derive(uniffi::Object)]
pub struct WebrtcPeerConnection {
pc: Arc<dyn PeerConnection>,
handler: Arc<dyn WebrtcEventHandler>,
channels: Arc<Mutex<Vec<DataChannelEntry>>>,
gather_rx: Mutex<webrtc::runtime::Receiver<()>>,
}
#[uniffi::export(async_runtime = "tokio")]
pub async fn create_webrtc_peer_connection(
ice_servers: Vec<IceServerConfig>,
handler: Box<dyn WebrtcEventHandler>,
) -> Result<Arc<WebrtcPeerConnection>, WebrtcError> {
let handler: Arc<dyn WebrtcEventHandler> = Arc::from(handler);
let mut media_engine = MediaEngine::default();
media_engine
.register_default_codecs()
.map_err(|e| WebrtcError::Internal {
reason: format!("media_engine: {e:?}"),
})?;
let registry = register_default_interceptors(Registry::new(), &mut media_engine)
.map_err(|e| WebrtcError::Internal {
reason: format!("interceptor_registry: {e:?}"),
})?;
let config = RTCConfigurationBuilder::new()
.with_ice_servers(
ice_servers
.into_iter()
.map(|s| RTCIceServer {
urls: s.urls,
username: s.username,
credential: s.credential,
})
.collect(),
)
.build();
// mDNS adds a multicast UDP socket per peer connection. On some platforms
// (notably Android) that socket can stall and ICE gathering then never
// completes. Real-IP host candidates (no mDNS) work fine alongside STUN/TURN,
// so mDNS is disabled.
let mut setting_engine = SettingEngine::default();
setting_engine.set_multicast_dns_mode(MulticastDnsMode::Disabled);
let (gather_tx, gather_rx) = channel::<()>(1);
let channels = Arc::new(Mutex::new(Vec::new()));
let pc_handler = Arc::new(PeerHandlerBridge {
handler: Arc::clone(&handler),
gather_tx,
channels: Arc::clone(&channels),
});
let pc = PeerConnectionBuilder::new()
.with_configuration(config)
.with_setting_engine(setting_engine)
.with_media_engine(media_engine)
.with_interceptor_registry(registry)
.with_handler(pc_handler)
.with_udp_addrs(vec!["0.0.0.0:0"])
.build()
.await?;
Ok(Arc::new(WebrtcPeerConnection {
pc: Arc::new(pc) as Arc<dyn PeerConnection>,
handler,
channels,
gather_rx: Mutex::new(gather_rx),
}))
}
impl WebrtcPeerConnection {
/// Waits for ICE gathering to reach `Complete` so the local SDP includes all
/// candidates (non-trickle exchange). Must be called after `set_local_description`,
/// which is what starts gathering.
///
/// Robust against a stalled gatherer (e.g. an unreachable STUN server): once at
/// least one candidate has landed in the local description, a short grace period
/// is enough — the SDP must never leave candidate-less. Hard cap at 5s.
async fn wait_for_ice_gathering(&self) {
let mut gather_rx = self.gather_rx.lock().clone();
let started = std::time::Instant::now();
loop {
let elapsed = started.elapsed();
if elapsed >= Duration::from_secs(5) {
log::warn!(
"ICE gathering did not complete within 5s; using the candidates gathered so far"
);
return;
}
match tokio::time::timeout(Duration::from_millis(100), gather_rx.recv()).await {
Ok(Some(())) => return, // gathering complete
Ok(None) => return, // handler dropped
Err(_) => {} // timed out, keep waiting
}
// Grace period once candidates are present, so the SDP always carries them.
if elapsed >= Duration::from_secs(1) {
let sdp = self.pc.local_description().await.map(|d| d.sdp);
if sdp.as_deref().map_or(false, |s| s.contains("a=candidate:")) {
return;
}
}
}
}
}
#[uniffi::export]
impl WebrtcPeerConnection {
#[uniffi::method(async_runtime = "tokio")]
pub async fn create_offer(&self) -> Result<String, WebrtcError> {
let offer = self.pc.create_offer(None).await?;
self.pc.set_local_description(offer.clone()).await?;
self.wait_for_ice_gathering().await;
Ok(self.pc.local_description().await.map(|d| d.sdp).unwrap_or(offer.sdp))
}
#[uniffi::method(async_runtime = "tokio")]
pub async fn create_answer(&self) -> Result<String, WebrtcError> {
let answer = self.pc.create_answer(None).await?;
self.pc.set_local_description(answer.clone()).await?;
self.wait_for_ice_gathering().await;
Ok(self.pc.local_description().await.map(|d| d.sdp).unwrap_or(answer.sdp))
}
#[uniffi::method(async_runtime = "tokio")]
pub async fn set_remote_offer(&self, sdp: String) -> Result<(), WebrtcError> {
let desc = RTCSessionDescription::offer(sdp)
.map_err(|e| WebrtcError::SdpError { reason: format!("{e:?}") })?;
self.pc.set_remote_description(desc).await?;
Ok(())
}
#[uniffi::method(async_runtime = "tokio")]
pub async fn set_remote_answer(&self, sdp: String) -> Result<(), WebrtcError> {
let desc = RTCSessionDescription::answer(sdp)
.map_err(|e| WebrtcError::SdpError { reason: format!("{e:?}") })?;
self.pc.set_remote_description(desc).await?;
Ok(())
}
#[uniffi::method(async_runtime = "tokio")]
pub async fn local_description(&self) -> Option<String> {
self.pc.local_description().await.map(|d| d.sdp)
}
#[uniffi::method(async_runtime = "tokio")]
pub async fn create_data_channel(&self, label: String) -> Result<(), WebrtcError> {
let dc = self
.pc
.create_data_channel(&label, None::<RTCDataChannelInit>)
.await?;
spawn_data_channel_poll_loop(Arc::clone(&dc), Arc::clone(&self.handler));
self.channels.lock().push(DataChannelEntry { dc, label });
Ok(())
}
#[uniffi::method(async_runtime = "tokio")]
pub async fn send_data(&self, label: String, data: String) -> Result<(), WebrtcError> {
let dc = {
let channels = self.channels.lock();
channels
.iter()
.find(|c| c.label == label)
.map(|c| Arc::clone(&c.dc))
};
let dc = dc.ok_or_else(|| WebrtcError::InvalidState {
reason: format!("No data channel with label '{label}'"),
})?;
dc.send_text(&data).await?;
Ok(())
}
#[uniffi::method(async_runtime = "tokio")]
pub async fn shutdown(&self) -> Result<(), WebrtcError> {
let channels: Vec<Arc<dyn DataChannel>> = {
let channels = self.channels.lock();
channels.iter().map(|c| Arc::clone(&c.dc)).collect()
};
for dc in channels.iter() {
let _ = dc.close().await;
}
self.pc.close().await?;
Ok(())
}
}
struct PeerHandlerBridge {
handler: Arc<dyn WebrtcEventHandler>,
gather_tx: webrtc::runtime::Sender<()>,
channels: Arc<Mutex<Vec<DataChannelEntry>>>,
}
#[async_trait::async_trait]
impl PeerConnectionEventHandler for PeerHandlerBridge {
async fn on_ice_candidate(&self, event: RTCPeerConnectionIceEvent) {
self.handler.on_ice_candidate(event.candidate.to_string());
}
async fn on_ice_gathering_state_change(&self, state: RTCIceGatheringState) {
let s = state.to_string();
if matches!(state, RTCIceGatheringState::Complete) {
let _ = self.gather_tx.try_send(());
}
self.handler.on_ice_gathering_state_change(s);
}
async fn on_connection_state_change(&self, state: RTCPeerConnectionState) {
self.handler.on_connection_state_change(state.to_string());
}
async fn on_data_channel(&self, dc: Arc<dyn DataChannel>) {
// Register in-band (remote-initiated) channels so send_data() can find
// them — without this, the answering peer can never send anything.
let label = match dc.label().await {
Ok(l) => l,
Err(_) => return,
};
self.channels
.lock()
.push(DataChannelEntry { dc: Arc::clone(&dc), label });
spawn_data_channel_poll_loop(dc, Arc::clone(&self.handler));
}
}
fn spawn_data_channel_poll_loop(
dc: Arc<dyn DataChannel>,
handler: Arc<dyn WebrtcEventHandler>,
) {
::tokio::spawn(async move {
let label = match dc.label().await {
Ok(l) => l,
Err(_) => return,
};
while let Some(event) = dc.poll().await {
match event {
DataChannelEvent::OnOpen => {
handler.on_data_channel_open(label.clone());
}
DataChannelEvent::OnMessage(msg) => {
let text = if msg.is_string {
String::from_utf8_lossy(&msg.data).into_owned()
} else {
format!("[binary:{}bytes]", msg.data.len())
};
handler.on_data_channel_message(label.clone(), text);
}
DataChannelEvent::OnClose | DataChannelEvent::OnClosing => {
handler.on_data_channel_close(label.clone());
if matches!(event, DataChannelEvent::OnClose) {
break;
}
}
_ => {}
}
}
});
}

View File

@ -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"
@ -122,6 +124,10 @@ 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" }