From 740d693be454f72b8a937c5eca3aab0173631675 Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 31 May 2025 21:51:13 +0200 Subject: [PATCH] feat: basic livekit/voice chat impl Signed-off-by: Infi --- app/build.gradle.kts | 4 +- .../chat/revolt/activities/MainActivity.kt | 18 ++- .../revolt/composables/voice/VoiceSheet.kt | 144 +++++++++++++++++- app/src/main/res/values/strings.xml | 4 + 4 files changed, 160 insertions(+), 10 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3f3d99d5..78be7001 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,8 +29,8 @@ val ktorVersion = "3.0.0-beta-2" val media3Version = "1.5.0" object LivekitVersion { - val core = "2.14.1" - val componentsCompose = "1.3.0" + val core = "2.16.0" + val componentsCompose = "1.3.1" } val material3Version = "1.4.0-alpha10" diff --git a/app/src/main/java/chat/revolt/activities/MainActivity.kt b/app/src/main/java/chat/revolt/activities/MainActivity.kt index 3588fdab..153b10c6 100644 --- a/app/src/main/java/chat/revolt/activities/MainActivity.kt +++ b/app/src/main/java/chat/revolt/activities/MainActivity.kt @@ -38,7 +38,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -88,6 +87,7 @@ import chat.revolt.api.settings.LoadedSettings import chat.revolt.api.settings.SyncedSettings import chat.revolt.composables.generic.HealthAlert import chat.revolt.composables.voice.VoicePermissionSwitch +import chat.revolt.composables.voice.VoiceSheet import chat.revolt.material.EasingTokens import chat.revolt.ndk.NativeLibraries import chat.revolt.persistence.KVStorage @@ -438,7 +438,7 @@ fun AppEntrypoint( onUpdateNextDestination: (String) -> Unit = {} ) { var showVoiceUI by rememberSaveable { mutableStateOf(false) } - var voiceChannelID by rememberSaveable { mutableStateOf(null) } + var voiceChannelId by rememberSaveable { mutableStateOf(null) } val chatUIScale by animateFloatAsState( if (showVoiceUI) 0.8f else 1.0f, @@ -625,7 +625,7 @@ fun AppEntrypoint( }, onEnterVoiceUI = { channelId -> showVoiceUI = true - voiceChannelID = channelId + voiceChannelId = channelId }, ) } @@ -769,10 +769,14 @@ fun AppEntrypoint( showVoiceUI = false } ) { - Button(onClick = { - showVoiceUI = false - }) { - Text("Close voice UI") + voiceChannelId?.let { + VoiceSheet( + it, + onDisconnect = { + showVoiceUI = false + voiceChannelId = null + } + ) } } } diff --git a/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt b/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt index 0eaa06b4..c2f83ada 100644 --- a/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt +++ b/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt @@ -1,8 +1,150 @@ package chat.revolt.composables.voice +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import chat.revolt.R +import chat.revolt.api.routes.misc.getRootRoute +import chat.revolt.api.routes.voice.joinCall +import io.livekit.android.compose.local.RoomLocal +import io.livekit.android.compose.local.RoomScope +import io.livekit.android.compose.state.rememberTracks +import io.livekit.android.compose.ui.VideoTrackView +import logcat.LogPriority +import logcat.asLog +import logcat.logcat + +class VoiceSheetViewModel(private val state: SavedStateHandle) : ViewModel() { + private val _channelId = mutableStateOf(state.get("channelId") ?: "") + var channelId: String + get() = _channelId.value + set(value) { + _channelId.value = value + state["channelId"] = value + } + + var voiceLkNode by mutableStateOf("") + private val _voiceToken = mutableStateOf(state.get("voiceToken") ?: "") + var voiceToken: String + get() = _voiceToken.value + private set(value) { + _voiceToken.value = value + state["voiceToken"] = value + } + + var errorResource by mutableStateOf(null) + private set + + suspend fun getVoiceToken() { + val root = getRootRoute() + val lk = root.features.livekit + + if (lk == null) { + logcat(LogPriority.ERROR) { + IllegalStateException("LiveKit is not supported by this API version!").asLog() + } + errorResource = R.string.voice_error_not_supported + return + } + + if (lk.nodes.isEmpty()) { + logcat(LogPriority.ERROR) { IllegalStateException("No LiveKit nodes available!").asLog() } + errorResource = R.string.voice_error_no_nodes + return + } + + val node = lk.nodes.random() + try { + val joined = joinCall(channelId, node.name) + voiceLkNode = joined.url + voiceToken = joined.token + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Could not get LiveKit token\n" + e.asLog() } + errorResource = R.string.voice_error_generic + return + } + } +} @Composable -fun VoiceSheet(channelId: String) { +fun VoiceSheet( + channelId: String, + onDisconnect: () -> Unit, + viewModel: VoiceSheetViewModel = viewModel() +) { + LaunchedEffect(channelId) { + viewModel.channelId = channelId + viewModel.getVoiceToken() + } + RoomScope( + url = viewModel.voiceLkNode, + token = viewModel.voiceToken, + audio = true, + video = false, + connect = true, + ) { + val room = RoomLocal.current + val trackRefs = rememberTracks() + + Column { + LazyColumn(modifier = Modifier.animateContentSize()) { + items(trackRefs.size) { index -> + VideoTrackView( + trackReference = trackRefs[index], + modifier = Modifier.fillParentMaxHeight(0.5f) + ) + } + item(key = "stats") { + Text("status = ${room.state.name}") + } + item(key = "controls") { + Button(onClick = { + room.setMicrophoneMute(true) + }) { + Text("mute 🎤🫷😤") + } + Button(onClick = { + room.setMicrophoneMute(false) + }) { + Text("unmute 🗣️📢🔥") + } + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Button( + colors = ButtonDefaults.buttonColors().copy( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + disabledContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f), + disabledContentColor = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.5f) + ), + onClick = { + room.disconnect() + onDisconnect() + }, + ) { + Text("disconnect") + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 08db6b0a..e4d98e87 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -604,6 +604,10 @@ Don\'t worry, we won\'t use your microphone or camera without your permission. Grant permissions + Voice channels are not available at the moment. + All voice nodes are unavailable at the moment. Please try again later. + An error occurred. Please try again later. + Stay in the loop Enable notifications to be kept up to date with messages and mentions. Enable notifications