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.highlights)
// implementation(libs.livekit.android)
// implementation(libs.livekit.android.camerax)
// implementation(libs.livekit.android.compose)
implementation(libs.livekit.android)
implementation(libs.livekit.android.camerax)
implementation(libs.livekit.android.compose)
implementation(platform(libs.firebase.bom))
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.composables.generic.HealthAlert
import chat.stoat.composables.voice.VoicePermissionSwitch
import chat.stoat.composables.voice.VoiceSheet
import chat.stoat.core.model.schemas.HealthNotice
import chat.stoat.material.EasingTokens
import chat.stoat.ndk.NativeLibraries
@ -791,18 +792,14 @@ fun AppEntrypoint(
showVoiceUI = false
}
) {
LaunchedEffect(Unit) {
showVoiceUI = false
voiceChannelId = null
}
voiceChannelId?.let {
/*VoiceSheet(
VoiceSheet(
it,
onDisconnect = {
showVoiceUI = false
voiceChannelId = null
}
)*/
)
}
}
}

View File

@ -11,6 +11,8 @@ import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@Serializable
data class JoinCallResponse(
@ -18,10 +20,19 @@ data class JoinCallResponse(
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()) {
contentType(ContentType.Application.Json)
setBody(mapOf("node" to nodeName))
setBody(
buildJsonObject {
put("node", nodeName)
put("force_disconnect", forceDisconnect)
}
)
}.bodyAsText()
try {

View File

@ -1,19 +1,28 @@
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.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.stoat.R
import chat.stoat.api.StoatAPI
import chat.stoat.core.model.util.UserVoiceState
import chat.stoat.composables.chat.displayNameInChannel
import chat.stoat.composables.generic.UserAvatar
import chat.stoat.core.model.util.UserVoiceState
import chat.stoat.internals.extensions.TransparentListItemColours
@Composable
@ -23,23 +32,40 @@ fun VoiceParticipant(
speaking: Boolean,
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]
ListItem(
modifier = modifier,
colors = TransparentListItemColours,
headlineContent = {
Text(displayNameInChannel(state.id, channelId))
},
leadingContent = {
// TODO circle when speaking
UserAvatar(
username = displayNameInChannel(state.id, channelId),
userId = state.id,
allowAnimation = speaking,
avatar = user?.avatar
avatar = user?.avatar,
modifier = Modifier.border(
speakingBorderWidth,
speakingBorderColour,
CircleShape
)
)
},
trailingContent = {
Row {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
) {
if (!state.isPublishing) {
Icon(
painter = painterResource(R.drawable.ic_mic_off_24dp),

View File

@ -1,8 +1,15 @@
package chat.stoat.composables.voice
/*
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
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.Column
import androidx.compose.foundation.layout.PaddingValues
@ -24,8 +31,11 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.ui.VideoTrackView
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.asLog
import logcat.logcat
@ -124,6 +137,8 @@ fun VoiceSheet(
viewModel.getVoiceToken()
}
val scope = rememberCoroutineScope()
RoomScope(
url = viewModel.voiceLkNode,
token = viewModel.voiceToken,
@ -132,10 +147,22 @@ fun VoiceSheet(
connect = true,
) {
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 {
LazyColumn(modifier = Modifier.animateContentSize()) {
LazyColumn(
modifier = Modifier.animateContentSize(
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing
)
)
) {
val voiceStates = StoatAPI.voiceStateCache[viewModel.channelId]
items(voiceStates?.participants?.size ?: 0) { index ->
val participantState = voiceStates?.participants[index]
@ -143,56 +170,104 @@ fun VoiceSheet(
VoiceParticipant(
state = participantState,
channelId = viewModel.channelId,
speaking = false
speaking = activeSpeakers.any { it.identity?.value == participantState.id }
)
}
}
items(trackRefs.size) { index ->
VideoTrackView(
trackReference = trackRefs[index],
room = room,
modifier = Modifier.fillParentMaxHeight(0.5f)
)
}
item(key = "status") {
AnimatedContent(
room.state
) { roomState ->
Row(
horizontalArrangement = Arrangement.spacedBy(
8.dp,
Alignment.CenterHorizontally
),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
CompositionLocalProvider(
LocalContentColor provides when (roomState) {
Room.State.CONNECTING, Room.State.RECONNECTING -> MaterialTheme.colorScheme.onSurfaceVariant
Room.State.CONNECTED -> MaterialTheme.colorScheme.primary
Room.State.DISCONNECTED -> MaterialTheme.colorScheme.error
}
var showStatus by remember { mutableStateOf(true) }
LaunchedEffect(roomState) {
if (roomState == Room.State.CONNECTED) {
delay(1000)
showStatus = false
} else {
showStatus = true
}
}
AnimatedVisibility(
visible = showStatus,
enter = fadeIn(
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing
)
) +
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(
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)
CompositionLocalProvider(
LocalContentColor provides when (roomState) {
Room.State.CONNECTING, Room.State.RECONNECTING -> MaterialTheme.colorScheme.onSurfaceVariant
Room.State.CONNECTED -> MaterialTheme.colorScheme.primary
Room.State.DISCONNECTED -> MaterialTheme.colorScheme.error
}
)
) {
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(
onClick = {
scope.launch {
room.localParticipant.setMicrophoneEnabled(!isMicOn)
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
containerColor = if (isMicOn) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
contentColor = if (isMicOn) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
),
shapes = ButtonDefaults.shapes(),
modifier = Modifier
@ -237,17 +315,23 @@ fun VoiceSheet(
.height(64.dp)
) {
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"
)
}
Spacer(Modifier.width(4.dp))
Button(
onClick = {
scope.launch {
room.localParticipant.setCameraEnabled(!isMicOn)
}
},
enabled = false,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
containerColor = if (isCameraOn) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
contentColor = if (isCameraOn) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
),
shapes = ButtonDefaults.shapes(),
modifier = Modifier
@ -255,14 +339,20 @@ fun VoiceSheet(
.height(64.dp)
) {
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"
)
}
Spacer(Modifier.width(4.dp))
Button(
onClick = {
scope.launch {
room.localParticipant.setScreenShareEnabled(!isScreenShared)
}
},
enabled = false,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
@ -280,7 +370,6 @@ fun VoiceSheet(
Spacer(Modifier.width(4.dp))
Button(
onClick = {
room.disconnect()
onDisconnect()
},
colors = ButtonDefaults.buttonColors(
@ -300,4 +389,4 @@ fun VoiceSheet(
}
}
}
}*/
}

View File

@ -11,8 +11,10 @@ import chat.stoat.api.internals.Roles
@Composable
fun rememberChannelPermissions(channelId: String, key1: Any = Unit): MutableLongState {
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.userCache[StoatAPI.selfId] == 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 &&
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.user.addUserIfUnknown
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.callbacks.Action
import chat.stoat.callbacks.ActionChannel
import chat.stoat.callbacks.UiCallback
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.persistence.KVStorage
import chat.stoat.screens.chat.ChatRouterDestination
@ -75,7 +75,10 @@ class ChannelScreenViewModel @Inject constructor(
var items = mutableStateListOf<ChannelScreenItem>()
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 keyboardHeight by mutableIntStateOf(0)
@ -116,7 +119,7 @@ class ChannelScreenViewModel @Inject constructor(
fun switchChannel(id: String) {
// Reset state
this.loadMessagesJob?.cancel()
this.channel = StoatAPI.channelCache[id]
this.channelId = id
this.items = mutableStateListOf(ChannelScreenItem.Loading)
this.activePane = ChannelScreenActivePane.None
this.typingUsers = mutableStateListOf()

View File

@ -23,8 +23,8 @@ telephoto = "1.0.0-alpha02"
haze = "1.7.2"
chucker = "4.3.1"
androidx-test = "1.6.1"
livekit = "2.21.0"
livekit-compose = "1.4.0"
livekit = "2.24.1"
livekit-compose = "2.3.0"
[libraries]
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" }
square-logcat = { module = "com.squareup.logcat:logcat", version = "0.1" }
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" }
[plugins]