feat: basic livekit/voice chat impl

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-05-31 21:51:13 +02:00
parent afba6f9ecf
commit 740d693be4
4 changed files with 160 additions and 10 deletions

View File

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

View File

@ -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<String?>(null) }
var voiceChannelId by rememberSaveable { mutableStateOf<String?>(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
}
)
}
}
}

View File

@ -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<String>("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<String>("voiceToken") ?: "")
var voiceToken: String
get() = _voiceToken.value
private set(value) {
_voiceToken.value = value
state["voiceToken"] = value
}
var errorResource by mutableStateOf<Int?>(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")
}
}
}
}
}

View File

@ -604,6 +604,10 @@
<string name="voice_join_permission_rationale_assurance">Don\'t worry, we won\'t use your microphone or camera without your permission.</string>
<string name="voice_join_permission_rationale_cta">Grant permissions</string>
<string name="voice_error_not_supported">Voice channels are not available at the moment.</string>
<string name="voice_error_no_nodes">All voice nodes are unavailable at the moment. Please try again later.</string>
<string name="voice_error_generic">An error occurred. Please try again later.</string>
<string name="spark_notifications_rationale">Stay in the loop</string>
<string name="spark_notifications_rationale_description">Enable notifications to be kept up to date with messages and mentions.</string>
<string name="spark_notifications_rationale_cta">Enable notifications</string>