feat(deeplinks): implement deep link handling for jam sessions

This commit is contained in:
Kingkor Roy Tirtho 2026-08-28 09:33:39 +06:00
parent d3a8548661
commit f21442b1c2
14 changed files with 890 additions and 263 deletions

View File

@ -55,6 +55,17 @@
<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"

View File

@ -17,10 +17,12 @@
package dev.krtirtho.spotube package dev.krtirtho.spotube
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.paths.Paths
import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.FileKit
@ -32,8 +34,14 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
FileKit.init(this) FileKit.init(this)
NewPipeDownloader.init(Paths(this)) NewPipeDownloader.init(Paths(this))
intent?.dataString?.let(ExternalUriHandler::onNewUri)
setContent { setContent {
App() App()
} }
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.dataString?.let(ExternalUriHandler::onNewUri)
}
} }

View File

@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
@ -32,6 +33,8 @@ 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
@ -103,6 +106,12 @@ 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

View File

@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.deeplink
/**
* Cross-platform receiver for URIs handed to the app by the operating system
* (deep links). Platform entry points (Android activity intents, desktop command
* line / open-URI handler, iOS `onOpenURL`) call [onNewUri]; the main composable
* installs a [listener] once composition starts.
*
* Follows the Compose Multiplatform deep linking docs pattern: URIs arriving
* before a listener is installed are cached and delivered as soon as one is set.
*/
object ExternalUriHandler {
private var cached: String? = null
var listener: ((uri: String) -> Unit)? = null
set(value) {
field = value
if (value != null) {
cached?.let(value::invoke)
cached = null
}
}
fun onNewUri(uri: String) {
if (uri.isBlank()) return
val currentListener = listener
if (currentListener != null) {
currentListener(uri)
} else {
cached = uri
}
}
}

View File

@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.deeplink
import dev.krtirtho.spotube.core.jam.JamInviteCodec
import dev.krtirtho.spotube.core.jam.JamInviteLink
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Parses incoming `spotude://jam/...` deep links, exposes them to the Jam UI,
* and navigates to [Routes.Jam] so the user lands where the link is handled.
*/
class JamDeepLinkService(
private val navigationCommands: NavigationCommands,
) {
private val _pendingLink = MutableStateFlow<JamInviteLink?>(null)
val pendingLink: StateFlow<JamInviteLink?> = _pendingLink.asStateFlow()
fun handleUri(uri: String) {
val link = JamInviteCodec.parse(uri) ?: return
_pendingLink.value = link
navigationCommands.navigateTo(Routes.Jam)
}
/** Consumes the currently pending link (if any). */
fun consume(): JamInviteLink? = _pendingLink.value.also { _pendingLink.value = null }
fun clear() {
_pendingLink.value = null
}
}

View File

@ -23,6 +23,7 @@ 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.JamSessionService
@ -219,6 +220,7 @@ val sharedModules = module {
createdAtStart() createdAtStart()
} }
single { JamSessionService(get(), get()) } single { JamSessionService(get(), get()) }
singleOf(::JamDeepLinkService)
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() } singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
single<AudioPlayerQueue> { single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get(), get(), get()) DeviceAudioPlayerQueue(get(), get(), get(), get(), get())

View File

@ -23,6 +23,7 @@ import com.appstractive.dnssd.createNetService
import com.appstractive.dnssd.discoverServices import com.appstractive.dnssd.discoverServices
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlin.text.decodeToString
data class DiscoveredDevice( data class DiscoveredDevice(
val name: String, val name: String,
@ -54,7 +55,7 @@ class DeviceDiscoveryService {
type = event.service.type, type = event.service.type,
host = event.service.host, host = event.service.host,
port = event.service.port, 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) DiscoveryState.Discovered(device = device, resolve = event.resolve)
} }
@ -65,7 +66,7 @@ class DeviceDiscoveryService {
type = event.service.type, type = event.service.type,
host = event.service.host, host = event.service.host,
port = event.service.port, 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) DiscoveryState.Resolved(device = device)
} }

View File

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

View File

