diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index 9e832592..3245b681 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -55,6 +55,17 @@
+
+
+
+
+
+
+
+
+
()
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
+ val jamDeepLinks: JamDeepLinkService = koinInject()
+ DisposableEffect(Unit) {
+ ExternalUriHandler.listener = { uri -> jamDeepLinks.handleUri(uri) }
+ onDispose { ExternalUriHandler.listener = null }
+ }
+
val navigationState = rememberNavigationState(
startRoute = Routes.Home,
topLevelRoutes = TOP_LEVEL_ROUTES
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt
new file mode 100644
index 00000000..00deb04d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package dev.krtirtho.spotube.core.deeplink
+
+/**
+ * Cross-platform receiver for URIs handed to the app by the operating system
+ * (deep links). Platform entry points (Android activity intents, desktop command
+ * line / open-URI handler, iOS `onOpenURL`) call [onNewUri]; the main composable
+ * installs a [listener] once composition starts.
+ *
+ * Follows the Compose Multiplatform deep linking docs pattern: URIs arriving
+ * before a listener is installed are cached and delivered as soon as one is set.
+ */
+object ExternalUriHandler {
+ private var cached: String? = null
+
+ var listener: ((uri: String) -> Unit)? = null
+ set(value) {
+ field = value
+ if (value != null) {
+ cached?.let(value::invoke)
+ cached = null
+ }
+ }
+
+ fun onNewUri(uri: String) {
+ if (uri.isBlank()) return
+ val currentListener = listener
+ if (currentListener != null) {
+ currentListener(uri)
+ } else {
+ cached = uri
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt
new file mode 100644
index 00000000..32e529db
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package dev.krtirtho.spotube.core.deeplink
+
+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(null)
+ val pendingLink: StateFlow = _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
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt
index 1a69b4e4..e898e202 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt
@@ -23,6 +23,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository
import dev.krtirtho.spotube.core.db.Database
+import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService
import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService
import dev.krtirtho.spotube.core.discord.DiscordRpcService
import dev.krtirtho.spotube.core.jam.JamSessionService
@@ -219,6 +220,7 @@ val sharedModules = module {
createdAtStart()
}
single { JamSessionService(get(), get()) }
+ singleOf(::JamDeepLinkService)
singleOf(::AudioPlayerQueueRepository) { bind() }
single {
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt
index 7c5d878b..1c6b0a22 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt
@@ -23,6 +23,7 @@ import com.appstractive.dnssd.createNetService
import com.appstractive.dnssd.discoverServices
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
+import kotlin.text.decodeToString
data class DiscoveredDevice(
val name: String,
@@ -54,7 +55,7 @@ class DeviceDiscoveryService {
type = event.service.type,
host = event.service.host,
port = event.service.port,
- deviceId = event.service.txt[TXT_DEVICE_ID]?.let { String(it) }.orEmpty(),
+ deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(),
)
DiscoveryState.Discovered(device = device, resolve = event.resolve)
}
@@ -65,7 +66,7 @@ class DeviceDiscoveryService {
type = event.service.type,
host = event.service.host,
port = event.service.port,
- deviceId = event.service.txt[TXT_DEVICE_ID]?.let { String(it) }.orEmpty(),
+ deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(),
)
DiscoveryState.Resolved(device = device)
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt
new file mode 100644
index 00000000..7ecb8ff9
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package dev.krtirtho.spotube.core.jam
+
+import 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=&sdp=`
+ * Guest answer : `spotube://jam/answer?name=&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
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt
index f90a91a1..c85db931 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt
@@ -23,6 +23,7 @@ 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
@@ -38,6 +39,16 @@ 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,
+)
+
class JamSessionService(
private val audioPlayer: AudioPlayerInterface,
private val settingsProvider: SettingsProvider,
@@ -66,100 +77,109 @@ class JamSessionService(
private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64)
val incomingMessages = _incomingMessages.asSharedFlow()
- private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
-
private val _incomingSuggestions = MutableSharedFlow(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()
+
+ /** Host side: guests whose handshake completed. Keyed by invite id. */
+ private val connectedGuests = mutableMapOf()
+
+ /** Guest side: the single connection to the host. */
private var hostConnection: WebrtcPeerConnection? = null
- private val guestConnections = mutableMapOf()
- private val guestLabels = mutableMapOf()
-
- private val eventHandler = object : WebrtcEventHandler {
- override fun onIceCandidate(candidate: String) {
- // No-op in non-trickle mode: candidates are bundled into SDP
- }
-
- override fun onIceGatheringStateChange(state: String) {
- log.d { "ICE gathering state: $state" }
- }
-
- override fun onConnectionStateChange(state: String) {
- log.i { "Connection state: $state" }
- }
-
- override fun onDataChannelOpen(label: String) {
- log.i { "Data channel '$label' open" }
- }
-
- override fun onDataChannelMessage(label: String, data: String) {
- handleIncomingMessage(label, data)
- }
-
- override fun onDataChannelClose(label: String) {
- log.i { "Data channel '$label' closed" }
- }
- }
suspend fun createSession(): String {
log.i { "Creating jam session" }
- val settings = settingsProvider.settingsState.first()
- val participantName = settings?.jamParticipantName?.ifBlank {
- "Host-${randomShortId()}"
- } ?: "Host"
+ val hostName = resolveParticipantName(defaultPrefix = "Host")
- val pc = createWebrtcPeerConnection(
- iceServers = listOf(
- IceServerConfig(
- urls = listOf("stun:stun.l.google.com:19302"),
- username = "",
- credential = "",
- )
- ),
- handler = eventHandler,
- )
-
- hostConnection = pc
_role.value = JamRole.Host
_localParticipantId.value = "host"
_participants.value = listOf(
JamParticipant(
id = "host",
- displayName = participantName,
+ displayName = hostName,
isHost = true,
)
)
_isActive.value = true
- pc.createDataChannel("jam")
- val offer = pc.createOffer()
- log.i { "Generated SDP offer (length=${offer.length})" }
- return offer
+ return generateInvite().sdp
}
- suspend fun acceptGuestAnswer(guestId: String, answer: String) {
- val pc = guestConnections[guestId] ?: run {
- log.w { "acceptGuestAnswer: no connection for $guestId" }
- return
+ /**
+ * 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")
}
- pc.setRemoteAnswer(answer)
- }
-
- suspend fun joinSession(offer: String): String {
- log.i { "Joining jam session" }
- val settings = settingsProvider.settingsState.first()
- val participantName = settings?.jamParticipantName?.ifBlank {
- "Guest-${randomShortId()}"
- } ?: "Guest"
+ val inviteId = "guest-${randomShortId()}"
+ log.i { "Generating invite $inviteId" }
val pc = createWebrtcPeerConnection(
- iceServers = listOf(
- IceServerConfig(
- urls = listOf("stun:stun.l.google.com:19302"),
- username = "",
- credential = "",
- )
- ),
+ iceServers = listOf(defaultIceServer()),
+ 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" }
+ return true
+ }
+
+ suspend fun joinSession(offerSdp: String): String {
+ log.i { "Joining jam session" }
+ val participantName = resolveParticipantName(defaultPrefix = "Guest")
+
+ val pc = createWebrtcPeerConnection(
+ iceServers = listOf(defaultIceServer()),
handler = eventHandler,
)
@@ -168,65 +188,31 @@ class JamSessionService(
_localParticipantId.value = "guest"
_isActive.value = true
- pc.setRemoteOffer(offer)
- pc.createDataChannel("jam")
+ // 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 hostAdmitGuest(guestOffer: String): String {
- if (_role.value != JamRole.Host) {
- error("hostAdmitGuest can only be called by the host")
- }
- val guestId = "guest-${guestConnections.size + 1}"
- log.i { "Admitting guest $guestId" }
-
- val handler = object : WebrtcEventHandler {
- override fun onIceCandidate(candidate: String) {}
- override fun onIceGatheringStateChange(state: String) {}
- override fun onConnectionStateChange(state: String) {}
- override fun onDataChannelOpen(label: String) {}
- override fun onDataChannelMessage(label: String, data: String) {
- handleIncomingMessage(label, data, guestId)
- }
- override fun onDataChannelClose(label: String) {}
- }
-
- val pc = createWebrtcPeerConnection(
- iceServers = listOf(
- IceServerConfig(
- urls = listOf("stun:stun.l.google.com:19302"),
- username = "",
- credential = "",
- )
- ),
- handler = handler,
- )
- guestConnections[guestId] = pc
- guestLabels[guestId] = "jam-$guestId"
-
- pc.setRemoteOffer(guestOffer)
- pc.createDataChannel("jam-${guestId}")
- val answer = pc.createAnswer()
- return answer
- }
-
suspend fun sendMessage(message: JamMessage, guestId: String? = null) {
- val json = json.encodeToString(JamMessage.serializer(), message)
+ val payload = json.encodeToString(JamMessage.serializer(), message)
when (_role.value) {
JamRole.Host -> {
- if (guestId != null) {
- guestConnections[guestId]?.sendData("jam-$guestId", json)
+ val targets = if (guestId != null) {
+ listOfNotNull(connectedGuests[guestId])
} else {
- guestConnections.forEach { (id, pc) ->
- pc.sendData("jam-$id", json)
- }
+ connectedGuests.values.toList()
+ }
+ targets.forEach { pc ->
+ runCatching { pc.sendData(CHANNEL_LABEL, payload) }
+ .onFailure { e -> log.w(e) { "Failed to send to guest" } }
}
}
JamRole.Guest -> {
- hostConnection?.sendData("jam", json)
+ hostConnection?.sendData(CHANNEL_LABEL, payload)
}
null -> log.w { "sendMessage called while no session is active" }
@@ -236,46 +222,13 @@ class JamSessionService(
suspend fun leave() {
log.i { "Leaving jam session" }
runCatching { sendMessage(JamMessage.Leave()) }
- hostConnection?.shutdown()
- guestConnections.values.forEach { runCatching { it.shutdown() } }
- hostConnection = null
- guestConnections.clear()
- guestLabels.clear()
+ shutdownAll()
_role.value = null
_participants.value = emptyList()
_isActive.value = false
_localParticipantId.value = null
}
- private fun handleIncomingMessage(label: String, data: String, fromGuestId: String? = null) {
- try {
- val message = json.decodeFromString(JamMessage.serializer(), data)
- _incomingMessages.tryEmit(message)
- when (message) {
- is JamMessage.SuggestTrack, is JamMessage.SuggestPlaylist -> {
- _incomingSuggestions.tryEmit(message)
- }
-
- is JamMessage.Leave -> {
- if (_role.value == JamRole.Host && fromGuestId != null) {
- val leavingPc = guestConnections.remove(fromGuestId)
- guestLabels.remove(fromGuestId)
- scope.launch {
- runCatching { leavingPc?.shutdown() }
- }
- _participants.update { current ->
- current.filterNot { it.id == fromGuestId }
- }
- }
- }
-
- else -> Unit
- }
- } catch (e: Exception) {
- log.w(e) { "Failed to parse jam message on $label" }
- }
- }
-
suspend fun broadcastPlaybackCommand(command: PlaybackCmd) {
if (_role.value != JamRole.Host) return
sendMessage(JamMessage.PlaybackCommand(command))
@@ -300,8 +253,113 @@ class JamSessionService(
if (_role.value != JamRole.Guest) return
sendMessage(JamMessage.SuggestPlaylist(tracks))
}
+
+ private fun defaultIceServer() = IceServerConfig(
+ urls = listOf("stun:stun.l.google.com:19302"),
+ username = "",
+ credential = "",
+ )
+
+ private suspend fun resolveParticipantName(defaultPrefix: String): String {
+ val settings = settingsProvider.settingsState.first()
+ return settings?.jamParticipantName?.ifBlank { "$defaultPrefix-${randomShortId()}" }
+ ?: "$defaultPrefix-${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) {}
+
+ override fun onIceGatheringStateChange(state: String) {
+ log.d { "[$guestId] ICE gathering state: $state" }
+ }
+
+ override fun onConnectionStateChange(state: String) {
+ log.i { "[$guestId] Connection state: $state" }
+ }
+
+ override fun onDataChannelOpen(label: String) {
+ log.i { "[$guestId] Data channel '$label' open" }
+ }
+
+ override fun onDataChannelMessage(label: String, data: String) {
+ handleIncomingMessage(data, fromGuestId = guestId)
+ }
+
+ override fun onDataChannelClose(label: String) {
+ log.i { "[$guestId] Data channel closed" }
+ }
+ }
+
+ private val eventHandler = object : WebrtcEventHandler {
+ override fun onIceCandidate(candidate: String) {}
+
+ override fun onIceGatheringStateChange(state: String) {
+ log.d { "ICE gathering state: $state" }
+ }
+
+ override fun onConnectionStateChange(state: String) {
+ log.i { "Connection state: $state" }
+ }
+
+ override fun onDataChannelOpen(label: String) {
+ log.i { "Data channel '$label' open" }
+ }
+
+ override fun onDataChannelMessage(label: String, data: String) {
+ handleIncomingMessage(data, fromGuestId = null)
+ }
+
+ override fun onDataChannelClose(label: String) {
+ log.i { "Data channel closed" }
+ }
+ }
+
+ 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.Leave -> {
+ if (_role.value == JamRole.Host && fromGuestId != null) {
+ val leavingPc = connectedGuests.remove(fromGuestId)
+ scope.launch {
+ runCatching { leavingPc?.shutdown() }
+ }
+ _participants.update { current ->
+ current.filterNot { it.id == fromGuestId }
+ }
+ } else if (_role.value == JamRole.Guest) {
+ scope.launch { leave() }
+ }
+ }
+
+ else -> Unit
+ }
+ } catch (e: Exception) {
+ log.w(e) { "Failed to parse jam message" }
+ }
+ }
+
+ 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 MutableStateFlow.update(transform: (T) -> T) {
value = transform(value)
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt
index 93b3ca37..26280783 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt
@@ -19,12 +19,17 @@ package dev.krtirtho.spotube.modules.jam
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
@@ -32,15 +37,20 @@ import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import org.koin.compose.viewmodel.koinViewModel
@@ -50,17 +60,7 @@ fun JamScreen(
navigationCommands: NavigationCommands,
) {
val viewModel = koinViewModel()
- val isActive by viewModel.isActive.collectAsStateWithLifecycle()
- val pendingOffer by viewModel.pendingHostOffer.collectAsStateWithLifecycle()
- val pendingAnswer by viewModel.pendingGuestAnswer.collectAsStateWithLifecycle()
- val error by viewModel.error.collectAsStateWithLifecycle()
-
- LaunchedEffect(isActive) {
- if (isActive && navigationCommands != null) {
- // navigationCommands doesn't navigate here automatically;
- // the session screen is the same screen so we just stay.
- }
- }
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
@@ -74,49 +74,70 @@ fun JamScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
- .padding(16.dp),
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
- if (error != null) {
- Text(
- text = error ?: "",
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodyMedium,
- )
+ state.error?.let { error ->
+ ErrorBanner(text = error, onDismiss = viewModel::clearError)
}
- if (pendingOffer == null && pendingAnswer == null) {
- CreateOrJoinView(
- onCreate = { viewModel.createSession() },
- onJoin = { offer -> viewModel.joinSession(offer) },
+ when {
+ !state.isActive && state.incomingOfferSdp != null -> IncomingInviteView(
+ hostName = state.incomingHostName.orEmpty(),
+ onJoin = viewModel::joinWithIncomingInvite,
+ onDismiss = viewModel::dismissIncomingInvite,
)
- } else if (pendingOffer != null) {
- HostOfferView(
- offer = pendingOffer!!,
- onLeave = { viewModel.leave() },
+
+ !state.isActive -> CreateOrJoinView(
+ onCreate = viewModel::createSession,
+ onJoin = viewModel::joinWithPasted,
)
- } else if (pendingAnswer != null) {
- GuestAnswerView(
- answer = pendingAnswer!!,
- onLeave = { viewModel.leave() },
+
+ state.role == JamRole.Host -> HostSessionView(
+ state = state,
+ onNewInvite = viewModel::generateNewInvite,
+ onSubmitAnswer = viewModel::submitAnswerPasted,
+ onShare = viewModel::share,
+ onLeave = viewModel::leave,
+ )
+
+ else -> GuestSessionView(
+ state = state,
+ onShare = viewModel::share,
+ onLeave = viewModel::leave,
)
}
}
}
}
+@Composable
+private fun ErrorBanner(text: String, onDismiss: () -> Unit) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = text,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ OutlinedButton(onClick = onDismiss) {
+ Text("Dismiss")
+ }
+ }
+}
+
@Composable
private fun CreateOrJoinView(
onCreate: () -> Unit,
onJoin: (String) -> Unit,
) {
var tab by remember { mutableIntStateOf(0) }
- var offer by remember { mutableStateOf("") }
+ var pasted by rememberSaveable { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
- text = "Listen Together with friends",
- style = MaterialTheme.typography.titleLarge,
+ text = "Listen together with friends over a peer-to-peer connection.",
+ style = MaterialTheme.typography.titleMedium,
)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
@@ -135,8 +156,10 @@ private fun CreateOrJoinView(
if (tab == 0) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
- text = "Create a new jam session. You'll be the host and can control playback. Share the SDP offer with your friends so they can join.",
+ text = "Start a session as the host. You'll get a shareable invite link " +
+ "to send to friends; when they accept, they appear here.",
style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = onCreate) {
Text("Create Session")
@@ -145,22 +168,24 @@ private fun CreateOrJoinView(
} else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
- text = "Paste the SDP offer from the host below. You'll get an SDP answer to send back.",
+ text = "Paste the invite link the host shared with you.",
style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
- value = offer,
- onValueChange = { offer = it },
+ value = pasted,
+ onValueChange = { pasted = it },
modifier = Modifier.fillMaxWidth(),
- label = { Text("Host's SDP offer") },
- minLines = 3,
+ label = { Text("Invite link") },
+ placeholder = { Text("spotube://jam/invite?...") },
+ minLines = 2,
maxLines = 6,
)
Button(
- onClick = { onJoin(offer.trim()) },
- enabled = offer.isNotBlank(),
+ onClick = { onJoin(pasted) },
+ enabled = pasted.isNotBlank(),
) {
- Text("Generate Answer")
+ Text("Join Session")
}
}
}
@@ -168,60 +193,196 @@ private fun CreateOrJoinView(
}
@Composable
-private fun HostOfferView(
- offer: String,
- onLeave: () -> Unit,
+private fun IncomingInviteView(
+ hostName: String,
+ onJoin: () -> Unit,
+ onDismiss: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
- text = "Session created. Send this SDP offer to your friends:",
- style = MaterialTheme.typography.bodyMedium,
+ text = "$hostName invited you to a jam session",
+ style = MaterialTheme.typography.titleMedium,
)
- SelectionContainer {
- OutlinedTextField(
- value = offer,
- onValueChange = {},
- readOnly = true,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("SDP Offer (copy and send to guests)") },
- minLines = 4,
- maxLines = 10,
- )
- }
- Text(
- text = "When a guest responds with an SDP answer, use the JamSessionScreen to add them.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Button(onClick = onLeave) {
- Text("Leave Session")
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = onJoin) {
+ Text("Join")
+ }
+ OutlinedButton(onClick = onDismiss) {
+ Text("Ignore")
+ }
}
}
}
@Composable
-private fun GuestAnswerView(
- answer: String,
+private fun HostSessionView(
+ state: JamUiState,
+ onNewInvite: () -> Unit,
+ onSubmitAnswer: (String) -> Unit,
+ onShare: (String) -> Unit,
onLeave: () -> Unit,
) {
- Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ val clipboard = LocalClipboardManager.current
+ var pastedAnswer by rememberSaveable { mutableStateOf("") }
+
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ ParticipantsSection(state.participants)
+
+ HorizontalDivider()
+
Text(
- text = "You've joined the session. Send this SDP answer back to the host:",
- style = MaterialTheme.typography.bodyMedium,
+ 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")
+ }
+
+ HorizontalDivider()
+
+ Text(
+ text = "Accept a guest's answer",
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Text(
+ text = "When your guest sends back their answer link, paste it below.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = pastedAnswer,
+ onValueChange = { pastedAnswer = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Answer link or SDP") },
+ minLines = 2,
+ maxLines = 6,
+ )
+ Button(
+ onClick = {
+ onSubmitAnswer(pastedAnswer)
+ pastedAnswer = ""
+ },
+ enabled = pastedAnswer.isNotBlank(),
+ ) {
+ Text("Accept Answer")
+ }
+
+ LeaveButton(onLeave)
+ }
+}
+
+@Composable
+private fun GuestSessionView(
+ state: JamUiState,
+ onShare: (String) -> Unit,
+ onLeave: () -> Unit,
+) {
+ val clipboard = LocalClipboardManager.current
+
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ ParticipantsSection(state.participants)
+
+ val answerLink = state.answerLink
+ if (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 ParticipantsSection(participants: List) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = "Participants (${participants.size})",
+ style = MaterialTheme.typography.titleSmall,
+ )
+ participants.forEach { participant ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = participant.displayName,
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ if (participant.isHost) {
+ Text(
+ text = "Host",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ShareableLinkBox(
+ label: String,
+ link: String,
+ onCopy: () -> Unit,
+ onShare: () -> Unit,
+) {
+ val viewModel: JamViewModel = koinViewModel()
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SelectionContainer {
OutlinedTextField(
- value = answer,
+ value = link,
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth(),
- label = { Text("SDP Answer (copy and send to host)") },
- minLines = 4,
- maxLines = 10,
+ label = { Text(label) },
+ minLines = 2,
+ maxLines = 6,
)
}
- Button(onClick = onLeave) {
- Text("Leave Session")
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = onCopy) {
+ Text("Copy")
+ }
+ if (viewModel.supportsNativeShare) {
+ OutlinedButton(onClick = onShare) {
+ Text("Share")
+ }
+ }
}
}
+}
+
+@Composable
+private fun LeaveButton(onLeave: () -> Unit) {
+ OutlinedButton(onClick = onLeave) {
+ Text("Leave Session")
+ }
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt
index 10500045..2033c322 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt
@@ -19,67 +19,205 @@ package dev.krtirtho.spotube.modules.jam
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
-import dev.krtirtho.spotube.core.jam.JamMessage
+import dev.krtirtho.spotube.PlatformType
+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.JamParticipant
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamSessionService
+import dev.krtirtho.spotube.core.share.ShareService
+import dev.krtirtho.spotube.getPlatform
+import dev.krtirtho.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import org.koin.core.component.KoinComponent
-import org.koin.core.component.inject
-class JamViewModel : ViewModel(), KoinComponent {
- private val jamSession: JamSessionService by inject()
- private val audioPlayer: AudioPlayerInterface by inject()
+data class JamUiState(
+ val isActive: Boolean = false,
+ val role: JamRole? = null,
+ val participants: List = emptyList(),
+ /** Host: deep link containing this session's SDP offer, ready to share. */
+ val inviteLink: String? = null,
+ /** Guest: deep link containing our SDP answer, to send back to the host. */
+ val answerLink: String? = null,
+ /** Guest: offer received via deep link (or paste), waiting for confirmation. */
+ val incomingHostName: String? = null,
+ val incomingOfferSdp: String? = null,
+ val error: String? = null,
+)
- val role: StateFlow = jamSession.role
- val participants: StateFlow> = jamSession.participants
- val isActive: StateFlow = jamSession.isActive
+class JamViewModel(
+ private val jamSession: JamSessionService,
+ private val deepLinks: JamDeepLinkService,
+ private val shareService: ShareService,
+ private val settingsProvider: SettingsProvider,
+) : ViewModel() {
- private val _pendingHostOffer = MutableStateFlow(null)
- val pendingHostOffer: StateFlow = _pendingHostOffer.asStateFlow()
+ private val _uiState = MutableStateFlow(JamUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
- private val _pendingGuestAnswer = MutableStateFlow(null)
- val pendingGuestAnswer: StateFlow = _pendingGuestAnswer.asStateFlow()
+ val supportsNativeShare: Boolean =
+ getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
- private val _error = MutableStateFlow(null)
- val error: StateFlow = _error.asStateFlow()
-
- fun createSession() {
+ init {
viewModelScope.launch {
- try {
- val offer = jamSession.createSession()
- _pendingHostOffer.value = offer
- } catch (e: Exception) {
- _error.value = "Failed to create session: ${e.message}"
+ // Mirror live session state into the UI state.
+ jamSession.isActive.collect { active ->
+ _uiState.update {
+ it.copy(
+ isActive = active,
+ 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 {
+ deepLinks.pendingLink.collect { link ->
+ handleDeepLink(link)
}
}
}
- fun joinSession(offer: String) {
+ fun createSession() {
viewModelScope.launch {
- try {
- val answer = jamSession.joinSession(offer)
- _pendingGuestAnswer.value = answer
- } catch (e: Exception) {
- _error.value = "Failed to join session: ${e.message}"
+ 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)
+ }
+
+ fun joinWithPasted(input: String) {
+ val sdp = JamInviteCodec.extractSdp(input)
+ if (sdp == null) {
+ _uiState.update { it.copy(error = "That doesn't look like a valid jam invite.") }
+ return
+ }
+ join(sdp)
+ }
+
+ /**
+ * 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()
- _pendingHostOffer.value = null
- _pendingGuestAnswer.value = null
+ deepLinks.clear()
+ _uiState.update {
+ JamUiState(incomingOfferSdp = it.incomingOfferSdp, incomingHostName = it.incomingHostName)
+ }
}
}
fun clearError() {
- _error.value = null
+ _uiState.update { it.copy(error = null) }
}
+
+ fun dismissIncomingInvite() {
+ deepLinks.clear()
+ _uiState.update { it.copy(incomingOfferSdp = null, incomingHostName = null) }
+ }
+
+ private fun join(offerSdp: String) {
+ viewModelScope.launch {
+ runCatching {
+ val answer = jamSession.joinSession(offerSdp)
+ 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()
}
\ No newline at end of file
diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt
index 3e597a48..171f215b 100644
--- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt
+++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt
@@ -29,6 +29,7 @@ import androidx.compose.ui.window.rememberWindowState
import com.sun.jna.Library
import com.sun.jna.Native
import dev.krtirtho.spotube.core.di.initKoin
+import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.core.systemtray.SystemTray
@@ -81,9 +82,28 @@ private fun disableWebKitGpuCompositing() {
LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1)
}
+/**
+ * Routes `spotube://` deep links into [ExternalUriHandler].
+ * macOS delivers them through the open-URI handler; on Linux/Windows they arrive
+ * as command line arguments (scheme registration is handled by the distribution
+ * packaging, e.g. the `.desktop` file's `Exec %u`).
+ */
+private fun handleStartupDeepLinks(args: Array) {
+ runCatching {
+ if (java.awt.Desktop.isDesktopSupported()) {
+ java.awt.Desktop.getDesktop().setOpenURIHandler { event ->
+ ExternalUriHandler.onNewUri(event.uri.toString())
+ }
+ }
+ }
+ args.firstOrNull { it.startsWith("spotube:", ignoreCase = true) }
+ ?.let(ExternalUriHandler::onNewUri)
+}
+
@OptIn(ExperimentalComposeUiApi::class)
-fun main() {
+fun main(args: Array) {
disableWebKitGpuCompositing()
+ handleStartupDeepLinks(args)
FileKit.init(appId = "dev.krtirtho.spotube")
initKoin()
NewPipeDownloader.init(KoinPathsProvider.paths)
diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist
index 870b2c71..a07e2e68 100644
--- a/iosApp/iosApp/Info.plist
+++ b/iosApp/iosApp/Info.plist
@@ -4,6 +4,17 @@
CADisableMinimumFrameDurationOnPhone
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ dev.krtirtho.spotube
+ CFBundleURLSchemes
+
+ spotube
+
+
+
NSLocalNetworkUsageDescription
Required to discover local network devices
NSBonjourServices
diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift
index 08b58d47..f0058387 100644
--- a/iosApp/iosApp/iOSApp.swift
+++ b/iosApp/iosApp/iOSApp.swift
@@ -20,6 +20,9 @@ struct iOSApp: App {
var body: some Scene {
WindowGroup {
ContentView()
+ .onOpenURL { url in
+ ExternalUriHandler.shared.onNewUri(uri: url.absoluteString)
+ }
}
}
}
\ No newline at end of file