feat: voice chat, video chat, screen share
This commit is contained in:
parent
97dff3a50e
commit
6a4b5d21e9
|
|
@ -244,9 +244,9 @@ dependencies {
|
|||
implementation(libs.jetbrains.markdown)
|
||||
implementation(libs.highlights)
|
||||
|
||||
/*implementation(libs.livekit.android)
|
||||
implementation(libs.livekit.android)
|
||||
implementation(libs.livekit.android.camerax)
|
||||
implementation(libs.livekit.android.compose)*/
|
||||
implementation(libs.livekit.android.compose)
|
||||
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.messaging)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@
|
|||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
|
||||
<!-- Up to Android 10, we need the following to take photos from the camera. -->
|
||||
<uses-permission
|
||||
|
|
@ -13,13 +19,6 @@
|
|||
android:maxSdkVersion="29"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<!--
|
||||
* FIXME LiveKit is temporarily not included, hence the following is commented out.
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
-->
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
|
@ -66,6 +65,11 @@
|
|||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".services.OngoingCallService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="phoneCall|microphone" />
|
||||
|
||||
<receiver
|
||||
android:name=".c2dm.ReplyReceiver"
|
||||
android:exported="false" />
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import coil3.ImageLoader
|
|||
import coil3.SingletonImageLoader
|
||||
import coil3.request.crossfade
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import io.livekit.android.LiveKit
|
||||
import io.livekit.android.util.LoggingLevel
|
||||
import logcat.AndroidLogcatLogger
|
||||
import logcat.LogPriority
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
|
|
@ -25,6 +27,10 @@ class StoatApplication : Application(), SingletonImageLoader.Factory {
|
|||
super.onCreate()
|
||||
AndroidLogcatLogger.installOnDebuggableApp(this, minPriority = LogPriority.VERBOSE)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
LiveKit.loggingLevel = LoggingLevel.DEBUG
|
||||
}
|
||||
|
||||
startKoin {
|
||||
androidContext(this@StoatApplication)
|
||||
androidLogger()
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ import chat.stoat.c2dm.NotificationDeepLink
|
|||
import chat.stoat.composables.generic.HealthAlert
|
||||
import chat.stoat.composables.voice.VoicePermissionSwitch
|
||||
import chat.stoat.composables.voice.VoiceSheet
|
||||
import chat.stoat.voice.VoiceCallManager
|
||||
import chat.stoat.core.model.schemas.HealthNotice
|
||||
import chat.stoat.material.EasingTokens
|
||||
import chat.stoat.persistence.KVStorage
|
||||
|
|
@ -448,8 +449,9 @@ fun AppEntrypoint(
|
|||
onRetryConnection: () -> Unit,
|
||||
onUpdateNextDestination: (String) -> Unit = {}
|
||||
) {
|
||||
var showVoiceUI by rememberSaveable { mutableStateOf(false) }
|
||||
var voiceChannelId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val showVoiceUI = VoiceCallManager.isSheetVisible
|
||||
val hideVoiceUI = { VoiceCallManager.isSheetVisible = false }
|
||||
val disconnectVoice = { VoiceCallManager.leave() }
|
||||
|
||||
val chatUIScale by animateFloatAsState(
|
||||
if (showVoiceUI) 0.8f else 1.0f,
|
||||
|
|
@ -467,7 +469,7 @@ fun AppEntrypoint(
|
|||
)
|
||||
|
||||
BackHandler(showVoiceUI) {
|
||||
showVoiceUI = false
|
||||
hideVoiceUI()
|
||||
}
|
||||
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
|
@ -636,8 +638,7 @@ fun AppEntrypoint(
|
|||
navController.navigate("default")
|
||||
},
|
||||
onEnterVoiceUI = { channelId ->
|
||||
showVoiceUI = true
|
||||
voiceChannelId = channelId
|
||||
VoiceCallManager.openSheet(channelId)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -760,7 +761,7 @@ fun AppEntrypoint(
|
|||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
showVoiceUI = false
|
||||
hideVoiceUI()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -793,17 +794,14 @@ fun AppEntrypoint(
|
|||
.padding(8.dp)
|
||||
) {
|
||||
VoicePermissionSwitch(
|
||||
onCancel = {
|
||||
showVoiceUI = false
|
||||
}
|
||||
onCancel = disconnectVoice
|
||||
) {
|
||||
voiceChannelId?.let {
|
||||
VoiceCallManager.requestedChannelId?.let { channelId ->
|
||||
LaunchedEffect(channelId) {
|
||||
VoiceCallManager.join(channelId)
|
||||
}
|
||||
VoiceSheet(
|
||||
it,
|
||||
onDisconnect = {
|
||||
showVoiceUI = false
|
||||
voiceChannelId = null
|
||||
}
|
||||
onDisconnect = disconnectVoice
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,8 +57,11 @@ import io.ktor.websocket.WebSocketSession
|
|||
import io.ktor.websocket.close
|
||||
import io.ktor.websocket.readText
|
||||
import io.ktor.websocket.send
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.serialization.SerializationException
|
||||
import logcat.LogPriority
|
||||
import logcat.asLog
|
||||
import logcat.logcat
|
||||
|
||||
enum class DisconnectionState {
|
||||
|
|
@ -120,10 +123,18 @@ object RealtimeSocket {
|
|||
incoming.consumeEach { frame ->
|
||||
if (frame is Frame.Text) {
|
||||
val frameString = frame.readText()
|
||||
val frameType =
|
||||
StoatJson.decodeFromString(AnyFrame.serializer(), frameString).type
|
||||
try {
|
||||
val frameType =
|
||||
StoatJson.decodeFromString(AnyFrame.serializer(), frameString).type
|
||||
|
||||
handleFrame(frameType, frameString)
|
||||
handleFrame(frameType, frameString)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) {
|
||||
"Failed to handle frame: $frameString\n" + e.asLog()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -886,10 +897,10 @@ object RealtimeSocket {
|
|||
val userVoiceStateUpdateFrame =
|
||||
StoatJson.decodeFromString(UserVoiceStateUpdateFrame.serializer(), rawFrame)
|
||||
|
||||
logcat { "Received user voice state update frame for user ${userVoiceStateUpdateFrame.id} in channel ${userVoiceStateUpdateFrame.id}." }
|
||||
logcat { "Received user voice state update frame for user ${userVoiceStateUpdateFrame.id} in channel ${userVoiceStateUpdateFrame.channelId}." }
|
||||
|
||||
val existingChannelState =
|
||||
StoatAPI.voiceStateCache[userVoiceStateUpdateFrame.id] ?: return
|
||||
StoatAPI.voiceStateCache[userVoiceStateUpdateFrame.channelId] ?: return
|
||||
|
||||
val newParticipants = existingChannelState.participants.map {
|
||||
if (it.id == userVoiceStateUpdateFrame.id) {
|
||||
|
|
@ -899,8 +910,8 @@ object RealtimeSocket {
|
|||
}
|
||||
}
|
||||
|
||||
StoatAPI.voiceStateCache[userVoiceStateUpdateFrame.id] =
|
||||
ChannelVoiceState(userVoiceStateUpdateFrame.id, newParticipants)
|
||||
StoatAPI.voiceStateCache[userVoiceStateUpdateFrame.channelId] =
|
||||
ChannelVoiceState(userVoiceStateUpdateFrame.channelId, newParticipants)
|
||||
}
|
||||
|
||||
"UserMoveVoiceChannel" -> {
|
||||
|
|
|
|||
|
|
@ -271,6 +271,7 @@ data class VoiceChannelMoveFrame(
|
|||
data class UserVoiceStateUpdateFrame(
|
||||
val type: String = "UserVoiceStateUpdate",
|
||||
val id: String,
|
||||
@SerialName("channel_id") val channelId: String,
|
||||
val data: PartialUserVoiceState
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ class ChannelRegistrator(val context: Context) {
|
|||
|
||||
const val CHANNEL_ID_GROUP_SOCIAL = "chat.stoat.c2dm.social"
|
||||
const val CHANNEL_ID_GROUP_SOCIAL_FRIENDREQUESTS = "chat.stoat.c2dm.social.friendrequests"
|
||||
|
||||
const val CHANNEL_ID_GROUP_VOICE = "chat.stoat.voice"
|
||||
const val CHANNEL_ID_GROUP_VOICE_ONGOING = "chat.stoat.voice.ongoing"
|
||||
}
|
||||
|
||||
private val notificationManager =
|
||||
|
|
@ -39,6 +42,12 @@ class ChannelRegistrator(val context: Context) {
|
|||
context.getString(R.string.notification_channel_group_social)
|
||||
)
|
||||
)
|
||||
notificationManager.createNotificationChannelGroup(
|
||||
NotificationChannelGroup(
|
||||
CHANNEL_ID_GROUP_VOICE,
|
||||
context.getString(R.string.notification_channel_group_voice)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun registerChannels() {
|
||||
|
|
@ -64,6 +73,19 @@ class ChannelRegistrator(val context: Context) {
|
|||
context.getString(R.string.notification_channel_messages_description)
|
||||
}
|
||||
)
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID_GROUP_VOICE_ONGOING,
|
||||
context.getString(R.string.notification_channel_ongoing_call),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
group = CHANNEL_ID_GROUP_VOICE
|
||||
description =
|
||||
context.getString(R.string.notification_channel_ongoing_call_description)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun register() {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ enum class SystemMessageType(val type: String) {
|
|||
USER_JOINED("user_joined"),
|
||||
MESSAGE_PINNED("message_pinned"),
|
||||
MESSAGE_UNPINNED("message_unpinned"),
|
||||
CALL_STARTED("call_started"),
|
||||
TEXT("text")
|
||||
}
|
||||
|
||||
|
|
@ -206,6 +207,16 @@ fun SystemMessage(message: Message) {
|
|||
)
|
||||
}
|
||||
|
||||
SystemMessageType.CALL_STARTED -> {
|
||||
ChatMarkdown(
|
||||
stringResource(
|
||||
R.string.system_message_call_started,
|
||||
message.system!!.by.mention()
|
||||
),
|
||||
serverId = serverId
|
||||
)
|
||||
}
|
||||
|
||||
SystemMessageType.TEXT -> {
|
||||
message.system!!.content?.let { ChatMarkdown(it) }
|
||||
}
|
||||
|
|
@ -329,6 +340,15 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
|
|||
)
|
||||
}
|
||||
|
||||
SystemMessageType.CALL_STARTED -> {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_call_24dp__fill),
|
||||
contentDescription = stringResource(R.string.system_message_call_started_alt),
|
||||
tint = LocalContentColor.current,
|
||||
modifier = modifier.size(size)
|
||||
)
|
||||
}
|
||||
|
||||
SystemMessageType.TEXT -> {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_info_24dp),
|
||||
|
|
@ -356,6 +376,7 @@ private fun shapeForType(type: SystemMessageType): Shape {
|
|||
SystemMessageType.USER_JOINED -> MaterialShapes.Cookie9Sided
|
||||
SystemMessageType.MESSAGE_PINNED -> MaterialShapes.Clover4Leaf
|
||||
SystemMessageType.MESSAGE_UNPINNED -> MaterialShapes.Clover8Leaf
|
||||
SystemMessageType.CALL_STARTED -> MaterialShapes.Fan
|
||||
SystemMessageType.TEXT -> MaterialShapes.Square
|
||||
}.toShape()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
package chat.stoat.composables.voice
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.stoat.R
|
||||
import chat.stoat.api.StoatAPI
|
||||
import chat.stoat.api.internals.ChannelUtils
|
||||
import chat.stoat.voice.VoiceCallManager
|
||||
|
||||
@Composable
|
||||
fun VoiceCallBanner(modifier: Modifier = Modifier) {
|
||||
val channelId = VoiceCallManager.activeChannelId
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = channelId != null && !VoiceCallManager.isSheetVisible,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
modifier = modifier
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { VoiceCallManager.isSheetVisible = true }
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, end = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_voice_chat_24dp),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = channelId
|
||||
?.let { StoatAPI.channelCache[it] }
|
||||
?.let(ChannelUtils::resolveName)
|
||||
?: stringResource(R.string.voice_notification_fallback_channel_name),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.voice_banner_tap_to_return),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { VoiceCallManager.leave() }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_call_end_24dp__fill),
|
||||
contentDescription = stringResource(R.string.voice_action_disconnect),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +1,55 @@
|
|||
package chat.stoat.composables.voice
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
class VoiceSheetViewModel(private val state: SavedStateHandle) : ViewModel() {}
|
||||
|
||||
@Composable
|
||||
fun VoiceSheet(
|
||||
channelId: String,
|
||||
onDisconnect: () -> Unit,
|
||||
viewModel: VoiceSheetViewModel = viewModel()
|
||||
) {
|
||||
}
|
||||
|
||||
/*
|
||||
import android.app.Activity
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.HorizontalFloatingToolbar
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -55,153 +59,170 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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 chat.stoat.composables.chat.displayNameInChannel
|
||||
import chat.stoat.core.model.util.UserVoiceState
|
||||
import chat.stoat.voice.VoiceCallManager
|
||||
import com.twilio.audioswitch.AudioDevice
|
||||
import com.twilio.audioswitch.AudioDeviceChangeListener
|
||||
import io.livekit.android.compose.local.RoomLocal
|
||||
import io.livekit.android.compose.local.RoomScope
|
||||
import io.livekit.android.compose.state.rememberParticipants
|
||||
import io.livekit.android.compose.state.rememberTracks
|
||||
import io.livekit.android.compose.ui.ScaleType
|
||||
import io.livekit.android.compose.ui.VideoTrackView
|
||||
import io.livekit.android.room.Room
|
||||
import io.livekit.android.room.track.LocalVideoTrack
|
||||
import io.livekit.android.room.track.Track
|
||||
import io.livekit.android.room.track.screencapture.ScreenCaptureParams
|
||||
import io.livekit.android.util.flow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import logcat.asLog
|
||||
import logcat.logcat
|
||||
|
||||
class VoiceSheetViewModel(private val state: SavedStateHandle) : ViewModel() {
|
||||
private val _channelId = mutableStateOf(state.get<String>("channelId") ?: "")
|
||||
var channelId: String
|
||||
get() = _channelId.value
|
||||
set(value) {
|
||||
_channelId.value = value
|
||||
state["channelId"] = value
|
||||
}
|
||||
|
||||
var voiceLkNode by mutableStateOf("")
|
||||
private val _voiceToken = mutableStateOf(state.get<String>("voiceToken") ?: "")
|
||||
var voiceToken: String
|
||||
get() = _voiceToken.value
|
||||
private set(value) {
|
||||
_voiceToken.value = value
|
||||
state["voiceToken"] = value
|
||||
}
|
||||
|
||||
var errorResource by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
|
||||
suspend fun getVoiceToken() {
|
||||
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) {
|
||||
logcat(LogPriority.ERROR) {
|
||||
IllegalStateException("LiveKit is not supported by this API version!").asLog()
|
||||
}
|
||||
errorResource = R.string.voice_error_not_supported
|
||||
return
|
||||
}
|
||||
|
||||
if (lk.nodes.isEmpty()) {
|
||||
logcat(LogPriority.ERROR) { IllegalStateException("No LiveKit nodes available!").asLog() }
|
||||
errorResource = R.string.voice_error_no_nodes
|
||||
return
|
||||
}
|
||||
|
||||
val node = lk.nodes.random()
|
||||
try {
|
||||
val joined = joinCall(channelId, node.name)
|
||||
voiceLkNode = joined.url
|
||||
voiceToken = joined.token
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "Could not get LiveKit token\n" + e.asLog() }
|
||||
errorResource = R.string.voice_error_generic
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun VoiceSheet(
|
||||
channelId: String,
|
||||
onDisconnect: () -> Unit,
|
||||
viewModel: VoiceSheetViewModel = viewModel()
|
||||
) {
|
||||
LaunchedEffect(channelId) {
|
||||
viewModel.channelId = channelId
|
||||
viewModel.getVoiceToken()
|
||||
}
|
||||
|
||||
fun VoiceSheet(onDisconnect: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
RoomScope(
|
||||
url = viewModel.voiceLkNode,
|
||||
token = viewModel.voiceToken,
|
||||
audio = true,
|
||||
video = false,
|
||||
connect = true,
|
||||
) {
|
||||
val room = RoomLocal.current
|
||||
val room = VoiceCallManager.room
|
||||
val channelId = VoiceCallManager.activeChannelId
|
||||
if (room == null || channelId == null) return
|
||||
|
||||
CompositionLocalProvider(RoomLocal provides room) {
|
||||
val roomState by room::state.flow.collectAsState()
|
||||
val isMicOn by room.localParticipant::isMicrophoneEnabled.flow.collectAsState()
|
||||
val isCameraOn by room.localParticipant::isCameraEnabled.flow.collectAsState()
|
||||
val isScreenShared by room.localParticipant::isScreenShareEnabled.flow.collectAsState()
|
||||
val activeSpeakers by room::activeSpeakers.flow.collectAsState()
|
||||
val participants by rememberParticipants(room)
|
||||
val trackRefs by rememberTracks(passedRoom = room)
|
||||
val isDeafened = VoiceCallManager.isDeafened
|
||||
|
||||
val audioHandler = room.audioSwitchHandler
|
||||
var audioDevices by remember {
|
||||
mutableStateOf(audioHandler?.availableAudioDevices ?: emptyList())
|
||||
}
|
||||
var selectedAudioDevice by remember { mutableStateOf(audioHandler?.selectedAudioDevice) }
|
||||
DisposableEffect(audioHandler) {
|
||||
val listener: AudioDeviceChangeListener = { devices, selected ->
|
||||
audioDevices = devices
|
||||
selectedAudioDevice = selected
|
||||
}
|
||||
audioHandler?.registerAudioDeviceChangeListener(listener)
|
||||
onDispose {
|
||||
audioHandler?.unregisterAudioDeviceChangeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
LazyColumn(
|
||||
modifier = Modifier.animateContentSize(
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = LinearOutSlowInEasing
|
||||
modifier = Modifier
|
||||
// don't push the toolbar off-screen
|
||||
.weight(1f, fill = false)
|
||||
.animateContentSize(
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = LinearOutSlowInEasing
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
val voiceStates = StoatAPI.voiceStateCache[viewModel.channelId]
|
||||
items(voiceStates?.participants?.size ?: 0) { index ->
|
||||
val participantState = voiceStates?.participants[index]
|
||||
participantState?.let {
|
||||
val voiceStates = StoatAPI.voiceStateCache[channelId]
|
||||
items(participants.size) { index ->
|
||||
val participant = participants[index]
|
||||
val userId = participant.identity?.value
|
||||
if (userId != null) {
|
||||
val micEnabled by participant::isMicrophoneEnabled.flow.collectAsState()
|
||||
val cameraEnabled by participant::isCameraEnabled.flow.collectAsState()
|
||||
val screenShareEnabled by participant::isScreenShareEnabled.flow.collectAsState()
|
||||
val cachedState = voiceStates?.participants?.find { it.id == userId }
|
||||
VoiceParticipant(
|
||||
state = participantState,
|
||||
channelId = viewModel.channelId,
|
||||
speaking = activeSpeakers.any { it.identity?.value == participantState.id }
|
||||
state = UserVoiceState(
|
||||
id = userId,
|
||||
isReceiving = cachedState?.isReceiving ?: true,
|
||||
isPublishing = micEnabled,
|
||||
screensharing = screenShareEnabled,
|
||||
camera = cameraEnabled,
|
||||
joinedAt = cachedState?.joinedAt
|
||||
),
|
||||
channelId = channelId,
|
||||
speaking = activeSpeakers.any { it.identity == participant.identity }
|
||||
)
|
||||
}
|
||||
}
|
||||
items(trackRefs.size) { index ->
|
||||
VideoTrackView(
|
||||
trackReference = trackRefs[index],
|
||||
room = room,
|
||||
modifier = Modifier.fillParentMaxHeight(0.5f)
|
||||
)
|
||||
val trackRef = trackRefs[index]
|
||||
val publication = trackRef.publication
|
||||
if (publication != null) {
|
||||
val isTrackMuted by publication::muted.flow.collectAsState()
|
||||
if (!isTrackMuted) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||
) {
|
||||
VideoTrackView(
|
||||
trackReference = trackRef,
|
||||
room = room,
|
||||
// Letterbox vertical feeds
|
||||
scaleType = ScaleType.FitInside,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer.copy(
|
||||
alpha = 0.85f
|
||||
),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 8.dp,
|
||||
vertical = 4.dp
|
||||
)
|
||||
) {
|
||||
if (trackRef.source == Track.Source.SCREEN_SHARE) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_screen_share_24dp),
|
||||
contentDescription = stringResource(R.string.voice_screen_sharing),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = trackRef.participant.identity?.value
|
||||
?.let { displayNameInChannel(it, channelId) }
|
||||
?: stringResource(R.string.unknown),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "status") {
|
||||
var showStatus by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(roomState) {
|
||||
if (roomState == Room.State.CONNECTED) {
|
||||
delay(1000)
|
||||
delay(1.seconds)
|
||||
showStatus = false
|
||||
} else {
|
||||
showStatus = true
|
||||
|
|
@ -290,8 +311,8 @@ fun VoiceSheet(
|
|||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(viewModel.errorResource != null) {
|
||||
viewModel.errorResource?.let { resId ->
|
||||
AnimatedVisibility(VoiceCallManager.errorResource != null) {
|
||||
VoiceCallManager.errorResource?.let { resId ->
|
||||
Text(
|
||||
text = stringResource(resId),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
|
|
@ -301,8 +322,211 @@ fun VoiceSheet(
|
|||
}
|
||||
}
|
||||
|
||||
// the prompt where you select whether you want to share a single app or the whole screen
|
||||
val screenCaptureLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
val data = result.data
|
||||
if (result.resultCode == Activity.RESULT_OK && data != null) {
|
||||
scope.launch {
|
||||
try {
|
||||
val published = room.localParticipant.setScreenShareEnabled(
|
||||
true,
|
||||
ScreenCaptureParams(data)
|
||||
)
|
||||
logcat { "Screen share enable result: $published" }
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) {
|
||||
"Could not start screen share\n" + e.asLog()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logcat(LogPriority.WARN) {
|
||||
"Screen capture consent denied or empty " +
|
||||
"(resultCode ${result.resultCode}, data $data)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var toolbarExpanded by remember { mutableStateOf(false) }
|
||||
var outputMenuOpen by remember { mutableStateOf(false) }
|
||||
val chevronRotation by animateFloatAsState(
|
||||
targetValue = if (toolbarExpanded) 90f else 270f,
|
||||
label = "voice toolbar chevron"
|
||||
)
|
||||
val firstOptionShape = MaterialTheme.shapes.extraSmall.copy(
|
||||
topStart = MaterialTheme.shapes.large.topStart,
|
||||
topEnd = MaterialTheme.shapes.large.topEnd
|
||||
)
|
||||
val toolbarTopCornerRadius by animateDpAsState(
|
||||
targetValue = if (toolbarExpanded) 4.dp else 48.dp,
|
||||
label = "voice toolbar corners"
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = toolbarExpanded,
|
||||
enter = fadeIn() + expandVertically(expandFrom = Alignment.Bottom),
|
||||
exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Bottom)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
bottom = 2.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
ListItem(
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
headlineColor = if (isCameraOn) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
ListItemDefaults.colors().contentColor
|
||||
},
|
||||
leadingIconColor = if (isCameraOn) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
ListItemDefaults.colors().leadingContentColor
|
||||
}
|
||||
),
|
||||
headlineContent = { Text(stringResource(R.string.voice_action_video)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = if (isCameraOn) painterResource(R.drawable.ic_videocam_24dp) else painterResource(
|
||||
R.drawable.ic_videocam_off_24dp
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(firstOptionShape)
|
||||
.clickable {
|
||||
scope.launch {
|
||||
room.localParticipant.setCameraEnabled(!isCameraOn)
|
||||
}
|
||||
}
|
||||
)
|
||||
AnimatedVisibility(visible = isCameraOn) {
|
||||
ListItem(
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
),
|
||||
headlineContent = {
|
||||
Text(stringResource(R.string.voice_action_flip_camera))
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_cameraswitch_24dp),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.extraSmall)
|
||||
.clickable {
|
||||
(room.localParticipant
|
||||
.getTrackPublication(Track.Source.CAMERA)
|
||||
?.track as? LocalVideoTrack)
|
||||
?.switchCamera()
|
||||
}
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
headlineColor = if (isScreenShared) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
ListItemDefaults.colors().contentColor
|
||||
},
|
||||
leadingIconColor = if (isScreenShared) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
ListItemDefaults.colors().leadingContentColor
|
||||
}
|
||||
),
|
||||
headlineContent = { Text(stringResource(R.string.voice_action_screen_share)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_mobile_share_24px),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.extraSmall)
|
||||
.clickable {
|
||||
if (isScreenShared) {
|
||||
scope.launch {
|
||||
room.localParticipant.setScreenShareEnabled(false)
|
||||
}
|
||||
} else {
|
||||
context.getSystemService(MediaProjectionManager::class.java)
|
||||
?.let { manager ->
|
||||
screenCaptureLauncher.launch(
|
||||
manager.createScreenCaptureIntent()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (audioHandler != null) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
ListItem(
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
),
|
||||
headlineContent = {
|
||||
Text(stringResource(R.string.voice_audio_output))
|
||||
},
|
||||
supportingContent = {
|
||||
selectedAudioDevice?.let { Text(audioDeviceLabel(it)) }
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_volume_up_24dp),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.extraSmall)
|
||||
.clickable { outputMenuOpen = true }
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = outputMenuOpen,
|
||||
onDismissRequest = { outputMenuOpen = false }
|
||||
) {
|
||||
audioDevices.forEach { device ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(audioDeviceLabel(device)) },
|
||||
trailingIcon = {
|
||||
if (device == selectedAudioDevice) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_check_24dp),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
audioHandler.selectDevice(device)
|
||||
outputMenuOpen = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalFloatingToolbar(
|
||||
expanded = true,
|
||||
shape = RoundedCornerShape(
|
||||
topStart = CornerSize(toolbarTopCornerRadius),
|
||||
topEnd = CornerSize(toolbarTopCornerRadius),
|
||||
bottomStart = CornerSize(50),
|
||||
bottomEnd = CornerSize(50)
|
||||
),
|
||||
modifier = Modifier.padding(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
|
|
@ -316,11 +540,7 @@ fun VoiceSheet(
|
|||
)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
room.localParticipant.setMicrophoneEnabled(!isMicOn)
|
||||
}
|
||||
},
|
||||
onClick = { VoiceCallManager.toggleMicrophone() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isMicOn) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = if (isMicOn) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
|
|
@ -334,20 +554,17 @@ fun VoiceSheet(
|
|||
painter = if (isMicOn) painterResource(R.drawable.ic_mic_24dp) else painterResource(
|
||||
R.drawable.ic_mic_off_24dp
|
||||
),
|
||||
contentDescription = "TODO change this string to res"
|
||||
contentDescription = stringResource(
|
||||
if (isMicOn) R.string.voice_action_mute else R.string.voice_action_unmute
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
room.localParticipant.setCameraEnabled(!isMicOn)
|
||||
}
|
||||
},
|
||||
enabled = false,
|
||||
onClick = { VoiceCallManager.toggleDeafen() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isCameraOn) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = if (isCameraOn) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
containerColor = if (!isDeafened) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = if (!isDeafened) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
shapes = ButtonDefaults.shapes(),
|
||||
modifier = Modifier
|
||||
|
|
@ -355,20 +572,17 @@ fun VoiceSheet(
|
|||
.height(64.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = if (isCameraOn) painterResource(R.drawable.ic_videocam_24dp) else painterResource(
|
||||
R.drawable.ic_videocam_off_24dp
|
||||
painter = if (isDeafened) painterResource(R.drawable.ic_headset_off_24dp) else painterResource(
|
||||
R.drawable.ic_headset_24dp
|
||||
),
|
||||
contentDescription = "TODO change this string to res"
|
||||
contentDescription = stringResource(
|
||||
if (isDeafened) R.string.voice_action_undeafen else R.string.voice_action_deafen
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
room.localParticipant.setScreenShareEnabled(!isScreenShared)
|
||||
}
|
||||
},
|
||||
enabled = false,
|
||||
onClick = { toolbarExpanded = !toolbarExpanded },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
|
|
@ -379,15 +593,14 @@ fun VoiceSheet(
|
|||
.height(64.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_mobile_share_24px),
|
||||
contentDescription = "TODO change this string to res"
|
||||
painter = painterResource(R.drawable.ic_chevron_forward_24dp),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.graphicsLayer { rotationZ = chevronRotation }
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
onDisconnect()
|
||||
},
|
||||
onClick = onDisconnect,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
|
|
@ -406,4 +619,11 @@ fun VoiceSheet(
|
|||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
@Composable
|
||||
private fun audioDeviceLabel(device: AudioDevice): String = when (device) {
|
||||
is AudioDevice.BluetoothHeadset -> device.name
|
||||
is AudioDevice.WiredHeadset -> stringResource(R.string.voice_audio_output_wired_headset)
|
||||
is AudioDevice.Earpiece -> stringResource(R.string.voice_audio_output_earpiece)
|
||||
is AudioDevice.Speakerphone -> stringResource(R.string.voice_audio_output_speaker)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
package chat.stoat.composables.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.SoundPool
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.RawRes
|
||||
import chat.stoat.R
|
||||
import logcat.LogPriority
|
||||
import logcat.logcat
|
||||
|
||||
internal enum class VoiceSound(@param:RawRes val resourceId: Int) {
|
||||
MUTE(R.raw.sfx_mute),
|
||||
UNMUTE(R.raw.sfx_unmute),
|
||||
DEAFEN(R.raw.sfx_deafen),
|
||||
UNDEAFEN(R.raw.sfx_undeafen),
|
||||
USER_JOIN(R.raw.sfx_user_join_voice),
|
||||
USER_LEAVE(R.raw.sfx_user_leave_voice),
|
||||
STREAM_START(R.raw.sfx_stream_start),
|
||||
STREAM_END(R.raw.sfx_stream_end),
|
||||
}
|
||||
|
||||
internal class VoiceSoundPlayer(context: Context) {
|
||||
private val soundPool = SoundPool.Builder()
|
||||
.setMaxStreams(4)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
private val soundIds = mutableMapOf<VoiceSound, Int>()
|
||||
private val loadedSoundIds = mutableSetOf<Int>()
|
||||
private val pendingSounds = mutableListOf<VoiceSound>()
|
||||
|
||||
init {
|
||||
soundPool.setOnLoadCompleteListener { _, sampleId, status ->
|
||||
if (status != 0) {
|
||||
logcat(LogPriority.ERROR) {
|
||||
"Failed to load voice sound sample $sampleId (status $status)"
|
||||
}
|
||||
return@setOnLoadCompleteListener
|
||||
}
|
||||
|
||||
loadedSoundIds += sampleId
|
||||
pendingSounds
|
||||
.filter { soundIds[it] == sampleId }
|
||||
.forEach(::playLoaded)
|
||||
pendingSounds.removeAll { soundIds[it] == sampleId }
|
||||
}
|
||||
VoiceSound.entries.forEach { sound ->
|
||||
soundIds[sound] =
|
||||
soundPool.load(context.applicationContext, sound.resourceId, 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun play(sound: VoiceSound) {
|
||||
val soundId = soundIds.getValue(sound)
|
||||
if (soundId in loadedSoundIds) {
|
||||
playLoaded(sound)
|
||||
} else {
|
||||
pendingSounds += sound
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
pendingSounds.clear()
|
||||
// let a leave sound finish
|
||||
Handler(Looper.getMainLooper()).postDelayed(soundPool::release, 1_000)
|
||||
}
|
||||
|
||||
private fun playLoaded(sound: VoiceSound) {
|
||||
val streamId = soundPool.play(soundIds.getValue(sound), 1f, 1f, 1, 0, 1f)
|
||||
logcat { "Playing voice sound $sound (stream $streamId)" }
|
||||
if (streamId == 0) {
|
||||
logcat(LogPriority.ERROR) { "SoundPool could not play $sound" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -124,6 +124,7 @@ import chat.stoat.composables.chat.Message
|
|||
import chat.stoat.composables.chat.MessageField
|
||||
import chat.stoat.composables.chat.SystemMessage
|
||||
import chat.stoat.composables.emoji.EmojiPicker
|
||||
import chat.stoat.composables.voice.VoiceCallBanner
|
||||
import chat.stoat.composables.generic.GroupIcon
|
||||
import chat.stoat.composables.generic.PresenceBadge
|
||||
import chat.stoat.composables.generic.UserAvatar
|
||||
|
|
@ -641,6 +642,28 @@ fun ChannelScreen(
|
|||
}
|
||||
},
|
||||
actions = {
|
||||
val isDmLike =
|
||||
viewModel.channel?.channelType == ChannelType.DirectMessage ||
|
||||
viewModel.channel?.channelType == ChannelType.Group
|
||||
if (isDmLike &&
|
||||
viewModel.channel?.voice == null &&
|
||||
StoatAPI.voiceStateCache[channelId]?.participants.isNullOrEmpty() &&
|
||||
channelPermissions has PermissionBit.Connect &&
|
||||
Experiments.useVoiceChats2p0.isEnabled
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
ActionChannel.send(
|
||||
Action.OpenVoiceChannelOverlay(channelId)
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_call_24dp__fill),
|
||||
contentDescription = stringResource(id = R.string.voice_start_call)
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
ActionChannel.send(
|
||||
|
|
@ -655,6 +678,7 @@ fun ChannelScreen(
|
|||
}
|
||||
}
|
||||
)
|
||||
VoiceCallBanner()
|
||||
}
|
||||
}
|
||||
) { pv ->
|
||||
|
|
@ -961,7 +985,15 @@ fun ChannelScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if ((viewModel.channel?.channelType == ChannelType.VoiceChannel || viewModel.channel?.voice != null) &&
|
||||
val isDmLikeWithOngoingCall =
|
||||
(viewModel.channel?.channelType == ChannelType.DirectMessage ||
|
||||
viewModel.channel?.channelType == ChannelType.Group) &&
|
||||
StoatAPI.voiceStateCache[channelId]
|
||||
?.participants
|
||||
?.isNotEmpty() == true
|
||||
if ((viewModel.channel?.channelType == ChannelType.VoiceChannel ||
|
||||
viewModel.channel?.voice != null ||
|
||||
isDmLikeWithOngoingCall) &&
|
||||
channelPermissions has PermissionBit.Connect &&
|
||||
Experiments.useVoiceChats2p0.isEnabled
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,302 @@
|
|||
package chat.stoat.services
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.telecom.DisconnectCause
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.telecom.CallAttributesCompat
|
||||
import androidx.core.telecom.CallControlScope
|
||||
import androidx.core.telecom.CallsManager
|
||||
import chat.stoat.R
|
||||
import chat.stoat.activities.MainActivity
|
||||
import chat.stoat.c2dm.ChannelRegistrator
|
||||
import chat.stoat.services.OngoingCallService.Companion.hangupEvents
|
||||
import chat.stoat.voice.VoiceCallManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import logcat.LogPriority
|
||||
import logcat.asLog
|
||||
import logcat.logcat
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Foreground service that runs while the user is connected to a voice channel.
|
||||
*
|
||||
* Posts a persistent [NotificationCompat.CallStyle] notification with a chronometer (the fancy chip!)
|
||||
* and registers the call with the Telecom framework via [CallsManager] so the system treats it like
|
||||
* any other ongoing call.
|
||||
*
|
||||
* The service does not own the call itself, the LiveKit room lives in the voice UIs composition.
|
||||
* Hangup requests from the notification or Telecom are relayed through [hangupEvents].
|
||||
*/
|
||||
class OngoingCallService : Service() {
|
||||
companion object {
|
||||
private const val ACTION_START = "chat.stoat.services.OngoingCallService.START"
|
||||
private const val ACTION_HANGUP = "chat.stoat.services.OngoingCallService.HANGUP"
|
||||
private const val ACTION_STOP = "chat.stoat.services.OngoingCallService.STOP"
|
||||
private const val ACTION_TOGGLE_MUTE = "chat.stoat.services.OngoingCallService.TOGGLE_MUTE"
|
||||
private const val ACTION_UPDATE_STATE =
|
||||
"chat.stoat.services.OngoingCallService.UPDATE_STATE"
|
||||
|
||||
private const val EXTRA_CHANNEL_ID = "channelId"
|
||||
private const val EXTRA_CHANNEL_NAME = "channelName"
|
||||
private const val EXTRA_MIC_MUTED = "micMuted"
|
||||
|
||||
private const val NOTIFICATION_ID = 67
|
||||
|
||||
/**
|
||||
* Emits when the user hangs up from outside the app (notification action or Telecom,
|
||||
* e.g. through a smartwatch).
|
||||
*/
|
||||
val hangupEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
|
||||
fun start(context: Context, channelId: String, channelName: String?) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
Intent(context, OngoingCallService::class.java)
|
||||
.setAction(ACTION_START)
|
||||
.putExtra(EXTRA_CHANNEL_ID, channelId)
|
||||
.putExtra(EXTRA_CHANNEL_NAME, channelName)
|
||||
)
|
||||
}
|
||||
|
||||
fun updateMicMuted(context: Context, muted: Boolean) {
|
||||
try {
|
||||
context.startService(
|
||||
Intent(context, OngoingCallService::class.java)
|
||||
.setAction(ACTION_UPDATE_STATE)
|
||||
.putExtra(EXTRA_MIC_MUTED, muted)
|
||||
)
|
||||
} catch (e: IllegalStateException) {
|
||||
logcat(LogPriority.WARN) {
|
||||
"Could not deliver mic state to call service\n" + e.asLog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
try {
|
||||
context.startService(
|
||||
Intent(context, OngoingCallService::class.java).setAction(ACTION_STOP)
|
||||
)
|
||||
} catch (e: IllegalStateException) {
|
||||
logcat(LogPriority.WARN) { "Could not deliver stop to call service\n" + e.asLog() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private var telecomJob: Job? = null
|
||||
private var callControl: CallControlScope? = null
|
||||
private var registeredChannelId: String? = null
|
||||
private var currentChannelName: String? = null
|
||||
private var callStartedAt = 0L
|
||||
private var isMicMuted = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
val channelId = intent.getStringExtra(EXTRA_CHANNEL_ID) ?: return START_NOT_STICKY
|
||||
val channelName = intent.getStringExtra(EXTRA_CHANNEL_NAME)
|
||||
?: getString(R.string.voice_notification_fallback_channel_name)
|
||||
|
||||
currentChannelName = channelName
|
||||
if (registeredChannelId != channelId) {
|
||||
callStartedAt = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
ChannelRegistrator(this).register()
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
buildNotification(channelName),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
)
|
||||
|
||||
if (registeredChannelId != channelId) {
|
||||
registeredChannelId = channelId
|
||||
registerCallWithTelecom(channelId, channelName)
|
||||
}
|
||||
}
|
||||
|
||||
ACTION_HANGUP -> hangupEvents.tryEmit(Unit)
|
||||
|
||||
ACTION_TOGGLE_MUTE -> VoiceCallManager.toggleMicrophone()
|
||||
|
||||
ACTION_UPDATE_STATE -> {
|
||||
val muted = intent.getBooleanExtra(EXTRA_MIC_MUTED, false)
|
||||
if (muted != isMicMuted) {
|
||||
isMicMuted = muted
|
||||
currentChannelName?.let { channelName ->
|
||||
try {
|
||||
NotificationManagerCompat.from(this)
|
||||
.notify(NOTIFICATION_ID, buildNotification(channelName))
|
||||
} catch (e: SecurityException) {
|
||||
logcat(LogPriority.WARN) {
|
||||
"Could not update call notification\n" + e.asLog()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ACTION_STOP -> endCall(startId)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
hangupEvents.tryEmit(Unit)
|
||||
endCall(startId = -1)
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun buildNotification(channelName: String): Notification {
|
||||
val caller = Person.Builder()
|
||||
.setName(channelName)
|
||||
.setImportant(true)
|
||||
.build()
|
||||
|
||||
val hangupIntent = PendingIntent.getService(
|
||||
this,
|
||||
0,
|
||||
Intent(this, OngoingCallService::class.java).setAction(ACTION_HANGUP),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val toggleMuteIntent = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
Intent(this, OngoingCallService::class.java).setAction(ACTION_TOGGLE_MUTE),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val contentIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java)
|
||||
.setAction(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, ChannelRegistrator.CHANNEL_ID_GROUP_VOICE_ONGOING)
|
||||
.setSmallIcon(R.drawable.ic_notification_monochrome)
|
||||
.setContentTitle(channelName)
|
||||
.setContentText(getString(R.string.voice_notification_ongoing_call))
|
||||
.setStyle(NotificationCompat.CallStyle.forOngoingCall(caller, hangupIntent))
|
||||
.addAction(
|
||||
if (isMicMuted) R.drawable.ic_mic_24dp else R.drawable.ic_mic_off_24dp,
|
||||
getString(
|
||||
if (isMicMuted) R.string.voice_action_unmute else R.string.voice_action_mute
|
||||
),
|
||||
toggleMuteIntent
|
||||
)
|
||||
.addPerson(caller)
|
||||
.setContentIntent(contentIntent)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setShowWhen(true)
|
||||
.setWhen(callStartedAt)
|
||||
.setUsesChronometer(true)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.apply {
|
||||
if (Build.VERSION.SDK_INT >= 36) {
|
||||
// TODO update sdk and dont use the literal string value here (lazy solution prevailed)
|
||||
extras.putBoolean("android.requestPromotedOngoing", true)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun registerCallWithTelecom(channelId: String, channelName: String) {
|
||||
telecomJob?.cancel()
|
||||
callControl = null
|
||||
|
||||
val callsManager = CallsManager(this)
|
||||
try {
|
||||
callsManager.registerAppWithTelecom(CallsManager.CAPABILITY_BASELINE)
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "Could not register app with Telecom\n" + e.asLog() }
|
||||
return
|
||||
}
|
||||
|
||||
telecomJob = scope.launch {
|
||||
try {
|
||||
callsManager.addCall(
|
||||
CallAttributesCompat(
|
||||
displayName = channelName,
|
||||
address = "stoat:$channelId".toUri(),
|
||||
direction = CallAttributesCompat.DIRECTION_OUTGOING,
|
||||
callType = CallAttributesCompat.CALL_TYPE_AUDIO_CALL,
|
||||
callCapabilities = 0
|
||||
),
|
||||
onAnswer = {},
|
||||
onDisconnect = {
|
||||
if (callControl != null) hangupEvents.tryEmit(Unit)
|
||||
},
|
||||
onSetActive = {},
|
||||
onSetInactive = {}
|
||||
) {
|
||||
callControl = this
|
||||
launch { setActive() }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "Could not add call to Telecom\n" + e.asLog() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun endCall(startId: Int) {
|
||||
val control = callControl
|
||||
callControl = null
|
||||
registeredChannelId = null
|
||||
|
||||
if (control == null) {
|
||||
telecomJob?.cancel()
|
||||
stopSelf(startId)
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
withTimeoutOrNull(1.seconds) {
|
||||
control.disconnect(DisconnectCause(DisconnectCause.LOCAL))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "Could not disconnect Telecom call\n" + e.asLog() }
|
||||
}
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
package chat.stoat.voice
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import chat.stoat.R
|
||||
import chat.stoat.StoatApplication
|
||||
import chat.stoat.api.StoatAPI
|
||||
import chat.stoat.api.internals.ChannelUtils
|
||||
import chat.stoat.api.routes.misc.Root
|
||||
import chat.stoat.api.routes.misc.getRootRoute
|
||||
import chat.stoat.api.routes.voice.JoinCallResponse
|
||||
import chat.stoat.api.routes.voice.joinCall
|
||||
import chat.stoat.composables.voice.VoiceSound
|
||||
import chat.stoat.composables.voice.VoiceSoundPlayer
|
||||
import chat.stoat.services.OngoingCallService
|
||||
import io.livekit.android.LiveKit
|
||||
import io.livekit.android.events.RoomEvent
|
||||
import io.livekit.android.room.Room
|
||||
import io.livekit.android.room.track.RemoteAudioTrack
|
||||
import io.livekit.android.room.track.Track
|
||||
import io.livekit.android.util.flow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import logcat.asLog
|
||||
import logcat.logcat
|
||||
|
||||
/**
|
||||
* Owns the lifecycle of the current voice call.
|
||||
* The LiveKit [Room] lives here
|
||||
*/
|
||||
object VoiceCallManager {
|
||||
private val context: Context get() = StoatApplication.instance
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
var activeChannelId: String? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var room: Room? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var errorResource: Int? by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
|
||||
var isDeafened: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var isSheetVisible: Boolean by mutableStateOf(false)
|
||||
|
||||
var requestedChannelId: String? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
fun openSheet(channelId: String) {
|
||||
requestedChannelId = channelId
|
||||
isSheetVisible = true
|
||||
}
|
||||
|
||||
private var micWasOnBeforeDeafen = false
|
||||
private var soundPlayer: VoiceSoundPlayer? = null
|
||||
private var sessionJob: Job? = null
|
||||
private var hasPlayedJoinSound = false
|
||||
private var hasPlayedLeaveSound = false
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
OngoingCallService.hangupEvents.collect { leave() }
|
||||
}
|
||||
}
|
||||
|
||||
fun join(channelId: String) {
|
||||
if (activeChannelId == channelId && room != null) return
|
||||
endSession()
|
||||
|
||||
activeChannelId = channelId
|
||||
errorResource = null
|
||||
hasPlayedJoinSound = false
|
||||
hasPlayedLeaveSound = false
|
||||
soundPlayer = VoiceSoundPlayer(context)
|
||||
|
||||
val newRoom = LiveKit.create(context.applicationContext)
|
||||
room = newRoom
|
||||
|
||||
sessionJob = scope.launch {
|
||||
launch { watchRoomState(newRoom, channelId) }
|
||||
launch { watchRoomEvents(newRoom) }
|
||||
launch { watchLocalScreenShare(newRoom) }
|
||||
launch { watchLocalMicrophone(newRoom) }
|
||||
|
||||
val connection = fetchVoiceToken(channelId) ?: return@launch
|
||||
try {
|
||||
newRoom.connect(connection.url, connection.token)
|
||||
newRoom.localParticipant.setMicrophoneEnabled(true)
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "Could not connect to LiveKit room\n" + e.asLog() }
|
||||
errorResource = R.string.voice_error_generic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun leave() {
|
||||
isSheetVisible = false
|
||||
requestedChannelId = null
|
||||
endSession()
|
||||
}
|
||||
|
||||
private fun endSession() {
|
||||
sessionJob?.cancel()
|
||||
sessionJob = null
|
||||
|
||||
val leftRoom = room
|
||||
room = null
|
||||
activeChannelId = null
|
||||
errorResource = null
|
||||
isDeafened = false
|
||||
micWasOnBeforeDeafen = false
|
||||
|
||||
if (leftRoom != null) {
|
||||
if (hasPlayedJoinSound && !hasPlayedLeaveSound) {
|
||||
hasPlayedLeaveSound = true
|
||||
soundPlayer?.play(VoiceSound.USER_LEAVE)
|
||||
}
|
||||
leftRoom.disconnect()
|
||||
leftRoom.release()
|
||||
}
|
||||
soundPlayer?.release()
|
||||
soundPlayer = null
|
||||
OngoingCallService.stop(context)
|
||||
}
|
||||
|
||||
fun toggleMicrophone() {
|
||||
val room = room ?: return
|
||||
val isMicOn = room.localParticipant.isMicrophoneEnabled
|
||||
soundPlayer?.play(if (isMicOn) VoiceSound.MUTE else VoiceSound.UNMUTE)
|
||||
scope.launch {
|
||||
room.localParticipant.setMicrophoneEnabled(!isMicOn)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleDeafen() {
|
||||
val room = room ?: return
|
||||
if (!isDeafened) {
|
||||
soundPlayer?.play(VoiceSound.DEAFEN)
|
||||
micWasOnBeforeDeafen = room.localParticipant.isMicrophoneEnabled
|
||||
isDeafened = true
|
||||
applyDeafenState(room)
|
||||
scope.launch {
|
||||
room.localParticipant.setMicrophoneEnabled(false)
|
||||
}
|
||||
} else {
|
||||
soundPlayer?.play(VoiceSound.UNDEAFEN)
|
||||
isDeafened = false
|
||||
applyDeafenState(room)
|
||||
if (micWasOnBeforeDeafen) {
|
||||
scope.launch {
|
||||
room.localParticipant.setMicrophoneEnabled(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyDeafenState(room: Room) {
|
||||
room.remoteParticipants.values.forEach { participant ->
|
||||
participant.audioTrackPublications.forEach { (_, track) ->
|
||||
(track as? RemoteAudioTrack)?.setVolume(if (isDeafened) 0.0 else 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun watchRoomState(room: Room, channelId: String) {
|
||||
room::state.flow.collect { state ->
|
||||
when (state) {
|
||||
Room.State.CONNECTED -> {
|
||||
if (!hasPlayedJoinSound) {
|
||||
hasPlayedJoinSound = true
|
||||
soundPlayer?.play(VoiceSound.USER_JOIN)
|
||||
OngoingCallService.start(
|
||||
context,
|
||||
channelId,
|
||||
StoatAPI.channelCache[channelId]?.let(ChannelUtils::resolveName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Room.State.DISCONNECTED -> {
|
||||
if (hasPlayedJoinSound) leave()
|
||||
}
|
||||
|
||||
Room.State.CONNECTING, Room.State.RECONNECTING -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun watchRoomEvents(room: Room) {
|
||||
room.events.events.collect { event ->
|
||||
when (event) {
|
||||
is RoomEvent.TrackSubscribed -> applyDeafenState(room)
|
||||
|
||||
is RoomEvent.ParticipantConnected -> soundPlayer?.play(VoiceSound.USER_JOIN)
|
||||
is RoomEvent.ParticipantDisconnected -> soundPlayer?.play(VoiceSound.USER_LEAVE)
|
||||
|
||||
is RoomEvent.TrackPublished -> {
|
||||
if (event.participant != room.localParticipant &&
|
||||
event.publication.source == Track.Source.SCREEN_SHARE
|
||||
) {
|
||||
soundPlayer?.play(VoiceSound.STREAM_START)
|
||||
}
|
||||
}
|
||||
|
||||
is RoomEvent.TrackUnpublished -> {
|
||||
if (event.participant != room.localParticipant &&
|
||||
event.publication.source == Track.Source.SCREEN_SHARE
|
||||
) {
|
||||
soundPlayer?.play(VoiceSound.STREAM_END)
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun watchLocalMicrophone(room: Room) {
|
||||
room.localParticipant::isMicrophoneEnabled.flow.collect { enabled ->
|
||||
OngoingCallService.updateMicMuted(context, muted = !enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun watchLocalScreenShare(room: Room) {
|
||||
var wasSharing: Boolean? = null
|
||||
room.localParticipant::isScreenShareEnabled.flow.collect { isSharing ->
|
||||
wasSharing?.let { previous ->
|
||||
if (isSharing && !previous) soundPlayer?.play(VoiceSound.STREAM_START)
|
||||
if (!isSharing && previous) soundPlayer?.play(VoiceSound.STREAM_END)
|
||||
}
|
||||
wasSharing = isSharing
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchVoiceToken(channelId: String): JoinCallResponse? {
|
||||
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 null
|
||||
}
|
||||
|
||||
val lk = root.features.livekit
|
||||
|
||||
if (lk == null) {
|
||||
logcat(LogPriority.ERROR) {
|
||||
IllegalStateException("LiveKit is not supported by this API version!").asLog()
|
||||
}
|
||||
errorResource = R.string.voice_error_not_supported
|
||||
return null
|
||||
}
|
||||
|
||||
if (lk.nodes.isEmpty()) {
|
||||
logcat(LogPriority.ERROR) { IllegalStateException("No LiveKit nodes available!").asLog() }
|
||||
errorResource = R.string.voice_error_no_nodes
|
||||
return null
|
||||
}
|
||||
|
||||
val node = lk.nodes.random()
|
||||
return try {
|
||||
joinCall(channelId, node.name)
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "Could not get LiveKit token\n" + e.asLog() }
|
||||
errorResource = R.string.voice_error_generic
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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="M320,680Q287,680 263.5,656.5Q240,633 240,600L240,360Q240,327 263.5,303.5Q287,280 320,280L360,280L400,240L560,240L600,280L640,280Q673,280 696.5,303.5Q720,327 720,360L720,600Q720,633 696.5,656.5Q673,680 640,680L320,680ZM320,600L640,600Q640,600 640,600Q640,600 640,600L640,360Q640,360 640,360Q640,360 640,360L320,360Q320,360 320,360Q320,360 320,360L320,600Q320,600 320,600Q320,600 320,600ZM480,560Q513,560 536.5,536.5Q560,513 560,480Q560,447 536.5,423.5Q513,400 480,400Q447,400 423.5,423.5Q400,447 400,480Q400,513 423.5,536.5Q447,560 480,560ZM342,20Q376,9 410.5,4.5Q445,0 480,0Q574,0 657.5,33.5Q741,67 805.5,126.5Q870,186 911,266.5Q952,347 960,440L880,440Q873,368 842,305.5Q811,243 762.5,195.5Q714,148 651,118Q588,88 516,82L578,144L522,200L342,20ZM618,940Q584,951 549.5,955.5Q515,960 480,960Q386,960 302.5,926.5Q219,893 154.5,833.5Q90,774 49,693.5Q8,613 0,520L80,520Q88,592 118.5,654.5Q149,717 197.5,764.5Q246,812 309,842Q372,872 444,878L382,816L438,760L618,940ZM480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480Z"/>
|
||||
</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="M360,840L200,840Q167,840 143.5,816.5Q120,793 120,760L120,480Q120,405 148.5,339.5Q177,274 226,225Q275,176 340.5,148Q406,120 480,120Q554,120 619.5,148Q685,176 734,225Q783,274 811.5,339.5Q840,405 840,480L840,760Q840,793 816.5,816.5Q793,840 760,840L600,840L600,520L760,520L760,480Q760,363 678.5,281.5Q597,200 480,200Q363,200 281.5,281.5Q200,363 200,480L200,520L360,520L360,840ZM280,600L200,600L200,760Q200,760 200,760Q200,760 200,760L280,760L280,600ZM680,600L680,760L760,760Q760,760 760,760Q760,760 760,760L760,600L680,600ZM280,600L200,600L280,600L280,600ZM680,600L760,600L680,600L680,600Z"/>
|
||||
</vector>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -296,6 +296,7 @@
|
|||
<string name="system_message_user_joined_alt">User joined</string>
|
||||
<string name="system_message_message_pinned_alt">Message pinned</string>
|
||||
<string name="system_message_message_unpinned_alt">Message unpinned</string>
|
||||
<string name="system_message_call_started_alt">Call started</string>
|
||||
<string name="system_message_text_alt">System message</string>
|
||||
|
||||
<string name="system_message_ownership_changed">%1$s transferred ownership to %2$s</string>
|
||||
|
|
@ -310,6 +311,7 @@
|
|||
<string name="system_message_user_joined">%1$s joined</string>
|
||||
<string name="system_message_message_pinned">%1$s pinned a message to this channel</string>
|
||||
<string name="system_message_message_unpinned">%1$s unpinned a message from this channel</string>
|
||||
<string name="system_message_call_started">%1$s started a call</string>
|
||||
|
||||
<string name="today">Today</string>
|
||||
<string name="yesterday">Yesterday</string>
|
||||
|
|
@ -677,11 +679,29 @@
|
|||
|
||||
<string name="voice_action_disconnect">Disconnect</string>
|
||||
|
||||
<string name="voice_action_mute">Mute</string>
|
||||
<string name="voice_action_unmute">Unmute</string>
|
||||
<string name="voice_action_deafen">Deafen</string>
|
||||
<string name="voice_action_undeafen">Undeafen</string>
|
||||
<string name="voice_action_video">Video</string>
|
||||
<string name="voice_action_screen_share">Share screen</string>
|
||||
<string name="voice_action_flip_camera">Flip camera</string>
|
||||
|
||||
<string name="voice_audio_output">Audio output</string>
|
||||
<string name="voice_audio_output_speaker">Speaker</string>
|
||||
<string name="voice_audio_output_earpiece">Earpiece</string>
|
||||
<string name="voice_audio_output_wired_headset">Wired headset</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="voice_notification_ongoing_call">Connected to voice</string>
|
||||
<string name="voice_banner_tap_to_return">Tap to return to the call</string>
|
||||
<string name="voice_start_call">Start a call</string>
|
||||
<string name="voice_notification_fallback_channel_name">Voice channel</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>
|
||||
|
|
@ -921,6 +941,9 @@
|
|||
<string name="notification_channel_friend_requests_description">Incoming friend requests, as well as accepted requests you\'ve sent.</string>
|
||||
<string name="notification_channel_messages">Messages</string>
|
||||
<string name="notification_channel_messages_description">Unread messages from your conversations and channels.</string>
|
||||
<string name="notification_channel_group_voice">Voice</string>
|
||||
<string name="notification_channel_ongoing_call">Ongoing calls</string>
|
||||
<string name="notification_channel_ongoing_call_description">Shown persistently while you are connected to a voice channel.</string>
|
||||
|
||||
<string name="keyboard_shortcut_messaging">Messaging</string>
|
||||
<string name="keyboard_shortcut_messaging_new_line">New Line</string>
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ telephoto = "1.0.0-alpha02"
|
|||
haze = "1.7.2"
|
||||
chucker = "4.3.1"
|
||||
androidx-test = "1.6.1"
|
||||
livekit = "2.24.1"
|
||||
livekit-compose = "2.3.0"
|
||||
livekit = "2.26.1"
|
||||
livekit-compose = "2.4.0"
|
||||
koin = "4.2.1"
|
||||
multiplatform-markdown = "0.41.0-b01"
|
||||
coil = "3.4.0"
|
||||
|
|
|
|||
Loading…
Reference in New Issue