feat: basic livekit/voice chat impl
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
afba6f9ecf
commit
740d693be4
|
|
@ -29,8 +29,8 @@ val ktorVersion = "3.0.0-beta-2"
|
||||||
val media3Version = "1.5.0"
|
val media3Version = "1.5.0"
|
||||||
|
|
||||||
object LivekitVersion {
|
object LivekitVersion {
|
||||||
val core = "2.14.1"
|
val core = "2.16.0"
|
||||||
val componentsCompose = "1.3.0"
|
val componentsCompose = "1.3.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
val material3Version = "1.4.0-alpha10"
|
val material3Version = "1.4.0-alpha10"
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,6 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
|
@ -88,6 +87,7 @@ import chat.revolt.api.settings.LoadedSettings
|
||||||
import chat.revolt.api.settings.SyncedSettings
|
import chat.revolt.api.settings.SyncedSettings
|
||||||
import chat.revolt.composables.generic.HealthAlert
|
import chat.revolt.composables.generic.HealthAlert
|
||||||
import chat.revolt.composables.voice.VoicePermissionSwitch
|
import chat.revolt.composables.voice.VoicePermissionSwitch
|
||||||
|
import chat.revolt.composables.voice.VoiceSheet
|
||||||
import chat.revolt.material.EasingTokens
|
import chat.revolt.material.EasingTokens
|
||||||
import chat.revolt.ndk.NativeLibraries
|
import chat.revolt.ndk.NativeLibraries
|
||||||
import chat.revolt.persistence.KVStorage
|
import chat.revolt.persistence.KVStorage
|
||||||
|
|
@ -438,7 +438,7 @@ fun AppEntrypoint(
|
||||||
onUpdateNextDestination: (String) -> Unit = {}
|
onUpdateNextDestination: (String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var showVoiceUI by rememberSaveable { mutableStateOf(false) }
|
var showVoiceUI by rememberSaveable { mutableStateOf(false) }
|
||||||
var voiceChannelID by rememberSaveable { mutableStateOf<String?>(null) }
|
var voiceChannelId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
val chatUIScale by animateFloatAsState(
|
val chatUIScale by animateFloatAsState(
|
||||||
if (showVoiceUI) 0.8f else 1.0f,
|
if (showVoiceUI) 0.8f else 1.0f,
|
||||||
|
|
@ -625,7 +625,7 @@ fun AppEntrypoint(
|
||||||
},
|
},
|
||||||
onEnterVoiceUI = { channelId ->
|
onEnterVoiceUI = { channelId ->
|
||||||
showVoiceUI = true
|
showVoiceUI = true
|
||||||
voiceChannelID = channelId
|
voiceChannelId = channelId
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -769,10 +769,14 @@ fun AppEntrypoint(
|
||||||
showVoiceUI = false
|
showVoiceUI = false
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Button(onClick = {
|
voiceChannelId?.let {
|
||||||
showVoiceUI = false
|
VoiceSheet(
|
||||||
}) {
|
it,
|
||||||
Text("Close voice UI")
|
onDisconnect = {
|
||||||
|
showVoiceUI = false
|
||||||
|
voiceChannelId = null
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,150 @@
|
||||||
package chat.revolt.composables.voice
|
package chat.revolt.composables.voice
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import chat.revolt.R
|
||||||
|
import chat.revolt.api.routes.misc.getRootRoute
|
||||||
|
import chat.revolt.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 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() {
|
||||||
|
val root = getRootRoute()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VoiceSheet(channelId: String) {
|
fun VoiceSheet(
|
||||||
|
channelId: String,
|
||||||
|
onDisconnect: () -> Unit,
|
||||||
|
viewModel: VoiceSheetViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
LaunchedEffect(channelId) {
|
||||||
|
viewModel.channelId = channelId
|
||||||
|
viewModel.getVoiceToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
RoomScope(
|
||||||
|
url = viewModel.voiceLkNode,
|
||||||
|
token = viewModel.voiceToken,
|
||||||
|
audio = true,
|
||||||
|
video = false,
|
||||||
|
connect = true,
|
||||||
|
) {
|
||||||
|
val room = RoomLocal.current
|
||||||
|
val trackRefs = rememberTracks()
|
||||||
|
|
||||||
|
Column {
|
||||||
|
LazyColumn(modifier = Modifier.animateContentSize()) {
|
||||||
|
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 🗣️📢🔥")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(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 = {
|
||||||
|
room.disconnect()
|
||||||
|
onDisconnect()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("disconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -604,6 +604,10 @@
|
||||||
<string name="voice_join_permission_rationale_assurance">Don\'t worry, we won\'t use your microphone or camera without your permission.</string>
|
<string name="voice_join_permission_rationale_assurance">Don\'t worry, we won\'t use your microphone or camera without your permission.</string>
|
||||||
<string name="voice_join_permission_rationale_cta">Grant permissions</string>
|
<string name="voice_join_permission_rationale_cta">Grant permissions</string>
|
||||||
|
|
||||||
|
<string name="voice_error_not_supported">Voice channels are not available at the moment.</string>
|
||||||
|
<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="spark_notifications_rationale">Stay in the loop</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_description">Enable notifications to be kept up to date with messages and mentions.</string>
|
||||||
<string name="spark_notifications_rationale_cta">Enable notifications</string>
|
<string name="spark_notifications_rationale_cta">Enable notifications</string>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue