feat: improve voice ui

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-10-19 16:43:39 +02:00
parent 23d2b115f9
commit a025d6e653
18 changed files with 542 additions and 40 deletions

View File

@ -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

View File

@ -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) {

View File

@ -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,
)

View File

@ -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,
)
}
}

View File

@ -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) {

View File

@ -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
)
}
}
}
)
}

View File

@ -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)
)
}
}
}

View File

@ -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

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="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>

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="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>

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="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>

View File

@ -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>

View File

@ -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>

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="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>

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="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>

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="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>

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="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>

View File

@ -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>