davidliu
Committed by GitHub

Compose support (#25)

* Use flow delegate to enable usage of flow observing in compose

* Make tracks flowable as well

* Compose sample

* documentation for flowable variables

* clean up dev change
正在显示 21 个修改的文件 包含 657 行增加404 行删除
... ... @@ -17,6 +17,8 @@ import io.livekit.android.renderer.TextureViewRenderer
import io.livekit.android.room.participant.*
import io.livekit.android.room.track.*
import io.livekit.android.util.LKLog
import io.livekit.android.util.flow
import io.livekit.android.util.flowDelegate
import kotlinx.coroutines.*
import livekit.LivekitModels
import livekit.LivekitRtc
... ... @@ -58,13 +60,24 @@ constructor(
@Deprecated("Use events instead.")
var listener: RoomListener? = null
var sid: Sid? = null
private set
var name: String? = null
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
var sid: Sid? by flowDelegate(null)
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
var name: String? by flowDelegate(null)
private set
var state: State = State.DISCONNECTED
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
var state: State by flowDelegate(State.DISCONNECTED)
private set
var metadata: String? = null
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
var metadata: String? by flowDelegate(null)
private set
var autoManageVideo: Boolean = false
... ... @@ -75,21 +88,28 @@ constructor(
lateinit var localParticipant: LocalParticipant
private set
private val mutableRemoteParticipants = mutableMapOf<String, RemoteParticipant>()
private var mutableRemoteParticipants by flowDelegate(emptyMap<String, RemoteParticipant>())
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
val remoteParticipants: Map<String, RemoteParticipant>
get() = mutableRemoteParticipants
private val mutableActiveSpeakers = mutableListOf<Participant>()
private var mutableActiveSpeakers by flowDelegate(emptyList<Participant>())
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
val activeSpeakers: List<Participant>
get() = mutableActiveSpeakers
private var hasLostConnectivity: Boolean = false
suspend fun connect(url: String, token: String, options: ConnectOptions?) {
if(this::coroutineScope.isInitialized) {
if (this::coroutineScope.isInitialized) {
coroutineScope.cancel()
}
coroutineScope = CoroutineScope(defaultDispatcher + SupervisorJob())
state = State.CONNECTING
::state.flow
val response = engine.join(url, token, options)
LKLog.i { "Connected to server, server version: ${response.serverVersion}, client version: ${Version.CLIENT_VERSION}" }
... ... @@ -122,11 +142,13 @@ constructor(
}
private fun handleParticipantDisconnect(sid: String) {
val removedParticipant = mutableRemoteParticipants.remove(sid) ?: return
val newParticipants = mutableRemoteParticipants.toMutableMap()
val removedParticipant = newParticipants.remove(sid) ?: return
removedParticipant.tracks.values.toList().forEach { publication ->
removedParticipant.unpublishTrack(publication.sid)
}
mutableRemoteParticipants = newParticipants
listener?.onParticipantDisconnected(this, removedParticipant)
eventBus.postEvent(RoomEvent.ParticipantDisconnected(this, removedParticipant), coroutineScope)
}
... ... @@ -154,7 +176,11 @@ constructor(
RemoteParticipant(sid, null, engine.client, ioDispatcher, defaultDispatcher)
}
participant.internalListener = this
mutableRemoteParticipants[sid] = participant
val newRemoteParticipants = mutableRemoteParticipants.toMutableMap()
newRemoteParticipants[sid] = participant
mutableRemoteParticipants = newRemoteParticipants
return participant
}
... ... @@ -183,10 +209,9 @@ constructor(
it.isSpeaking = false
}
mutableActiveSpeakers.clear()
mutableActiveSpeakers.addAll(speakers)
listener?.onActiveSpeakersChanged(speakers, this)
eventBus.postEvent(RoomEvent.ActiveSpeakersChanged(this, speakers), coroutineScope)
mutableActiveSpeakers = speakers.toList()
listener?.onActiveSpeakersChanged(mutableActiveSpeakers, this)
eventBus.postEvent(RoomEvent.ActiveSpeakersChanged(this, mutableActiveSpeakers), coroutineScope)
}
private fun handleSpeakersChanged(speakerInfos: List<LivekitModels.SpeakerInfo>) {
... ... @@ -211,10 +236,9 @@ constructor(
val updatedSpeakersList = updatedSpeakers.values.toList()
.sortedBy { it.audioLevel }
mutableActiveSpeakers.clear()
mutableActiveSpeakers.addAll(updatedSpeakersList)
listener?.onActiveSpeakersChanged(updatedSpeakersList, this)
eventBus.postEvent(RoomEvent.ActiveSpeakersChanged(this, updatedSpeakersList), coroutineScope)
mutableActiveSpeakers = updatedSpeakersList.toList()
listener?.onActiveSpeakersChanged(mutableActiveSpeakers, this)
eventBus.postEvent(RoomEvent.ActiveSpeakersChanged(this, mutableActiveSpeakers), coroutineScope)
}
private fun reconnect() {
... ...
... ... @@ -366,13 +366,8 @@ internal constructor(
return
}
val sid = publication.sid
tracks.remove(sid)
when (publication.kind) {
Track.Kind.AUDIO -> audioTracks.remove(sid)
Track.Kind.VIDEO -> videoTracks.remove(sid)
else -> {
}
}
tracks = tracks.toMutableMap().apply { remove(sid) }
val senders = engine.publisher.peerConnection.senders ?: return
for (sender in senders) {
val t = sender.track() ?: continue
... ...
... ... @@ -7,9 +7,14 @@ import io.livekit.android.room.track.LocalTrackPublication
import io.livekit.android.room.track.RemoteTrackPublication
import io.livekit.android.room.track.Track
import io.livekit.android.room.track.TrackPublication
import io.livekit.android.util.flow
import io.livekit.android.util.flowDelegate
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import livekit.LivekitModels
import javax.inject.Named
... ... @@ -24,33 +29,50 @@ open class Participant(
protected val eventBus = BroadcastEventBus<ParticipantEvent>()
val events = eventBus.readOnly()
var participantInfo: LivekitModels.ParticipantInfo? = null
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
var participantInfo: LivekitModels.ParticipantInfo? by flowDelegate(null)
private set
var identity: String? = identity
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
var identity: String? by flowDelegate(identity)
internal set
var audioLevel: Float = 0f
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
var audioLevel: Float by flowDelegate(0f)
internal set
var isSpeaking: Boolean = false
internal set(v) {
val changed = v != field
field = v
if (changed) {
listener?.onSpeakingChanged(this)
internalListener?.onSpeakingChanged(this)
eventBus.postEvent(ParticipantEvent.SpeakingChanged(this, v), scope)
}
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
var isSpeaking: Boolean by flowDelegate(false) { newValue, oldValue ->
if (newValue != oldValue) {
listener?.onSpeakingChanged(this)
internalListener?.onSpeakingChanged(this)
eventBus.postEvent(ParticipantEvent.SpeakingChanged(this, newValue), scope)
}
var metadata: String? = null
internal set(v) {
val prevMetadata = field
field = v
if (prevMetadata != v) {
listener?.onMetadataChanged(this, prevMetadata)
internalListener?.onMetadataChanged(this, prevMetadata)
eventBus.postEvent(ParticipantEvent.MetadataChanged(this, prevMetadata), scope)
}
}
internal set
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
var metadata: String? by flowDelegate(null) { newMetadata, oldMetadata ->
if (newMetadata != oldMetadata) {
listener?.onMetadataChanged(this, oldMetadata)
internalListener?.onMetadataChanged(this, oldMetadata)
eventBus.postEvent(ParticipantEvent.MetadataChanged(this, oldMetadata), scope)
}
var connectionQuality: ConnectionQuality = ConnectionQuality.UNKNOWN
}
internal set
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
var connectionQuality by flowDelegate(ConnectionQuality.UNKNOWN)
internal set
/**
... ... @@ -68,11 +90,29 @@ open class Participant(
val hasInfo
get() = participantInfo != null
var tracks = mutableMapOf<String, TrackPublication>()
var audioTracks = mutableMapOf<String, TrackPublication>()
private set
var videoTracks = mutableMapOf<String, TrackPublication>()
private set
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
var tracks by flowDelegate(emptyMap<String, TrackPublication>())
protected set
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
val audioTracks by flowDelegate(
stateFlow = ::tracks.flow
.map { it.filterValues { publication -> publication.kind == Track.Kind.AUDIO } }
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
)
/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
val videoTracks by flowDelegate(
stateFlow = ::tracks.flow
.map {
it.filterValues { publication -> publication.kind == Track.Kind.VIDEO }
}
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
)
/**
* @suppress
... ... @@ -80,12 +120,8 @@ open class Participant(
fun addTrackPublication(publication: TrackPublication) {
val track = publication.track
track?.sid = publication.sid
tracks[publication.sid] = publication
when (publication.kind) {
Track.Kind.AUDIO -> audioTracks[publication.sid] = publication
Track.Kind.VIDEO -> videoTracks[publication.sid] = publication
else -> {
}
tracks = tracks.toMutableMap().apply {
this[publication.sid] = publication
}
}
... ...
... ... @@ -141,12 +141,8 @@ class RemoteParticipant(
}
fun unpublishTrack(trackSid: String, sendUnpublish: Boolean = false) {
val publication = tracks.remove(trackSid) as? RemoteTrackPublication ?: return
when (publication.kind) {
Track.Kind.AUDIO -> audioTracks.remove(trackSid)
Track.Kind.VIDEO -> videoTracks.remove(trackSid)
else -> throw TrackException.InvalidTrackTypeException()
}
val publication = tracks[trackSid] as? RemoteTrackPublication ?: return
tracks = tracks.toMutableMap().apply { remove(trackSid) }
val track = publication.track
if (track != null) {
... ...
/*
Taken from: https://github.com/aartikov/Sesame/tree/master/sesame-property/src/main/kotlin/me/aartikov/sesame/property
The MIT License (MIT)
Copyright (c) 2021 Artur Artikov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
package io.livekit.android.util
import io.livekit.android.util.DelegateAccess.delegate
import io.livekit.android.util.DelegateAccess.delegateRequested
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.reflect.KProperty
import kotlin.reflect.KProperty0
/**
* A little circuitous but the way this works is:
* 1. [delegateRequested] set to true indicates that [delegate] should be filled.
* 2. Upon [getValue], [delegate] is set.
* 3. [KProperty0.delegate] returns the value previously set to [delegate]
*/
internal object DelegateAccess {
internal val delegate = ThreadLocal<Any?>()
internal val delegateRequested = ThreadLocal<Boolean>().apply { set(false) }
}
internal val <T> KProperty0<T>.delegate: Any?
get() {
try {
DelegateAccess.delegateRequested.set(true)
this.get()
return DelegateAccess.delegate.get()
} finally {
DelegateAccess.delegate.set(null)
DelegateAccess.delegateRequested.set(false)
}
}
@Suppress("UNCHECKED_CAST")
val <T> KProperty0<T>.flow: StateFlow<T>
get() = delegate as StateFlow<T>
class MutableStateFlowDelegate<T>
internal constructor(
private val flow: MutableStateFlow<T>,
private val onSetValue: ((newValue: T, oldValue: T) -> Unit)? = null
) : MutableStateFlow<T> by flow {
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
if (DelegateAccess.delegateRequested.get() == true) {
DelegateAccess.delegate.set(this)
}
return flow.value
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
val oldValue = flow.value
flow.value = value
onSetValue?.invoke(value, oldValue)
}
}
class StateFlowDelegate<T>
internal constructor(
private val flow: StateFlow<T>
) : StateFlow<T> by flow {
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
if (DelegateAccess.delegateRequested.get() == true) {
DelegateAccess.delegate.set(this)
}
return flow.value
}
}
internal fun <T> flowDelegate(
initialValue: T,
onSetValue: ((newValue: T, oldValue: T) -> Unit)? = null
): MutableStateFlowDelegate<T> {
return MutableStateFlowDelegate(MutableStateFlow(initialValue), onSetValue)
}
internal fun <T> flowDelegate(
stateFlow: StateFlow<T>
): StateFlowDelegate<T> {
return StateFlowDelegate(stateFlow)
}
\ No newline at end of file
... ...
package io.livekit.android.coroutines
import io.livekit.android.events.EventListenable
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
... ... @@ -8,10 +7,10 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
/**
* Collect events until signal is given.
* Collect all items until signal is given.
*/
suspend fun <T> EventListenable<T>.collectEvents(signal: Flow<Unit?>): List<T> {
return events.takeUntilSignal(signal)
suspend fun <T> Flow<T>.toListUntilSignal(signal: Flow<Unit?>): List<T> {
return takeUntilSignal(signal)
.fold(emptyList()) { list, event ->
list.plus(event)
}
... ...
package io.livekit.android.events
import io.livekit.android.coroutines.collectEvents
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runBlockingTest
class EventCollector<T : Event>(
private val eventListenable: EventListenable<T>,
eventListenable: EventListenable<T>,
coroutineScope: CoroutineScope
) {
val signal = MutableStateFlow<Unit?>(null)
val collectEventsDeferred = coroutineScope.async {
eventListenable.collectEvents(signal)
}
/**
* Stop collecting events. returns the events collected.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun stopCollectingEvents(): List<T> {
signal.compareAndSet(null, Unit)
var events: List<T> = emptyList()
runBlockingTest {
events = collectEventsDeferred.await()
}
return events
}
}
\ No newline at end of file
) : FlowCollector<T>(eventListenable.events, coroutineScope)
\ No newline at end of file
... ...
package io.livekit.android.events
import io.livekit.android.coroutines.toListUntilSignal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runBlockingTest
open class FlowCollector<T>(
private val flow: Flow<T>,
coroutineScope: CoroutineScope
) {
val signal = MutableStateFlow<Unit?>(null)
val collectEventsDeferred = coroutineScope.async {
flow.toListUntilSignal(signal)
}
/**
* Stop collecting events. returns the events collected.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun stopCollecting(): List<T> {
signal.compareAndSet(null, Unit)
var events: List<T> = emptyList()
runBlockingTest {
events = collectEventsDeferred.await()
}
return events
}
}
\ No newline at end of file
... ...
... ... @@ -4,10 +4,12 @@ import android.content.Context
import androidx.test.core.app.ApplicationProvider
import io.livekit.android.coroutines.TestCoroutineRule
import io.livekit.android.events.EventCollector
import io.livekit.android.events.FlowCollector
import io.livekit.android.events.RoomEvent
import io.livekit.android.mock.MockWebsocketFactory
import io.livekit.android.mock.dagger.DaggerTestLiveKitComponent
import io.livekit.android.room.participant.ConnectionQuality
import io.livekit.android.util.flow
import io.livekit.android.util.toOkioByteString
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
... ... @@ -72,7 +74,7 @@ class RoomMockE2ETest {
connect()
val eventCollector = EventCollector(room.events, coroutineRule.scope)
wsFactory.listener.onMessage(wsFactory.ws, SignalClientTest.ROOM_UPDATE.toOkioByteString())
val events = eventCollector.stopCollectingEvents()
val events = eventCollector.stopCollecting()
Assert.assertEquals(
SignalClientTest.ROOM_UPDATE.roomUpdate.room.metadata,
... ... @@ -90,7 +92,7 @@ class RoomMockE2ETest {
wsFactory.ws,
SignalClientTest.CONNECTION_QUALITY.toOkioByteString()
)
val events = eventCollector.stopCollectingEvents()
val events = eventCollector.stopCollecting()
Assert.assertEquals(ConnectionQuality.EXCELLENT, room.localParticipant.connectionQuality)
Assert.assertEquals(1, events.size)
... ... @@ -106,7 +108,7 @@ class RoomMockE2ETest {
wsFactory.ws,
SignalClientTest.PARTICIPANT_JOIN.toOkioByteString()
)
val events = eventCollector.stopCollectingEvents()
val events = eventCollector.stopCollecting()
Assert.assertEquals(1, events.size)
Assert.assertEquals(true, events[0] is RoomEvent.ParticipantConnected)
... ... @@ -125,7 +127,7 @@ class RoomMockE2ETest {
wsFactory.ws,
SignalClientTest.PARTICIPANT_DISCONNECT.toOkioByteString()
)
val events = eventCollector.stopCollectingEvents()
val events = eventCollector.stopCollecting()
Assert.assertEquals(1, events.size)
Assert.assertEquals(true, events[0] is RoomEvent.ParticipantDisconnected)
... ... @@ -140,7 +142,7 @@ class RoomMockE2ETest {
wsFactory.ws,
SignalClientTest.ACTIVE_SPEAKER_UPDATE.toOkioByteString()
)
val events = eventCollector.stopCollectingEvents()
val events = eventCollector.stopCollecting()
Assert.assertEquals(1, events.size)
Assert.assertEquals(true, events[0] is RoomEvent.ActiveSpeakersChanged)
... ... @@ -160,7 +162,7 @@ class RoomMockE2ETest {
wsFactory.ws,
SignalClientTest.PARTICIPANT_METADATA_CHANGED.toOkioByteString()
)
val events = eventCollector.stopCollectingEvents()
val events = eventCollector.stopCollecting()
Assert.assertEquals(1, events.size)
Assert.assertEquals(true, events[0] is RoomEvent.ParticipantMetadataChanged)
... ... @@ -174,7 +176,7 @@ class RoomMockE2ETest {
wsFactory.ws,
SignalClientTest.LEAVE.toOkioByteString()
)
val events = eventCollector.stopCollectingEvents()
val events = eventCollector.stopCollecting()
Assert.assertEquals(1, events.size)
Assert.assertEquals(true, events[0] is RoomEvent.Disconnected)
... ...
... ... @@ -4,16 +4,11 @@ import android.content.Context
import android.net.Network
import androidx.test.core.app.ApplicationProvider
import io.livekit.android.coroutines.TestCoroutineRule
import io.livekit.android.coroutines.collectEvents
import io.livekit.android.events.Event
import io.livekit.android.events.EventCollector
import io.livekit.android.events.EventListenable
import io.livekit.android.events.RoomEvent
import io.livekit.android.mock.MockEglBase
import io.livekit.android.mock.TestData
import io.livekit.android.room.participant.LocalParticipant
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runBlockingTest
import livekit.LivekitModels
import org.junit.Assert
... ... @@ -104,7 +99,7 @@ class RoomTest {
room.onLost(network)
room.onAvailable(network)
val events = eventCollector.stopCollectingEvents()
val events = eventCollector.stopCollecting()
Assert.assertEquals(1, events.size)
Assert.assertEquals(true, events[0] is RoomEvent.Reconnecting)
... ... @@ -116,7 +111,7 @@ class RoomTest {
val eventCollector = EventCollector(room.events, coroutineRule.scope)
room.onDisconnect("")
val events = eventCollector.stopCollectingEvents()
val events = eventCollector.stopCollecting()
Assert.assertEquals(1, events.size)
Assert.assertEquals(true, events[0] is RoomEvent.Disconnected)
... ...
... ... @@ -74,7 +74,7 @@ class ParticipantTest {
val metadata = "metadata"
participant.metadata = metadata
val events = eventCollector.stopCollectingEvents()
val events = eventCollector.stopCollecting()
assertEquals(1, events.size)
assertEquals(true, events[0] is ParticipantEvent.MetadataChanged)
... ... @@ -91,7 +91,7 @@ class ParticipantTest {
val newIsSpeaking = !participant.isSpeaking
participant.isSpeaking = newIsSpeaking
val events = eventCollector.stopCollectingEvents()
val events = eventCollector.stopCollecting()
assertEquals(1, events.size)
assertEquals(true, events[0] is ParticipantEvent.SpeakingChanged)
... ...
... ... @@ -10,8 +10,8 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.*
import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
... ... @@ -20,18 +20,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import com.github.ajalt.timberkt.Timber
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.rememberPagerState
import io.livekit.android.composesample.ui.theme.AppTheme
import io.livekit.android.renderer.TextureViewRenderer
import io.livekit.android.room.Room
import io.livekit.android.room.participant.RemoteParticipant
import io.livekit.android.room.track.LocalVideoTrack
import io.livekit.android.room.participant.Participant
import kotlinx.coroutines.Dispatchers
import kotlinx.parcelize.Parcelize
@OptIn(ExperimentalPagerApi::class)
... ... @@ -60,6 +56,7 @@ class CallActivity : AppCompatActivity() {
}
@OptIn(ExperimentalMaterialApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
... ... @@ -83,22 +80,24 @@ class CallActivity : AppCompatActivity() {
}
setContent {
AppTheme(darkTheme = true) {
val room by viewModel.room.observeAsState()
val participants by viewModel.remoteParticipants.observeAsState(emptyList())
val micEnabled by viewModel.micEnabled.observeAsState(true)
val videoEnabled by viewModel.cameraEnabled.observeAsState(true)
val flipButtonEnabled by viewModel.flipButtonVideoEnabled.observeAsState(true)
val screencastEnabled by viewModel.screencastEnabled.observeAsState(false)
Content(
room,
participants,
micEnabled,
videoEnabled,
flipButtonEnabled,
screencastEnabled,
)
}
val room by viewModel.room.collectAsState()
val participants by viewModel.participants.collectAsState(initial = emptyList())
val primarySpeaker by viewModel.primarySpeaker.collectAsState()
val activeSpeakers by viewModel.activeSpeakers.collectAsState(initial = emptyList())
val micEnabled by viewModel.micEnabled.observeAsState(true)
val videoEnabled by viewModel.cameraEnabled.observeAsState(true)
val flipButtonEnabled by viewModel.flipButtonVideoEnabled.observeAsState(true)
val screencastEnabled by viewModel.screencastEnabled.observeAsState(false)
Content(
room,
participants,
primarySpeaker,
activeSpeakers,
micEnabled,
videoEnabled,
flipButtonEnabled,
screencastEnabled,
)
}
}
... ... @@ -108,164 +107,133 @@ class CallActivity : AppCompatActivity() {
screenCaptureIntentLauncher.launch(mediaProjectionManager.createScreenCaptureIntent())
}
val previewParticipant = Participant("asdf", "asdf", Dispatchers.Main)
@ExperimentalMaterialApi
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun Content(
room: Room? = null,
participants: List<RemoteParticipant> = emptyList(),
participants: List<Participant> = listOf(previewParticipant),
primarySpeaker: Participant? = previewParticipant,
activeSpeakers: List<Participant> = listOf(previewParticipant),
micEnabled: Boolean = true,
videoEnabled: Boolean = true,
flipButtonEnabled: Boolean = true,
screencastEnabled: Boolean = false,
) {
ConstraintLayout(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
val (tabRow, pager, buttonBar, cameraView) = createRefs()
AppTheme(darkTheme = true) {
ConstraintLayout(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
val (speakerView, audienceRow, buttonBar) = createRefs()
if (participants.isNotEmpty()) {
val pagerState = rememberPagerState()
ScrollableTabRow(
// Our selected tab is our current page
selectedTabIndex = pagerState.currentPage,
// Override the indicator, using the provided pagerTabIndicatorOffset modifier
indicator = { tabPositions ->
TabRowDefaults.Indicator(
modifier = Modifier
.height(1.dp)
.tabIndicatorOffset(tabPositions[pagerState.currentPage]),
height = 1.dp,
color = Color.Gray
)
},
modifier = Modifier
.background(Color.DarkGray)
.constrainAs(tabRow) {
top.linkTo(parent.top)
width = Dimension.fillToConstraints
}
) {
// Add tabs for all of our pages
participants.forEachIndexed { index, participant ->
Tab(
text = { Text(participant.identity ?: "Unnamed $index") },
selected = pagerState.currentPage == index,
onClick = { /* TODO*/ },
Surface(modifier = Modifier.constrainAs(speakerView) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
bottom.linkTo(audienceRow.top)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}) {
if (room != null && primarySpeaker != null) {
ParticipantItem(
room = room,
participant = primarySpeaker,
isSpeaking = activeSpeakers.contains(primarySpeaker)
)
}
}
HorizontalPager(
count = participants.size,
state = pagerState,
LazyRow(
modifier = Modifier
.constrainAs(pager) {
top.linkTo(tabRow.bottom)
.constrainAs(audienceRow) {
top.linkTo(speakerView.bottom)
bottom.linkTo(buttonBar.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
height = Dimension.value(120.dp)
}
) { index ->
) {
if (room != null) {
ParticipantItem(room = room, participant = participants[index])
items(
count = participants.size,
key = { index -> participants[index].sid }
) { index ->
ParticipantItem(
room = room,
participant = participants[index],
isSpeaking = activeSpeakers.contains(participants[index]),
modifier = Modifier
.fillMaxHeight()
.aspectRatio(1.0f, true)
)
}
}
}
}
if (room != null) {
var videoNeedsSetup by remember { mutableStateOf(true) }
AndroidView(
factory = { context ->
TextureViewRenderer(context).apply {
room.initVideoRenderer(this)
}
},
Row(
modifier = Modifier
.width(200.dp)
.height(200.dp)
.padding(bottom = 10.dp, end = 10.dp)
.background(Color.Black)
.constrainAs(cameraView) {
bottom.linkTo(buttonBar.top)
end.linkTo(parent.end)
.padding(top = 10.dp, bottom = 20.dp)
.fillMaxWidth()
.constrainAs(buttonBar) {
bottom.linkTo(parent.bottom)
width = Dimension.fillToConstraints
height = Dimension.wrapContent
},
update = { view ->
val videoTrack = room.localParticipant.videoTracks.values
.firstOrNull()
?.track as? LocalVideoTrack
if (videoNeedsSetup) {
videoTrack?.addRenderer(view)
videoNeedsSetup = false
}
}
)
}
Row(
modifier = Modifier
.padding(top = 10.dp, bottom = 20.dp)
.fillMaxWidth()
.constrainAs(buttonBar) {
bottom.linkTo(parent.bottom)
width = Dimension.fillToConstraints
},
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.Bottom,
) {
FloatingActionButton(
onClick = { viewModel.setMicEnabled(!micEnabled) },
backgroundColor = Color.DarkGray,
) {
val resource =
if (micEnabled) R.drawable.outline_mic_24 else R.drawable.outline_mic_off_24
Icon(
painterResource(id = resource),
contentDescription = "Mic",
tint = Color.White,
)
}
FloatingActionButton(
onClick = { viewModel.setCameraEnabled(!videoEnabled) },
backgroundColor = Color.DarkGray,
) {
val resource =
if (videoEnabled) R.drawable.outline_videocam_24 else R.drawable.outline_videocam_off_24
Icon(
painterResource(id = resource),
contentDescription = "Video",
tint = Color.White,
)
}
FloatingActionButton(
onClick = { viewModel.flipVideo() },
backgroundColor = Color.DarkGray,
) {
Icon(
painterResource(id = R.drawable.outline_flip_camera_android_24),
contentDescription = "Flip Camera",
tint = Color.White,
)
}
FloatingActionButton(
onClick = {
if (!screencastEnabled) {
requestMediaProjection()
} else {
viewModel.stopScreenCapture()
}
},
backgroundColor = Color.DarkGray,
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.Bottom,
) {
val resource =
if (screencastEnabled) R.drawable.baseline_cast_connected_24 else R.drawable.baseline_cast_24
Icon(
painterResource(id = resource),
contentDescription = "Flip Camera",
tint = Color.White,
)
Surface(
onClick = { viewModel.setMicEnabled(!micEnabled) },
) {
val resource =
if (micEnabled) R.drawable.outline_mic_24 else R.drawable.outline_mic_off_24
Icon(
painterResource(id = resource),
contentDescription = "Mic",
tint = Color.White,
)
}
Surface(
onClick = { viewModel.setCameraEnabled(!videoEnabled) },
) {
val resource =
if (videoEnabled) R.drawable.outline_videocam_24 else R.drawable.outline_videocam_off_24
Icon(
painterResource(id = resource),
contentDescription = "Video",
tint = Color.White,
)
}
Surface(
onClick = { viewModel.flipVideo() },
) {
Icon(
painterResource(id = R.drawable.outline_flip_camera_android_24),
contentDescription = "Flip Camera",
tint = Color.White,
)
}
Surface(
onClick = {
if (!screencastEnabled) {
requestMediaProjection()
} else {
viewModel.stopScreenCapture()
}
},
) {
val resource =
if (screencastEnabled) R.drawable.baseline_cast_connected_24 else R.drawable.baseline_cast_24
Icon(
painterResource(id = resource),
contentDescription = "Flip Camera",
tint = Color.White,
)
}
}
}
}
... ...
... ... @@ -2,10 +2,7 @@ package io.livekit.android.composesample
import android.app.Application
import android.content.Intent
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.*
import com.github.ajalt.timberkt.Timber
import io.livekit.android.ConnectOptions
import io.livekit.android.LiveKit
... ... @@ -14,17 +11,44 @@ import io.livekit.android.room.RoomListener
import io.livekit.android.room.participant.Participant
import io.livekit.android.room.participant.RemoteParticipant
import io.livekit.android.room.track.*
import io.livekit.android.util.flow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
class CallViewModel(
val url: String,
val token: String,
application: Application
) : AndroidViewModel(application), RoomListener {
private val mutableRoom = MutableLiveData<Room>()
val room: LiveData<Room> = mutableRoom
private val mutableRemoteParticipants = MutableLiveData<List<RemoteParticipant>>()
val remoteParticipants: LiveData<List<RemoteParticipant>> = mutableRemoteParticipants
private val mutableRoom = MutableStateFlow<Room?>(null)
val room: MutableStateFlow<Room?> = mutableRoom
val participants = mutableRoom.flatMapLatest { room ->
if (room != null) {
room::remoteParticipants.flow
.map { remoteParticipants ->
listOf<Participant>(room.localParticipant) +
remoteParticipants
.keys
.sortedBy { it }
.mapNotNull { remoteParticipants[it] }
}
} else {
emptyFlow()
}
}
private val mutablePrimarySpeaker = MutableStateFlow<Participant?>(null)
val primarySpeaker: StateFlow<Participant?> = mutablePrimarySpeaker
val activeSpeakers = mutableRoom.flatMapLatest { room ->
if (room != null) {
room::activeSpeakers.flow
} else {
emptyFlow()
}
}
private var localScreencastTrack: LocalScreencastVideoTrack? = null
... ... @@ -59,9 +83,9 @@ class CallViewModel(
localParticipant.setCameraEnabled(true)
mutableCameraEnabled.postValue(localParticipant.isCameraEnabled())
updateParticipants(room)
mutableRoom.value = room
mutablePrimarySpeaker.value = room.remoteParticipants.values.firstOrNull() ?: localParticipant
}
}
... ... @@ -93,15 +117,6 @@ class CallViewModel(
}
}
private fun updateParticipants(room: Room) {
mutableRemoteParticipants.postValue(
room.remoteParticipants
.keys
.sortedBy { it }
.mapNotNull { room.remoteParticipants[it] }
)
}
override fun onCleared() {
super.onCleared()
mutableRoom.value?.disconnect()
... ... @@ -110,29 +125,15 @@ class CallViewModel(
override fun onDisconnect(room: Room, error: Exception?) {
}
override fun onParticipantConnected(
room: Room,
participant: RemoteParticipant
) {
updateParticipants(room)
}
override fun onParticipantDisconnected(
room: Room,
participant: RemoteParticipant
) {
updateParticipants(room)
}
override fun onFailedToConnect(room: Room, error: Exception) {
}
override fun onActiveSpeakersChanged(speakers: List<Participant>, room: Room) {
Timber.i { "active speakers changed ${speakers.count()}" }
}
override fun onMetadataChanged(participant: Participant, prevMetadata: String?, room: Room) {
Timber.i { "Participant metadata changed: ${participant.identity}" }
// If old active speaker is still active, don't change.
if (speakers.isEmpty() || speakers.contains(mutablePrimarySpeaker.value)) {
return
}
val newSpeaker = speakers
.filter { it is RemoteParticipant } // Try not to display local participant as speaker.
.firstOrNull() ?: return
mutablePrimarySpeaker.value = newSpeaker
}
fun setMicEnabled(enabled: Boolean) {
... ...
... ... @@ -4,15 +4,18 @@ import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Space
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
... ... @@ -88,11 +91,20 @@ class MainActivity : ComponentActivity() {
var url by remember { mutableStateOf(defaultUrl) }
var token by remember { mutableStateOf(defaultToken) }
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
Surface(
color = MaterialTheme.colors.background,
modifier = Modifier.fillMaxSize()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(10.dp)
) {
Spacer(modifier = Modifier.height(50.dp))
Image(
painter = painterResource(id = R.drawable.banner_dark),
contentDescription = "",
)
Spacer(modifier = Modifier.height(20.dp))
OutlinedTextField(
value = url,
onValueChange = { url = it },
... ...
package io.livekit.android.composesample
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.material.Icon
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.viewinterop.AndroidView
import com.github.ajalt.timberkt.Timber
import io.livekit.android.renderer.TextureViewRenderer
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import io.livekit.android.composesample.ui.theme.BlueMain
import io.livekit.android.composesample.ui.theme.NoVideoBackground
import io.livekit.android.room.Room
import io.livekit.android.room.participant.ParticipantListener
import io.livekit.android.room.participant.RemoteParticipant
import io.livekit.android.room.track.RemoteTrackPublication
import io.livekit.android.room.track.RemoteVideoTrack
import io.livekit.android.room.participant.Participant
import io.livekit.android.room.track.Track
import io.livekit.android.room.track.video.ComposeVisibility
import io.livekit.android.room.track.VideoTrack
import io.livekit.android.util.flow
@Composable
fun ParticipantItem(
room: Room,
participant: RemoteParticipant,
participant: Participant,
modifier: Modifier = Modifier,
isSpeaking: Boolean,
) {
val videoSinkVisibility = remember(room, participant) { ComposeVisibility() }
var videoBound by remember(room, participant) { mutableStateOf(false) }
fun getVideoTrack(): RemoteVideoTrack? {
return participant
.videoTracks.values
.firstOrNull()?.track as? RemoteVideoTrack
}
fun setupVideoIfNeeded(videoTrack: RemoteVideoTrack, view: TextureViewRenderer) {
if (videoBound) {
return
}
val identity by participant::identity.flow.collectAsState()
val videoTracks by participant::videoTracks.flow.collectAsState()
val audioTracks by participant::audioTracks.flow.collectAsState()
val identityBarPadding = 4.dp
ConstraintLayout(
modifier = modifier.background(NoVideoBackground)
.run {
if (isSpeaking) {
border(2.dp, BlueMain)
} else {
this
}
}
) {
val (videoCamOff, identityBar, identityText, muteIndicator) = createRefs()
val videoTrack = participant.getTrackPublication(Track.Source.SCREEN_SHARE)?.track as? VideoTrack
?: participant.getTrackPublication(Track.Source.CAMERA)?.track as? VideoTrack
?: videoTracks.values.firstOrNull()?.track as? VideoTrack
videoBound = true
Timber.v { "adding renderer to $videoTrack" }
videoTrack.addRenderer(view, videoSinkVisibility)
}
DisposableEffect(room, participant) {
onDispose {
videoSinkVisibility.onDispose()
if (videoTrack != null) {
VideoItemTrackSelector(
room = room,
participant = participant,
videoTracks = videoTracks,
modifier = Modifier.fillMaxSize()
)
} else {
Icon(
painter = painterResource(id = R.drawable.outline_videocam_off_24),
contentDescription = null,
tint = Color.White,
modifier = Modifier.constrainAs(videoCamOff) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.wrapContent
height = Dimension.wrapContent
}
)
}
}
AndroidView(
factory = { context ->
TextureViewRenderer(context).apply {
room.initVideoRenderer(this)
Surface(
color = Color(0x80000000),
modifier = Modifier.constrainAs(identityBar) {
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
height = Dimension.value(30.dp)
}
},
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { videoSinkVisibility.onGloballyPositioned(it) },
update = { view ->
participant.listener = object : ParticipantListener {
override fun onTrackSubscribed(
track: Track,
publication: RemoteTrackPublication,
participant: RemoteParticipant
) {
if (track is RemoteVideoTrack) {
setupVideoIfNeeded(track, view)
}
}
) {}
Text(
text = identity ?: "",
color = Color.White,
modifier = Modifier.constrainAs(identityText) {
top.linkTo(identityBar.top)
bottom.linkTo(identityBar.bottom)
start.linkTo(identityBar.start, margin = identityBarPadding)
end.linkTo(muteIndicator.end, margin = 10.dp)
width = Dimension.fillToConstraints
height = Dimension.wrapContent
},
)
val isMuted = audioTracks.isEmpty()
override fun onTrackUnpublished(
publication: RemoteTrackPublication,
participant: RemoteParticipant
) {
super.onTrackUnpublished(publication, participant)
Timber.e { "Track unpublished" }
if (isMuted) {
Icon(
painter = painterResource(id = R.drawable.outline_mic_off_24),
contentDescription = "",
tint = Color.Red,
modifier = Modifier.constrainAs(muteIndicator) {
top.linkTo(identityBar.top)
bottom.linkTo(identityBar.bottom)
end.linkTo(identityBar.end, margin = identityBarPadding)
}
}
val existingTrack = getVideoTrack()
if (existingTrack != null) {
setupVideoIfNeeded(existingTrack, view)
}
)
}
)
}
}
\ No newline at end of file
... ...
package io.livekit.android.composesample
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.viewinterop.AndroidView
import io.livekit.android.renderer.TextureViewRenderer
import io.livekit.android.room.Room
import io.livekit.android.room.participant.Participant
import io.livekit.android.room.track.RemoteVideoTrack
import io.livekit.android.room.track.Track
import io.livekit.android.room.track.TrackPublication
import io.livekit.android.room.track.VideoTrack
import io.livekit.android.room.track.video.ComposeVisibility
@Composable
fun VideoItem(
room: Room,
videoTrack: VideoTrack,
modifier: Modifier = Modifier
) {
val videoSinkVisibility = remember(room, videoTrack) { ComposeVisibility() }
var boundVideoTrack by remember { mutableStateOf<VideoTrack?>(null) }
var view: TextureViewRenderer? by remember { mutableStateOf(null) }
fun cleanupVideoTrack() {
view?.let { boundVideoTrack?.removeRenderer(it) }
boundVideoTrack = null
}
fun setupVideoIfNeeded(videoTrack: VideoTrack, view: TextureViewRenderer) {
if (boundVideoTrack == videoTrack) {
return
}
cleanupVideoTrack()
boundVideoTrack = videoTrack
if (videoTrack is RemoteVideoTrack) {
videoTrack.addRenderer(view, videoSinkVisibility)
} else {
videoTrack.addRenderer(view)
}
}
DisposableEffect(room, videoTrack) {
onDispose {
videoSinkVisibility.onDispose()
cleanupVideoTrack()
}
}
AndroidView(
factory = { context ->
TextureViewRenderer(context).apply {
room.initVideoRenderer(this)
setupVideoIfNeeded(videoTrack, this)
view = this
}
},
update = { view ->
setupVideoIfNeeded(videoTrack, view)
},
modifier = modifier
.onGloballyPositioned { videoSinkVisibility.onGloballyPositioned(it) },
)
}
@Composable
fun VideoItemTrackSelector(
room: Room,
participant: Participant,
videoTracks: Map<String, TrackPublication>,
modifier: Modifier = Modifier
) {
val videoTrack = participant.getTrackPublication(Track.Source.SCREEN_SHARE)?.track as? VideoTrack
?: participant.getTrackPublication(Track.Source.CAMERA)?.track as? VideoTrack
?: videoTracks.values.firstOrNull()?.track as? VideoTrack
if (videoTrack != null) {
VideoItem(
room = room,
videoTrack = videoTrack,
modifier = modifier
)
}
}
\ No newline at end of file
... ...
... ... @@ -2,7 +2,8 @@ package io.livekit.android.composesample.ui.theme
import androidx.compose.ui.graphics.Color
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
\ No newline at end of file
val BlueMain = Color(0xFF007DFF)
val BlueDark = Color(0xFF0058B3)
val BlueLight = Color(0xFF66B1FF)
val NoVideoIconTint = Color(0xFF5A8BFF)
val NoVideoBackground = Color(0xFF00153C)
\ No newline at end of file
... ...
package io.livekit.android.composesample.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
... ... @@ -8,17 +7,21 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColorPalette = darkColors(
primary = Purple200,
primaryVariant = Purple700,
secondary = Teal200,
background = Color.Black
primary = BlueMain,
primaryVariant = BlueDark,
secondary = BlueMain,
background = Color.Black,
surface = Color.Transparent,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.White,
onSurface = Color.White,
)
private val LightColorPalette = lightColors(
primary = Purple500,
primaryVariant = Purple700,
secondary = Teal200
primary = BlueMain,
primaryVariant = BlueDark,
secondary = BlueMain,
/* Other default colors to override
background = Color.White,
surface = Color.White,
... ... @@ -31,7 +34,7 @@ private val LightColorPalette = lightColors(
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
darkTheme: Boolean = true,
content: @Composable() () -> Unit
) {
val colors = if (darkTheme) {
... ...
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Livekitandroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
\ No newline at end of file
... ... @@ -2,13 +2,9 @@
<!-- Base application theme. -->
<style name="Theme.Livekitandroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryVariant">@color/colorPrimaryDark</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
... ...