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" />
</intent-filter>
<!-- Group Jam invite/answer deep links: spotube://jam/... -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="jam"
android:scheme="spotube" />
</intent-filter>
</activity>
<service
android:name=".media.PlaybackService"

View File

@ -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)
}
}

View File

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

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.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())

View File

@ -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)
}

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 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)
}

View File

@ -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,49 +74,70 @@ fun JamScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (error != null) {
Text(
text = error ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
state.error?.let { error ->
ErrorBanner(text = error, onDismiss = viewModel::clearError)
}
if (pendingOffer == null && pendingAnswer == null) {
CreateOrJoinView(
onCreate = { viewModel.createSession() },
onJoin = { offer -> viewModel.joinSession(offer) },
when {
!state.isActive && state.incomingOfferSdp != null -> IncomingInviteView(
hostName = state.incomingHostName.orEmpty(),
onJoin = viewModel::joinWithIncomingInvite,
onDismiss = viewModel::dismissIncomingInvite,
)
} else if (pendingOffer != null) {
HostOfferView(
offer = pendingOffer!!,
onLeave = { viewModel.leave() },
!state.isActive -> CreateOrJoinView(
onCreate = viewModel::createSession,
onJoin = viewModel::joinWithPasted,
)
} else if (pendingAnswer != null) {
GuestAnswerView(
answer = pendingAnswer!!,
onLeave = { viewModel.leave() },
state.role == JamRole.Host -> HostSessionView(
state = state,
onNewInvite = viewModel::generateNewInvite,
onSubmitAnswer = viewModel::submitAnswerPasted,
onShare = viewModel::share,
onLeave = viewModel::leave,
)
else -> GuestSessionView(
state = state,
onShare = viewModel::share,
onLeave = viewModel::leave,
)
}
}
}
}
@Composable
private fun ErrorBanner(text: String, onDismiss: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = text,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(onClick = onDismiss) {
Text("Dismiss")
}
}
}
@Composable
private fun CreateOrJoinView(
onCreate: () -> Unit,
onJoin: (String) -> Unit,
) {
var tab by remember { mutableIntStateOf(0) }
var offer by remember { mutableStateOf("") }
var pasted by rememberSaveable { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = "Listen Together with friends",
style = MaterialTheme.typography.titleLarge,
text = "Listen together with friends over a peer-to-peer connection.",
style = MaterialTheme.typography.titleMedium,
)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
@ -135,8 +156,10 @@ private fun CreateOrJoinView(
if (tab == 0) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Create a new jam session. You'll be the host and can control playback. Share the SDP offer with your friends so they can join.",
text = "Start a session as the host. You'll get a shareable invite link " +
"to send to friends; when they accept, they appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = onCreate) {
Text("Create Session")
@ -145,22 +168,24 @@ private fun CreateOrJoinView(
} else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Paste the SDP offer from the host below. You'll get an SDP answer to send back.",
text = "Paste the invite link the host shared with you.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = offer,
onValueChange = { offer = it },
value = pasted,
onValueChange = { pasted = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Host's SDP offer") },
minLines = 3,
label = { Text("Invite link") },
placeholder = { Text("spotube://jam/invite?...") },
minLines = 2,
maxLines = 6,
)
Button(
onClick = { onJoin(offer.trim()) },
enabled = offer.isNotBlank(),
onClick = { onJoin(pasted) },
enabled = pasted.isNotBlank(),
) {
Text("Generate Answer")
Text("Join Session")
}
}
}
@ -168,60 +193,196 @@ private fun CreateOrJoinView(
}
@Composable
private fun HostOfferView(
offer: String,
onLeave: () -> Unit,
private fun IncomingInviteView(
hostName: String,
onJoin: () -> Unit,
onDismiss: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Session created. Send this SDP offer to your friends:",
style = MaterialTheme.typography.bodyMedium,
text = "$hostName invited you to a jam session",
style = MaterialTheme.typography.titleMedium,
)
SelectionContainer {
OutlinedTextField(
value = offer,
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth(),
label = { Text("SDP Offer (copy and send to guests)") },
minLines = 4,
maxLines = 10,
)
}
Text(
text = "When a guest responds with an SDP answer, use the JamSessionScreen to add them.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = onLeave) {
Text("Leave Session")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onJoin) {
Text("Join")
}
OutlinedButton(onClick = onDismiss) {
Text("Ignore")
}
}
}
}
@Composable
private fun GuestAnswerView(
answer: String,
private fun HostSessionView(
state: JamUiState,
onNewInvite: () -> Unit,
onSubmitAnswer: (String) -> Unit,
onShare: (String) -> Unit,
onLeave: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val clipboard = LocalClipboardManager.current
var pastedAnswer by rememberSaveable { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ParticipantsSection(state.participants)
HorizontalDivider()
Text(
text = "You've joined the session. Send this SDP answer back to the host:",
style = MaterialTheme.typography.bodyMedium,
text = "Invite someone",
style = MaterialTheme.typography.titleSmall,
)
val inviteLink = state.inviteLink
if (inviteLink != null) {
ShareableLinkBox(
label = "Invite link",
link = inviteLink,
onCopy = { clipboard.setText(AnnotatedString(inviteLink)) },
onShare = { onShare(inviteLink) },
)
}
OutlinedButton(onClick = onNewInvite) {
Text("Generate new invite")
}
HorizontalDivider()
Text(
text = "Accept a guest's answer",
style = MaterialTheme.typography.titleSmall,
)
Text(
text = "When your guest sends back their answer link, paste it below.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = pastedAnswer,
onValueChange = { pastedAnswer = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Answer link or SDP") },
minLines = 2,
maxLines = 6,
)
Button(
onClick = {
onSubmitAnswer(pastedAnswer)
pastedAnswer = ""
},
enabled = pastedAnswer.isNotBlank(),
) {
Text("Accept Answer")
}
LeaveButton(onLeave)
}
}
@Composable
private fun GuestSessionView(
state: JamUiState,
onShare: (String) -> Unit,
onLeave: () -> Unit,
) {
val clipboard = LocalClipboardManager.current
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ParticipantsSection(state.participants)
val answerLink = state.answerLink
if (answerLink == null) {
Text(
text = "Connecting to the session...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Text(
text = "Almost there! Send your answer back to the host:",
style = MaterialTheme.typography.titleSmall,
)
ShareableLinkBox(
label = "Answer link",
link = answerLink,
onCopy = { clipboard.setText(AnnotatedString(answerLink)) },
onShare = { onShare(answerLink) },
)
}
LeaveButton(onLeave)
}
}
@Composable
private fun ParticipantsSection(participants: List<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 ShareableLinkBox(
label: String,
link: String,
onCopy: () -> Unit,
onShare: () -> Unit,
) {
val viewModel: JamViewModel = koinViewModel()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SelectionContainer {
OutlinedTextField(
value = answer,
value = link,
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth(),
label = { Text("SDP Answer (copy and send to host)") },
minLines = 4,
maxLines = 10,
label = { Text(label) },
minLines = 2,
maxLines = 6,
)
}
Button(onClick = onLeave) {
Text("Leave Session")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onCopy) {
Text("Copy")
}
if (viewModel.supportsNativeShare) {
OutlinedButton(onClick = onShare) {
Text("Share")
}
}
}
}
}
@Composable
private fun LeaveButton(onLeave: () -> Unit) {
OutlinedButton(onClick = onLeave) {
Text("Leave Session")
}
}

