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