feat: improve voice ui
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
23d2b115f9
commit
a025d6e653
|
|
@ -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<Any>(Channel.UNLIMITED)
|
||||
val wsFrameChannel = MutableSharedFlow<Any>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = Int.MAX_VALUE,
|
||||
)
|
||||
|
||||
private var socketCoroutine: Job? = null
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -17,4 +17,25 @@ data class UserVoiceState(
|
|||
val screensharing: Boolean,
|
||||
val camera: Boolean,
|
||||
@SerialName(value = "joined_at") val joinedAt: String? = null,
|
||||
)
|
||||
)
|
||||
|
||||
@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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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="M480,320Q598,320 712.5,367.5Q827,415 916,510Q928,522 928,538Q928,554 916,566L824,656Q813,667 798.5,668Q784,669 772,660L656,572Q648,566 644,558Q640,550 640,540L640,426Q602,414 562,407Q522,400 480,400Q438,400 398,407Q358,414 320,426L320,540Q320,550 316,558Q312,566 304,572L188,660Q176,669 161.5,668Q147,667 136,656L44,566Q32,554 32,538Q32,522 44,510Q132,415 247,367.5Q362,320 480,320Z"/>
|
||||
</vector>
|
||||
|
|
@ -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="M840,726L760,646L760,560L674,560L594,480L760,480L760,440Q760,322 678,241Q596,160 480,160Q436,160 396.5,172.5Q357,185 324,208L266,152Q311,117 365.5,98.5Q420,80 480,80Q554,80 619.5,108Q685,136 734,185Q783,234 811.5,299.5Q840,365 840,440L840,726ZM480,920L480,840L727,840L687,800L600,800L600,713L221,334Q212,358 206,385.5Q200,413 200,440L200,480L360,480L360,800L200,800Q167,800 143.5,776.5Q120,753 120,720L120,440Q120,395 130.5,353Q141,311 161,273L27,140L84,84L875,876L875,920L480,920ZM200,720L280,720L280,560L200,560L200,720Q200,720 200,720Q200,720 200,720ZM200,560Q200,560 200,560Q200,560 200,560L200,560L280,560L280,560L200,560ZM674,560L760,560L760,560L674,560Z"/>
|
||||
</vector>
|
||||
|
|
@ -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="M710,598L652,540Q666,517 673,492Q680,467 680,440L760,440Q760,484 747,523.5Q734,563 710,598ZM480,366Q480,366 480,366Q480,366 480,366L480,366L480,366L480,366Q480,366 480,366Q480,366 480,366ZM592,478L520,406L520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200L440,326L360,246L360,200Q360,150 395,115Q430,80 480,80Q530,80 565,115Q600,150 600,200L600,440Q600,451 597.5,460Q595,469 592,478ZM440,840L440,717Q336,703 268,624Q200,545 200,440L280,440Q280,523 337.5,581.5Q395,640 480,640Q514,640 544.5,629.5Q575,619 600,600L657,657Q628,680 593.5,696Q559,712 520,717L520,840L440,840ZM792,904L56,168L112,112L848,848L792,904Z"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<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"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M320,640L400,640L400,520Q400,520 400,520Q400,520 400,520L486,520L444,564L500,620L640,480L500,340L444,396L486,440L400,440Q367,440 343.5,463.5Q320,487 320,520L320,640ZM280,920Q247,920 223.5,896.5Q200,873 200,840L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,244Q778,251 789,266Q800,281 800,300L800,380Q800,399 789,414Q778,429 760,436L760,840Q760,873 736.5,896.5Q713,920 680,920L280,920ZM280,840L680,840Q680,840 680,840Q680,840 680,840L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840ZM280,840Q280,840 280,840Q280,840 280,840L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840Z"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<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"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M320,560L400,560L400,480Q400,463 411.5,451.5Q423,440 440,440L520,440L520,520L640,400L520,280L520,360L440,360Q390,360 355,395Q320,430 320,480L320,560ZM160,720Q127,720 103.5,696.5Q80,673 80,640L80,200Q80,167 103.5,143.5Q127,120 160,120L800,120Q833,120 856.5,143.5Q880,167 880,200L880,640Q880,673 856.5,696.5Q833,720 800,720L160,720ZM160,640L800,640Q800,640 800,640Q800,640 800,640L800,200Q800,200 800,200Q800,200 800,200L160,200Q160,200 160,200Q160,200 160,200L160,640Q160,640 160,640Q160,640 160,640ZM160,640Q160,640 160,640Q160,640 160,640L160,200Q160,200 160,200Q160,200 160,200L160,200Q160,200 160,200Q160,200 160,200L160,640Q160,640 160,640Q160,640 160,640ZM40,840L40,760L920,760L920,840L40,840Z"/>
|
||||
</vector>
|
||||
|
|
@ -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="M216,800L160,744L544,360L440,360L440,440L360,440L360,280L593,280Q609,280 624,286Q639,292 650,303L770,422Q797,449 836,464Q875,479 920,480L920,560Q858,560 807.5,541Q757,522 718,484L678,442L590,530L680,620L418,771L378,702L550,603L482,535L216,800ZM120,520L120,440L320,440L320,520L120,520ZM40,400L40,320L240,320L240,400L40,400ZM779,320Q746,320 722,296.5Q698,273 698,240Q698,207 722,183.5Q746,160 779,160Q812,160 836,183.5Q860,207 860,240Q860,273 836,296.5Q812,320 779,320ZM120,280L120,200L320,200L320,280L120,280Z"/>
|
||||
</vector>
|
||||
|
|
@ -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="M880,700L720,540L720,607L640,527L640,240Q640,240 640,240Q640,240 640,240L353,240L273,160L640,160Q673,160 696.5,183.5Q720,207 720,240L720,420L880,260L880,700ZM822,934L26,138L82,82L878,878L822,934ZM498,385L498,385Q498,385 498,385Q498,385 498,385L498,385ZM382,496Q382,496 382,496Q382,496 382,496L382,496Q382,496 382,496Q382,496 382,496L382,496Q382,496 382,496Q382,496 382,496ZM160,160L240,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720L640,720Q640,720 640,720Q640,720 640,720L640,640L720,720L720,720Q720,753 696.5,776.5Q673,800 640,800L160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160Z"/>
|
||||
</vector>
|
||||
|
|
@ -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="M204,810Q147,755 113.5,680.5Q80,606 80,520Q80,437 111.5,364Q143,291 197,237Q251,183 324,151.5Q397,120 480,120Q563,120 636,151.5Q709,183 763,237Q817,291 848.5,364Q880,437 880,520Q880,606 846.5,681Q813,756 756,810L700,754Q746,710 773,649.5Q800,589 800,520Q800,386 707,293Q614,200 480,200Q346,200 253,293Q160,386 160,520Q160,589 187,649Q214,709 261,753L204,810ZM317,697Q282,664 261,618.5Q240,573 240,520Q240,420 310,350Q380,280 480,280Q580,280 650,350Q720,420 720,520Q720,573 699,619Q678,665 643,697L586,640Q611,617 625.5,586Q640,555 640,520Q640,454 593,407Q546,360 480,360Q414,360 367,407Q320,454 320,520Q320,556 334.5,586.5Q349,617 374,640L317,697ZM480,600Q447,600 423.5,576.5Q400,553 400,520Q400,487 423.5,463.5Q447,440 480,440Q513,440 536.5,463.5Q560,487 560,520Q560,553 536.5,576.5Q513,600 480,600Z"/>
|
||||
</vector>
|
||||
|
|
@ -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="M204,810Q147,755 113.5,680.5Q80,606 80,520Q80,437 111.5,364Q143,291 197,237Q251,183 324,151.5Q397,120 480,120Q592,120 683,174.5Q774,229 827,320L730,320Q685,265 620.5,232.5Q556,200 480,200Q346,200 253,293Q160,386 160,520Q160,589 187,649Q214,709 261,753L204,810ZM317,697Q282,664 261,618.5Q240,573 240,520Q240,420 310,350Q380,280 480,280Q580,280 650,350Q720,420 720,520Q720,573 699,619Q678,665 643,697L586,640Q611,617 625.5,586Q640,555 640,520Q640,454 593,407Q546,360 480,360Q414,360 367,407Q320,454 320,520Q320,556 334.5,586.5Q349,617 374,640L317,697ZM480,600Q447,600 423.5,576.5Q400,553 400,520Q400,487 423.5,463.5Q447,440 480,440Q513,440 536.5,463.5Q560,487 560,520Q560,553 536.5,576.5Q513,600 480,600ZM840,800Q823,800 811.5,788.5Q800,777 800,760Q800,743 811.5,731.5Q823,720 840,720Q857,720 868.5,731.5Q880,743 880,760Q880,777 868.5,788.5Q857,800 840,800ZM800,640L800,400L880,400L880,640L800,640Z"/>
|
||||
</vector>
|
||||
|
|
@ -628,6 +628,18 @@
|
|||
<string name="voice_error_no_nodes">All voice nodes are unavailable at the moment. Please try again later.</string>
|
||||
<string name="voice_error_generic">An error occurred. Please try again later.</string>
|
||||
|
||||
<string name="voice_muted">Muted</string>
|
||||
<string name="voice_deafened">Deafened</string>
|
||||
<string name="voice_screen_sharing">Sharing their screen</string>
|
||||
<string name="voice_camera_on">Camera on</string>
|
||||
|
||||
<string name="voice_action_disconnect">Disconnect</string>
|
||||
|
||||
<string name="voice_status_connecting">Connecting…</string>
|
||||
<string name="voice_status_reconnecting">Reconnecting…</string>
|
||||
<string name="voice_status_connected">Connected</string>
|
||||
<string name="voice_status_disconnected">No connection</string>
|
||||
|
||||
<string name="spark_notifications_rationale">Stay in the loop</string>
|
||||
<string name="spark_notifications_rationale_description">Enable notifications to be kept up to date with messages and mentions.</string>
|
||||
<string name="spark_notifications_rationale_cta">Enable notifications</string>
|
||||
|
|
|
|||
Loading…
Reference in New Issue