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