View File

@ -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()
fun createSession() {
init {
viewModelScope.launch {
try {
val offer = jamSession.createSession()
_pendingHostOffer.value = offer
} catch (e: Exception) {
_error.value = "Failed to create session: ${e.message}"
// Mirror live session state into the UI state.
jamSession.isActive.collect { active ->
_uiState.update {
it.copy(
isActive = active,
role = jamSession.role.value,
participants = jamSession.participants.value,
inviteLink = if (!active) null else it.inviteLink,
answerLink = if (!active) null else it.answerLink,
incomingOfferSdp = if (!active) it.incomingOfferSdp else null,
incomingHostName = if (!active) it.incomingHostName else null,
)
}
}
}
viewModelScope.launch {
jamSession.participants.collect { participants ->
_uiState.update { it.copy(participants = participants) }
}
}
viewModelScope.launch {
deepLinks.pendingLink.collect { link ->
handleDeepLink(link)
}
}
}
fun joinSession(offer: String) {
fun createSession() {
viewModelScope.launch {
try {
val answer = jamSession.joinSession(offer)
_pendingGuestAnswer.value = answer
} catch (e: Exception) {
_error.value = "Failed to join session: ${e.message}"
runCatching {
val offer = jamSession.createSession()
JamInviteCodec.buildHostInvite(localName(), offer)
}.onSuccess { link ->
_uiState.update { it.copy(inviteLink = link, error = null) }
}.onFailure { e ->
_uiState.update { it.copy(error = "Failed to create session: ${e.message}") }
}
}
}
fun generateNewInvite() {
viewModelScope.launch {
runCatching {
val invite = jamSession.generateInvite()
JamInviteCodec.buildHostInvite(localName(), invite.sdp)
}.onSuccess { link ->
_uiState.update { it.copy(inviteLink = link, error = null) }
}.onFailure { e ->
_uiState.update { it.copy(error = "Failed to generate invite: ${e.message}") }
}
}
}
fun joinWithIncomingInvite() {
val sdp = _uiState.value.incomingOfferSdp ?: return
join(sdp)
}
fun joinWithPasted(input: String) {
val sdp = JamInviteCodec.extractSdp(input)
if (sdp == null) {
_uiState.update { it.copy(error = "That doesn't look like a valid jam invite.") }
return
}
join(sdp)
}
/**
* Host side: accepts an answer pasted as raw SDP or as a full `spotube://jam/answer` link.
*/
fun submitAnswerPasted(input: String) {
when (val parsed = JamInviteCodec.parse(input.trim())) {
is JamInviteLink.GuestAnswer -> acceptAnswerInternal(parsed.sdp, parsed.peerName)
else -> {
val sdp = JamInviteCodec.extractSdp(input)
if (sdp == null) {
_uiState.update { it.copy(error = "That doesn't look like a valid SDP answer.") }
} else {
acceptAnswerInternal(sdp, "")
}
}
}
}
fun share(text: String) {
shareService.share(text, "Spotube Group Jam")
}
fun leave() {
viewModelScope.launch {
jamSession.leave()
_pendingHostOffer.value = null
_pendingGuestAnswer.value = null
deepLinks.clear()
_uiState.update {
JamUiState(incomingOfferSdp = it.incomingOfferSdp, incomingHostName = it.incomingHostName)
}
}
}
fun clearError() {
_error.value = null
_uiState.update { it.copy(error = null) }
}
fun dismissIncomingInvite() {
deepLinks.clear()
_uiState.update { it.copy(incomingOfferSdp = null, incomingHostName = null) }
}
private fun join(offerSdp: String) {
viewModelScope.launch {
runCatching {
val answer = jamSession.joinSession(offerSdp)
JamInviteCodec.buildGuestAnswer(localName(), answer)
}.onSuccess { link ->
_uiState.update {
it.copy(answerLink = link, incomingOfferSdp = null, incomingHostName = null, error = null)
}
}.onFailure { e ->
_uiState.update { it.copy(error = "Failed to join session: ${e.message}") }
}
}
}
private fun acceptAnswerInternal(answerSdp: String, peerName: String) {
viewModelScope.launch {
val accepted = runCatching { jamSession.acceptAnswer(null, answerSdp, peerName) }
.getOrDefault(false)
if (!accepted) {
_uiState.update { it.copy(error = "Couldn't accept that answer — no pending invite matched.") }
} else {
_uiState.update { it.copy(error = null) }
}
}
}
private suspend fun handleDeepLink(link: JamInviteLink?) {
when (link) {
is JamInviteLink.HostInvite -> {
if (!jamSession.isActive.value) {
_uiState.update {
it.copy(incomingHostName = link.peerName.ifBlank { "Someone" }, incomingOfferSdp = link.sdp)
}
}
}
is JamInviteLink.GuestAnswer -> {
if (jamSession.role.value == JamRole.Host) {
acceptAnswerInternal(link.sdp, link.peerName)
}
}
null -> Unit
}
}
private fun localName(): String =
settingsProvider.settingsState.value?.jamParticipantName.orEmpty()
}

View File

@ -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)

View File

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

View File

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