mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 06:34:00 +00:00
feat(jam-session): integrate MQTT broker configuration and enhance jam session settings
This commit is contained in:
parent
571aea8d38
commit
a2b9f4178c
2157
composeApp/Cargo.lock
generated
2157
composeApp/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -10,10 +10,6 @@ discord-rich-presence = "1.1.0"
|
|||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
log = "0.4"
|
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"] }
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@ -166,6 +166,12 @@ kotlin {
|
|||||||
|
|
||||||
// DLNA
|
// DLNA
|
||||||
implementation(libs.dns.sd.kt)
|
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 {
|
commonTest.dependencies {
|
||||||
|
|||||||
@ -55,17 +55,6 @@
|
|||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
<service
|
<service
|
||||||
android:name=".media.PlaybackService"
|
android:name=".media.PlaybackService"
|
||||||
|
|||||||
@ -173,5 +173,19 @@
|
|||||||
<string name="plugin_permissions_capability_network_desc">Send and receive data over the internet</string>
|
<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_title">WebView</string>
|
||||||
<string name="plugin_permissions_capability_webview_desc">Display web content inside the app</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>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@ -33,8 +33,6 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation3.ui.NavDisplay
|
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.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
||||||
@ -106,12 +104,6 @@ fun App(
|
|||||||
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
|
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
|
||||||
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
|
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(
|
val navigationState = rememberNavigationState(
|
||||||
startRoute = Routes.Home,
|
startRoute = Routes.Home,
|
||||||
topLevelRoutes = TOP_LEVEL_ROUTES
|
topLevelRoutes = TOP_LEVEL_ROUTES
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable
|
@Serializable
|
||||||
sealed interface QueueEntry {
|
sealed interface QueueEntry {
|
||||||
val url: String
|
val url: String
|
||||||
|
val addedBy: String
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("streaming")
|
@SerialName("streaming")
|
||||||
@ -32,6 +33,7 @@ sealed interface QueueEntry {
|
|||||||
val track: MetadataTrack,
|
val track: MetadataTrack,
|
||||||
override val url: String,
|
override val url: String,
|
||||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||||
|
override val addedBy: String = "",
|
||||||
) : QueueEntry
|
) : QueueEntry
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@ -42,7 +44,8 @@ sealed interface QueueEntry {
|
|||||||
val duration: Long,
|
val duration: Long,
|
||||||
val album: String?,
|
val album: String?,
|
||||||
val coverBytes: ByteArray?,
|
val coverBytes: ByteArray?,
|
||||||
override val url: String
|
override val url: String,
|
||||||
|
override val addedBy: String = "",
|
||||||
) : QueueEntry {
|
) : QueueEntry {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
@ -56,6 +59,7 @@ sealed interface QueueEntry {
|
|||||||
if (album != other.album) return false
|
if (album != other.album) return false
|
||||||
if (!coverBytes.contentEquals(other.coverBytes)) return false
|
if (!coverBytes.contentEquals(other.coverBytes)) return false
|
||||||
if (url != other.url) return false
|
if (url != other.url) return false
|
||||||
|
if (addedBy != other.addedBy) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -67,6 +71,7 @@ sealed interface QueueEntry {
|
|||||||
result = 31 * result + (album?.hashCode() ?: 0)
|
result = 31 * result + (album?.hashCode() ?: 0)
|
||||||
result = 31 * result + (coverBytes?.contentHashCode() ?: 0)
|
result = 31 * result + (coverBytes?.contentHashCode() ?: 0)
|
||||||
result = 31 * result + url.hashCode()
|
result = 31 * result + url.hashCode()
|
||||||
|
result = 31 * result + addedBy.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -23,10 +23,10 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository
|
import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository
|
||||||
import dev.krtirtho.spotube.core.db.Database
|
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.discovery.DeviceDiscoveryService
|
||||||
import dev.krtirtho.spotube.core.discord.DiscordRpcService
|
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.navigation.navigationModule
|
||||||
import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
||||||
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
|
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
|
||||||
@ -185,12 +185,9 @@ val sharedModules = module {
|
|||||||
viewModelOf(::RemoteControlViewModel)
|
viewModelOf(::RemoteControlViewModel)
|
||||||
viewModel {
|
viewModel {
|
||||||
JamViewModel(
|
JamViewModel(
|
||||||
jamSession = get(),
|
jamRoomService = get(),
|
||||||
deepLinks = get(),
|
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
settingsProvider = get(),
|
settingsProvider = get(),
|
||||||
audioPlayer = get(),
|
|
||||||
audioPlayerQueue = get(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,8 +236,8 @@ val sharedModules = module {
|
|||||||
createdAtStart()
|
createdAtStart()
|
||||||
}
|
}
|
||||||
single { RemotePlaybackController(get(), get(), get(), get(), get()) }
|
single { RemotePlaybackController(get(), get(), get(), get(), get()) }
|
||||||
single { JamSessionService(get(), get(), get()) }
|
singleOf(::JamRoomClient)
|
||||||
singleOf(::JamDeepLinkService)
|
single { JamRoomService(get(), get(), get(), get()) }
|
||||||
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
||||||
single<AudioPlayerQueue> {
|
single<AudioPlayerQueue> {
|
||||||
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -18,61 +18,46 @@
|
|||||||
package dev.krtirtho.spotube.core.jam
|
package dev.krtirtho.spotube.core.jam
|
||||||
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
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.MediaItem
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
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
|
@Serializable
|
||||||
sealed class JamMessage {
|
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
|
@Serializable
|
||||||
@SerialName("queueState")
|
@SerialName("queueState")
|
||||||
data class QueueState(
|
data class QueueState(
|
||||||
val items: List<JamMediaItem>,
|
val items: List<JamMediaItem>,
|
||||||
val currentIndex: Int,
|
val currentIndex: Int,
|
||||||
val isPlaying: Boolean,
|
val shuffleEnabled: Boolean = false,
|
||||||
val positionMs: Long,
|
|
||||||
) : JamMessage()
|
) : JamMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("playbackCommand")
|
@SerialName("playbackCommand")
|
||||||
data class PlaybackCommand(
|
data class PlaybackCommand(val command: PlaybackCmd) : JamMessage()
|
||||||
val command: PlaybackCmd,
|
|
||||||
) : JamMessage()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("suggestTrack")
|
@SerialName("suggestTrack")
|
||||||
data class SuggestTrack(val mediaItem: JamMediaItem) : JamMessage()
|
data class SuggestTrack(
|
||||||
|
val mediaItem: JamMediaItem,
|
||||||
@Serializable
|
val addedBy: String = "",
|
||||||
@SerialName("suggestPlaylist")
|
|
||||||
data class SuggestPlaylist(val tracks: List<JamMediaItem>) : JamMessage()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("chat")
|
|
||||||
data class Chat(
|
|
||||||
val fromName: String,
|
|
||||||
val text: String,
|
|
||||||
) : JamMessage()
|
) : JamMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("participantList")
|
@SerialName("suggestPlaylist")
|
||||||
data class ParticipantList(val participants: List<JamParticipant>) : JamMessage()
|
data class SuggestPlaylist(
|
||||||
|
val tracks: List<JamMediaItem>,
|
||||||
|
val addedBy: String = "",
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("kick")
|
@SerialName("kick")
|
||||||
@ -86,24 +71,12 @@ sealed class JamMessage {
|
|||||||
data class Leave(val reason: String = "user_left") : 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
|
@Serializable
|
||||||
sealed class PlaybackCmd {
|
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
|
@Serializable
|
||||||
@SerialName("skipNext")
|
@SerialName("skipNext")
|
||||||
data object SkipNext : PlaybackCmd()
|
data object SkipNext : PlaybackCmd()
|
||||||
@ -112,23 +85,20 @@ sealed class PlaybackCmd {
|
|||||||
@SerialName("skipPrevious")
|
@SerialName("skipPrevious")
|
||||||
data object SkipPrevious : PlaybackCmd()
|
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
|
@Serializable
|
||||||
@SerialName("jumpTo")
|
@SerialName("jumpTo")
|
||||||
data class JumpTo(val index: Int) : PlaybackCmd()
|
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
|
@Serializable
|
||||||
data class JamMediaItem(
|
data class JamMediaItem(
|
||||||
val url: String,
|
val url: String,
|
||||||
@ -139,6 +109,7 @@ data class JamMediaItem(
|
|||||||
val durationMs: Long,
|
val durationMs: Long,
|
||||||
val coverUrl: String,
|
val coverUrl: String,
|
||||||
val protocol: String,
|
val protocol: String,
|
||||||
|
val addedBy: String = "",
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
||||||
@ -153,6 +124,7 @@ data class JamMediaItem(
|
|||||||
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
protocol = entry.protocol.name,
|
protocol = entry.protocol.name,
|
||||||
|
addedBy = entry.addedBy,
|
||||||
)
|
)
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> JamMediaItem(
|
is QueueEntry.LocalTrack -> JamMediaItem(
|
||||||
@ -164,6 +136,7 @@ data class JamMediaItem(
|
|||||||
durationMs = entry.duration,
|
durationMs = entry.duration,
|
||||||
coverUrl = "",
|
coverUrl = "",
|
||||||
protocol = "PROGRESSIVE",
|
protocol = "PROGRESSIVE",
|
||||||
|
addedBy = entry.addedBy,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,13 +188,3 @@ enum class JamRole {
|
|||||||
Host,
|
Host,
|
||||||
Guest,
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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/#"
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -21,16 +21,18 @@ import co.touchlab.kermit.Logger
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
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.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.core.playback.CollectionPlaybackHelper
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
@ -87,7 +89,7 @@ class RemotePlaybackController(
|
|||||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
private val jamSession: JamSessionService,
|
private val jamRoomService: JamRoomService,
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
private val logger = Logger.withTag("RemotePlaybackController")
|
private val logger = Logger.withTag("RemotePlaybackController")
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
@ -95,6 +97,10 @@ class RemotePlaybackController(
|
|||||||
private val _pendingRequest = MutableStateFlow<PlaybackDestinationRequest?>(null)
|
private val _pendingRequest = MutableStateFlow<PlaybackDestinationRequest?>(null)
|
||||||
val pendingRequest: StateFlow<PlaybackDestinationRequest?> = _pendingRequest.asStateFlow()
|
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 {
|
fun isRemoteConnected(): Boolean {
|
||||||
return remoteControlClient.connectionState.value is ConnectionState.Connected
|
return remoteControlClient.connectionState.value is ConnectionState.Connected
|
||||||
}
|
}
|
||||||
@ -168,34 +174,41 @@ class RemotePlaybackController(
|
|||||||
_pendingRequest.value = null
|
_pendingRequest.value = null
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
when (jamSession.role.value) {
|
when (jamRoomService.role.value) {
|
||||||
JamRole.Host -> executeLocally(request)
|
JamRole.Host -> executeLocally(request)
|
||||||
JamRole.Guest -> suggestToJam(request)
|
JamRole.Guest -> suggestToJam(request)
|
||||||
null -> {}
|
null -> return@launch
|
||||||
}
|
}
|
||||||
|
_events.emit(confirmationMessage(request))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.e(e) { "Failed to send content to jam session" }
|
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) {
|
private suspend fun suggestToJam(request: PlaybackDestinationRequest) {
|
||||||
when (request) {
|
when (request) {
|
||||||
is PlaybackDestinationRequest.Collection -> {
|
is PlaybackDestinationRequest.Collection -> {
|
||||||
val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id)
|
val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id)
|
||||||
if (tracks.isNotEmpty()) {
|
if (tracks.isNotEmpty()) {
|
||||||
jamSession.suggestPlaylist(tracks.map { it.toJamMediaItem() })
|
jamRoomService.suggestPlaylist(tracks)
|
||||||
logger.i { "Suggested ${tracks.size} track(s) to the jam session" }
|
logger.i { "Suggested ${tracks.size} track(s) to the jam session" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is PlaybackDestinationRequest.Track -> {
|
is PlaybackDestinationRequest.Track -> {
|
||||||
jamSession.suggestTrack(request.track.toJamMediaItem())
|
jamRoomService.suggestTrack(request.track)
|
||||||
}
|
}
|
||||||
|
|
||||||
is PlaybackDestinationRequest.Tracks -> {
|
is PlaybackDestinationRequest.Tracks -> {
|
||||||
if (request.tracks.isNotEmpty()) {
|
if (request.tracks.isNotEmpty()) {
|
||||||
jamSession.suggestPlaylist(request.tracks.map { it.toJamMediaItem() })
|
jamRoomService.suggestPlaylist(request.tracks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -204,7 +217,9 @@ class RemotePlaybackController(
|
|||||||
// ---------- Internals ----------
|
// ---------- Internals ----------
|
||||||
|
|
||||||
private fun request(request: PlaybackDestinationRequest) {
|
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
|
_pendingRequest.value = request
|
||||||
} else {
|
} else {
|
||||||
executeLocally(request)
|
executeLocally(request)
|
||||||
@ -369,5 +384,3 @@ class RemotePlaybackController(
|
|||||||
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
|
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MetadataTrack.toJamMediaItem(): JamMediaItem = JamMediaItem.fromTrack(this)
|
|
||||||
@ -30,7 +30,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.ConnectionState
|
||||||
import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction
|
import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction
|
||||||
import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
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.IconsaxCd
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,10 +53,11 @@ import org.koin.compose.koinInject
|
|||||||
fun PlayDestinationPickerHost() {
|
fun PlayDestinationPickerHost() {
|
||||||
val controller = koinInject<RemotePlaybackController>()
|
val controller = koinInject<RemotePlaybackController>()
|
||||||
val remoteControlClient = koinInject<RemoteControlClient>()
|
val remoteControlClient = koinInject<RemoteControlClient>()
|
||||||
val jamSession = koinInject<JamSessionService>()
|
val jamRoomService = koinInject<JamRoomService>()
|
||||||
val request by controller.pendingRequest.collectAsStateWithLifecycle()
|
val request by controller.pendingRequest.collectAsStateWithLifecycle()
|
||||||
val connectionState by remoteControlClient.connectionState.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
|
val pendingRequest = request ?: return
|
||||||
|
|
||||||
|
|||||||
@ -17,25 +17,18 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.jam
|
package dev.krtirtho.spotube.modules.jam
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@ -48,38 +41,28 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
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.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.core.ui.component.ApplicationMainBar
|
||||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
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
|
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
|
@Composable
|
||||||
fun JamScreen(
|
fun JamScreen(
|
||||||
navigationCommands: NavigationCommands,
|
navigationCommands: NavigationCommands,
|
||||||
@ -110,48 +93,19 @@ fun JamScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
!state.isActive && state.incomingOfferSdp != null -> IncomingInviteView(
|
|
||||||
hostName = state.incomingHostName.orEmpty(),
|
|
||||||
onJoin = viewModel::joinWithIncomingInvite,
|
|
||||||
onDismiss = viewModel::dismissIncomingInvite,
|
|
||||||
)
|
|
||||||
|
|
||||||
!state.isActive -> CreateOrJoinView(
|
!state.isActive -> CreateOrJoinView(
|
||||||
|
state = state,
|
||||||
onCreate = viewModel::createSession,
|
onCreate = viewModel::createSession,
|
||||||
onJoin = viewModel::joinWithPasted,
|
onJoin = viewModel::joinWithCode,
|
||||||
)
|
)
|
||||||
|
|
||||||
state.role == JamRole.Host -> HostSessionView(
|
else -> SessionView(
|
||||||
state = state,
|
state = state,
|
||||||
playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value,
|
onShareCode = viewModel::shareRoomCode,
|
||||||
onNewInvite = viewModel::generateNewInvite,
|
|
||||||
onSubmitAnswer = viewModel::submitAnswerPasted,
|
|
||||||
onShare = viewModel::share,
|
|
||||||
onLeave = viewModel::leave,
|
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,
|
onKick = viewModel::kickParticipant,
|
||||||
onBan = viewModel::banParticipant,
|
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
|
@Composable
|
||||||
private fun CreateOrJoinView(
|
private fun CreateOrJoinView(
|
||||||
|
state: JamUiState,
|
||||||
onCreate: () -> Unit,
|
onCreate: () -> Unit,
|
||||||
onJoin: (String) -> Unit,
|
onJoin: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var tab by remember { mutableIntStateOf(0) }
|
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||||
var pasted by rememberSaveable { mutableStateOf("") }
|
var pasted by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
Text(
|
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,
|
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()) {
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
SegmentedButton(
|
SegmentedButton(
|
||||||
selected = tab == 0,
|
selected = tab == 0,
|
||||||
@ -201,34 +170,36 @@ private fun CreateOrJoinView(
|
|||||||
if (tab == 0) {
|
if (tab == 0) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Start a session as the host. You'll get a shareable invite link " +
|
text = "Start a session as the host. You'll get a 6-character room code to " +
|
||||||
"to send to friends; when they accept, they appear here.",
|
"share with friends; you control the queue.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Button(onClick = onCreate) {
|
Button(
|
||||||
|
onClick = onCreate,
|
||||||
|
enabled = state.brokerConfigured,
|
||||||
|
) {
|
||||||
Text("Create Session")
|
Text("Create Session")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = pasted,
|
value = pasted,
|
||||||
onValueChange = { pasted = it },
|
onValueChange = { pasted = JamRoomCode.normalize(it) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("Invite link") },
|
label = { Text("Room code") },
|
||||||
placeholder = { Text("spotube://jam/invite?...") },
|
placeholder = { Text("ABC123") },
|
||||||
minLines = 2,
|
singleLine = true,
|
||||||
maxLines = 6,
|
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = { onJoin(pasted) },
|
onClick = { onJoin(pasted) },
|
||||||
enabled = pasted.isNotBlank(),
|
enabled = state.brokerConfigured && JamRoomCode.isValid(pasted),
|
||||||
) {
|
) {
|
||||||
Text("Join Session")
|
Text("Join Session")
|
||||||
}
|
}
|
||||||
@ -238,401 +209,54 @@ private fun CreateOrJoinView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun IncomingInviteView(
|
private fun SessionView(
|
||||||
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(
|
|
||||||
state: JamUiState,
|
state: JamUiState,
|
||||||
playerState: JamPlayerUiState,
|
onShareCode: () -> Unit,
|
||||||
onNewInvite: () -> Unit,
|
|
||||||
onSubmitAnswer: (String) -> Unit,
|
|
||||||
onShare: (String) -> Unit,
|
|
||||||
onLeave: () -> Unit,
|
onLeave: () -> Unit,
|
||||||
onTogglePlayPause: () -> Unit,
|
|
||||||
onSkipNext: () -> Unit,
|
|
||||||
onSkipPrevious: () -> Unit,
|
|
||||||
onSeek: (Long) -> Unit,
|
|
||||||
onJumpTo: (Int) -> Unit,
|
|
||||||
onToggleShuffle: () -> Unit,
|
|
||||||
onCycleLoop: () -> Unit,
|
|
||||||
onKick: (String) -> Unit,
|
onKick: (String) -> Unit,
|
||||||
onBan: (String) -> Unit,
|
onBan: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val clipboard = LocalClipboardManager.current
|
val isHost = state.role == JamRole.Host
|
||||||
var pastedAnswer by rememberSaveable { mutableStateOf("") }
|
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
ParticipantsSection(state.participants, isHost = true, onKick = onKick, onBan = onBan)
|
if (!state.isConnected) {
|
||||||
|
Text(
|
||||||
JamNowPlayingView(
|
text = "Connecting to the session…",
|
||||||
playerState = playerState,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
onTogglePlayPause = onTogglePlayPause,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
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) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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()
|
HorizontalDivider()
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Accept a guest's answer",
|
text = "The queue and playback controls are in the player at the bottom of the app — " +
|
||||||
style = MaterialTheme.typography.titleSmall,
|
"the jam queue is shared with every participant.",
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "When your guest sends back their answer link, paste it below.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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)
|
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
|
@Composable
|
||||||
private fun ParticipantsSection(
|
private fun ParticipantsSection(
|
||||||
participants: List<dev.krtirtho.spotube.core.jam.JamParticipant>,
|
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
|
@Composable
|
||||||
private fun ShareableLinkBox(
|
private fun RoomCodeBox(
|
||||||
label: String,
|
code: String,
|
||||||
link: String,
|
|
||||||
onCopy: () -> Unit,
|
|
||||||
onShare: () -> Unit,
|
onShare: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
val viewModel: JamViewModel = koinViewModel()
|
val viewModel: JamViewModel = koinViewModel()
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
OutlinedTextField(
|
Text(
|
||||||
value = link,
|
text = code,
|
||||||
onValueChange = {},
|
style = MaterialTheme.typography.displaySmall,
|
||||||
readOnly = true,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
color = MaterialTheme.colorScheme.primary,
|
||||||
label = { Text(label) },
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
minLines = 2,
|
|
||||||
maxLines = 6,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Button(onClick = onCopy) {
|
Button(onClick = { clipboard.setText(AnnotatedString(code)) }) {
|
||||||
Text("Copy")
|
Text("Copy")
|
||||||
}
|
}
|
||||||
if (viewModel.supportsNativeShare) {
|
if (viewModel.supportsNativeShare) {
|
||||||
|
|||||||
@ -20,22 +20,10 @@ package dev.krtirtho.spotube.modules.jam
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dev.krtirtho.spotube.PlatformType
|
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.JamParticipant
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
import dev.krtirtho.spotube.core.jam.JamSessionService
|
import dev.krtirtho.spotube.core.jam.JamRoomCode
|
||||||
import dev.krtirtho.spotube.core.jam.PlaybackCmd
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
import dev.krtirtho.spotube.getPlatform
|
import dev.krtirtho.spotube.getPlatform
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
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.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class JamUiState(
|
data class JamUiState(
|
||||||
@ -53,377 +40,104 @@ data class JamUiState(
|
|||||||
val isConnected: Boolean = false,
|
val isConnected: Boolean = false,
|
||||||
val role: JamRole? = null,
|
val role: JamRole? = null,
|
||||||
val participants: List<JamParticipant> = emptyList(),
|
val participants: List<JamParticipant> = emptyList(),
|
||||||
/** Host: deep link containing this session's SDP offer, ready to share. */
|
val roomCode: String? = null,
|
||||||
val inviteLink: String? = null,
|
val brokerHost: String = "",
|
||||||
/** Guest: deep link containing our SDP answer, to send back to the host. */
|
val brokerConfigured: Boolean = false,
|
||||||
val answerLink: String? = null,
|
|
||||||
/** Guest: offer received via deep link (or paste), waiting for confirmation. */
|
|
||||||
val incomingHostName: String? = null,
|
|
||||||
val incomingOfferSdp: String? = null,
|
|
||||||
val error: String? = null,
|
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(
|
class JamViewModel(
|
||||||
private val jamSession: JamSessionService,
|
private val jamRoomService: JamRoomService,
|
||||||
private val deepLinks: JamDeepLinkService,
|
|
||||||
private val shareService: ShareService,
|
private val shareService: ShareService,
|
||||||
private val settingsProvider: SettingsProvider,
|
private val settingsProvider: SettingsProvider,
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(JamUiState())
|
private val _localError = MutableStateFlow<String?>(null)
|
||||||
val uiState: StateFlow<JamUiState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
val supportsNativeShare: Boolean =
|
val supportsNativeShare: Boolean =
|
||||||
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
|
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
|
||||||
|
|
||||||
init {
|
val uiState: StateFlow<JamUiState> = combine(
|
||||||
viewModelScope.launch {
|
jamRoomService.role,
|
||||||
// Mirror live session state into the UI state.
|
jamRoomService.participants,
|
||||||
jamSession.isActive.collect { active ->
|
jamRoomService.isConnected,
|
||||||
_uiState.update {
|
jamRoomService.roomCode,
|
||||||
it.copy(
|
jamRoomService.connectionError,
|
||||||
isActive = active,
|
settingsProvider.settingsState,
|
||||||
isConnected = jamSession.isConnected.value,
|
_localError,
|
||||||
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,
|
|
||||||
) { values ->
|
) { values ->
|
||||||
val queue: List<QueueEntry> = values[0] as List<QueueEntry>
|
@Suppress("UNCHECKED_CAST")
|
||||||
val currentEntry: QueueEntry? = values[1] as QueueEntry?
|
val role = values[0] as JamRole?
|
||||||
val playlist: List<MediaItem> = values[2] as List<MediaItem>
|
@Suppress("UNCHECKED_CAST")
|
||||||
val currentItem: MediaItem? = values[3] as MediaItem?
|
val participants = values[1] as List<JamParticipant>
|
||||||
val playerState: PlayerState = values[4] as PlayerState
|
val isConnected = values[2] as Boolean
|
||||||
val position: kotlin.time.Duration = values[5] as kotlin.time.Duration
|
val roomCode = values[3] as String?
|
||||||
val duration: kotlin.time.Duration = values[6] as kotlin.time.Duration
|
val connectionError = values[4] as String?
|
||||||
val loop: LoopState = values[7] as LoopState
|
val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings
|
||||||
val shuffle: Boolean = values[8] as Boolean
|
val localError = values[6] as String?
|
||||||
|
|
||||||
val isHost = jamSession.role.value == JamRole.Host
|
JamUiState(
|
||||||
|
isActive = role != null,
|
||||||
val items: List<JamQueueUiItem>
|
isConnected = isConnected,
|
||||||
val currentIndex: Int
|
role = role,
|
||||||
val currentTitle: String?
|
participants = participants,
|
||||||
val currentArtist: String?
|
roomCode = roomCode,
|
||||||
val currentCoverUrl: String?
|
brokerHost = settings?.jamBroker?.host.orEmpty(),
|
||||||
|
brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(),
|
||||||
if (isHost) {
|
error = localError ?: connectionError,
|
||||||
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(),
|
|
||||||
)
|
)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamPlayerUiState())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState())
|
||||||
|
|
||||||
// ---------- Playback controls ----------
|
// ---------- Session lifecycle ----------
|
||||||
|
|
||||||
fun togglePlayPause() = sendOrApply(PlaybackCmd.Toggle)
|
fun createSession() {
|
||||||
|
|
||||||
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) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (jamSession.role.value == JamRole.Host) {
|
jamRoomService.createRoom()
|
||||||
applyCommandLocally(command)
|
.onFailure { e ->
|
||||||
} else {
|
_localError.value = e.message ?: "Failed to create jam session"
|
||||||
jamSession.sendMessage(JamMessage.PlaybackCommand(command))
|
}
|
||||||
}
|
.onSuccess { _localError.value = null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun applyCommandLocally(command: PlaybackCmd) {
|
fun joinWithCode(input: String) {
|
||||||
when (command) {
|
val code = JamRoomCode.normalize(input)
|
||||||
PlaybackCmd.Play -> audioPlayer.play()
|
if (!JamRoomCode.isValid(code)) {
|
||||||
PlaybackCmd.Pause -> audioPlayer.pause()
|
_localError.value = "Room codes are ${JamRoomCode.LENGTH} characters (letters and digits)"
|
||||||
PlaybackCmd.Toggle -> {
|
return
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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 ----------
|
// ---------- Host moderation ----------
|
||||||
|
|
||||||
fun kickParticipant(participantId: String) {
|
fun kickParticipant(participantId: String) {
|
||||||
viewModelScope.launch { jamSession.kickParticipant(participantId) }
|
viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun banParticipant(participantId: String) {
|
fun banParticipant(participantId: String) {
|
||||||
viewModelScope.launch { jamSession.banParticipant(participantId) }
|
viewModelScope.launch { jamRoomService.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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -61,8 +61,10 @@ data class UserSettings(
|
|||||||
val remoteControlDeviceName: String = "",
|
val remoteControlDeviceName: String = "",
|
||||||
val remoteControlDeviceId: String = "",
|
val remoteControlDeviceId: String = "",
|
||||||
|
|
||||||
// Group Jam (P2P)
|
// Group Jam (MQTT)
|
||||||
val jamParticipantName: String = "",
|
val jamParticipantName: String = "",
|
||||||
|
val jamBroker: JamBroker = JamBroker(),
|
||||||
|
val lastJamCode: String = "",
|
||||||
|
|
||||||
// Downloads
|
// Downloads
|
||||||
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
|
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
|
||||||
@ -86,3 +88,20 @@ data class UserSettings(
|
|||||||
// Updates
|
// Updates
|
||||||
val autoCheckForUpdates: Boolean = true,
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@ -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.cacheSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.desktopSection
|
import dev.krtirtho.spotube.modules.settings.sections.desktopSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.downloadsSection
|
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.languageRegionSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.playbackSection
|
import dev.krtirtho.spotube.modules.settings.sections.playbackSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.pluginsSection
|
import dev.krtirtho.spotube.modules.settings.sections.pluginsSection
|
||||||
@ -110,6 +111,11 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
|||||||
navigatorCommands = navigatorCommands,
|
navigatorCommands = navigatorCommands,
|
||||||
requestLocalNetworkPermission = requestLocalNetworkPermission,
|
requestLocalNetworkPermission = requestLocalNetworkPermission,
|
||||||
)
|
)
|
||||||
|
if (settingsState != null)
|
||||||
|
jamSection(
|
||||||
|
settings = settingsState!!,
|
||||||
|
settingsViewModel = settingsViewModel,
|
||||||
|
)
|
||||||
if (settingsState != null)
|
if (settingsState != null)
|
||||||
cacheSection(
|
cacheSection(
|
||||||
settings = settingsState!!,
|
settings = settingsState!!,
|
||||||
|
|||||||
@ -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),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
@ -41,6 +42,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SheetValue
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.VerticalDivider
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
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.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost
|
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.devices.PlayDestinationPickerHost
|
||||||
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
||||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
|
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
|
||||||
@ -95,6 +99,13 @@ fun AppShell(
|
|||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val navigatorCommands: NavigationCommands = koinInject()
|
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 isQueueVisible by queueViewModel.isQueueVisible.collectAsState()
|
||||||
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
|
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
|
||||||
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
|
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
|
||||||
@ -122,6 +133,13 @@ fun AppShell(
|
|||||||
PlayDestinationPickerHost()
|
PlayDestinationPickerHost()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 96.dp),
|
||||||
|
)
|
||||||
|
|
||||||
val useSidebar = viewModel.useSidebar()
|
val useSidebar = viewModel.useSidebar()
|
||||||
val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar)
|
val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar)
|
||||||
|
|
||||||
|
|||||||
@ -68,7 +68,7 @@ class PlayerQueueContentViewModel(
|
|||||||
}
|
}
|
||||||
queue.mapIndexed { index, entry ->
|
queue.mapIndexed { index, entry ->
|
||||||
val title: String
|
val title: String
|
||||||
val subtitle: String
|
var subtitle: String
|
||||||
val durationMs: Long
|
val durationMs: Long
|
||||||
val imageUrl: String?
|
val imageUrl: String?
|
||||||
|
|
||||||
@ -89,6 +89,11 @@ class PlayerQueueContentViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val addedBy = entry.addedBy
|
||||||
|
if (addedBy.isNotBlank()) {
|
||||||
|
subtitle = "$subtitle • Added by $addedBy"
|
||||||
|
}
|
||||||
|
|
||||||
QueueItemUi(
|
QueueItemUi(
|
||||||
id = "${entry.url}@$index",
|
id = "${entry.url}@$index",
|
||||||
title = title,
|
title = title,
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
mod metadata;
|
mod metadata;
|
||||||
mod discord_rpc;
|
mod discord_rpc;
|
||||||
mod webrtc_p2p;
|
|
||||||
|
|
||||||
pub use metadata::*;
|
pub use metadata::*;
|
||||||
pub use discord_rpc::*;
|
pub use discord_rpc::*;
|
||||||
pub use webrtc_p2p::*;
|
|
||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -44,6 +44,8 @@ kotlinx-io = "0.9.1"
|
|||||||
material3 = "1.10.0-alpha05"
|
material3 = "1.10.0-alpha05"
|
||||||
kotlinx-serialization-json = "1.11.0"
|
kotlinx-serialization-json = "1.11.0"
|
||||||
materialKolor = "4.1.1"
|
materialKolor = "4.1.1"
|
||||||
|
mqttClient = "2.1.1"
|
||||||
|
mqttBuffer = "6.30.8"
|
||||||
murmurhash = "0.4.2"
|
murmurhash = "0.4.2"
|
||||||
newpipeextractor = "v0.26.2"
|
newpipeextractor = "v0.26.2"
|
||||||
newpipeExtractorKmp = "1.3.0"
|
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-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
|
||||||
ktor-client-websockets = { module = "io.ktor:ktor-client-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" }
|
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" }
|
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
|
||||||
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
|
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
|
||||||
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user