diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1e9ee07f..aed0212e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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")) diff --git a/app/src/main/java/chat/stoat/api/StoatAPI.kt b/app/src/main/java/chat/stoat/api/StoatAPI.kt index 1e571827..956ab1fd 100644 --- a/app/src/main/java/chat/stoat/api/StoatAPI.kt +++ b/app/src/main/java/chat/stoat/api/StoatAPI.kt @@ -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() val emojiCache = mutableStateMapOf() val messageCache = mutableStateMapOf() + val voiceStateCache = mutableStateMapOf() val members = Members() diff --git a/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt b/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt index 76223016..476cfb69 100644 --- a/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt @@ -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() diff --git a/app/src/main/java/chat/stoat/api/schemas/Channel.kt b/app/src/main/java/chat/stoat/api/schemas/Channel.kt index 10b1e2c0..bef809ac 100644 --- a/app/src/main/java/chat/stoat/api/schemas/Channel.kt +++ b/app/src/main/java/chat/stoat/api/schemas/Channel.kt @@ -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 { diff --git a/app/src/main/java/chat/stoat/api/schemas/Voice.kt b/app/src/main/java/chat/stoat/api/schemas/Voice.kt index 01bb4943..d075cb1d 100644 --- a/app/src/main/java/chat/stoat/api/schemas/Voice.kt +++ b/app/src/main/java/chat/stoat/api/schemas/Voice.kt @@ -6,8 +6,7 @@ import kotlinx.serialization.Serializable @Serializable data class ChannelVoiceState( val id: String, - val participants: List, - val node: String, + val participants: List = 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, ) \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/api/settings/Experiments.kt b/app/src/main/java/chat/stoat/api/settings/Experiments.kt index 4972d291..ea72c4e7 100644 --- a/app/src/main/java/chat/stoat/api/settings/Experiments.kt +++ b/app/src/main/java/chat/stoat/api/settings/Experiments.kt @@ -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 diff --git a/app/src/main/java/chat/stoat/api/settings/FeatureFlags.kt b/app/src/main/java/chat/stoat/api/settings/FeatureFlags.kt index db70fb8c..c7829d09 100644 --- a/app/src/main/java/chat/stoat/api/settings/FeatureFlags.kt +++ b/app/src/main/java/chat/stoat/api/settings/FeatureFlags.kt @@ -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.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.Disabled diff --git a/app/src/main/java/chat/stoat/composables/screens/chat/molecules/JoinVoiceChannelButton.kt b/app/src/main/java/chat/stoat/composables/screens/chat/molecules/JoinVoiceChannelButton.kt index 30ee165a..68a10694 100644 --- a/app/src/main/java/chat/stoat/composables/screens/chat/molecules/JoinVoiceChannelButton.kt +++ b/app/src/main/java/chat/stoat/composables/screens/chat/molecules/JoinVoiceChannelButton.kt @@ -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 ) } } \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt b/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt index 69029f69..be78ca75 100644 --- a/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt +++ b/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt @@ -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( } } } - }*/ + } } \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt index 92472609..0b4ed969 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt @@ -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) } diff --git a/app/src/main/java/chat/stoat/screens/settings/ExperimentsSettingsScreen.kt b/app/src/main/java/chat/stoat/screens/settings/ExperimentsSettingsScreen.kt index 3dc9c1f5..93f0c121 100644 --- a/app/src/main/java/chat/stoat/screens/settings/ExperimentsSettingsScreen.kt +++ b/app/src/main/java/chat/stoat/screens/settings/ExperimentsSettingsScreen.kt @@ -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") diff --git a/app/src/main/res/drawable/icn_voice_chat_24dp.xml b/app/src/main/res/drawable/icn_voice_chat_24dp.xml new file mode 100644 index 00000000..4fd108bc --- /dev/null +++ b/app/src/main/res/drawable/icn_voice_chat_24dp.xml @@ -0,0 +1,10 @@ + + +