feat: move voice chat to experiments and improve UI, schemas

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-10-12 14:21:37 +02:00
parent 5ec47975e3
commit da62bba946
12 changed files with 118 additions and 50 deletions

View File

@ -30,8 +30,8 @@ val material3Version = "1.4.0-alpha15"
val androidXTestVersion = "1.6.1"
object LivekitVersion {
val core = "2.16.0"
val componentsCompose = "1.3.1"
val core = "2.20.3"
val componentsCompose = "1.4.0"
}
fun property(fileName: String, propertyName: String, fallbackEnv: String? = null): String? {
@ -297,8 +297,9 @@ dependencies {
implementation("dev.snipme:highlights:1.0.0")
// Livekit - Commented out for now to not inflate the app size.
/*implementation("io.livekit:livekit-android:${LivekitVersion.core}")
implementation("io.livekit:livekit-android-compose-components:${LivekitVersion.componentsCompose}")*/
implementation("io.livekit:livekit-android:${LivekitVersion.core}")
implementation("io.livekit:livekit-android-camerax:${LivekitVersion.core}")
implementation("io.livekit:livekit-android-compose-components:${LivekitVersion.componentsCompose}")
// Firebase - Cloud Messaging
implementation(platform("com.google.firebase:firebase-bom:33.15.0"))

View File

@ -13,6 +13,7 @@ import chat.stoat.api.realtime.RealtimeSocket
import chat.stoat.api.routes.user.fetchSelf
import chat.stoat.api.schemas.AutumnResource
import chat.stoat.api.schemas.ChannelType
import chat.stoat.api.schemas.ChannelVoiceState
import chat.stoat.api.schemas.Emoji
import chat.stoat.api.schemas.Message
import chat.stoat.api.schemas.Server
@ -155,6 +156,7 @@ object StoatAPI {
val channelCache = mutableStateMapOf<String, ChannelSchema>()
val emojiCache = mutableStateMapOf<String, Emoji>()
val messageCache = mutableStateMapOf<String, Message>()
val voiceStateCache = mutableStateMapOf<String, ChannelVoiceState>()
val members = Members()

View File

@ -244,6 +244,10 @@ object RealtimeSocket {
val emojiMap = readyFrame.emojis.associateBy { it.id!! }
StoatAPI.emojiCache.putAll(emojiMap)
logcat { "Adding voice states to cache." }
val voiceStateMap = readyFrame.voiceStates.associateBy { it.id }
StoatAPI.voiceStateCache.putAll(voiceStateMap)
Log.d("RealtimeSocket", "Registering push notification channels.")
channelRegistrator.register()

View File

@ -9,6 +9,7 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonElement
@Serializable
data class MessagesInChannel(
@ -75,6 +76,7 @@ data class Channel(
@SerialName("default_permissions")
val defaultPermissions: PermissionDescription? = null,
val nsfw: Boolean? = null,
val voice: JsonElement? = null,
val type: String? = null // this is _only_ used for websocket events!
) {
fun mergeWithPartial(partial: Channel): Channel {

View File

@ -6,8 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class ChannelVoiceState(
val id: String,
val participants: List<UserVoiceState>,
val node: String,
val participants: List<UserVoiceState> = emptyList(),
)
@Serializable
@ -17,4 +16,5 @@ data class UserVoiceState(
@SerialName("is_publishing") val isPublishing: Boolean,
val screensharing: Boolean,
val camera: Boolean,
@SerialName(value = "joined_at") val joinedAt: String? = null,
)

View File

@ -30,6 +30,7 @@ object Experiments {
val usePolar = ExperimentInstance(false)
val enableServerIdentityOptions = ExperimentInstance(false)
val useFinalMarkdownRenderer = ExperimentInstance(false)
val useVoiceChats2p0 = ExperimentInstance(false)
suspend fun hydrateWithKv() {
val kvStorage = KVStorage(StoatApplication.instance)
@ -52,6 +53,9 @@ object Experiments {
useFinalMarkdownRenderer.setEnabled(
kvStorage.getBoolean("exp/useFinalMarkdownRenderer") == true
)
useVoiceChats2p0.setEnabled(
kvStorage.getBoolean("exp/useVoiceChats2p0") == true
)
if (useFinalMarkdownRenderer.isEnabled && useKotlinBasedMarkdownRenderer.isEnabled) {
// if jbm and fm are enabled, fm takes precedence. this should not be possible in practice

View File

@ -43,19 +43,6 @@ sealed class MassMentionsVariates {
object Disabled : MassMentionsVariates()
}
@FeatureFlag("VoiceChannels2_0")
sealed class VoiceChannels2_0Variates {
@Treatment(
"Enable the new voice channels 2.0 for all users"
)
object Enabled : VoiceChannels2_0Variates()
@Treatment(
"Disable the new voice channels 2.0 for all users"
)
object Disabled : VoiceChannels2_0Variates()
}
@FeatureFlag("FinalMarkdown")
sealed class FinalMarkdownVariates {
@Treatment(
@ -105,16 +92,6 @@ object FeatureFlags {
is MassMentionsVariates.Disabled -> false
}
@FeatureFlag("VoiceChannels2_0")
var voiceChannels2_0 by mutableStateOf<VoiceChannels2_0Variates>(
VoiceChannels2_0Variates.Disabled
)
val voiceChannels2_0Granted: Boolean
get() = when (voiceChannels2_0) {
is VoiceChannels2_0Variates.Enabled -> true
is VoiceChannels2_0Variates.Disabled -> false
}
@FeatureFlag("FinalMarkdown")
var finalMarkdown by mutableStateOf<FinalMarkdownVariates>(
FinalMarkdownVariates.Disabled

View File

@ -6,16 +6,21 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.stoat.R
import chat.stoat.api.StoatAPI
import chat.stoat.api.schemas.User
import chat.stoat.callbacks.Action
import chat.stoat.callbacks.ActionChannel
import chat.stoat.composables.screens.chat.StackedUserAvatars
@ -24,6 +29,7 @@ import kotlinx.coroutines.launch
@Composable
fun JoinVoiceChannelButton(channelId: String, modifier: Modifier = Modifier) {
val scope = rememberCoroutineScope()
val voiceStatesForChannel = StoatAPI.voiceStateCache[channelId]
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -36,26 +42,47 @@ fun JoinVoiceChannelButton(channelId: String, modifier: Modifier = Modifier) {
.padding(16.dp)
.fillMaxWidth()
) {
StackedUserAvatars(
listOf(
"01FHGJ3NPP7XANQQH8C2BE44ZY",
"01F1WKM5TK2V6KCZWR6DGBJDTZ",
"01EX2NCWQ0CHS3QJF0FEQS1GR4"
),
size = 24.dp,
offset = 12.dp,
amount = 3,
serverId = null
)
if (voiceStatesForChannel?.participants?.isNotEmpty() == true) {
StackedUserAvatars(
users = voiceStatesForChannel.participants.map { it.id },
size = 24.dp,
offset = 12.dp,
amount = voiceStatesForChannel.participants.size,
serverId = null
)
} else {
Icon(
painter = painterResource(R.drawable.icn_voice_chat_24dp),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
}
Text(
stringResource(R.string.voice_join_offering),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
textAlign = TextAlign.Start
)
Text(
stringResource(R.string.voice_join_offering_description_other, Integer.MAX_VALUE),
when {
voiceStatesForChannel == null || voiceStatesForChannel.participants.isEmpty() ->
stringResource(R.string.voice_join_offering_description_zero)
voiceStatesForChannel.participants.size == 1 ->
stringResource(
R.string.voice_join_offering_description_one,
voiceStatesForChannel.participants[0].id.let {
StoatAPI.userCache[it]?.let { u -> User.resolveDefaultName(u) }
?: R.string.unknown
})
else ->
stringResource(
R.string.voice_join_offering_description_other,
voiceStatesForChannel.participants.size
)
},
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
textAlign = TextAlign.Start
)
}
}

View File

@ -1,16 +1,31 @@
package chat.stoat.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.stoat.R
import chat.stoat.api.routes.misc.getRootRoute
import chat.stoat.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
@ -78,9 +93,7 @@ fun VoiceSheet(
viewModel.getVoiceToken()
}
// TODO - Voice channels are not supported yet
LaunchedEffect(Unit) { onDisconnect() }
/*RoomScope(
RoomScope(
url = viewModel.voiceLkNode,
token = viewModel.voiceToken,
audio = true,
@ -133,5 +146,5 @@ fun VoiceSheet(
}
}
}
}*/
}
}

View File

@ -118,7 +118,7 @@ import chat.stoat.api.routes.channel.react
import chat.stoat.api.routes.microservices.autumn.FileArgs
import chat.stoat.api.schemas.ChannelType
import chat.stoat.api.schemas.Message
import chat.stoat.api.settings.FeatureFlags
import chat.stoat.api.settings.Experiments
import chat.stoat.callbacks.Action
import chat.stoat.callbacks.ActionChannel
import chat.stoat.composables.chat.DateDivider
@ -943,8 +943,9 @@ fun ChannelScreen(
}
}
if (viewModel.channel?.channelType == ChannelType.VoiceChannel
&& FeatureFlags.voiceChannels2_0Granted
if (viewModel.channel?.voice != null &&
channelPermissions has PermissionBit.Connect &&
Experiments.useVoiceChats2p0.isEnabled
) {
JoinVoiceChannelButton(channelId)
}

View File

@ -71,6 +71,7 @@ class ExperimentsSettingsScreenViewModel : ViewModel() {
usePolarChecked.value = Experiments.usePolar.isEnabled
enableServerIdentityOptionsChecked.value =
Experiments.enableServerIdentityOptions.isEnabled
useVoiceChats2p0.value = Experiments.useVoiceChats2p0.isEnabled
}
}
@ -148,6 +149,16 @@ class ExperimentsSettingsScreenViewModel : ViewModel() {
enableServerIdentityOptionsChecked.value = value
}
}
val useVoiceChats2p0 = mutableStateOf(false)
fun setUseVoiceChats2p0(value: Boolean) {
viewModelScope.launch {
kv.set("exp/useVoiceChats2p0", value)
Experiments.useVoiceChats2p0.setEnabled(value)
useVoiceChats2p0.value = value
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@ -290,6 +301,22 @@ fun ExperimentsSettingsScreen(
modifier = Modifier.clickable { viewModel.setEnableServerIdentityOptionsChecked(!viewModel.enableServerIdentityOptionsChecked.value) }
)
ListItem(
headlineContent = {
Text("Voice Chats 2.0")
},
supportingContent = {
Text("Enable voice chats support.")
},
trailingContent = {
Switch(
checked = viewModel.useVoiceChats2p0.value,
onCheckedChange = null
)
},
modifier = Modifier.clickable { viewModel.setUseVoiceChats2p0(!viewModel.useVoiceChats2p0.value) }
)
Subcategory(
title = {
Text("Disable experiments")

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M240,440L300,440L300,360L240,360L240,440ZM340,520L400,520L400,280L340,280L340,520ZM450,600L510,600L510,200L450,200L450,600ZM560,520L620,520L620,280L560,280L560,520ZM660,440L720,440L720,360L660,360L660,440ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z"/>
</vector>