feat: continue voice impl

This commit is contained in:
infi 2026-04-24 17:29:57 +02:00
parent 623ace3998
commit 10efd73965
9 changed files with 201 additions and 73 deletions

View File

@ -258,9 +258,9 @@ dependencies {
implementation(libs.jetbrains.markdown) implementation(libs.jetbrains.markdown)
implementation(libs.highlights) implementation(libs.highlights)
// implementation(libs.livekit.android) implementation(libs.livekit.android)
// implementation(libs.livekit.android.camerax) implementation(libs.livekit.android.camerax)
// implementation(libs.livekit.android.compose) implementation(libs.livekit.android.compose)
implementation(platform(libs.firebase.bom)) implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging) implementation(libs.firebase.messaging)

View File

@ -88,6 +88,7 @@ import chat.stoat.api.settings.LoadedSettings
import chat.stoat.api.settings.SyncedSettings import chat.stoat.api.settings.SyncedSettings
import chat.stoat.composables.generic.HealthAlert import chat.stoat.composables.generic.HealthAlert
import chat.stoat.composables.voice.VoicePermissionSwitch import chat.stoat.composables.voice.VoicePermissionSwitch
import chat.stoat.composables.voice.VoiceSheet
import chat.stoat.core.model.schemas.HealthNotice import chat.stoat.core.model.schemas.HealthNotice
import chat.stoat.material.EasingTokens import chat.stoat.material.EasingTokens
import chat.stoat.ndk.NativeLibraries import chat.stoat.ndk.NativeLibraries
@ -791,18 +792,14 @@ fun AppEntrypoint(
showVoiceUI = false showVoiceUI = false
} }
) { ) {
LaunchedEffect(Unit) {
showVoiceUI = false
voiceChannelId = null
}
voiceChannelId?.let { voiceChannelId?.let {
/*VoiceSheet( VoiceSheet(
it, it,
onDisconnect = { onDisconnect = {
showVoiceUI = false showVoiceUI = false
voiceChannelId = null voiceChannelId = null
} }
)*/ )
} }
} }
} }

View File

