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"
|
||||
|
||||
object LivekitVersion {
|
||||
val core = "2.14.1"
|
||||
val componentsCompose = "1.3.0"
|
||||
val core = "2.16.0"
|
||||
val componentsCompose = "1.3.1"
|
||||
}
|
||||
|
||||
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.widthIn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
|
|
@ -88,6 +87,7 @@ import chat.revolt.api.settings.LoadedSettings
|
|||
import chat.revolt.api.settings.SyncedSettings
|
||||
import chat.revolt.composables.generic.HealthAlert
|
||||
import chat.revolt.composables.voice.VoicePermissionSwitch
|
||||
import chat.revolt.composables.voice.VoiceSheet
|
||||
import chat.revolt.material.EasingTokens
|
||||
import chat.revolt.ndk.NativeLibraries
|
||||
import chat.revolt.persistence.KVStorage
|
||||
|
|
@ -438,7 +438,7 @@ fun AppEntrypoint(
|
|||
onUpdateNextDestination: (String) -> Unit = {}
|
||||
) {
|
||||
var showVoiceUI by rememberSaveable { mutableStateOf(false) }
|
||||
var voiceChannelID by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var voiceChannelId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val chatUIScale by animateFloatAsState(
|
||||
if (showVoiceUI) 0.8f else 1.0f,
|
||||
|
|
@ -625,7 +625,7 @@ fun AppEntrypoint(
|
|||
},
|
||||
onEnterVoiceUI = { channelId ->
|
||||
showVoiceUI = true
|
||||
voiceChannelID = channelId
|
||||
voiceChannelId = channelId
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -769,10 +769,14 @@ fun AppEntrypoint(
|
|||
showVoiceUI = false
|
||||
}
|
||||
) {
|
||||
Button(onClick = {
|
||||
showVoiceUI = false
|
||||
}) {
|
||||
Text("Close voice UI")
|
||||
voiceChannelId?.let {
|
||||
VoiceSheet(
|
||||
it,
|
||||
onDisconnect = {
|
||||
showVoiceUI = false
|
||||
voiceChannelId = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,150 @@
|
|||
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.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
|
||||
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_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_description">Enable notifications to be kept up to date with messages and mentions.</string>
|
||||
<string name="spark_notifications_rationale_cta">Enable notifications</string>
|
||||
|
|
|
|||
Loading…
Reference in New Issue