mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 06:34:00 +00:00
feat(deeplinks): implement deep link handling for jam sessions
This commit is contained in:
parent
d3a8548661
commit
f21442b1c2
@ -55,6 +55,17 @@
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Group Jam invite/answer deep links: spotube://jam/... -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="jam"
|
||||
android:scheme="spotube" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".media.PlaybackService"
|
||||
|
||||
@ -17,10 +17,12 @@
|
||||
|
||||
package dev.krtirtho.spotube
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler
|
||||
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import io.github.vinceglb.filekit.FileKit
|
||||
@ -32,8 +34,14 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
FileKit.init(this)
|
||||
NewPipeDownloader.init(Paths(this))
|
||||
intent?.dataString?.let(ExternalUriHandler::onNewUri)
|
||||
setContent {
|
||||
App()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
intent.dataString?.let(ExternalUriHandler::onNewUri)
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
@ -32,6 +33,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler
|
||||
import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService
|
||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
||||
@ -103,6 +106,12 @@ fun App(
|
||||
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
|
||||
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
|
||||
|
||||
val jamDeepLinks: JamDeepLinkService = koinInject()
|
||||
DisposableEffect(Unit) {
|
||||
ExternalUriHandler.listener = { uri -> jamDeepLinks.handleUri(uri) }
|
||||
onDispose { ExternalUriHandler.listener = null }
|
||||
}
|
||||
|
||||
val navigationState = rememberNavigationState(
|
||||
startRoute = Routes.Home,
|
||||
topLevelRoutes = TOP_LEVEL_ROUTES
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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<QueueStateRepository>() }
|
||||
single<AudioPlayerQueue> {
|
||||
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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<JamMessage>(extraBufferCapacity = 64)
|
||||
val incomingMessages = _incomingMessages.asSharedFlow()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private val _incomingSuggestions = MutableSharedFlow<JamMessage>(extraBufferCapacity = 32)
|
||||
val incomingSuggestions = _incomingSuggestions.asSharedFlow()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
/** Host side: invites whose answers have not arrived yet. */
|
||||
private val pendingInvites = mutableMapOf<String, WebrtcPeerConnection>()
|
||||
|
||||
/** Host side: guests whose handshake completed. Keyed by invite id. */
|
||||
private val connectedGuests = mutableMapOf<String, WebrtcPeerConnection>()
|
||||
|
||||
/** Guest side: the single connection to the host. */
|
||||
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 {
|
||||
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 <T> MutableStateFlow<T>.update(transform: (T) -> T) {
|
||||
value = transform(value)
|
||||
}
|
||||
|
||||
@ -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<JamViewModel>()
|
||||
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,33 +74,54 @@ fun JamScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp),
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
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 = error ?: "",
|
||||
text = text,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
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() },
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = onDismiss) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -111,12 +132,12 @@ private fun CreateOrJoinView(
|
||||
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,
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onJoin) {
|
||||
Text("Join")
|
||||
}
|
||||
OutlinedButton(onClick = onDismiss) {
|
||||
Text("Ignore")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 = "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,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(onClick = onLeave) {
|
||||
Text("Leave Session")
|
||||
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<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
|
||||
private fun GuestAnswerView(
|
||||
answer: String,
|
||||
onLeave: () -> Unit,
|
||||
private fun ShareableLinkBox(
|
||||
label: String,
|
||||
link: String,
|
||||
onCopy: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "You've joined the session. Send this SDP answer back to the host:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
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")
|
||||
}
|
||||
}
|
||||
@ -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<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
|
||||
val participants: StateFlow<List<JamParticipant>> = jamSession.participants
|
||||
val isActive: StateFlow<Boolean> = 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<String?>(null)
|
||||
val pendingHostOffer: StateFlow<String?> = _pendingHostOffer.asStateFlow()
|
||||
private val _uiState = MutableStateFlow(JamUiState())
|
||||
val uiState: StateFlow<JamUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _pendingGuestAnswer = MutableStateFlow<String?>(null)
|
||||
val pendingGuestAnswer: StateFlow<String?> = _pendingGuestAnswer.asStateFlow()
|
||||
val supportsNativeShare: Boolean =
|
||||
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
init {
|
||||
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() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
runCatching {
|
||||
val offer = jamSession.createSession()
|
||||
_pendingHostOffer.value = offer
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Failed to create session: ${e.message}"
|
||||
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 joinSession(offer: String) {
|
||||
fun generateNewInvite() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val answer = jamSession.joinSession(offer)
|
||||
_pendingGuestAnswer.value = answer
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Failed to join session: ${e.message}"
|
||||
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()
|
||||
}
|
||||
@ -29,6 +29,7 @@ import androidx.compose.ui.window.rememberWindowState
|
||||
import com.sun.jna.Library
|
||||
import com.sun.jna.Native
|
||||
import dev.krtirtho.spotube.core.di.initKoin
|
||||
import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler
|
||||
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import dev.krtirtho.spotube.core.systemtray.SystemTray
|
||||
@ -81,9 +82,28 @@ private fun disableWebKitGpuCompositing() {
|
||||
LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes `spotube://` deep links into [ExternalUriHandler].
|
||||
* macOS delivers them through the open-URI handler; on Linux/Windows they arrive
|
||||
* as command line arguments (scheme registration is handled by the distribution
|
||||
* packaging, e.g. the `.desktop` file's `Exec %u`).
|
||||
*/
|
||||
private fun handleStartupDeepLinks(args: Array<String>) {
|
||||
runCatching {
|
||||
if (java.awt.Desktop.isDesktopSupported()) {
|
||||
java.awt.Desktop.getDesktop().setOpenURIHandler { event ->
|
||||
ExternalUriHandler.onNewUri(event.uri.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
args.firstOrNull { it.startsWith("spotube:", ignoreCase = true) }
|
||||
?.let(ExternalUriHandler::onNewUri)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
fun main(args: Array<String>) {
|
||||
disableWebKitGpuCompositing()
|
||||
handleStartupDeepLinks(args)
|
||||
FileKit.init(appId = "dev.krtirtho.spotube")
|
||||
initKoin()
|
||||
NewPipeDownloader.init(KoinPathsProvider.paths)
|
||||
|
||||
@ -4,6 +4,17 @@
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>dev.krtirtho.spotube</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>spotube</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Required to discover local network devices</string>
|
||||
<key>NSBonjourServices</key>
|
||||
|
||||
@ -20,6 +20,9 @@ struct iOSApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.onOpenURL { url in
|
||||
ExternalUriHandler.shared.onNewUri(uri: url.absoluteString)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user