@ -11,6 +11,8 @@ import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@Serializable @Serializable
data class JoinCallResponse( data class JoinCallResponse(
@ -18,10 +20,19 @@ data class JoinCallResponse(
val url: String, val url: String,
) )
suspend fun joinCall(channelId: String, nodeName: String): JoinCallResponse { suspend fun joinCall(
channelId: String,
nodeName: String,
forceDisconnect: Boolean = true
): JoinCallResponse {
val response = StoatHttp.post("/channels/$channelId/join_call".api()) { val response = StoatHttp.post("/channels/$channelId/join_call".api()) {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(mapOf("node" to nodeName)) setBody(
buildJsonObject {
put("node", nodeName)
put("force_disconnect", forceDisconnect)
}
)
}.bodyAsText() }.bodyAsText()
try { try {

View File

@ -1,19 +1,28 @@
package chat.stoat.composables.voice package chat.stoat.composables.voice
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.stoat.R import chat.stoat.R
import chat.stoat.api.StoatAPI import chat.stoat.api.StoatAPI
import chat.stoat.core.model.util.UserVoiceState
import chat.stoat.composables.chat.displayNameInChannel import chat.stoat.composables.chat.displayNameInChannel
import chat.stoat.composables.generic.UserAvatar import chat.stoat.composables.generic.UserAvatar
import chat.stoat.core.model.util.UserVoiceState
import chat.stoat.internals.extensions.TransparentListItemColours import chat.stoat.internals.extensions.TransparentListItemColours
@Composable @Composable
@ -23,23 +32,40 @@ fun VoiceParticipant(
speaking: Boolean, speaking: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val speakingBorderColour by animateColorAsState(
targetValue = if (speaking) MaterialTheme.colorScheme.primary else Color.Transparent,
animationSpec = spring()
)
val speakingBorderWidth by animateDpAsState(
targetValue = if (speaking) 2.dp else 0.dp,
animationSpec = spring()
)
val user = StoatAPI.userCache[state.id] val user = StoatAPI.userCache[state.id]
ListItem( ListItem(
modifier = modifier,
colors = TransparentListItemColours, colors = TransparentListItemColours,
headlineContent = { headlineContent = {
Text(displayNameInChannel(state.id, channelId)) Text(displayNameInChannel(state.id, channelId))
}, },
leadingContent = { leadingContent = {
// TODO circle when speaking
UserAvatar( UserAvatar(
username = displayNameInChannel(state.id, channelId), username = displayNameInChannel(state.id, channelId),
userId = state.id, userId = state.id,
allowAnimation = speaking, allowAnimation = speaking,
avatar = user?.avatar avatar = user?.avatar,
modifier = Modifier.border(
speakingBorderWidth,
speakingBorderColour,
CircleShape
)
) )
}, },
trailingContent = { trailingContent = {
Row { Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
) {
if (!state.isPublishing) { if (!state.isPublishing) {
Icon( Icon(
painter = painterResource(R.drawable.ic_mic_off_24dp), painter = painterResource(R.drawable.ic_mic_off_24dp),

View File

@ -1,8 +1,15 @@
package chat.stoat.composables.voice package chat.stoat.composables.voice
/*
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideOutVertically
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.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -24,8 +31,11 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -46,6 +56,9 @@ import io.livekit.android.compose.local.RoomScope
import io.livekit.android.compose.state.rememberTracks import io.livekit.android.compose.state.rememberTracks
import io.livekit.android.compose.ui.VideoTrackView import io.livekit.android.compose.ui.VideoTrackView
import io.livekit.android.room.Room import io.livekit.android.room.Room
import io.livekit.android.util.flow
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import logcat.asLog import logcat.asLog
import logcat.logcat import logcat.logcat
@ -124,6 +137,8 @@ fun VoiceSheet(
viewModel.getVoiceToken() viewModel.getVoiceToken()
} }
val scope = rememberCoroutineScope()
RoomScope( RoomScope(
url = viewModel.voiceLkNode, url = viewModel.voiceLkNode,
token = viewModel.voiceToken, token = viewModel.voiceToken,
@ -132,10 +147,22 @@ fun VoiceSheet(
connect = true, connect = true,
) { ) {
val room = RoomLocal.current val room = RoomLocal.current
val trackRefs = rememberTracks() val roomState by room::state.flow.collectAsState()
val isMicOn by room.localParticipant::isMicrophoneEnabled.flow.collectAsState()
val isCameraOn by room.localParticipant::isCameraEnabled.flow.collectAsState()
val isScreenShared by room.localParticipant::isScreenShareEnabled.flow.collectAsState()
val activeSpeakers by room::activeSpeakers.flow.collectAsState()
val trackRefs by rememberTracks(passedRoom = room)
Column { Column {
LazyColumn(modifier = Modifier.animateContentSize()) { LazyColumn(
modifier = Modifier.animateContentSize(
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing
)
)
) {
val voiceStates = StoatAPI.voiceStateCache[viewModel.channelId] val voiceStates = StoatAPI.voiceStateCache[viewModel.channelId]
items(voiceStates?.participants?.size ?: 0) { index -> items(voiceStates?.participants?.size ?: 0) { index ->
val participantState = voiceStates?.participants[index] val participantState = voiceStates?.participants[index]
@ -143,56 +170,104 @@ fun VoiceSheet(
VoiceParticipant( VoiceParticipant(
state = participantState, state = participantState,
channelId = viewModel.channelId, channelId = viewModel.channelId,
speaking = false speaking = activeSpeakers.any { it.identity?.value == participantState.id }
) )
} }
} }
items(trackRefs.size) { index -> items(trackRefs.size) { index ->
VideoTrackView( VideoTrackView(
trackReference = trackRefs[index], trackReference = trackRefs[index],
room = room,
modifier = Modifier.fillParentMaxHeight(0.5f) modifier = Modifier.fillParentMaxHeight(0.5f)
) )
} }
item(key = "status") { item(key = "status") {
AnimatedContent( var showStatus by remember { mutableStateOf(true) }
room.state LaunchedEffect(roomState) {
) { roomState -> if (roomState == Room.State.CONNECTED) {
Row( delay(1000)
horizontalArrangement = Arrangement.spacedBy( showStatus = false
8.dp, } else {
Alignment.CenterHorizontally showStatus = true
), }
verticalAlignment = Alignment.CenterVertically, }
modifier = Modifier
.fillMaxWidth() AnimatedVisibility(
.padding(vertical = 8.dp) visible = showStatus,
) { enter = fadeIn(
CompositionLocalProvider( animationSpec = tween(
LocalContentColor provides when (roomState) { durationMillis = 300,
Room.State.CONNECTING, Room.State.RECONNECTING -> MaterialTheme.colorScheme.onSurfaceVariant easing = LinearOutSlowInEasing
Room.State.CONNECTED -> MaterialTheme.colorScheme.primary )
Room.State.DISCONNECTED -> MaterialTheme.colorScheme.error ) +
} expandVertically(
expandFrom = Alignment.Top,
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing
)
),
exit = fadeOut(
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing
)
) +
slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing
)
) +
shrinkVertically(
shrinkTowards = Alignment.Top,
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing
)
)
) {
AnimatedContent(
roomState
) { roomState ->
Row(
horizontalArrangement = Arrangement.spacedBy(
8.dp,
Alignment.CenterHorizontally
),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) { ) {
Icon( CompositionLocalProvider(
painter = painterResource( LocalContentColor provides when (roomState) {
when (roomState) { Room.State.CONNECTING, Room.State.RECONNECTING -> MaterialTheme.colorScheme.onSurfaceVariant
Room.State.CONNECTING -> R.drawable.ic_sprint_24dp Room.State.CONNECTED -> MaterialTheme.colorScheme.primary
Room.State.CONNECTED -> R.drawable.ic_wifi_tethering_24dp Room.State.DISCONNECTED -> MaterialTheme.colorScheme.error
Room.State.DISCONNECTED -> R.drawable.ic_wifi_tethering_error_24dp
Room.State.RECONNECTING -> R.drawable.ic_sprint_24dp
}
),
contentDescription = null
)
Text(
text = when (roomState) {
Room.State.CONNECTING -> stringResource(R.string.voice_status_connecting)
Room.State.CONNECTED -> stringResource(R.string.voice_status_connected)
Room.State.DISCONNECTED -> stringResource(R.string.voice_status_disconnected)
Room.State.RECONNECTING -> stringResource(R.string.voice_status_reconnecting)
} }
) ) {
Icon(
painter = painterResource(
when (roomState) {
Room.State.CONNECTING -> R.drawable.ic_sprint_24dp
Room.State.CONNECTED -> R.drawable.ic_wifi_tethering_24dp
Room.State.DISCONNECTED -> R.drawable.ic_wifi_tethering_error_24dp
Room.State.RECONNECTING -> R.drawable.ic_sprint_24dp
}
),
contentDescription = null
)
Text(
text = when (roomState) {
Room.State.CONNECTING -> stringResource(R.string.voice_status_connecting)
Room.State.CONNECTED -> stringResource(R.string.voice_status_connected)
Room.State.DISCONNECTED -> stringResource(R.string.voice_status_disconnected)
Room.State.RECONNECTING -> stringResource(R.string.voice_status_reconnecting)
}
)
}
} }
} }
} }
@ -226,10 +301,13 @@ fun VoiceSheet(
) { ) {
Button( Button(
onClick = { onClick = {
scope.launch {
room.localParticipant.setMicrophoneEnabled(!isMicOn)
}
}, },
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer, containerColor = if (isMicOn) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer contentColor = if (isMicOn) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
), ),
shapes = ButtonDefaults.shapes(), shapes = ButtonDefaults.shapes(),
modifier = Modifier modifier = Modifier
@ -237,17 +315,23 @@ fun VoiceSheet(
.height(64.dp) .height(64.dp)
) { ) {
Icon( Icon(
painter = painterResource(R.drawable.ic_mic_off_24dp), painter = if (isMicOn) painterResource(R.drawable.ic_mic_24dp) else painterResource(
R.drawable.ic_mic_off_24dp
),
contentDescription = "TODO change this string to res" contentDescription = "TODO change this string to res"
) )
} }
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Button( Button(
onClick = { onClick = {
scope.launch {
room.localParticipant.setCameraEnabled(!isMicOn)
}
}, },
enabled = false,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer, containerColor = if (isCameraOn) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer contentColor = if (isCameraOn) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
), ),
shapes = ButtonDefaults.shapes(), shapes = ButtonDefaults.shapes(),
modifier = Modifier modifier = Modifier
@ -255,14 +339,20 @@ fun VoiceSheet(
.height(64.dp) .height(64.dp)
) { ) {
Icon( Icon(
painter = painterResource(R.drawable.ic_videocam_off_24dp), painter = if (isCameraOn) painterResource(R.drawable.ic_videocam_24dp) else painterResource(
R.drawable.ic_videocam_off_24dp
),
contentDescription = "TODO change this string to res" contentDescription = "TODO change this string to res"
) )
} }
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Button( Button(
onClick = { onClick = {
scope.launch {
room.localParticipant.setScreenShareEnabled(!isScreenShared)
}
}, },
enabled = false,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer, containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer contentColor = MaterialTheme.colorScheme.onSecondaryContainer
@ -280,7 +370,6 @@ fun VoiceSheet(
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Button( Button(
onClick = { onClick = {
room.disconnect()
onDisconnect() onDisconnect()
}, },
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
@ -300,4 +389,4 @@ fun VoiceSheet(
} }
} }
} }
}*/ }

View File

@ -11,8 +11,10 @@ import chat.stoat.api.internals.Roles
@Composable @Composable
fun rememberChannelPermissions(channelId: String, key1: Any = Unit): MutableLongState { fun rememberChannelPermissions(channelId: String, key1: Any = Unit): MutableLongState {
val permissions = rememberSaveable { mutableLongStateOf(0L) } val permissions = rememberSaveable { mutableLongStateOf(0L) }
val selfId = StoatAPI.selfId
val channel = StoatAPI.channelCache[channelId]
LaunchedEffect(channelId, key1) { LaunchedEffect(channelId, key1, selfId, channel) {
if (StoatAPI.selfId == null) return@LaunchedEffect if (StoatAPI.selfId == null) return@LaunchedEffect
if (StoatAPI.userCache[StoatAPI.selfId] == null) return@LaunchedEffect if (StoatAPI.userCache[StoatAPI.selfId] == null) return@LaunchedEffect
if (StoatAPI.channelCache[channelId] == null) return@LaunchedEffect if (StoatAPI.channelCache[channelId] == null) return@LaunchedEffect

View File

@ -952,7 +952,7 @@ fun ChannelScreen(
} }
} }
if (viewModel.channel?.voice != null && if ((viewModel.channel?.channelType == ChannelType.VoiceChannel || viewModel.channel?.voice != null) &&
channelPermissions has PermissionBit.Connect && channelPermissions has PermissionBit.Connect &&
Experiments.useVoiceChats2p0.isEnabled Experiments.useVoiceChats2p0.isEnabled
) { ) {

View File

@ -41,13 +41,13 @@ import chat.stoat.api.routes.microservices.autumn.uploadToAutumn
import chat.stoat.api.routes.server.fetchMember import chat.stoat.api.routes.server.fetchMember
import chat.stoat.api.routes.user.addUserIfUnknown import chat.stoat.api.routes.user.addUserIfUnknown
import chat.stoat.api.routes.user.fetchUser import chat.stoat.api.routes.user.fetchUser
import chat.stoat.core.model.schemas.Channel
import chat.stoat.core.model.schemas.Message
import chat.stoat.api.settings.GeoStateProvider import chat.stoat.api.settings.GeoStateProvider
import chat.stoat.callbacks.Action import chat.stoat.callbacks.Action
import chat.stoat.callbacks.ActionChannel import chat.stoat.callbacks.ActionChannel
import chat.stoat.callbacks.UiCallback import chat.stoat.callbacks.UiCallback
import chat.stoat.callbacks.UiCallbacks import chat.stoat.callbacks.UiCallbacks
import chat.stoat.core.model.schemas.Channel
import chat.stoat.core.model.schemas.Message
import chat.stoat.internals.text.MessageProcessor import chat.stoat.internals.text.MessageProcessor
import chat.stoat.persistence.KVStorage import chat.stoat.persistence.KVStorage
import chat.stoat.screens.chat.ChatRouterDestination import chat.stoat.screens.chat.ChatRouterDestination
@ -75,7 +75,10 @@ class ChannelScreenViewModel @Inject constructor(
var items = mutableStateListOf<ChannelScreenItem>() var items = mutableStateListOf<ChannelScreenItem>()
var typingUsers = mutableStateListOf<String>() var typingUsers = mutableStateListOf<String>()
var channel by mutableStateOf<Channel?>(null) var channelId by mutableStateOf<String?>(null)
val channel: Channel?
get() = channelId?.let { StoatAPI.channelCache[it] }
var activePane by mutableStateOf<ChannelScreenActivePane>(ChannelScreenActivePane.None) var activePane by mutableStateOf<ChannelScreenActivePane>(ChannelScreenActivePane.None)
var keyboardHeight by mutableIntStateOf(0) var keyboardHeight by mutableIntStateOf(0)
@ -116,7 +119,7 @@ class ChannelScreenViewModel @Inject constructor(
fun switchChannel(id: String) { fun switchChannel(id: String) {
// Reset state // Reset state
this.loadMessagesJob?.cancel() this.loadMessagesJob?.cancel()
this.channel = StoatAPI.channelCache[id] this.channelId = id
this.items = mutableStateListOf(ChannelScreenItem.Loading) this.items = mutableStateListOf(ChannelScreenItem.Loading)
this.activePane = ChannelScreenActivePane.None this.activePane = ChannelScreenActivePane.None
this.typingUsers = mutableStateListOf() this.typingUsers = mutableStateListOf()

View File

@ -23,8 +23,8 @@ telephoto = "1.0.0-alpha02"
haze = "1.7.2" haze = "1.7.2"
chucker = "4.3.1" chucker = "4.3.1"
androidx-test = "1.6.1" androidx-test = "1.6.1"
livekit = "2.21.0" livekit = "2.24.1"
livekit-compose = "1.4.0" livekit-compose = "2.3.0"
[libraries] [libraries]
android-core-ktx = { module = "androidx.core:core-ktx", version = "1.16.0" } android-core-ktx = { module = "androidx.core:core-ktx", version = "1.16.0" }
@ -99,7 +99,7 @@ chucker = { module = "com.github.chuckerteam.chucker:library", version.ref = "ch
chucker-noop = { module = "com.github.chuckerteam.chucker:library-no-op", version.ref = "chucker" } chucker-noop = { module = "com.github.chuckerteam.chucker:library-no-op", version.ref = "chucker" }
square-logcat = { module = "com.squareup.logcat:logcat", version = "0.1" } square-logcat = { module = "com.squareup.logcat:logcat", version = "0.1" }
livekit-android = { module = "io.livekit:livekit-android", version.ref = "livekit" } livekit-android = { module = "io.livekit:livekit-android", version.ref = "livekit" }
livekit-android-camerax = { module = "io.livekit:android-camerax", version.ref = "livekit" } livekit-android-camerax = { module = "io.livekit:livekit-android-camerax", version.ref = "livekit" }
livekit-android-compose = { module = "io.livekit:livekit-android-compose-components", version.ref = "livekit-compose" } livekit-android-compose = { module = "io.livekit:livekit-android-compose-components", version.ref = "livekit-compose" }
[plugins] [plugins]