@ -23,6 +23,7 @@ import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -38,6 +39,16 @@ import uniffi.compose_app.WebrtcEventHandler
import uniffi.compose_app.WebrtcPeerConnection import uniffi.compose_app.WebrtcPeerConnection
import uniffi.compose_app.createWebrtcPeerConnection 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( class JamSessionService(
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
private val settingsProvider: SettingsProvider, private val settingsProvider: SettingsProvider,
@ -66,100 +77,109 @@ class JamSessionService(
private val _incomingMessages = MutableSharedFlow<JamMessage>(extraBufferCapacity = 64) private val _incomingMessages = MutableSharedFlow<JamMessage>(extraBufferCapacity = 64)
val incomingMessages = _incomingMessages.asSharedFlow() val incomingMessages = _incomingMessages.asSharedFlow()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _incomingSuggestions = MutableSharedFlow<JamMessage>(extraBufferCapacity = 32) private val _incomingSuggestions = MutableSharedFlow<JamMessage>(extraBufferCapacity = 32)
val incomingSuggestions = _incomingSuggestions.asSharedFlow() 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>()
/** Guest side: the single connection to the host. */
private var hostConnection: WebrtcPeerConnection? = null private var hostConnection: WebrtcPeerConnection? = null
private val guestConnections = mutableMapOf<String, WebrtcPeerConnection>()
private val guestLabels = mutableMapOf<String, String>()
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 { suspend fun createSession(): String {
log.i { "Creating jam session" } log.i { "Creating jam session" }
val settings = settingsProvider.settingsState.first() val hostName = resolveParticipantName(defaultPrefix = "Host")
val participantName = settings?.jamParticipantName?.ifBlank {
"Host-${randomShortId()}"
} ?: "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 _role.value = JamRole.Host
_localParticipantId.value = "host" _localParticipantId.value = "host"
_participants.value = listOf( _participants.value = listOf(
JamParticipant( JamParticipant(
id = "host", id = "host",
displayName = participantName, displayName = hostName,
isHost = true, isHost = true,
) )
) )
_isActive.value = true _isActive.value = true
pc.createDataChannel("jam") return generateInvite().sdp
val offer = pc.createOffer()
log.i { "Generated SDP offer (length=${offer.length})" }
return offer
} }
suspend fun acceptGuestAnswer(guestId: String, answer: String) { /**
val pc = guestConnections[guestId] ?: run { * Generates a new invite (peer connection + SDP offer with bundled ICE candidates).
log.w { "acceptGuestAnswer: no connection for $guestId" } * Each invite admits exactly one guest.
return */
suspend fun generateInvite(): JamInvite {
if (_role.value != JamRole.Host) {
error("generateInvite can only be called by the host")
} }
pc.setRemoteAnswer(answer) val inviteId = "guest-${randomShortId()}"
} log.i { "Generating invite $inviteId" }
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 pc = createWebrtcPeerConnection( val pc = createWebrtcPeerConnection(
iceServers = listOf( iceServers = listOf(defaultIceServer()),
IceServerConfig( handler = guestEventHandler(inviteId),
urls = listOf("stun:stun.l.google.com:19302"),
username = "",
credential = "",
) )
),
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, handler = eventHandler,
) )
@ -168,65 +188,31 @@ class JamSessionService(
_localParticipantId.value = "guest" _localParticipantId.value = "guest"
_isActive.value = true _isActive.value = true
pc.setRemoteOffer(offer) // The data channel arrives in-band from the host's offer via on_data_channel;
pc.createDataChannel("jam") // we only answer here.
pc.setRemoteOffer(offerSdp)
val answer = pc.createAnswer() val answer = pc.createAnswer()
log.i { "Generated SDP answer (length=${answer.length})" } log.i { "Generated SDP answer (length=${answer.length})" }
return answer 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) { 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) { when (_role.value) {
JamRole.Host -> { JamRole.Host -> {
if (guestId != null) { val targets = if (guestId != null) {
guestConnections[guestId]?.sendData("jam-$guestId", json) listOfNotNull(connectedGuests[guestId])
} else { } else {
guestConnections.forEach { (id, pc) -> connectedGuests.values.toList()
pc.sendData("jam-$id", json)
} }
targets.forEach { pc ->
runCatching { pc.sendData(CHANNEL_LABEL, payload) }
.onFailure { e -> log.w(e) { "Failed to send to guest" } }
} }
} }
JamRole.Guest -> { JamRole.Guest -> {
hostConnection?.sendData("jam", json) hostConnection?.sendData(CHANNEL_LABEL, payload)
} }
null -> log.w { "sendMessage called while no session is active" } null -> log.w { "sendMessage called while no session is active" }
@ -236,46 +222,13 @@ class JamSessionService(
suspend fun leave() { suspend fun leave() {
log.i { "Leaving jam session" } log.i { "Leaving jam session" }
runCatching { sendMessage(JamMessage.Leave()) } runCatching { sendMessage(JamMessage.Leave()) }
hostConnection?.shutdown() shutdownAll()
guestConnections.values.forEach { runCatching { it.shutdown() } }
hostConnection = null
guestConnections.clear()
guestLabels.clear()
_role.value = null _role.value = null
_participants.value = emptyList() _participants.value = emptyList()
_isActive.value = false _isActive.value = false
_localParticipantId.value = null _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) { suspend fun broadcastPlaybackCommand(command: PlaybackCmd) {
if (_role.value != JamRole.Host) return if (_role.value != JamRole.Host) return
sendMessage(JamMessage.PlaybackCommand(command)) sendMessage(JamMessage.PlaybackCommand(command))
@ -300,8 +253,113 @@ class JamSessionService(
if (_role.value != JamRole.Guest) return if (_role.value != JamRole.Guest) return
sendMessage(JamMessage.SuggestPlaylist(tracks)) 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 <T> MutableStateFlow<T>.update(transform: (T) -> T) { private fun <T> MutableStateFlow<T>.update(transform: (T) -> T) {
value = transform(value) value = transform(value)
} }

View File

@ -19,12 +19,17 @@ package dev.krtirtho.spotube.modules.jam
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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
@ -32,15 +37,20 @@ import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.remember
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.Modifier 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@ -50,17 +60,7 @@ fun JamScreen(
navigationCommands: NavigationCommands, navigationCommands: NavigationCommands,
) { ) {
val viewModel = koinViewModel<JamViewModel>() val viewModel = koinViewModel<JamViewModel>()
val isActive by viewModel.isActive.collectAsStateWithLifecycle() val state by viewModel.uiState.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.
}
}
Scaffold( Scaffold(
topBar = { topBar = {
@ -74,33 +74,54 @@ fun JamScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding)
.padding(16.dp), .padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
if (error != null) { state.error?.let { error ->
ErrorBanner(text = error, onDismiss = viewModel::clearError)
}
when {
!state.isActive && state.incomingOfferSdp != null -> IncomingInviteView(
hostName = state.incomingHostName.orEmpty(),
onJoin = viewModel::joinWithIncomingInvite,
onDismiss = viewModel::dismissIncomingInvite,
)
!state.isActive -> CreateOrJoinView(
onCreate = viewModel::createSession,
onJoin = viewModel::joinWithPasted,
)
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 = error ?: "", text = text,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
} OutlinedButton(onClick = onDismiss) {
Text("Dismiss")
if (pendingOffer == null && pendingAnswer == null) {
CreateOrJoinView(
onCreate = { viewModel.createSession() },
onJoin = { offer -> viewModel.joinSession(offer) },
)
} else if (pendingOffer != null) {
HostOfferView(
offer = pendingOffer!!,
onLeave = { viewModel.leave() },
)
} else if (pendingAnswer != null) {
GuestAnswerView(
answer = pendingAnswer!!,
onLeave = { viewModel.leave() },
)
}
} }
} }
} }
@ -111,12 +132,12 @@ private fun CreateOrJoinView(
onJoin: (String) -> Unit, onJoin: (String) -> Unit,
) { ) {
var tab by remember { mutableIntStateOf(0) } var tab by remember { mutableIntStateOf(0) }
var offer by remember { 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", text = "Listen together with friends over a peer-to-peer connection.",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleMedium,
) )
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
@ -135,8 +156,10 @@ private fun CreateOrJoinView(
if (tab == 0) { if (tab == 0) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Button(onClick = onCreate) { Button(onClick = onCreate) {
Text("Create Session") Text("Create Session")
@ -145,22 +168,24 @@ private fun CreateOrJoinView(
} else { } else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
OutlinedTextField( OutlinedTextField(
value = offer, value = pasted,
onValueChange = { offer = it }, onValueChange = { pasted = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text("Host's SDP offer") }, label = { Text("Invite link") },
minLines = 3, placeholder = { Text("spotube://jam/invite?...") },
minLines = 2,
maxLines = 6, maxLines = 6,
) )
Button( Button(
onClick = { onJoin(offer.trim()) }, onClick = { onJoin(pasted) },
enabled = offer.isNotBlank(), enabled = pasted.isNotBlank(),
) { ) {
Text("Generate Answer") Text("Join Session")
} }
} }
} }
@ -168,60 +193,196 @@ private fun CreateOrJoinView(
} }
@Composable @Composable
private fun HostOfferView( private fun IncomingInviteView(
offer: String, hostName: String,
onLeave: () -> Unit, onJoin: () -> Unit,
onDismiss: () -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( Text(
text = "Session created. Send this SDP offer to your friends:", text = "$hostName invited you to a jam session",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleMedium,
) )
SelectionContainer { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField( Button(onClick = onJoin) {
value = offer, Text("Join")
onValueChange = {}, }
readOnly = true, OutlinedButton(onClick = onDismiss) {
modifier = Modifier.fillMaxWidth(), Text("Ignore")
label = { Text("SDP Offer (copy and send to guests)") }, }
minLines = 4, }
maxLines = 10, }
}
@Composable
private fun HostSessionView(
state: JamUiState,
onNewInvite: () -> Unit,
onSubmitAnswer: (String) -> Unit,
onShare: (String) -> Unit,
onLeave: () -> Unit,
) {
val clipboard = LocalClipboardManager.current
var pastedAnswer by rememberSaveable { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ParticipantsSection(state.participants)
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")
}
HorizontalDivider()
Text( Text(
text = "When a guest responds with an SDP answer, use the JamSessionScreen to add them.", 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Button(onClick = onLeave) { OutlinedTextField(
Text("Leave Session") 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<dev.krtirtho.spotube.core.jam.JamParticipant>) {
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 @Composable
private fun GuestAnswerView( private fun ShareableLinkBox(
answer: String, label: String,
onLeave: () -> Unit, link: String,
onCopy: () -> Unit,
onShare: () -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { val viewModel: JamViewModel = koinViewModel()
Text( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
text = "You've joined the session. Send this SDP answer back to the host:",
style = MaterialTheme.typography.bodyMedium,
)
SelectionContainer { SelectionContainer {
OutlinedTextField( OutlinedTextField(
value = answer, value = link,
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text("SDP Answer (copy and send to host)") }, label = { Text(label) },
minLines = 4, minLines = 2,
maxLines = 10, maxLines = 6,
) )
} }
Button(onClick = onLeave) { 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") Text("Leave Session")
} }
} }
}

View File

@ -19,67 +19,205 @@ 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.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.core.jam.JamMessage 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.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.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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class JamViewModel : ViewModel(), KoinComponent { data class JamUiState(
private val jamSession: JamSessionService by inject() val isActive: Boolean = false,
private val audioPlayer: AudioPlayerInterface by inject() val role: JamRole? = null,
val participants: List<JamParticipant> = emptyList(),
/** Host: deep link containing this session's SDP offer, ready to share. */
val inviteLink: String? = null,
/** Guest: deep link containing our SDP answer, to send back to the host. */
val answerLink: String? = null,
/** Guest: offer received via deep link (or paste), waiting for confirmation. */
val incomingHostName: String? = null,
val incomingOfferSdp: String? = null,
val error: String? = null,
)
val role: StateFlow<JamRole?> = jamSession.role class JamViewModel(
val participants: StateFlow<List<JamParticipant>> = jamSession.participants private val jamSession: JamSessionService,
val isActive: StateFlow<Boolean> = jamSession.isActive private val deepLinks: JamDeepLinkService,
private val shareService: ShareService,
private val settingsProvider: SettingsProvider,
) : ViewModel() {
private val _pendingHostOffer = MutableStateFlow<String?>(null) private val _uiState = MutableStateFlow(JamUiState())
val pendingHostOffer: StateFlow<String?> = _pendingHostOffer.asStateFlow() val uiState: StateFlow<JamUiState> = _uiState.asStateFlow()
private val _pendingGuestAnswer = MutableStateFlow<String?>(null) val supportsNativeShare: Boolean =
val pendingGuestAnswer: StateFlow<String?> = _pendingGuestAnswer.asStateFlow() getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
private val _error = MutableStateFlow<String?>(null) init {
val error: StateFlow<String?> = _error.asStateFlow() viewModelScope.launch {
// 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 createSession() { fun createSession() {
viewModelScope.launch { viewModelScope.launch {
try { runCatching {
val offer = jamSession.createSession() val offer = jamSession.createSession()
_pendingHostOffer.value = offer JamInviteCodec.buildHostInvite(localName(), offer)
} catch (e: Exception) { }.onSuccess { link ->
_error.value = "Failed to create session: ${e.message}" _uiState.update { it.copy(inviteLink = link, error = null) }
}.onFailure { e ->
_uiState.update { it.copy(error = "Failed to create session: ${e.message}") }
} }
} }
} }
fun joinSession(offer: String) { fun generateNewInvite() {
viewModelScope.launch { viewModelScope.launch {
try { runCatching {
val answer = jamSession.joinSession(offer) val invite = jamSession.generateInvite()
_pendingGuestAnswer.value = answer JamInviteCodec.buildHostInvite(localName(), invite.sdp)
} catch (e: Exception) { }.onSuccess { link ->
_error.value = "Failed to join session: ${e.message}" _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() { fun leave() {
viewModelScope.launch { viewModelScope.launch {
jamSession.leave() jamSession.leave()
_pendingHostOffer.value = null deepLinks.clear()
_pendingGuestAnswer.value = null _uiState.update {
JamUiState(incomingOfferSdp = it.incomingOfferSdp, incomingHostName = it.incomingHostName)
}
} }
} }
fun clearError() { 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()
}

View File

@ -29,6 +29,7 @@ import androidx.compose.ui.window.rememberWindowState
import com.sun.jna.Library import com.sun.jna.Library
import com.sun.jna.Native import com.sun.jna.Native
import dev.krtirtho.spotube.core.di.initKoin 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.newpipe.NewPipeDownloader
import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.core.systemtray.SystemTray import dev.krtirtho.spotube.core.systemtray.SystemTray
@ -81,9 +82,28 @@ private fun disableWebKitGpuCompositing() {
LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1) LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1)
} }
/**
* Routes `spotube://` deep links into [ExternalUriHandler].
* macOS delivers them through the open-URI handler; on Linux/Windows they arrive
* as command line arguments (scheme registration is handled by the distribution
* packaging, e.g. the `.desktop` file's `Exec %u`).
*/
private fun handleStartupDeepLinks(args: Array<String>) {
runCatching {
if (java.awt.Desktop.isDesktopSupported()) {
java.awt.Desktop.getDesktop().setOpenURIHandler { event ->
ExternalUriHandler.onNewUri(event.uri.toString())
}
}
}
args.firstOrNull { it.startsWith("spotube:", ignoreCase = true) }
?.let(ExternalUriHandler::onNewUri)
}
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
fun main() { fun main(args: Array<String>) {
disableWebKitGpuCompositing() disableWebKitGpuCompositing()
handleStartupDeepLinks(args)
FileKit.init(appId = "dev.krtirtho.spotube") FileKit.init(appId = "dev.krtirtho.spotube")
initKoin() initKoin()
NewPipeDownloader.init(KoinPathsProvider.paths) NewPipeDownloader.init(KoinPathsProvider.paths)

View File

@ -4,6 +4,17 @@
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>dev.krtirtho.spotube</string>
<key>CFBundleURLSchemes</key>
<array>
<string>spotube</string>
</array>
</dict>
</array>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Required to discover local network devices</string> <string>Required to discover local network devices</string>
<key>NSBonjourServices</key> <key>NSBonjourServices</key>

View File

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