diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 33a7d0d7..2465a003 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -258,9 +258,9 @@ dependencies { implementation(libs.jetbrains.markdown) implementation(libs.highlights) - // implementation(libs.livekit.android) - // implementation(libs.livekit.android.camerax) - // implementation(libs.livekit.android.compose) + implementation(libs.livekit.android) + implementation(libs.livekit.android.camerax) + implementation(libs.livekit.android.compose) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) diff --git a/app/src/main/java/chat/stoat/activities/MainActivity.kt b/app/src/main/java/chat/stoat/activities/MainActivity.kt index 0c91a030..d468fa95 100644 --- a/app/src/main/java/chat/stoat/activities/MainActivity.kt +++ b/app/src/main/java/chat/stoat/activities/MainActivity.kt @@ -88,6 +88,7 @@ import chat.stoat.api.settings.LoadedSettings import chat.stoat.api.settings.SyncedSettings import chat.stoat.composables.generic.HealthAlert import chat.stoat.composables.voice.VoicePermissionSwitch +import chat.stoat.composables.voice.VoiceSheet import chat.stoat.core.model.schemas.HealthNotice import chat.stoat.material.EasingTokens import chat.stoat.ndk.NativeLibraries @@ -791,18 +792,14 @@ fun AppEntrypoint( showVoiceUI = false } ) { - LaunchedEffect(Unit) { - showVoiceUI = false - voiceChannelId = null - } voiceChannelId?.let { - /*VoiceSheet( + VoiceSheet( it, onDisconnect = { showVoiceUI = false voiceChannelId = null } - )*/ + ) } } } diff --git a/app/src/main/java/chat/stoat/api/routes/voice/Voice.kt b/app/src/main/java/chat/stoat/api/routes/voice/Voice.kt index 0da3f083..0282d8a6 100644 --- a/app/src/main/java/chat/stoat/api/routes/voice/Voice.kt +++ b/app/src/main/java/chat/stoat/api/routes/voice/Voice.kt @@ -11,6 +11,8 @@ import io.ktor.http.ContentType import io.ktor.http.contentType import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put @Serializable data class JoinCallResponse( @@ -18,10 +20,19 @@ data class JoinCallResponse( val url: String, ) -suspend fun joinCall(channelId: String, nodeName: String): JoinCallResponse { +suspend fun joinCall( + channelId: String, + nodeName: String, + forceDisconnect: Boolean = true +): JoinCallResponse { val response = StoatHttp.post("/channels/$channelId/join_call".api()) { contentType(ContentType.Application.Json) - setBody(mapOf("node" to nodeName)) + setBody( + buildJsonObject { + put("node", nodeName) + put("force_disconnect", forceDisconnect) + } + ) }.bodyAsText() try { diff --git a/app/src/main/java/chat/stoat/composables/voice/VoiceParticipant.kt b/app/src/main/java/chat/stoat/composables/voice/VoiceParticipant.kt index 98d9fd23..c7e3b5df 100644 --- a/app/src/main/java/chat/stoat/composables/voice/VoiceParticipant.kt +++ b/app/src/main/java/chat/stoat/composables/voice/VoiceParticipant.kt @@ -1,19 +1,28 @@ package chat.stoat.composables.voice +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.shape.CircleShape 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.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import chat.stoat.R import chat.stoat.api.StoatAPI -import chat.stoat.core.model.util.UserVoiceState import chat.stoat.composables.chat.displayNameInChannel import chat.stoat.composables.generic.UserAvatar +import chat.stoat.core.model.util.UserVoiceState import chat.stoat.internals.extensions.TransparentListItemColours @Composable @@ -23,23 +32,40 @@ fun VoiceParticipant( speaking: Boolean, modifier: Modifier = Modifier ) { + val speakingBorderColour by animateColorAsState( + targetValue = if (speaking) MaterialTheme.colorScheme.primary else Color.Transparent, + animationSpec = spring() + ) + val speakingBorderWidth by animateDpAsState( + targetValue = if (speaking) 2.dp else 0.dp, + animationSpec = spring() + ) + val user = StoatAPI.userCache[state.id] ListItem( + modifier = modifier, 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 + avatar = user?.avatar, + modifier = Modifier.border( + speakingBorderWidth, + speakingBorderColour, + CircleShape + ) ) }, trailingContent = { - Row { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + ) { if (!state.isPublishing) { Icon( painter = painterResource(R.drawable.ic_mic_off_24dp), diff --git a/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt b/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt index 19f552da..fe47a3cb 100644 --- a/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt +++ b/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt @@ -1,8 +1,15 @@ package chat.stoat.composables.voice -/* + 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.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.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -24,8 +31,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,6 +56,9 @@ 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 io.livekit.android.util.flow +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import logcat.LogPriority import logcat.asLog import logcat.logcat @@ -124,6 +137,8 @@ fun VoiceSheet( viewModel.getVoiceToken() } + val scope = rememberCoroutineScope() + RoomScope( url = viewModel.voiceLkNode, token = viewModel.voiceToken, @@ -132,10 +147,22 @@ fun VoiceSheet( connect = true, ) { val room = RoomLocal.current - val trackRefs = rememberTracks() + 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 trackRefs by rememberTracks(passedRoom = room) Column { - LazyColumn(modifier = Modifier.animateContentSize()) { + LazyColumn( + modifier = Modifier.animateContentSize( + animationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing + ) + ) + ) { val voiceStates = StoatAPI.voiceStateCache[viewModel.channelId] items(voiceStates?.participants?.size ?: 0) { index -> val participantState = voiceStates?.participants[index] @@ -143,56 +170,104 @@ fun VoiceSheet( VoiceParticipant( state = participantState, channelId = viewModel.channelId, - speaking = false + speaking = activeSpeakers.any { it.identity?.value == participantState.id } ) } } items(trackRefs.size) { index -> VideoTrackView( trackReference = trackRefs[index], + room = room, modifier = Modifier.fillParentMaxHeight(0.5f) ) } 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 - } + var showStatus by remember { mutableStateOf(true) } + LaunchedEffect(roomState) { + if (roomState == Room.State.CONNECTED) { + delay(1000) + showStatus = false + } else { + showStatus = true + } + } + + AnimatedVisibility( + visible = showStatus, + enter = fadeIn( + animationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing + ) + ) + + expandVertically( + expandFrom = Alignment.Top, + animationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing + ) + ), + exit = fadeOut( + animationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing + ) + ) + + slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing + ) + ) + + shrinkVertically( + shrinkTowards = Alignment.Top, + animationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing + ) + ) + ) { + AnimatedContent( + roomState + ) { roomState -> + Row( + horizontalArrangement = Arrangement.spacedBy( + 8.dp, + Alignment.CenterHorizontally + ), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) ) { - Icon( - painter = painterResource( - when (roomState) { - Room.State.CONNECTING -> R.drawable.ic_sprint_24dp - Room.State.CONNECTED -> R.drawable.ic_wifi_tethering_24dp - Room.State.DISCONNECTED -> R.drawable.ic_wifi_tethering_error_24dp - Room.State.RECONNECTING -> R.drawable.ic_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) + 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.ic_sprint_24dp + Room.State.CONNECTED -> R.drawable.ic_wifi_tethering_24dp + Room.State.DISCONNECTED -> R.drawable.ic_wifi_tethering_error_24dp + Room.State.RECONNECTING -> R.drawable.ic_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) + } + ) + } } } } @@ -226,10 +301,13 @@ fun VoiceSheet( ) { Button( onClick = { + scope.launch { + room.localParticipant.setMicrophoneEnabled(!isMicOn) + } }, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer + containerColor = if (isMicOn) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer, + contentColor = if (isMicOn) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer, ), shapes = ButtonDefaults.shapes(), modifier = Modifier @@ -237,17 +315,23 @@ fun VoiceSheet( .height(64.dp) ) { Icon( - painter = painterResource(R.drawable.ic_mic_off_24dp), + painter = if (isMicOn) painterResource(R.drawable.ic_mic_24dp) else painterResource( + R.drawable.ic_mic_off_24dp + ), contentDescription = "TODO change this string to res" ) } Spacer(Modifier.width(4.dp)) Button( onClick = { + scope.launch { + room.localParticipant.setCameraEnabled(!isMicOn) + } }, + enabled = false, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer + containerColor = if (isCameraOn) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer, + contentColor = if (isCameraOn) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer, ), shapes = ButtonDefaults.shapes(), modifier = Modifier @@ -255,14 +339,20 @@ fun VoiceSheet( .height(64.dp) ) { Icon( - painter = painterResource(R.drawable.ic_videocam_off_24dp), + painter = if (isCameraOn) painterResource(R.drawable.ic_videocam_24dp) else painterResource( + R.drawable.ic_videocam_off_24dp + ), contentDescription = "TODO change this string to res" ) } Spacer(Modifier.width(4.dp)) Button( onClick = { + scope.launch { + room.localParticipant.setScreenShareEnabled(!isScreenShared) + } }, + enabled = false, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer @@ -280,7 +370,6 @@ fun VoiceSheet( Spacer(Modifier.width(4.dp)) Button( onClick = { - room.disconnect() onDisconnect() }, colors = ButtonDefaults.buttonColors( @@ -300,4 +389,4 @@ fun VoiceSheet( } } } -}*/ +} diff --git a/app/src/main/java/chat/stoat/internals/extensions/Permissions.kt b/app/src/main/java/chat/stoat/internals/extensions/Permissions.kt index 78ce4a21..12d4741c 100644 --- a/app/src/main/java/chat/stoat/internals/extensions/Permissions.kt +++ b/app/src/main/java/chat/stoat/internals/extensions/Permissions.kt @@ -11,8 +11,10 @@ import chat.stoat.api.internals.Roles @Composable fun rememberChannelPermissions(channelId: String, key1: Any = Unit): MutableLongState { val permissions = rememberSaveable { mutableLongStateOf(0L) } + val selfId = StoatAPI.selfId + val channel = StoatAPI.channelCache[channelId] - LaunchedEffect(channelId, key1) { + LaunchedEffect(channelId, key1, selfId, channel) { if (StoatAPI.selfId == null) return@LaunchedEffect if (StoatAPI.userCache[StoatAPI.selfId] == null) return@LaunchedEffect if (StoatAPI.channelCache[channelId] == null) return@LaunchedEffect diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt index a92e896d..7489c66b 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt @@ -952,7 +952,7 @@ fun ChannelScreen( } } - if (viewModel.channel?.voice != null && + if ((viewModel.channel?.channelType == ChannelType.VoiceChannel || viewModel.channel?.voice != null) && channelPermissions has PermissionBit.Connect && Experiments.useVoiceChats2p0.isEnabled ) { diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt index 5dd82f28..6acabddc 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt @@ -41,13 +41,13 @@ import chat.stoat.api.routes.microservices.autumn.uploadToAutumn import chat.stoat.api.routes.server.fetchMember import chat.stoat.api.routes.user.addUserIfUnknown import chat.stoat.api.routes.user.fetchUser -import chat.stoat.core.model.schemas.Channel -import chat.stoat.core.model.schemas.Message import chat.stoat.api.settings.GeoStateProvider import chat.stoat.callbacks.Action import chat.stoat.callbacks.ActionChannel import chat.stoat.callbacks.UiCallback import chat.stoat.callbacks.UiCallbacks +import chat.stoat.core.model.schemas.Channel +import chat.stoat.core.model.schemas.Message import chat.stoat.internals.text.MessageProcessor import chat.stoat.persistence.KVStorage import chat.stoat.screens.chat.ChatRouterDestination @@ -75,7 +75,10 @@ class ChannelScreenViewModel @Inject constructor( var items = mutableStateListOf() var typingUsers = mutableStateListOf() - var channel by mutableStateOf(null) + var channelId by mutableStateOf(null) + val channel: Channel? + get() = channelId?.let { StoatAPI.channelCache[it] } + var activePane by mutableStateOf(ChannelScreenActivePane.None) var keyboardHeight by mutableIntStateOf(0) @@ -116,7 +119,7 @@ class ChannelScreenViewModel @Inject constructor( fun switchChannel(id: String) { // Reset state this.loadMessagesJob?.cancel() - this.channel = StoatAPI.channelCache[id] + this.channelId = id this.items = mutableStateListOf(ChannelScreenItem.Loading) this.activePane = ChannelScreenActivePane.None this.typingUsers = mutableStateListOf() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e0a1919c..e394bf9a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,8 +23,8 @@ telephoto = "1.0.0-alpha02" haze = "1.7.2" chucker = "4.3.1" androidx-test = "1.6.1" -livekit = "2.21.0" -livekit-compose = "1.4.0" +livekit = "2.24.1" +livekit-compose = "2.3.0" [libraries] android-core-ktx = { module = "androidx.core:core-ktx", version = "1.16.0" } @@ -99,7 +99,7 @@ chucker = { module = "com.github.chuckerteam.chucker:library", version.ref = "ch chucker-noop = { module = "com.github.chuckerteam.chucker:library-no-op", version.ref = "chucker" } square-logcat = { module = "com.squareup.logcat:logcat", version = "0.1" } livekit-android = { module = "io.livekit:livekit-android", version.ref = "livekit" } -livekit-android-camerax = { module = "io.livekit:android-camerax", version.ref = "livekit" } +livekit-android-camerax = { module = "io.livekit:livekit-android-camerax", version.ref = "livekit" } livekit-android-compose = { module = "io.livekit:livekit-android-compose-components", version.ref = "livekit-compose" } [plugins]