From a025d6e653b58287a107c9eba7fca2047d0bac06 Mon Sep 17 00:00:00 2001 From: Infi Date: Sun, 19 Oct 2025 16:43:39 +0200 Subject: [PATCH] feat: improve voice ui Signed-off-by: Infi --- app/src/main/java/chat/stoat/api/StoatAPI.kt | 7 +- .../chat/stoat/api/realtime/RealtimeSocket.kt | 121 ++++++++++- .../frames/receivable/ReceivableFrames.kt | 38 ++++ .../main/java/chat/stoat/api/schemas/Voice.kt | 23 +- .../chat/stoat/composables/chat/Message.kt | 15 ++ .../composables/voice/VoiceParticipant.kt | 71 +++++++ .../stoat/composables/voice/VoiceSheet.kt | 197 ++++++++++++++++-- .../views/channel/ChannelScreenViewModel.kt | 6 +- .../res/drawable/icn_call_end_24dp__fill.xml | 10 + .../res/drawable/icn_headset_off_24dp.xml | 10 + .../main/res/drawable/icn_mic_off_24dp.xml | 10 + .../res/drawable/icn_mobile_share_24px.xml | 11 + .../res/drawable/icn_screen_share_24dp.xml | 11 + app/src/main/res/drawable/icn_sprint_24dp.xml | 10 + .../res/drawable/icn_videocam_off_24dp.xml | 10 + .../res/drawable/icn_wifi_tethering_24dp.xml | 10 + .../icn_wifi_tethering_error_24dp.xml | 10 + app/src/main/res/values/strings.xml | 12 ++ 18 files changed, 542 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/chat/stoat/composables/voice/VoiceParticipant.kt create mode 100644 app/src/main/res/drawable/icn_call_end_24dp__fill.xml create mode 100644 app/src/main/res/drawable/icn_headset_off_24dp.xml create mode 100644 app/src/main/res/drawable/icn_mic_off_24dp.xml create mode 100644 app/src/main/res/drawable/icn_mobile_share_24px.xml create mode 100644 app/src/main/res/drawable/icn_screen_share_24dp.xml create mode 100644 app/src/main/res/drawable/icn_sprint_24dp.xml create mode 100644 app/src/main/res/drawable/icn_videocam_off_24dp.xml create mode 100644 app/src/main/res/drawable/icn_wifi_tethering_24dp.xml create mode 100644 app/src/main/res/drawable/icn_wifi_tethering_error_24dp.xml diff --git a/app/src/main/java/chat/stoat/api/StoatAPI.kt b/app/src/main/java/chat/stoat/api/StoatAPI.kt index 956ab1fd..9c5ce4eb 100644 --- a/app/src/main/java/chat/stoat/api/StoatAPI.kt +++ b/app/src/main/java/chat/stoat/api/StoatAPI.kt @@ -41,7 +41,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.runBlocking @@ -171,7 +171,10 @@ object StoatAPI { @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) val realtimeContext = newSingleThreadContext("RealtimeContext") - val wsFrameChannel = Channel(Channel.UNLIMITED) + val wsFrameChannel = MutableSharedFlow( + replay = 0, + extraBufferCapacity = Int.MAX_VALUE, + ) private var socketCoroutine: Job? = null 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 476cfb69..2c40fcf1 100644 --- a/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt @@ -29,8 +29,13 @@ import chat.stoat.api.realtime.frames.receivable.ServerMemberUpdateFrame import chat.stoat.api.realtime.frames.receivable.ServerRoleDeleteFrame import chat.stoat.api.realtime.frames.receivable.ServerRoleUpdateFrame import chat.stoat.api.realtime.frames.receivable.ServerUpdateFrame +import chat.stoat.api.realtime.frames.receivable.UserMoveVoiceChannelFrame import chat.stoat.api.realtime.frames.receivable.UserRelationshipFrame import chat.stoat.api.realtime.frames.receivable.UserUpdateFrame +import chat.stoat.api.realtime.frames.receivable.UserVoiceStateUpdateFrame +import chat.stoat.api.realtime.frames.receivable.VoiceChannelJoinFrame +import chat.stoat.api.realtime.frames.receivable.VoiceChannelLeaveFrame +import chat.stoat.api.realtime.frames.receivable.VoiceChannelMoveFrame import chat.stoat.api.realtime.frames.sendable.AuthorizationFrame import chat.stoat.api.realtime.frames.sendable.BeginTypingFrame import chat.stoat.api.realtime.frames.sendable.EndTypingFrame @@ -38,6 +43,7 @@ import chat.stoat.api.realtime.frames.sendable.PingFrame import chat.stoat.api.routes.server.fetchMember import chat.stoat.api.schemas.Channel import chat.stoat.api.schemas.ChannelType +import chat.stoat.api.schemas.ChannelVoiceState import chat.stoat.api.schemas.Role import chat.stoat.api.settings.LoadedSettings import chat.stoat.api.settings.SyncedSettings @@ -248,6 +254,8 @@ object RealtimeSocket { val voiceStateMap = readyFrame.voiceStates.associateBy { it.id } StoatAPI.voiceStateCache.putAll(voiceStateMap) + logcat { "New voice states: ${voiceStateMap}" } + Log.d("RealtimeSocket", "Registering push notification channels.") channelRegistrator.register() @@ -277,7 +285,7 @@ object RealtimeSocket { StoatAPI.channelCache[it] = StoatAPI.channelCache[it]!!.copy(lastMessageID = messageFrame.id) - StoatAPI.wsFrameChannel.send(messageFrame) + StoatAPI.wsFrameChannel.emit(messageFrame) } } @@ -305,7 +313,7 @@ object RealtimeSocket { StoatAPI.messageCache[messageAppendFrame.id] = message!! - StoatAPI.wsFrameChannel.send(messageAppendFrame) + StoatAPI.wsFrameChannel.emit(messageAppendFrame) } "MessageUpdate" -> { @@ -352,7 +360,7 @@ object RealtimeSocket { } } - StoatAPI.wsFrameChannel.send(messageUpdateFrame) + StoatAPI.wsFrameChannel.emit(messageUpdateFrame) } "MessageDelete" -> { @@ -373,7 +381,7 @@ object RealtimeSocket { } StoatAPI.messageCache.remove(messageDeleteFrame.id) - StoatAPI.wsFrameChannel.send(messageDeleteFrame) + StoatAPI.wsFrameChannel.emit(messageDeleteFrame) } "MessageReact" -> { @@ -402,7 +410,7 @@ object RealtimeSocket { StoatAPI.messageCache[messageReactFrame.id] = oldMessage.copy(reactions = reactions) - StoatAPI.wsFrameChannel.send(messageReactFrame) + StoatAPI.wsFrameChannel.emit(messageReactFrame) } "MessageUnreact" -> { @@ -436,7 +444,7 @@ object RealtimeSocket { StoatAPI.messageCache[messageUnreactFrame.id] = oldMessage.copy(reactions = reactions) - StoatAPI.wsFrameChannel.send(messageUnreactFrame) + StoatAPI.wsFrameChannel.emit(messageUnreactFrame) } "UserUpdate" -> { @@ -567,7 +575,7 @@ object RealtimeSocket { ) } - StoatAPI.wsFrameChannel.send(channelDeleteFrame) + StoatAPI.wsFrameChannel.emit(channelDeleteFrame) } "ChannelAck" -> { @@ -617,7 +625,7 @@ object RealtimeSocket { "Received channel start typing frame for ${channelStartTypingFrame.id}." ) - StoatAPI.wsFrameChannel.send(channelStartTypingFrame) + StoatAPI.wsFrameChannel.emit(channelStartTypingFrame) } "ChannelStopTyping" -> { @@ -628,7 +636,7 @@ object RealtimeSocket { "Received channel stop typing frame for ${channelStopTypingFrame.id}." ) - StoatAPI.wsFrameChannel.send(channelStopTypingFrame) + StoatAPI.wsFrameChannel.emit(channelStopTypingFrame) } "ServerUpdate" -> { @@ -812,6 +820,99 @@ object RealtimeSocket { server.copy(roles = newRoles) } + "VoiceChannelJoin" -> { + val voiceChannelJoinFrame = + StoatJson.decodeFromString(VoiceChannelJoinFrame.serializer(), rawFrame) + + logcat { "Received voice channel join frame for channel ${voiceChannelJoinFrame.id}." } + + val newParticipants = + StoatAPI.voiceStateCache[voiceChannelJoinFrame.id]?.participants?.filter { + it.id != voiceChannelJoinFrame.state.id + }?.toMutableList() ?: mutableListOf() + newParticipants.add(voiceChannelJoinFrame.state) + + StoatAPI.voiceStateCache[voiceChannelJoinFrame.id] = + ChannelVoiceState(voiceChannelJoinFrame.id, newParticipants) + } + + "VoiceChannelLeave" -> { + val voiceChannelLeaveFrame = + StoatJson.decodeFromString(VoiceChannelLeaveFrame.serializer(), rawFrame) + + logcat { "Received voice channel leave frame for channel ${voiceChannelLeaveFrame.id}." } + + val existingChannelState = + StoatAPI.voiceStateCache[voiceChannelLeaveFrame.id] ?: return + + val newParticipants = existingChannelState.participants.filter { + it.id != voiceChannelLeaveFrame.user + } + + StoatAPI.voiceStateCache[voiceChannelLeaveFrame.id] = + ChannelVoiceState(voiceChannelLeaveFrame.id, newParticipants) + } + + "VoiceChannelMove" -> { + val voiceChannelMoveFrame = + StoatJson.decodeFromString(VoiceChannelMoveFrame.serializer(), rawFrame) + + logcat { "Received voice channel move frame from ${voiceChannelMoveFrame.from} to ${voiceChannelMoveFrame.to}." } + + // Remove from old channel + val existingFromChannelState = + StoatAPI.voiceStateCache[voiceChannelMoveFrame.from] ?: return + + val newFromParticipants = existingFromChannelState.participants.filter { + it.id != voiceChannelMoveFrame.state.id + } + + StoatAPI.voiceStateCache[voiceChannelMoveFrame.from] = + ChannelVoiceState(voiceChannelMoveFrame.from, newFromParticipants) + + // Add to new channel + val existingToChannelState = + StoatAPI.voiceStateCache[voiceChannelMoveFrame.to] + + val newToParticipants = + existingToChannelState?.participants?.toMutableList() ?: mutableListOf() + newToParticipants.add(voiceChannelMoveFrame.state) + + StoatAPI.voiceStateCache[voiceChannelMoveFrame.to] = + ChannelVoiceState(voiceChannelMoveFrame.to, newToParticipants) + } + + "UserVoiceStateUpdate" -> { + val userVoiceStateUpdateFrame = + StoatJson.decodeFromString(UserVoiceStateUpdateFrame.serializer(), rawFrame) + + logcat { "Received user voice state update frame for user ${userVoiceStateUpdateFrame.id} in channel ${userVoiceStateUpdateFrame.id}." } + + val existingChannelState = + StoatAPI.voiceStateCache[userVoiceStateUpdateFrame.id] ?: return + + val newParticipants = existingChannelState.participants.map { + if (it.id == userVoiceStateUpdateFrame.id) { + userVoiceStateUpdateFrame.data.overrideInto(it) + } else { + it + } + } + + StoatAPI.voiceStateCache[userVoiceStateUpdateFrame.id] = + ChannelVoiceState(userVoiceStateUpdateFrame.id, newParticipants) + } + + "UserMoveVoiceChannel" -> { + val userMoveVoiceChannelFrame = + StoatJson.decodeFromString(UserMoveVoiceChannelFrame.serializer(), rawFrame) + + logcat { "We got moved into a different channel on node ${userMoveVoiceChannelFrame.node}." } + + // Send message to UI to handle the move + StoatAPI.wsFrameChannel.emit(userMoveVoiceChannelFrame) + } + "Authenticated" -> { SyncedSettings.fetch() LoadedSettings.hydrateWithSettings(SyncedSettings) @@ -824,7 +925,7 @@ object RealtimeSocket { } private suspend fun pushReconnectEvent() { - StoatAPI.wsFrameChannel.send(RealtimeSocketFrames.Reconnected) + StoatAPI.wsFrameChannel.emit(RealtimeSocketFrames.Reconnected) } suspend fun beginTyping(channelId: String) { diff --git a/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt b/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt index 4949b2ff..7f490379 100644 --- a/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt +++ b/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt @@ -6,10 +6,12 @@ import chat.stoat.api.schemas.Embed import chat.stoat.api.schemas.Emoji import chat.stoat.api.schemas.Member import chat.stoat.api.schemas.Message +import chat.stoat.api.schemas.PartialUserVoiceState import chat.stoat.api.schemas.Role import chat.stoat.api.schemas.Server import chat.stoat.api.schemas.ServerUserChoice import chat.stoat.api.schemas.User +import chat.stoat.api.schemas.UserVoiceState import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject @@ -242,3 +244,39 @@ data class EmojiDeleteFrame( val type: String = "EmojiDelete", val id: String ) + +@Serializable +data class VoiceChannelJoinFrame( + val type: String = "VoiceChannelJoin", + val id: String, + val state: UserVoiceState +) + +@Serializable +data class VoiceChannelLeaveFrame( + val type: String = "VoiceChannelLeave", + val id: String, + val user: String +) + +@Serializable +data class VoiceChannelMoveFrame( + val type: String = "VoiceChannelMove", + val from: String, + val to: String, + val state: UserVoiceState +) + +@Serializable +data class UserVoiceStateUpdateFrame( + val type: String = "UserVoiceStateUpdate", + val id: String, + val data: PartialUserVoiceState +) + +@Serializable +data class UserMoveVoiceChannelFrame( + val type: String = "UserMoveVoiceChannel", + val node: String, + val token: String, +) \ No newline at end of file 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 d075cb1d..0fde0417 100644 --- a/app/src/main/java/chat/stoat/api/schemas/Voice.kt +++ b/app/src/main/java/chat/stoat/api/schemas/Voice.kt @@ -17,4 +17,25 @@ data class UserVoiceState( val screensharing: Boolean, val camera: Boolean, @SerialName(value = "joined_at") val joinedAt: String? = null, -) \ No newline at end of file +) + +@Serializable +data class PartialUserVoiceState( + val id: String? = null, + @SerialName("is_receiving") val isReceiving: Boolean? = null, + @SerialName("is_publishing") val isPublishing: Boolean? = null, + val screensharing: Boolean? = null, + val camera: Boolean? = null, + @SerialName(value = "joined_at") val joinedAt: String? = null, +) { + fun overrideInto(other: UserVoiceState): UserVoiceState { + return UserVoiceState( + id = this.id ?: other.id, + isReceiving = this.isReceiving ?: other.isReceiving, + isPublishing = this.isPublishing ?: other.isPublishing, + screensharing = this.screensharing ?: other.screensharing, + camera = this.camera ?: other.camera, + joinedAt = this.joinedAt ?: other.joinedAt, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/composables/chat/Message.kt b/app/src/main/java/chat/stoat/composables/chat/Message.kt index 556eff86..f485f487 100644 --- a/app/src/main/java/chat/stoat/composables/chat/Message.kt +++ b/app/src/main/java/chat/stoat/composables/chat/Message.kt @@ -1,5 +1,6 @@ package chat.stoat.composables.chat +import android.R.id.message import android.annotation.SuppressLint import android.content.Intent import android.icu.text.DateFormat @@ -113,6 +114,20 @@ fun authorColour(message: MessageSchema): Brush { } } +@Composable +fun displayNameInChannel(userId: String, channelId: String): String { + val serverId = + StoatAPI.channelCache[channelId]?.server + ?: return StoatAPI.userCache[userId]?.let { User.resolveDefaultName(it) } + ?: stringResource(R.string.unknown) + + val member = userId.let { StoatAPI.members.getMember(serverId, it) } + ?: return stringResource(R.string.unknown) + return member.nickname + ?: StoatAPI.userCache[userId]?.let { User.resolveDefaultName(it) } + ?: stringResource(R.string.unknown) +} + @Composable fun authorName(message: MessageSchema): String { if (message.masquerade?.name != null) { diff --git a/app/src/main/java/chat/stoat/composables/voice/VoiceParticipant.kt b/app/src/main/java/chat/stoat/composables/voice/VoiceParticipant.kt new file mode 100644 index 00000000..1ca02658 --- /dev/null +++ b/app/src/main/java/chat/stoat/composables/voice/VoiceParticipant.kt @@ -0,0 +1,71 @@ +package chat.stoat.composables.voice + +import androidx.compose.foundation.layout.Row +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.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import chat.stoat.R +import chat.stoat.api.StoatAPI +import chat.stoat.api.schemas.UserVoiceState +import chat.stoat.composables.chat.displayNameInChannel +import chat.stoat.composables.generic.UserAvatar +import chat.stoat.internals.extensions.TransparentListItemColours + +@Composable +fun VoiceParticipant( + state: UserVoiceState, + channelId: String, + speaking: Boolean, + modifier: Modifier = Modifier +) { + val user = StoatAPI.userCache[state.id] + ListItem( + 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 + ) + }, + trailingContent = { + Row { + if (!state.isPublishing) { + Icon( + painter = painterResource(R.drawable.icn_mic_off_24dp), + contentDescription = stringResource(R.string.voice_muted), + ) + } + if (!state.isReceiving) { + Icon( + painter = painterResource(R.drawable.icn_headset_off_24dp), + contentDescription = stringResource(R.string.voice_deafened), + ) + } + if (state.camera) { + Icon( + painter = painterResource(R.drawable.icn_videocam_24dp), + contentDescription = stringResource(R.string.voice_camera_on), + ) + } + if (state.screensharing) { + Icon( + painter = painterResource(R.drawable.icn_screen_share_24dp), + contentDescription = stringResource(R.string.voice_screen_sharing), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + ) +} \ 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 be78ca75..571de4a5 100644 --- a/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt +++ b/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt @@ -1,31 +1,51 @@ package chat.stoat.composables.voice +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalFloatingToolbar +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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 androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewmodel.compose.viewModel import chat.stoat.R +import chat.stoat.api.StoatAPI +import chat.stoat.api.routes.misc.Root 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 io.livekit.android.room.Room import logcat.LogPriority import logcat.asLog import logcat.logcat @@ -52,7 +72,17 @@ class VoiceSheetViewModel(private val state: SavedStateHandle) : ViewModel() { private set suspend fun getVoiceToken() { - val root = getRootRoute() + errorResource = null + + val root: Root + try { + root = getRootRoute() + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Could not get root route\n" + e.asLog() } + errorResource = R.string.voice_error_generic + return + } + val lk = root.features.livekit if (lk == null) { @@ -82,6 +112,7 @@ class VoiceSheetViewModel(private val state: SavedStateHandle) : ViewModel() { } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun VoiceSheet( channelId: String, @@ -105,44 +136,166 @@ fun VoiceSheet( Column { LazyColumn(modifier = Modifier.animateContentSize()) { + val voiceStates = StoatAPI.voiceStateCache[viewModel.channelId] + items(voiceStates?.participants?.size ?: 0) { index -> + val participantState = voiceStates?.participants[index] + participantState?.let { + VoiceParticipant( + state = participantState, + channelId = viewModel.channelId, + speaking = false + ) + } + } 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 🗣️📢🔥") + 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 + } + ) { + Icon( + painter = painterResource( + when (roomState) { + Room.State.CONNECTING -> R.drawable.icn_sprint_24dp + Room.State.CONNECTED -> R.drawable.icn_wifi_tethering_24dp + Room.State.DISCONNECTED -> R.drawable.icn_wifi_tethering_error_24dp + Room.State.RECONNECTING -> R.drawable.icn_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) + } + ) + } + } } } } - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp) + + AnimatedVisibility(viewModel.errorResource != null) { + viewModel.errorResource?.let { resId -> + Text( + text = stringResource(resId), + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + + HorizontalFloatingToolbar( + expanded = true, + modifier = Modifier.padding( + start = 8.dp, + end = 8.dp, + bottom = 16.dp, + ), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = 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 = { + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer ), + shapes = ButtonDefaults.shapes(), + modifier = Modifier + .weight(1f) + .height(64.dp) + ) { + Icon( + painter = painterResource(R.drawable.icn_mic_off_24dp), + contentDescription = "TODO change this string to res" + ) + } + Spacer(Modifier.width(4.dp)) + Button( + onClick = { + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + shapes = ButtonDefaults.shapes(), + modifier = Modifier + .weight(1f) + .height(64.dp) + ) { + Icon( + painter = painterResource(R.drawable.icn_videocam_off_24dp), + contentDescription = "TODO change this string to res" + ) + } + Spacer(Modifier.width(4.dp)) + Button( + onClick = { + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + shapes = ButtonDefaults.shapes(), + modifier = Modifier + .weight(1f) + .height(64.dp) + ) { + Icon( + painter = painterResource(R.drawable.icn_mobile_share_24px), + contentDescription = "TODO change this string to res" + ) + } + Spacer(Modifier.width(4.dp)) + Button( onClick = { room.disconnect() onDisconnect() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + shapes = ButtonDefaults.shapes(), + modifier = Modifier + .weight(2f) + .height(64.dp) ) { - Text("disconnect") + Icon( + painter = painterResource(R.drawable.icn_call_end_24dp__fill), + contentDescription = stringResource(R.string.voice_action_disconnect) + ) } } } diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt index 9a07c83b..e19df3e8 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt @@ -513,11 +513,7 @@ class ChannelScreenViewModel @Inject constructor( suspend fun listenToWsEvents() { withContext(StoatAPI.realtimeContext) { - flow { - while (true) { - emit(StoatAPI.wsFrameChannel.receive()) - } - }.onEach { + StoatAPI.wsFrameChannel.onEach { when (it) { is MessageFrame -> { if (it.channel != channel?.id) return@onEach diff --git a/app/src/main/res/drawable/icn_call_end_24dp__fill.xml b/app/src/main/res/drawable/icn_call_end_24dp__fill.xml new file mode 100644 index 00000000..786a1bc0 --- /dev/null +++ b/app/src/main/res/drawable/icn_call_end_24dp__fill.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/icn_headset_off_24dp.xml b/app/src/main/res/drawable/icn_headset_off_24dp.xml new file mode 100644 index 00000000..714d4941 --- /dev/null +++ b/app/src/main/res/drawable/icn_headset_off_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/icn_mic_off_24dp.xml b/app/src/main/res/drawable/icn_mic_off_24dp.xml new file mode 100644 index 00000000..4b7c3ce6 --- /dev/null +++ b/app/src/main/res/drawable/icn_mic_off_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/icn_mobile_share_24px.xml b/app/src/main/res/drawable/icn_mobile_share_24px.xml new file mode 100644 index 00000000..31de70e9 --- /dev/null +++ b/app/src/main/res/drawable/icn_mobile_share_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/icn_screen_share_24dp.xml b/app/src/main/res/drawable/icn_screen_share_24dp.xml new file mode 100644 index 00000000..f0ba76ac --- /dev/null +++ b/app/src/main/res/drawable/icn_screen_share_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/icn_sprint_24dp.xml b/app/src/main/res/drawable/icn_sprint_24dp.xml new file mode 100644 index 00000000..1aa4a6be --- /dev/null +++ b/app/src/main/res/drawable/icn_sprint_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/icn_videocam_off_24dp.xml b/app/src/main/res/drawable/icn_videocam_off_24dp.xml new file mode 100644 index 00000000..f8fad1f9 --- /dev/null +++ b/app/src/main/res/drawable/icn_videocam_off_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/icn_wifi_tethering_24dp.xml b/app/src/main/res/drawable/icn_wifi_tethering_24dp.xml new file mode 100644 index 00000000..b10e6859 --- /dev/null +++ b/app/src/main/res/drawable/icn_wifi_tethering_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/icn_wifi_tethering_error_24dp.xml b/app/src/main/res/drawable/icn_wifi_tethering_error_24dp.xml new file mode 100644 index 00000000..2f9157f0 --- /dev/null +++ b/app/src/main/res/drawable/icn_wifi_tethering_error_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 172675f5..bc8a8bba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -628,6 +628,18 @@ All voice nodes are unavailable at the moment. Please try again later. An error occurred. Please try again later. + Muted + Deafened + Sharing their screen + Camera on + + Disconnect + + Connecting… + Reconnecting… + Connected + No connection + Stay in the loop Enable notifications to be kept up to date with messages and mentions. Enable notifications