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 @@ -17,6 +17,8 @@ import io.livekit.android.renderer.TextureViewRenderer
17 import io.livekit.android.room.participant.* 17 import io.livekit.android.room.participant.*
18 import io.livekit.android.room.track.* 18 import io.livekit.android.room.track.*
19 import io.livekit.android.util.LKLog 19 import io.livekit.android.util.LKLog
  20 +import io.livekit.android.util.flow
  21 +import io.livekit.android.util.flowDelegate
20 import kotlinx.coroutines.* 22 import kotlinx.coroutines.*
21 import livekit.LivekitModels 23 import livekit.LivekitModels
22 import livekit.LivekitRtc 24 import livekit.LivekitRtc
@@ -58,13 +60,24 @@ constructor( @@ -58,13 +60,24 @@ constructor(
58 @Deprecated("Use events instead.") 60 @Deprecated("Use events instead.")
59 var listener: RoomListener? = null 61 var listener: RoomListener? = null
60 62
61 - var sid: Sid? = null  
62 - private set  
63 - var name: String? = null 63 + /**
  64 + * Changes can be observed by using [io.livekit.android.util.flow]
  65 + */
  66 + var sid: Sid? by flowDelegate(null)
  67 + /**
  68 + * Changes can be observed by using [io.livekit.android.util.flow]
  69 + */
  70 + var name: String? by flowDelegate(null)
64 private set 71 private set
65 - var state: State = State.DISCONNECTED 72 + /**
  73 + * Changes can be observed by using [io.livekit.android.util.flow]
  74 + */
  75 + var state: State by flowDelegate(State.DISCONNECTED)
66 private set 76 private set
67 - var metadata: String? = null 77 + /**
  78 + * Changes can be observed by using [io.livekit.android.util.flow]
  79 + */
  80 + var metadata: String? by flowDelegate(null)
68 private set 81 private set
69 82
70 var autoManageVideo: Boolean = false 83 var autoManageVideo: Boolean = false
@@ -75,21 +88,28 @@ constructor( @@ -75,21 +88,28 @@ constructor(
75 88
76 lateinit var localParticipant: LocalParticipant 89 lateinit var localParticipant: LocalParticipant
77 private set 90 private set
78 - private val mutableRemoteParticipants = mutableMapOf<String, RemoteParticipant>() 91 + private var mutableRemoteParticipants by flowDelegate(emptyMap<String, RemoteParticipant>())
  92 + /**
  93 + * Changes can be observed by using [io.livekit.android.util.flow]
  94 + */
79 val remoteParticipants: Map<String, RemoteParticipant> 95 val remoteParticipants: Map<String, RemoteParticipant>
80 get() = mutableRemoteParticipants 96 get() = mutableRemoteParticipants
81 97
82 - private val mutableActiveSpeakers = mutableListOf<Participant>() 98 + private var mutableActiveSpeakers by flowDelegate(emptyList<Participant>())
  99 + /**
  100 + * Changes can be observed by using [io.livekit.android.util.flow]
  101 + */
83 val activeSpeakers: List<Participant> 102 val activeSpeakers: List<Participant>
84 get() = mutableActiveSpeakers 103 get() = mutableActiveSpeakers
85 104
86 private var hasLostConnectivity: Boolean = false 105 private var hasLostConnectivity: Boolean = false
87 suspend fun connect(url: String, token: String, options: ConnectOptions?) { 106 suspend fun connect(url: String, token: String, options: ConnectOptions?) {
88 - if(this::coroutineScope.isInitialized) { 107 + if (this::coroutineScope.isInitialized) {
89 coroutineScope.cancel() 108 coroutineScope.cancel()
90 } 109 }
91 coroutineScope = CoroutineScope(defaultDispatcher + SupervisorJob()) 110 coroutineScope = CoroutineScope(defaultDispatcher + SupervisorJob())
92 state = State.CONNECTING 111 state = State.CONNECTING
  112 + ::state.flow
93 val response = engine.join(url, token, options) 113 val response = engine.join(url, token, options)
94 LKLog.i { "Connected to server, server version: ${response.serverVersion}, client version: ${Version.CLIENT_VERSION}" } 114 LKLog.i { "Connected to server, server version: ${response.serverVersion}, client version: ${Version.CLIENT_VERSION}" }
95 115
@@ -122,11 +142,13 @@ constructor( @@ -122,11 +142,13 @@ constructor(
122 } 142 }
123 143
124 private fun handleParticipantDisconnect(sid: String) { 144 private fun handleParticipantDisconnect(sid: String) {
125 - val removedParticipant = mutableRemoteParticipants.remove(sid) ?: return 145 + val newParticipants = mutableRemoteParticipants.toMutableMap()
  146 + val removedParticipant = newParticipants.remove(sid) ?: return
126 removedParticipant.tracks.values.toList().forEach { publication -> 147 removedParticipant.tracks.values.toList().forEach { publication ->
127 removedParticipant.unpublishTrack(publication.sid) 148 removedParticipant.unpublishTrack(publication.sid)
128 } 149 }
129 150
  151 + mutableRemoteParticipants = newParticipants
130 listener?.onParticipantDisconnected(this, removedParticipant) 152 listener?.onParticipantDisconnected(this, removedParticipant)
131 eventBus.postEvent(RoomEvent.ParticipantDisconnected(this, removedParticipant), coroutineScope) 153 eventBus.postEvent(RoomEvent.ParticipantDisconnected(this, removedParticipant), coroutineScope)
132 } 154 }
@@ -154,7 +176,11 @@ constructor( @@ -154,7 +176,11 @@ constructor(
154 RemoteParticipant(sid, null, engine.client, ioDispatcher, defaultDispatcher) 176 RemoteParticipant(sid, null, engine.client, ioDispatcher, defaultDispatcher)
155 } 177 }
156 participant.internalListener = this 178 participant.internalListener = this
157 - mutableRemoteParticipants[sid] = participant 179 +
  180 + val newRemoteParticipants = mutableRemoteParticipants.toMutableMap()
  181 + newRemoteParticipants[sid] = participant
  182 + mutableRemoteParticipants = newRemoteParticipants
  183 +
158 return participant 184 return participant
159 } 185 }
160 186
@@ -183,10 +209,9 @@ constructor( @@ -183,10 +209,9 @@ constructor(
183 it.isSpeaking = false 209 it.isSpeaking = false
184 } 210 }
185 211
186 - mutableActiveSpeakers.clear()  
187 - mutableActiveSpeakers.addAll(speakers)  
188 - listener?.onActiveSpeakersChanged(speakers, this)  
189 - eventBus.postEvent(RoomEvent.ActiveSpeakersChanged(this, speakers), coroutineScope) 212 + mutableActiveSpeakers = speakers.toList()
  213 + listener?.onActiveSpeakersChanged(mutableActiveSpeakers, this)
  214 + eventBus.postEvent(RoomEvent.ActiveSpeakersChanged(this, mutableActiveSpeakers), coroutineScope)
190 } 215 }
191 216
192 private fun handleSpeakersChanged(speakerInfos: List<LivekitModels.SpeakerInfo>) { 217 private fun handleSpeakersChanged(speakerInfos: List<LivekitModels.SpeakerInfo>) {
@@ -211,10 +236,9 @@ constructor( @@ -211,10 +236,9 @@ constructor(
211 val updatedSpeakersList = updatedSpeakers.values.toList() 236 val updatedSpeakersList = updatedSpeakers.values.toList()
212 .sortedBy { it.audioLevel } 237 .sortedBy { it.audioLevel }
213 238
214 - mutableActiveSpeakers.clear()  
215 - mutableActiveSpeakers.addAll(updatedSpeakersList)  
216 - listener?.onActiveSpeakersChanged(updatedSpeakersList, this)  
217 - eventBus.postEvent(RoomEvent.ActiveSpeakersChanged(this, updatedSpeakersList), coroutineScope) 239 + mutableActiveSpeakers = updatedSpeakersList.toList()
  240 + listener?.onActiveSpeakersChanged(mutableActiveSpeakers, this)
  241 + eventBus.postEvent(RoomEvent.ActiveSpeakersChanged(this, mutableActiveSpeakers), coroutineScope)
218 } 242 }
219 243
220 private fun reconnect() { 244 private fun reconnect() {
@@ -366,13 +366,8 @@ internal constructor( @@ -366,13 +366,8 @@ internal constructor(
366 return 366 return
367 } 367 }
368 val sid = publication.sid 368 val sid = publication.sid
369 - tracks.remove(sid)  
370 - when (publication.kind) {  
371 - Track.Kind.AUDIO -> audioTracks.remove(sid)  
372 - Track.Kind.VIDEO -> videoTracks.remove(sid)  
373 - else -> {  
374 - }  
375 - } 369 + tracks = tracks.toMutableMap().apply { remove(sid) }
  370 +
376 val senders = engine.publisher.peerConnection.senders ?: return 371 val senders = engine.publisher.peerConnection.senders ?: return
377 for (sender in senders) { 372 for (sender in senders) {
378 val t = sender.track() ?: continue 373 val t = sender.track() ?: continue
@@ -7,9 +7,14 @@ import io.livekit.android.room.track.LocalTrackPublication @@ -7,9 +7,14 @@ import io.livekit.android.room.track.LocalTrackPublication
7 import io.livekit.android.room.track.RemoteTrackPublication 7 import io.livekit.android.room.track.RemoteTrackPublication
8 import io.livekit.android.room.track.Track 8 import io.livekit.android.room.track.Track
9 import io.livekit.android.room.track.TrackPublication 9 import io.livekit.android.room.track.TrackPublication
  10 +import io.livekit.android.util.flow
  11 +import io.livekit.android.util.flowDelegate
10 import kotlinx.coroutines.CoroutineDispatcher 12 import kotlinx.coroutines.CoroutineDispatcher
11 import kotlinx.coroutines.CoroutineScope 13 import kotlinx.coroutines.CoroutineScope
12 import kotlinx.coroutines.SupervisorJob 14 import kotlinx.coroutines.SupervisorJob
  15 +import kotlinx.coroutines.flow.SharingStarted
  16 +import kotlinx.coroutines.flow.map
  17 +import kotlinx.coroutines.flow.stateIn
13 import livekit.LivekitModels 18 import livekit.LivekitModels
14 import javax.inject.Named 19 import javax.inject.Named
15 20
@@ -24,33 +29,50 @@ open class Participant( @@ -24,33 +29,50 @@ open class Participant(
24 protected val eventBus = BroadcastEventBus<ParticipantEvent>() 29 protected val eventBus = BroadcastEventBus<ParticipantEvent>()
25 val events = eventBus.readOnly() 30 val events = eventBus.readOnly()
26 31
27 - var participantInfo: LivekitModels.ParticipantInfo? = null 32 + /**
  33 + * Changes can be observed by using [io.livekit.android.util.flow]
  34 + */
  35 + var participantInfo: LivekitModels.ParticipantInfo? by flowDelegate(null)
28 private set 36 private set
29 - var identity: String? = identity 37 +
  38 + /**
  39 + * Changes can be observed by using [io.livekit.android.util.flow]
  40 + */
  41 + var identity: String? by flowDelegate(identity)
30 internal set 42 internal set
31 - var audioLevel: Float = 0f 43 +
  44 + /**
  45 + * Changes can be observed by using [io.livekit.android.util.flow]
  46 + */
  47 + var audioLevel: Float by flowDelegate(0f)
32 internal set 48 internal set
33 - var isSpeaking: Boolean = false  
34 - internal set(v) {  
35 - val changed = v != field  
36 - field = v  
37 - if (changed) {  
38 - listener?.onSpeakingChanged(this)  
39 - internalListener?.onSpeakingChanged(this)  
40 - eventBus.postEvent(ParticipantEvent.SpeakingChanged(this, v), scope)  
41 - } 49 + /**
  50 + * Changes can be observed by using [io.livekit.android.util.flow]
  51 + */
  52 + var isSpeaking: Boolean by flowDelegate(false) { newValue, oldValue ->
  53 + if (newValue != oldValue) {
  54 + listener?.onSpeakingChanged(this)
  55 + internalListener?.onSpeakingChanged(this)
  56 + eventBus.postEvent(ParticipantEvent.SpeakingChanged(this, newValue), scope)
42 } 57 }
43 - var metadata: String? = null  
44 - internal set(v) {  
45 - val prevMetadata = field  
46 - field = v  
47 - if (prevMetadata != v) {  
48 - listener?.onMetadataChanged(this, prevMetadata)  
49 - internalListener?.onMetadataChanged(this, prevMetadata)  
50 - eventBus.postEvent(ParticipantEvent.MetadataChanged(this, prevMetadata), scope)  
51 - } 58 + }
  59 + internal set
  60 + /**
  61 + * Changes can be observed by using [io.livekit.android.util.flow]
  62 + */
  63 + var metadata: String? by flowDelegate(null) { newMetadata, oldMetadata ->
  64 + if (newMetadata != oldMetadata) {
  65 + listener?.onMetadataChanged(this, oldMetadata)
  66 + internalListener?.onMetadataChanged(this, oldMetadata)
  67 + eventBus.postEvent(ParticipantEvent.MetadataChanged(this, oldMetadata), scope)
52 } 68 }
53 - var connectionQuality: ConnectionQuality = ConnectionQuality.UNKNOWN 69 + }
  70 + internal set
  71 +
  72 + /**
  73 + * Changes can be observed by using [io.livekit.android.util.flow]
  74 + */
  75 + var connectionQuality by flowDelegate(ConnectionQuality.UNKNOWN)
54 internal set 76 internal set
55 77
56 /** 78 /**
@@ -68,11 +90,29 @@ open class Participant( @@ -68,11 +90,29 @@ open class Participant(
68 val hasInfo 90 val hasInfo
69 get() = participantInfo != null 91 get() = participantInfo != null
70 92
71 - var tracks = mutableMapOf<String, TrackPublication>()  
72 - var audioTracks = mutableMapOf<String, TrackPublication>()  
73 - private set  
74 - var videoTracks = mutableMapOf<String, TrackPublication>()  
75 - private set 93 + /**
  94 + * Changes can be observed by using [io.livekit.android.util.flow]
  95 + */
  96 + var tracks by flowDelegate(emptyMap<String, TrackPublication>())
  97 + protected set
  98 + /**
  99 + * Changes can be observed by using [io.livekit.android.util.flow]
  100 + */
  101 + val audioTracks by flowDelegate(
  102 + stateFlow = ::tracks.flow
  103 + .map { it.filterValues { publication -> publication.kind == Track.Kind.AUDIO } }
  104 + .stateIn(scope, SharingStarted.Eagerly, emptyMap())
  105 + )
  106 + /**
  107 + * Changes can be observed by using [io.livekit.android.util.flow]
  108 + */
  109 + val videoTracks by flowDelegate(
  110 + stateFlow = ::tracks.flow
  111 + .map {
  112 + it.filterValues { publication -> publication.kind == Track.Kind.VIDEO }
  113 + }
  114 + .stateIn(scope, SharingStarted.Eagerly, emptyMap())
  115 + )
76 116
77 /** 117 /**
78 * @suppress 118 * @suppress
@@ -80,12 +120,8 @@ open class Participant( @@ -80,12 +120,8 @@ open class Participant(
80 fun addTrackPublication(publication: TrackPublication) { 120 fun addTrackPublication(publication: TrackPublication) {
81 val track = publication.track 121 val track = publication.track
82 track?.sid = publication.sid 122 track?.sid = publication.sid
83 - tracks[publication.sid] = publication  
84 - when (publication.kind) {  
85 - Track.Kind.AUDIO -> audioTracks[publication.sid] = publication  
86 - Track.Kind.VIDEO -> videoTracks[publication.sid] = publication  
87 - else -> {  
88 - } 123 + tracks = tracks.toMutableMap().apply {
  124 + this[publication.sid] = publication
89 } 125 }
90 } 126 }
91 127
@@ -141,12 +141,8 @@ class RemoteParticipant( @@ -141,12 +141,8 @@ class RemoteParticipant(
141 } 141 }
142 142
143 fun unpublishTrack(trackSid: String, sendUnpublish: Boolean = false) { 143 fun unpublishTrack(trackSid: String, sendUnpublish: Boolean = false) {
144 - val publication = tracks.remove(trackSid) as? RemoteTrackPublication ?: return  
145 - when (publication.kind) {  
146 - Track.Kind.AUDIO -> audioTracks.remove(trackSid)  
147 - Track.Kind.VIDEO -> videoTracks.remove(trackSid)  
148 - else -> throw TrackException.InvalidTrackTypeException()  
149 - } 144 + val publication = tracks[trackSid] as? RemoteTrackPublication ?: return
  145 + tracks = tracks.toMutableMap().apply { remove(trackSid) }
150 146
151 val track = publication.track 147 val track = publication.track
152 if (track != null) { 148 if (track != null) {
  1 +/*
  2 +
  3 +Taken from: https://github.com/aartikov/Sesame/tree/master/sesame-property/src/main/kotlin/me/aartikov/sesame/property
  4 +
  5 +The MIT License (MIT)
  6 +
  7 +Copyright (c) 2021 Artur Artikov
  8 +
  9 +Permission is hereby granted, free of charge, to any person obtaining a copy
  10 +of this software and associated documentation files (the "Software"), to deal
  11 +in the Software without restriction, including without limitation the rights
  12 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  13 +copies of the Software, and to permit persons to whom the Software is
  14 +furnished to do so, subject to the following conditions:
  15 +
  16 +The above copyright notice and this permission notice shall be included in all
  17 +copies or substantial portions of the Software.
  18 +
  19 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  20 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  21 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  22 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  23 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  24 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  25 +SOFTWARE.
  26 + */
  27 +
  28 +package io.livekit.android.util
  29 +
  30 +import io.livekit.android.util.DelegateAccess.delegate
  31 +import io.livekit.android.util.DelegateAccess.delegateRequested
  32 +import kotlinx.coroutines.flow.MutableStateFlow
  33 +import kotlinx.coroutines.flow.StateFlow
  34 +import kotlin.reflect.KProperty
  35 +import kotlin.reflect.KProperty0
  36 +
  37 +
  38 +/**
  39 + * A little circuitous but the way this works is:
  40 + * 1. [delegateRequested] set to true indicates that [delegate] should be filled.
  41 + * 2. Upon [getValue], [delegate] is set.
  42 + * 3. [KProperty0.delegate] returns the value previously set to [delegate]
  43 + */
  44 +internal object DelegateAccess {
  45 + internal val delegate = ThreadLocal<Any?>()
  46 + internal val delegateRequested = ThreadLocal<Boolean>().apply { set(false) }
  47 +}
  48 +
  49 +internal val <T> KProperty0<T>.delegate: Any?
  50 + get() {
  51 + try {
  52 + DelegateAccess.delegateRequested.set(true)
  53 + this.get()
  54 + return DelegateAccess.delegate.get()
  55 + } finally {
  56 + DelegateAccess.delegate.set(null)
  57 + DelegateAccess.delegateRequested.set(false)
  58 + }
  59 + }
  60 +
  61 +@Suppress("UNCHECKED_CAST")
  62 +val <T> KProperty0<T>.flow: StateFlow<T>
  63 + get() = delegate as StateFlow<T>
  64 +
  65 +class MutableStateFlowDelegate<T>
  66 +internal constructor(
  67 + private val flow: MutableStateFlow<T>,
  68 + private val onSetValue: ((newValue: T, oldValue: T) -> Unit)? = null
  69 +) : MutableStateFlow<T> by flow {
  70 +
  71 + operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
  72 + if (DelegateAccess.delegateRequested.get() == true) {
  73 + DelegateAccess.delegate.set(this)
  74 + }
  75 + return flow.value
  76 + }
  77 +
  78 + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
  79 + val oldValue = flow.value
  80 + flow.value = value
  81 + onSetValue?.invoke(value, oldValue)
  82 + }
  83 +}
  84 +
  85 +class StateFlowDelegate<T>
  86 +internal constructor(
  87 + private val flow: StateFlow<T>
  88 +) : StateFlow<T> by flow {
  89 +
  90 + operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
  91 + if (DelegateAccess.delegateRequested.get() == true) {
  92 + DelegateAccess.delegate.set(this)
  93 + }
  94 + return flow.value
  95 + }
  96 +}
  97 +
  98 +internal fun <T> flowDelegate(
  99 + initialValue: T,
  100 + onSetValue: ((newValue: T, oldValue: T) -> Unit)? = null
  101 +): MutableStateFlowDelegate<T> {
  102 + return MutableStateFlowDelegate(MutableStateFlow(initialValue), onSetValue)
  103 +}
  104 +
  105 +internal fun <T> flowDelegate(
  106 + stateFlow: StateFlow<T>
  107 +): StateFlowDelegate<T> {
  108 + return StateFlowDelegate(stateFlow)
  109 +}
1 package io.livekit.android.coroutines 1 package io.livekit.android.coroutines
2 2
3 -import io.livekit.android.events.EventListenable  
4 import kotlinx.coroutines.CancellationException 3 import kotlinx.coroutines.CancellationException
5 import kotlinx.coroutines.cancel 4 import kotlinx.coroutines.cancel
6 import kotlinx.coroutines.coroutineScope 5 import kotlinx.coroutines.coroutineScope
@@ -8,10 +7,10 @@ import kotlinx.coroutines.flow.* @@ -8,10 +7,10 @@ import kotlinx.coroutines.flow.*
8 import kotlinx.coroutines.launch 7 import kotlinx.coroutines.launch
9 8
10 /** 9 /**
11 - * Collect events until signal is given. 10 + * Collect all items until signal is given.
12 */ 11 */
13 -suspend fun <T> EventListenable<T>.collectEvents(signal: Flow<Unit?>): List<T> {  
14 - return events.takeUntilSignal(signal) 12 +suspend fun <T> Flow<T>.toListUntilSignal(signal: Flow<Unit?>): List<T> {
  13 + return takeUntilSignal(signal)
15 .fold(emptyList()) { list, event -> 14 .fold(emptyList()) { list, event ->
16 list.plus(event) 15 list.plus(event)
17 } 16 }
1 package io.livekit.android.events 1 package io.livekit.android.events
2 2
3 -import io.livekit.android.coroutines.collectEvents  
4 import kotlinx.coroutines.CoroutineScope 3 import kotlinx.coroutines.CoroutineScope
5 -import kotlinx.coroutines.ExperimentalCoroutinesApi  
6 -import kotlinx.coroutines.async  
7 -import kotlinx.coroutines.flow.MutableStateFlow  
8 -import kotlinx.coroutines.test.runBlockingTest  
9 4
10 class EventCollector<T : Event>( 5 class EventCollector<T : Event>(
11 - private val eventListenable: EventListenable<T>, 6 + eventListenable: EventListenable<T>,
12 coroutineScope: CoroutineScope 7 coroutineScope: CoroutineScope
13 -) {  
14 - val signal = MutableStateFlow<Unit?>(null)  
15 - val collectEventsDeferred = coroutineScope.async {  
16 - eventListenable.collectEvents(signal)  
17 - }  
18 -  
19 - /**  
20 - * Stop collecting events. returns the events collected.  
21 - */  
22 - @OptIn(ExperimentalCoroutinesApi::class)  
23 - fun stopCollectingEvents(): List<T> {  
24 - signal.compareAndSet(null, Unit)  
25 - var events: List<T> = emptyList()  
26 - runBlockingTest {  
27 - events = collectEventsDeferred.await()  
28 - }  
29 - return events  
30 - }  
31 -  
32 -}  
  8 +) : FlowCollector<T>(eventListenable.events, coroutineScope)
  1 +package io.livekit.android.events
  2 +
  3 +import io.livekit.android.coroutines.toListUntilSignal
  4 +import kotlinx.coroutines.CoroutineScope
  5 +import kotlinx.coroutines.ExperimentalCoroutinesApi
  6 +import kotlinx.coroutines.async
  7 +import kotlinx.coroutines.flow.Flow
  8 +import kotlinx.coroutines.flow.MutableStateFlow
  9 +import kotlinx.coroutines.test.runBlockingTest
  10 +
  11 +open class FlowCollector<T>(
  12 + private val flow: Flow<T>,
  13 + coroutineScope: CoroutineScope
  14 +) {
  15 + val signal = MutableStateFlow<Unit?>(null)
  16 + val collectEventsDeferred = coroutineScope.async {
  17 + flow.toListUntilSignal(signal)
  18 + }
  19 +
  20 + /**
  21 + * Stop collecting events. returns the events collected.
  22 + */
  23 + @OptIn(ExperimentalCoroutinesApi::class)
  24 + fun stopCollecting(): List<T> {
  25 + signal.compareAndSet(null, Unit)
  26 + var events: List<T> = emptyList()
  27 + runBlockingTest {
  28 + events = collectEventsDeferred.await()
  29 + }
  30 + return events
  31 + }
  32 +
  33 +}
@@ -4,10 +4,12 @@ import android.content.Context @@ -4,10 +4,12 @@ import android.content.Context
4 import androidx.test.core.app.ApplicationProvider 4 import androidx.test.core.app.ApplicationProvider
5 import io.livekit.android.coroutines.TestCoroutineRule 5 import io.livekit.android.coroutines.TestCoroutineRule
6 import io.livekit.android.events.EventCollector 6 import io.livekit.android.events.EventCollector
  7 +import io.livekit.android.events.FlowCollector
7 import io.livekit.android.events.RoomEvent 8 import io.livekit.android.events.RoomEvent
8 import io.livekit.android.mock.MockWebsocketFactory 9 import io.livekit.android.mock.MockWebsocketFactory
9 import io.livekit.android.mock.dagger.DaggerTestLiveKitComponent 10 import io.livekit.android.mock.dagger.DaggerTestLiveKitComponent
10 import io.livekit.android.room.participant.ConnectionQuality 11 import io.livekit.android.room.participant.ConnectionQuality
  12 +import io.livekit.android.util.flow
11 import io.livekit.android.util.toOkioByteString 13 import io.livekit.android.util.toOkioByteString
12 import kotlinx.coroutines.ExperimentalCoroutinesApi 14 import kotlinx.coroutines.ExperimentalCoroutinesApi
13 import kotlinx.coroutines.launch 15 import kotlinx.coroutines.launch
@@ -72,7 +74,7 @@ class RoomMockE2ETest { @@ -72,7 +74,7 @@ class RoomMockE2ETest {
72 connect() 74 connect()
73 val eventCollector = EventCollector(room.events, coroutineRule.scope) 75 val eventCollector = EventCollector(room.events, coroutineRule.scope)
74 wsFactory.listener.onMessage(wsFactory.ws, SignalClientTest.ROOM_UPDATE.toOkioByteString()) 76 wsFactory.listener.onMessage(wsFactory.ws, SignalClientTest.ROOM_UPDATE.toOkioByteString())
75 - val events = eventCollector.stopCollectingEvents() 77 + val events = eventCollector.stopCollecting()
76 78
77 Assert.assertEquals( 79 Assert.assertEquals(
78 SignalClientTest.ROOM_UPDATE.roomUpdate.room.metadata, 80 SignalClientTest.ROOM_UPDATE.roomUpdate.room.metadata,
@@ -90,7 +92,7 @@ class RoomMockE2ETest { @@ -90,7 +92,7 @@ class RoomMockE2ETest {
90 wsFactory.ws, 92 wsFactory.ws,
91 SignalClientTest.CONNECTION_QUALITY.toOkioByteString() 93 SignalClientTest.CONNECTION_QUALITY.toOkioByteString()
92 ) 94 )
93 - val events = eventCollector.stopCollectingEvents() 95 + val events = eventCollector.stopCollecting()
94 96
95 Assert.assertEquals(ConnectionQuality.EXCELLENT, room.localParticipant.connectionQuality) 97 Assert.assertEquals(ConnectionQuality.EXCELLENT, room.localParticipant.connectionQuality)
96 Assert.assertEquals(1, events.size) 98 Assert.assertEquals(1, events.size)
@@ -106,7 +108,7 @@ class RoomMockE2ETest { @@ -106,7 +108,7 @@ class RoomMockE2ETest {
106 wsFactory.ws, 108 wsFactory.ws,
107 SignalClientTest.PARTICIPANT_JOIN.toOkioByteString() 109 SignalClientTest.PARTICIPANT_JOIN.toOkioByteString()
108 ) 110 )
109 - val events = eventCollector.stopCollectingEvents() 111 + val events = eventCollector.stopCollecting()
110 112
111 Assert.assertEquals(1, events.size) 113 Assert.assertEquals(1, events.size)
112 Assert.assertEquals(true, events[0] is RoomEvent.ParticipantConnected) 114 Assert.assertEquals(true, events[0] is RoomEvent.ParticipantConnected)
@@ -125,7 +127,7 @@ class RoomMockE2ETest { @@ -125,7 +127,7 @@ class RoomMockE2ETest {
125 wsFactory.ws, 127 wsFactory.ws,
126 SignalClientTest.PARTICIPANT_DISCONNECT.toOkioByteString() 128 SignalClientTest.PARTICIPANT_DISCONNECT.toOkioByteString()
127 ) 129 )
128 - val events = eventCollector.stopCollectingEvents() 130 + val events = eventCollector.stopCollecting()
129 131
130 Assert.assertEquals(1, events.size) 132 Assert.assertEquals(1, events.size)
131 Assert.assertEquals(true, events[0] is RoomEvent.ParticipantDisconnected) 133 Assert.assertEquals(true, events[0] is RoomEvent.ParticipantDisconnected)
@@ -140,7 +142,7 @@ class RoomMockE2ETest { @@ -140,7 +142,7 @@ class RoomMockE2ETest {
140 wsFactory.ws, 142 wsFactory.ws,
141 SignalClientTest.ACTIVE_SPEAKER_UPDATE.toOkioByteString() 143 SignalClientTest.ACTIVE_SPEAKER_UPDATE.toOkioByteString()
142 ) 144 )
143 - val events = eventCollector.stopCollectingEvents() 145 + val events = eventCollector.stopCollecting()
144 146
145 Assert.assertEquals(1, events.size) 147 Assert.assertEquals(1, events.size)
146 Assert.assertEquals(true, events[0] is RoomEvent.ActiveSpeakersChanged) 148 Assert.assertEquals(true, events[0] is RoomEvent.ActiveSpeakersChanged)
@@ -160,7 +162,7 @@ class RoomMockE2ETest { @@ -160,7 +162,7 @@ class RoomMockE2ETest {
160 wsFactory.ws, 162 wsFactory.ws,
161 SignalClientTest.PARTICIPANT_METADATA_CHANGED.toOkioByteString() 163 SignalClientTest.PARTICIPANT_METADATA_CHANGED.toOkioByteString()
162 ) 164 )
163 - val events = eventCollector.stopCollectingEvents() 165 + val events = eventCollector.stopCollecting()
164 166
165 Assert.assertEquals(1, events.size) 167 Assert.assertEquals(1, events.size)
166 Assert.assertEquals(true, events[0] is RoomEvent.ParticipantMetadataChanged) 168 Assert.assertEquals(true, events[0] is RoomEvent.ParticipantMetadataChanged)
@@ -174,7 +176,7 @@ class RoomMockE2ETest { @@ -174,7 +176,7 @@ class RoomMockE2ETest {
174 wsFactory.ws, 176 wsFactory.ws,
175 SignalClientTest.LEAVE.toOkioByteString() 177 SignalClientTest.LEAVE.toOkioByteString()
176 ) 178 )
177 - val events = eventCollector.stopCollectingEvents() 179 + val events = eventCollector.stopCollecting()
178 180
179 Assert.assertEquals(1, events.size) 181 Assert.assertEquals(1, events.size)
180 Assert.assertEquals(true, events[0] is RoomEvent.Disconnected) 182 Assert.assertEquals(true, events[0] is RoomEvent.Disconnected)
@@ -4,16 +4,11 @@ import android.content.Context @@ -4,16 +4,11 @@ import android.content.Context
4 import android.net.Network 4 import android.net.Network
5 import androidx.test.core.app.ApplicationProvider 5 import androidx.test.core.app.ApplicationProvider
6 import io.livekit.android.coroutines.TestCoroutineRule 6 import io.livekit.android.coroutines.TestCoroutineRule
7 -import io.livekit.android.coroutines.collectEvents  
8 -import io.livekit.android.events.Event  
9 import io.livekit.android.events.EventCollector 7 import io.livekit.android.events.EventCollector
10 -import io.livekit.android.events.EventListenable  
11 import io.livekit.android.events.RoomEvent 8 import io.livekit.android.events.RoomEvent
12 import io.livekit.android.mock.MockEglBase 9 import io.livekit.android.mock.MockEglBase
13 -import io.livekit.android.mock.TestData  
14 import io.livekit.android.room.participant.LocalParticipant 10 import io.livekit.android.room.participant.LocalParticipant
15 import kotlinx.coroutines.* 11 import kotlinx.coroutines.*
16 -import kotlinx.coroutines.flow.MutableStateFlow  
17 import kotlinx.coroutines.test.runBlockingTest 12 import kotlinx.coroutines.test.runBlockingTest
18 import livekit.LivekitModels 13 import livekit.LivekitModels
19 import org.junit.Assert 14 import org.junit.Assert
@@ -104,7 +99,7 @@ class RoomTest { @@ -104,7 +99,7 @@ class RoomTest {
104 room.onLost(network) 99 room.onLost(network)
105 room.onAvailable(network) 100 room.onAvailable(network)
106 101
107 - val events = eventCollector.stopCollectingEvents() 102 + val events = eventCollector.stopCollecting()
108 103
109 Assert.assertEquals(1, events.size) 104 Assert.assertEquals(1, events.size)
110 Assert.assertEquals(true, events[0] is RoomEvent.Reconnecting) 105 Assert.assertEquals(true, events[0] is RoomEvent.Reconnecting)
@@ -116,7 +111,7 @@ class RoomTest { @@ -116,7 +111,7 @@ class RoomTest {
116 111
117 val eventCollector = EventCollector(room.events, coroutineRule.scope) 112 val eventCollector = EventCollector(room.events, coroutineRule.scope)
118 room.onDisconnect("") 113 room.onDisconnect("")
119 - val events = eventCollector.stopCollectingEvents() 114 + val events = eventCollector.stopCollecting()
120 115
121 Assert.assertEquals(1, events.size) 116 Assert.assertEquals(1, events.size)
122 Assert.assertEquals(true, events[0] is RoomEvent.Disconnected) 117 Assert.assertEquals(true, events[0] is RoomEvent.Disconnected)
@@ -74,7 +74,7 @@ class ParticipantTest { @@ -74,7 +74,7 @@ class ParticipantTest {
74 val metadata = "metadata" 74 val metadata = "metadata"
75 participant.metadata = metadata 75 participant.metadata = metadata
76 76
77 - val events = eventCollector.stopCollectingEvents() 77 + val events = eventCollector.stopCollecting()
78 78
79 assertEquals(1, events.size) 79 assertEquals(1, events.size)
80 assertEquals(true, events[0] is ParticipantEvent.MetadataChanged) 80 assertEquals(true, events[0] is ParticipantEvent.MetadataChanged)
@@ -91,7 +91,7 @@ class ParticipantTest { @@ -91,7 +91,7 @@ class ParticipantTest {
91 val newIsSpeaking = !participant.isSpeaking 91 val newIsSpeaking = !participant.isSpeaking
92 participant.isSpeaking = newIsSpeaking 92 participant.isSpeaking = newIsSpeaking
93 93
94 - val events = eventCollector.stopCollectingEvents() 94 + val events = eventCollector.stopCollecting()
95 95
96 assertEquals(1, events.size) 96 assertEquals(1, events.size)
97 assertEquals(true, events[0] is ParticipantEvent.SpeakingChanged) 97 assertEquals(true, events[0] is ParticipantEvent.SpeakingChanged)
@@ -10,8 +10,8 @@ import androidx.activity.result.contract.ActivityResultContracts @@ -10,8 +10,8 @@ import androidx.activity.result.contract.ActivityResultContracts
10 import androidx.appcompat.app.AppCompatActivity 10 import androidx.appcompat.app.AppCompatActivity
11 import androidx.compose.foundation.background 11 import androidx.compose.foundation.background
12 import androidx.compose.foundation.layout.* 12 import androidx.compose.foundation.layout.*
  13 +import androidx.compose.foundation.lazy.LazyRow
13 import androidx.compose.material.* 14 import androidx.compose.material.*
14 -import androidx.compose.material.TabRowDefaults.tabIndicatorOffset  
15 import androidx.compose.runtime.* 15 import androidx.compose.runtime.*
16 import androidx.compose.runtime.livedata.observeAsState 16 import androidx.compose.runtime.livedata.observeAsState
17 import androidx.compose.ui.Alignment 17 import androidx.compose.ui.Alignment
@@ -20,18 +20,14 @@ import androidx.compose.ui.graphics.Color @@ -20,18 +20,14 @@ import androidx.compose.ui.graphics.Color
20 import androidx.compose.ui.res.painterResource 20 import androidx.compose.ui.res.painterResource
21 import androidx.compose.ui.tooling.preview.Preview 21 import androidx.compose.ui.tooling.preview.Preview
22 import androidx.compose.ui.unit.dp 22 import androidx.compose.ui.unit.dp
23 -import androidx.compose.ui.viewinterop.AndroidView  
24 import androidx.constraintlayout.compose.ConstraintLayout 23 import androidx.constraintlayout.compose.ConstraintLayout
25 import androidx.constraintlayout.compose.Dimension 24 import androidx.constraintlayout.compose.Dimension
26 import com.github.ajalt.timberkt.Timber 25 import com.github.ajalt.timberkt.Timber
27 import com.google.accompanist.pager.ExperimentalPagerApi 26 import com.google.accompanist.pager.ExperimentalPagerApi
28 -import com.google.accompanist.pager.HorizontalPager  
29 -import com.google.accompanist.pager.rememberPagerState  
30 import io.livekit.android.composesample.ui.theme.AppTheme 27 import io.livekit.android.composesample.ui.theme.AppTheme
31 -import io.livekit.android.renderer.TextureViewRenderer  
32 import io.livekit.android.room.Room 28 import io.livekit.android.room.Room
33 -import io.livekit.android.room.participant.RemoteParticipant  
34 -import io.livekit.android.room.track.LocalVideoTrack 29 +import io.livekit.android.room.participant.Participant
  30 +import kotlinx.coroutines.Dispatchers
35 import kotlinx.parcelize.Parcelize 31 import kotlinx.parcelize.Parcelize
36 32
37 @OptIn(ExperimentalPagerApi::class) 33 @OptIn(ExperimentalPagerApi::class)
@@ -60,6 +56,7 @@ class CallActivity : AppCompatActivity() { @@ -60,6 +56,7 @@ class CallActivity : AppCompatActivity() {
60 } 56 }
61 57
62 58
  59 + @OptIn(ExperimentalMaterialApi::class)
63 override fun onCreate(savedInstanceState: Bundle?) { 60 override fun onCreate(savedInstanceState: Bundle?) {
64 super.onCreate(savedInstanceState) 61 super.onCreate(savedInstanceState)
65 62
@@ -83,22 +80,24 @@ class CallActivity : AppCompatActivity() { @@ -83,22 +80,24 @@ class CallActivity : AppCompatActivity() {
83 } 80 }
84 81
85 setContent { 82 setContent {
86 - AppTheme(darkTheme = true) {  
87 - val room by viewModel.room.observeAsState()  
88 - val participants by viewModel.remoteParticipants.observeAsState(emptyList())  
89 - val micEnabled by viewModel.micEnabled.observeAsState(true)  
90 - val videoEnabled by viewModel.cameraEnabled.observeAsState(true)  
91 - val flipButtonEnabled by viewModel.flipButtonVideoEnabled.observeAsState(true)  
92 - val screencastEnabled by viewModel.screencastEnabled.observeAsState(false)  
93 - Content(  
94 - room,  
95 - participants,  
96 - micEnabled,  
97 - videoEnabled,  
98 - flipButtonEnabled,  
99 - screencastEnabled,  
100 - )  
101 - } 83 + val room by viewModel.room.collectAsState()
  84 + val participants by viewModel.participants.collectAsState(initial = emptyList())
  85 + val primarySpeaker by viewModel.primarySpeaker.collectAsState()
  86 + val activeSpeakers by viewModel.activeSpeakers.collectAsState(initial = emptyList())
  87 + val micEnabled by viewModel.micEnabled.observeAsState(true)
  88 + val videoEnabled by viewModel.cameraEnabled.observeAsState(true)
  89 + val flipButtonEnabled by viewModel.flipButtonVideoEnabled.observeAsState(true)
  90 + val screencastEnabled by viewModel.screencastEnabled.observeAsState(false)
  91 + Content(
  92 + room,
  93 + participants,
  94 + primarySpeaker,
  95 + activeSpeakers,
  96 + micEnabled,
  97 + videoEnabled,
  98 + flipButtonEnabled,
  99 + screencastEnabled,
  100 + )
102 } 101 }
103 } 102 }
104 103
@@ -108,164 +107,133 @@ class CallActivity : AppCompatActivity() { @@ -108,164 +107,133 @@ class CallActivity : AppCompatActivity() {
108 screenCaptureIntentLauncher.launch(mediaProjectionManager.createScreenCaptureIntent()) 107 screenCaptureIntentLauncher.launch(mediaProjectionManager.createScreenCaptureIntent())
109 } 108 }
110 109
  110 + val previewParticipant = Participant("asdf", "asdf", Dispatchers.Main)
  111 +
  112 + @ExperimentalMaterialApi
111 @Preview(showBackground = true, showSystemUi = true) 113 @Preview(showBackground = true, showSystemUi = true)
112 @Composable 114 @Composable
113 fun Content( 115 fun Content(
114 room: Room? = null, 116 room: Room? = null,
115 - participants: List<RemoteParticipant> = emptyList(), 117 + participants: List<Participant> = listOf(previewParticipant),
  118 + primarySpeaker: Participant? = previewParticipant,
  119 + activeSpeakers: List<Participant> = listOf(previewParticipant),
116 micEnabled: Boolean = true, 120 micEnabled: Boolean = true,
117 videoEnabled: Boolean = true, 121 videoEnabled: Boolean = true,
118 flipButtonEnabled: Boolean = true, 122 flipButtonEnabled: Boolean = true,
119 screencastEnabled: Boolean = false, 123 screencastEnabled: Boolean = false,
120 ) { 124 ) {
121 - ConstraintLayout(  
122 - modifier = Modifier  
123 - .fillMaxSize()  
124 - .background(MaterialTheme.colors.background)  
125 - ) {  
126 - val (tabRow, pager, buttonBar, cameraView) = createRefs() 125 + AppTheme(darkTheme = true) {
  126 + ConstraintLayout(
  127 + modifier = Modifier
  128 + .fillMaxSize()
  129 + .background(MaterialTheme.colors.background)
  130 + ) {
  131 + val (speakerView, audienceRow, buttonBar) = createRefs()
127 132
128 - if (participants.isNotEmpty()) {  
129 - val pagerState = rememberPagerState()  
130 - ScrollableTabRow(  
131 - // Our selected tab is our current page  
132 - selectedTabIndex = pagerState.currentPage,  
133 - // Override the indicator, using the provided pagerTabIndicatorOffset modifier  
134 - indicator = { tabPositions ->  
135 - TabRowDefaults.Indicator(  
136 - modifier = Modifier  
137 - .height(1.dp)  
138 - .tabIndicatorOffset(tabPositions[pagerState.currentPage]),  
139 - height = 1.dp,  
140 - color = Color.Gray  
141 - )  
142 - },  
143 - modifier = Modifier  
144 - .background(Color.DarkGray)  
145 - .constrainAs(tabRow) {  
146 - top.linkTo(parent.top)  
147 - width = Dimension.fillToConstraints  
148 - }  
149 - ) {  
150 - // Add tabs for all of our pages  
151 - participants.forEachIndexed { index, participant ->  
152 - Tab(  
153 - text = { Text(participant.identity ?: "Unnamed $index") },  
154 - selected = pagerState.currentPage == index,  
155 - onClick = { /* TODO*/ }, 133 + Surface(modifier = Modifier.constrainAs(speakerView) {
  134 + top.linkTo(parent.top)
  135 + start.linkTo(parent.start)
  136 + end.linkTo(parent.end)
  137 + bottom.linkTo(audienceRow.top)
  138 + width = Dimension.fillToConstraints
  139 + height = Dimension.fillToConstraints
  140 + }) {
  141 + if (room != null && primarySpeaker != null) {
  142 + ParticipantItem(
  143 + room = room,
  144 + participant = primarySpeaker,
  145 + isSpeaking = activeSpeakers.contains(primarySpeaker)
156 ) 146 )
157 } 147 }
158 } 148 }
159 - HorizontalPager(  
160 - count = participants.size,  
161 - state = pagerState, 149 + LazyRow(
162 modifier = Modifier 150 modifier = Modifier
163 - .constrainAs(pager) {  
164 - top.linkTo(tabRow.bottom) 151 + .constrainAs(audienceRow) {
  152 + top.linkTo(speakerView.bottom)
165 bottom.linkTo(buttonBar.top) 153 bottom.linkTo(buttonBar.top)
166 start.linkTo(parent.start) 154 start.linkTo(parent.start)
167 end.linkTo(parent.end) 155 end.linkTo(parent.end)
168 width = Dimension.fillToConstraints 156 width = Dimension.fillToConstraints
169 - height = Dimension.fillToConstraints 157 + height = Dimension.value(120.dp)
170 } 158 }
171 - ) { index -> 159 + ) {
172 if (room != null) { 160 if (room != null) {
173 - ParticipantItem(room = room, participant = participants[index]) 161 + items(
  162 + count = participants.size,
  163 + key = { index -> participants[index].sid }
  164 + ) { index ->
  165 + ParticipantItem(
  166 + room = room,
  167 + participant = participants[index],
  168 + isSpeaking = activeSpeakers.contains(participants[index]),
  169 + modifier = Modifier
  170 + .fillMaxHeight()
  171 + .aspectRatio(1.0f, true)
  172 + )
  173 + }
174 } 174 }
175 } 175 }
176 - }  
177 176
178 - if (room != null) {  
179 - var videoNeedsSetup by remember { mutableStateOf(true) }  
180 - AndroidView(  
181 - factory = { context ->  
182 - TextureViewRenderer(context).apply {  
183 - room.initVideoRenderer(this)  
184 - }  
185 - }, 177 + Row(
186 modifier = Modifier 178 modifier = Modifier
187 - .width(200.dp)  
188 - .height(200.dp)  
189 - .padding(bottom = 10.dp, end = 10.dp)  
190 - .background(Color.Black)  
191 - .constrainAs(cameraView) {  
192 - bottom.linkTo(buttonBar.top)  
193 - end.linkTo(parent.end) 179 + .padding(top = 10.dp, bottom = 20.dp)
  180 + .fillMaxWidth()
  181 + .constrainAs(buttonBar) {
  182 + bottom.linkTo(parent.bottom)
  183 + width = Dimension.fillToConstraints
  184 + height = Dimension.wrapContent
194 }, 185 },
195 - update = { view ->  
196 - val videoTrack = room.localParticipant.videoTracks.values  
197 - .firstOrNull()  
198 - ?.track as? LocalVideoTrack  
199 -  
200 - if (videoNeedsSetup) {  
201 - videoTrack?.addRenderer(view)  
202 - videoNeedsSetup = false  
203 - }  
204 - }  
205 - )  
206 - }  
207 - Row(  
208 - modifier = Modifier  
209 - .padding(top = 10.dp, bottom = 20.dp)  
210 - .fillMaxWidth()  
211 - .constrainAs(buttonBar) {  
212 - bottom.linkTo(parent.bottom)  
213 - width = Dimension.fillToConstraints  
214 - },  
215 - horizontalArrangement = Arrangement.SpaceEvenly,  
216 - verticalAlignment = Alignment.Bottom,  
217 - ) {  
218 - FloatingActionButton(  
219 - onClick = { viewModel.setMicEnabled(!micEnabled) },  
220 - backgroundColor = Color.DarkGray,  
221 - ) {  
222 - val resource =  
223 - if (micEnabled) R.drawable.outline_mic_24 else R.drawable.outline_mic_off_24  
224 - Icon(  
225 - painterResource(id = resource),  
226 - contentDescription = "Mic",  
227 - tint = Color.White,  
228 - )  
229 - }  
230 - FloatingActionButton(  
231 - onClick = { viewModel.setCameraEnabled(!videoEnabled) },  
232 - backgroundColor = Color.DarkGray,  
233 - ) {  
234 - val resource =  
235 - if (videoEnabled) R.drawable.outline_videocam_24 else R.drawable.outline_videocam_off_24  
236 - Icon(  
237 - painterResource(id = resource),  
238 - contentDescription = "Video",  
239 - tint = Color.White,  
240 - )  
241 - }  
242 - FloatingActionButton(  
243 - onClick = { viewModel.flipVideo() },  
244 - backgroundColor = Color.DarkGray,  
245 - ) {  
246 - Icon(  
247 - painterResource(id = R.drawable.outline_flip_camera_android_24),  
248 - contentDescription = "Flip Camera",  
249 - tint = Color.White,  
250 - )  
251 - }  
252 - FloatingActionButton(  
253 - onClick = {  
254 - if (!screencastEnabled) {  
255 - requestMediaProjection()  
256 - } else {  
257 - viewModel.stopScreenCapture()  
258 - }  
259 - },  
260 - backgroundColor = Color.DarkGray, 186 + horizontalArrangement = Arrangement.SpaceEvenly,
  187 + verticalAlignment = Alignment.Bottom,
261 ) { 188 ) {
262 - val resource =  
263 - if (screencastEnabled) R.drawable.baseline_cast_connected_24 else R.drawable.baseline_cast_24  
264 - Icon(  
265 - painterResource(id = resource),  
266 - contentDescription = "Flip Camera",  
267 - tint = Color.White,  
268 - ) 189 + Surface(
  190 + onClick = { viewModel.setMicEnabled(!micEnabled) },
  191 + ) {
  192 + val resource =
  193 + if (micEnabled) R.drawable.outline_mic_24 else R.drawable.outline_mic_off_24
  194 + Icon(
  195 + painterResource(id = resource),
  196 + contentDescription = "Mic",
  197 + tint = Color.White,
  198 + )
  199 + }
  200 + Surface(
  201 + onClick = { viewModel.setCameraEnabled(!videoEnabled) },
  202 + ) {
  203 + val resource =
  204 + if (videoEnabled) R.drawable.outline_videocam_24 else R.drawable.outline_videocam_off_24
  205 + Icon(
  206 + painterResource(id = resource),
  207 + contentDescription = "Video",
  208 + tint = Color.White,
  209 + )
  210 + }
  211 + Surface(
  212 + onClick = { viewModel.flipVideo() },
  213 + ) {
  214 + Icon(
  215 + painterResource(id = R.drawable.outline_flip_camera_android_24),
  216 + contentDescription = "Flip Camera",
  217 + tint = Color.White,
  218 + )
  219 + }
  220 + Surface(
  221 + onClick = {
  222 + if (!screencastEnabled) {
  223 + requestMediaProjection()
  224 + } else {
  225 + viewModel.stopScreenCapture()
  226 + }
  227 + },
  228 + ) {
  229 + val resource =
  230 + if (screencastEnabled) R.drawable.baseline_cast_connected_24 else R.drawable.baseline_cast_24
  231 + Icon(
  232 + painterResource(id = resource),
  233 + contentDescription = "Flip Camera",
  234 + tint = Color.White,
  235 + )
  236 + }
269 } 237 }
270 } 238 }
271 } 239 }
@@ -2,10 +2,7 @@ package io.livekit.android.composesample @@ -2,10 +2,7 @@ package io.livekit.android.composesample
2 2
3 import android.app.Application 3 import android.app.Application
4 import android.content.Intent 4 import android.content.Intent
5 -import androidx.lifecycle.AndroidViewModel  
6 -import androidx.lifecycle.LiveData  
7 -import androidx.lifecycle.MutableLiveData  
8 -import androidx.lifecycle.viewModelScope 5 +import androidx.lifecycle.*
9 import com.github.ajalt.timberkt.Timber 6 import com.github.ajalt.timberkt.Timber
10 import io.livekit.android.ConnectOptions 7 import io.livekit.android.ConnectOptions
11 import io.livekit.android.LiveKit 8 import io.livekit.android.LiveKit
@@ -14,17 +11,44 @@ import io.livekit.android.room.RoomListener @@ -14,17 +11,44 @@ import io.livekit.android.room.RoomListener
14 import io.livekit.android.room.participant.Participant 11 import io.livekit.android.room.participant.Participant
15 import io.livekit.android.room.participant.RemoteParticipant 12 import io.livekit.android.room.participant.RemoteParticipant
16 import io.livekit.android.room.track.* 13 import io.livekit.android.room.track.*
  14 +import io.livekit.android.util.flow
  15 +import kotlinx.coroutines.ExperimentalCoroutinesApi
  16 +import kotlinx.coroutines.flow.*
17 import kotlinx.coroutines.launch 17 import kotlinx.coroutines.launch
18 18
  19 +@OptIn(ExperimentalCoroutinesApi::class)
19 class CallViewModel( 20 class CallViewModel(
20 val url: String, 21 val url: String,
21 val token: String, 22 val token: String,
22 application: Application 23 application: Application
23 ) : AndroidViewModel(application), RoomListener { 24 ) : AndroidViewModel(application), RoomListener {
24 - private val mutableRoom = MutableLiveData<Room>()  
25 - val room: LiveData<Room> = mutableRoom  
26 - private val mutableRemoteParticipants = MutableLiveData<List<RemoteParticipant>>()  
27 - val remoteParticipants: LiveData<List<RemoteParticipant>> = mutableRemoteParticipants 25 + private val mutableRoom = MutableStateFlow<Room?>(null)
  26 + val room: MutableStateFlow<Room?> = mutableRoom
  27 + val participants = mutableRoom.flatMapLatest { room ->
  28 + if (room != null) {
  29 + room::remoteParticipants.flow
  30 + .map { remoteParticipants ->
  31 + listOf<Participant>(room.localParticipant) +
  32 + remoteParticipants
  33 + .keys
  34 + .sortedBy { it }
  35 + .mapNotNull { remoteParticipants[it] }
  36 + }
  37 + } else {
  38 + emptyFlow()
  39 + }
  40 + }
  41 +
  42 + private val mutablePrimarySpeaker = MutableStateFlow<Participant?>(null)
  43 + val primarySpeaker: StateFlow<Participant?> = mutablePrimarySpeaker
  44 +
  45 + val activeSpeakers = mutableRoom.flatMapLatest { room ->
  46 + if (room != null) {
  47 + room::activeSpeakers.flow
  48 + } else {
  49 + emptyFlow()
  50 + }
  51 + }
28 52
29 private var localScreencastTrack: LocalScreencastVideoTrack? = null 53 private var localScreencastTrack: LocalScreencastVideoTrack? = null
30 54
@@ -59,9 +83,9 @@ class CallViewModel( @@ -59,9 +83,9 @@ class CallViewModel(
59 83
60 localParticipant.setCameraEnabled(true) 84 localParticipant.setCameraEnabled(true)
61 mutableCameraEnabled.postValue(localParticipant.isCameraEnabled()) 85 mutableCameraEnabled.postValue(localParticipant.isCameraEnabled())
62 -  
63 - updateParticipants(room)  
64 mutableRoom.value = room 86 mutableRoom.value = room
  87 +
  88 + mutablePrimarySpeaker.value = room.remoteParticipants.values.firstOrNull() ?: localParticipant
65 } 89 }
66 } 90 }
67 91
@@ -93,15 +117,6 @@ class CallViewModel( @@ -93,15 +117,6 @@ class CallViewModel(
93 } 117 }
94 } 118 }
95 119
96 - private fun updateParticipants(room: Room) {  
97 - mutableRemoteParticipants.postValue(  
98 - room.remoteParticipants  
99 - .keys  
100 - .sortedBy { it }  
101 - .mapNotNull { room.remoteParticipants[it] }  
102 - )  
103 - }  
104 -  
105 override fun onCleared() { 120 override fun onCleared() {
106 super.onCleared() 121 super.onCleared()
107 mutableRoom.value?.disconnect() 122 mutableRoom.value?.disconnect()
@@ -110,29 +125,15 @@ class CallViewModel( @@ -110,29 +125,15 @@ class CallViewModel(
110 override fun onDisconnect(room: Room, error: Exception?) { 125 override fun onDisconnect(room: Room, error: Exception?) {
111 } 126 }
112 127
113 - override fun onParticipantConnected(  
114 - room: Room,  
115 - participant: RemoteParticipant  
116 - ) {  
117 - updateParticipants(room)  
118 - }  
119 -  
120 - override fun onParticipantDisconnected(  
121 - room: Room,  
122 - participant: RemoteParticipant  
123 - ) {  
124 - updateParticipants(room)  
125 - }  
126 -  
127 - override fun onFailedToConnect(room: Room, error: Exception) {  
128 - }  
129 -  
130 override fun onActiveSpeakersChanged(speakers: List<Participant>, room: Room) { 128 override fun onActiveSpeakersChanged(speakers: List<Participant>, room: Room) {
131 - Timber.i { "active speakers changed ${speakers.count()}" }  
132 - }  
133 -  
134 - override fun onMetadataChanged(participant: Participant, prevMetadata: String?, room: Room) {  
135 - Timber.i { "Participant metadata changed: ${participant.identity}" } 129 + // If old active speaker is still active, don't change.
  130 + if (speakers.isEmpty() || speakers.contains(mutablePrimarySpeaker.value)) {
  131 + return
  132 + }
  133 + val newSpeaker = speakers
  134 + .filter { it is RemoteParticipant } // Try not to display local participant as speaker.
  135 + .firstOrNull() ?: return
  136 + mutablePrimarySpeaker.value = newSpeaker
136 } 137 }
137 138
138 fun setMicEnabled(enabled: Boolean) { 139 fun setMicEnabled(enabled: Boolean) {
@@ -4,15 +4,18 @@ import android.Manifest @@ -4,15 +4,18 @@ import android.Manifest
4 import android.content.Intent 4 import android.content.Intent
5 import android.content.pm.PackageManager 5 import android.content.pm.PackageManager
6 import android.os.Bundle 6 import android.os.Bundle
  7 +import android.widget.Space
7 import android.widget.Toast 8 import android.widget.Toast
8 import androidx.activity.ComponentActivity 9 import androidx.activity.ComponentActivity
9 import androidx.activity.compose.setContent 10 import androidx.activity.compose.setContent
10 import androidx.activity.result.contract.ActivityResultContracts 11 import androidx.activity.result.contract.ActivityResultContracts
  12 +import androidx.compose.foundation.Image
11 import androidx.compose.foundation.layout.* 13 import androidx.compose.foundation.layout.*
12 import androidx.compose.material.* 14 import androidx.compose.material.*
13 import androidx.compose.runtime.* 15 import androidx.compose.runtime.*
14 import androidx.compose.ui.Alignment 16 import androidx.compose.ui.Alignment
15 import androidx.compose.ui.Modifier 17 import androidx.compose.ui.Modifier
  18 +import androidx.compose.ui.res.painterResource
16 import androidx.compose.ui.tooling.preview.Preview 19 import androidx.compose.ui.tooling.preview.Preview
17 import androidx.compose.ui.unit.dp 20 import androidx.compose.ui.unit.dp
18 import androidx.core.content.ContextCompat 21 import androidx.core.content.ContextCompat
@@ -88,11 +91,20 @@ class MainActivity : ComponentActivity() { @@ -88,11 +91,20 @@ class MainActivity : ComponentActivity() {
88 var url by remember { mutableStateOf(defaultUrl) } 91 var url by remember { mutableStateOf(defaultUrl) }
89 var token by remember { mutableStateOf(defaultToken) } 92 var token by remember { mutableStateOf(defaultToken) }
90 // A surface container using the 'background' color from the theme 93 // A surface container using the 'background' color from the theme
91 - Surface(color = MaterialTheme.colors.background) { 94 + Surface(
  95 + color = MaterialTheme.colors.background,
  96 + modifier = Modifier.fillMaxSize()
  97 + ) {
92 Column( 98 Column(
93 horizontalAlignment = Alignment.CenterHorizontally, 99 horizontalAlignment = Alignment.CenterHorizontally,
94 modifier = Modifier.padding(10.dp) 100 modifier = Modifier.padding(10.dp)
95 ) { 101 ) {
  102 + Spacer(modifier = Modifier.height(50.dp))
  103 + Image(
  104 + painter = painterResource(id = R.drawable.banner_dark),
  105 + contentDescription = "",
  106 + )
  107 + Spacer(modifier = Modifier.height(20.dp))
96 OutlinedTextField( 108 OutlinedTextField(
97 value = url, 109 value = url,
98 onValueChange = { url = it }, 110 onValueChange = { url = it },
1 package io.livekit.android.composesample 1 package io.livekit.android.composesample
2 2
  3 +import androidx.compose.foundation.background
  4 +import androidx.compose.foundation.border
3 import androidx.compose.foundation.layout.fillMaxSize 5 import androidx.compose.foundation.layout.fillMaxSize
4 -import androidx.compose.runtime.* 6 +import androidx.compose.material.Icon
  7 +import androidx.compose.material.Surface
  8 +import androidx.compose.material.Text
  9 +import androidx.compose.runtime.Composable
  10 +import androidx.compose.runtime.collectAsState
  11 +import androidx.compose.runtime.getValue
5 import androidx.compose.ui.Modifier 12 import androidx.compose.ui.Modifier
6 -import androidx.compose.ui.layout.onGloballyPositioned  
7 -import androidx.compose.ui.viewinterop.AndroidView  
8 -import com.github.ajalt.timberkt.Timber  
9 -import io.livekit.android.renderer.TextureViewRenderer 13 +import androidx.compose.ui.graphics.Color
  14 +import androidx.compose.ui.res.painterResource
  15 +import androidx.compose.ui.unit.dp
  16 +import androidx.constraintlayout.compose.ConstraintLayout
  17 +import androidx.constraintlayout.compose.Dimension
  18 +import io.livekit.android.composesample.ui.theme.BlueMain
  19 +import io.livekit.android.composesample.ui.theme.NoVideoBackground
10 import io.livekit.android.room.Room 20 import io.livekit.android.room.Room
11 -import io.livekit.android.room.participant.ParticipantListener  
12 -import io.livekit.android.room.participant.RemoteParticipant  
13 -import io.livekit.android.room.track.RemoteTrackPublication  
14 -import io.livekit.android.room.track.RemoteVideoTrack 21 +import io.livekit.android.room.participant.Participant
15 import io.livekit.android.room.track.Track 22 import io.livekit.android.room.track.Track
16 -import io.livekit.android.room.track.video.ComposeVisibility 23 +import io.livekit.android.room.track.VideoTrack
  24 +import io.livekit.android.util.flow
17 25
18 @Composable 26 @Composable
19 fun ParticipantItem( 27 fun ParticipantItem(
20 room: Room, 28 room: Room,
21 - participant: RemoteParticipant, 29 + participant: Participant,
  30 + modifier: Modifier = Modifier,
  31 + isSpeaking: Boolean,
22 ) { 32 ) {
23 - val videoSinkVisibility = remember(room, participant) { ComposeVisibility() }  
24 - var videoBound by remember(room, participant) { mutableStateOf(false) }  
25 - fun getVideoTrack(): RemoteVideoTrack? {  
26 - return participant  
27 - .videoTracks.values  
28 - .firstOrNull()?.track as? RemoteVideoTrack  
29 - }  
30 33
31 - fun setupVideoIfNeeded(videoTrack: RemoteVideoTrack, view: TextureViewRenderer) {  
32 - if (videoBound) {  
33 - return  
34 - } 34 + val identity by participant::identity.flow.collectAsState()
  35 + val videoTracks by participant::videoTracks.flow.collectAsState()
  36 + val audioTracks by participant::audioTracks.flow.collectAsState()
  37 + val identityBarPadding = 4.dp
  38 + ConstraintLayout(
  39 + modifier = modifier.background(NoVideoBackground)
  40 + .run {
  41 + if (isSpeaking) {
  42 + border(2.dp, BlueMain)
  43 + } else {
  44 + this
  45 + }
  46 + }
  47 + ) {
  48 + val (videoCamOff, identityBar, identityText, muteIndicator) = createRefs()
  49 + val videoTrack = participant.getTrackPublication(Track.Source.SCREEN_SHARE)?.track as? VideoTrack
  50 + ?: participant.getTrackPublication(Track.Source.CAMERA)?.track as? VideoTrack
  51 + ?: videoTracks.values.firstOrNull()?.track as? VideoTrack
35 52
36 - videoBound = true  
37 - Timber.v { "adding renderer to $videoTrack" }  
38 - videoTrack.addRenderer(view, videoSinkVisibility)  
39 - }  
40 - DisposableEffect(room, participant) {  
41 - onDispose {  
42 - videoSinkVisibility.onDispose() 53 +
  54 + if (videoTrack != null) {
  55 + VideoItemTrackSelector(
  56 + room = room,
  57 + participant = participant,
  58 + videoTracks = videoTracks,
  59 + modifier = Modifier.fillMaxSize()
  60 + )
  61 + } else {
  62 + Icon(
  63 + painter = painterResource(id = R.drawable.outline_videocam_off_24),
  64 + contentDescription = null,
  65 + tint = Color.White,
  66 + modifier = Modifier.constrainAs(videoCamOff) {
  67 + top.linkTo(parent.top)
  68 + bottom.linkTo(parent.bottom)
  69 + start.linkTo(parent.start)
  70 + end.linkTo(parent.end)
  71 + width = Dimension.wrapContent
  72 + height = Dimension.wrapContent
  73 + }
  74 + )
43 } 75 }
44 - }  
45 - AndroidView(  
46 - factory = { context ->  
47 - TextureViewRenderer(context).apply {  
48 - room.initVideoRenderer(this) 76 +
  77 + Surface(
  78 + color = Color(0x80000000),
  79 + modifier = Modifier.constrainAs(identityBar) {
  80 + bottom.linkTo(parent.bottom)
  81 + start.linkTo(parent.start)
  82 + end.linkTo(parent.end)
  83 + width = Dimension.fillToConstraints
  84 + height = Dimension.value(30.dp)
49 } 85 }
50 - },  
51 - modifier = Modifier  
52 - .fillMaxSize()  
53 - .onGloballyPositioned { videoSinkVisibility.onGloballyPositioned(it) },  
54 - update = { view ->  
55 - participant.listener = object : ParticipantListener {  
56 - override fun onTrackSubscribed(  
57 - track: Track,  
58 - publication: RemoteTrackPublication,  
59 - participant: RemoteParticipant  
60 - ) {  
61 - if (track is RemoteVideoTrack) {  
62 - setupVideoIfNeeded(track, view)  
63 - }  
64 - } 86 + ) {}
  87 +
  88 + Text(
  89 + text = identity ?: "",
  90 + color = Color.White,
  91 + modifier = Modifier.constrainAs(identityText) {
  92 + top.linkTo(identityBar.top)
  93 + bottom.linkTo(identityBar.bottom)
  94 + start.linkTo(identityBar.start, margin = identityBarPadding)
  95 + end.linkTo(muteIndicator.end, margin = 10.dp)
  96 + width = Dimension.fillToConstraints
  97 + height = Dimension.wrapContent
  98 + },
  99 + )
  100 +
  101 + val isMuted = audioTracks.isEmpty()
65 102
66 - override fun onTrackUnpublished(  
67 - publication: RemoteTrackPublication,  
68 - participant: RemoteParticipant  
69 - ) {  
70 - super.onTrackUnpublished(publication, participant)  
71 - Timber.e { "Track unpublished" } 103 + if (isMuted) {
  104 + Icon(
  105 + painter = painterResource(id = R.drawable.outline_mic_off_24),
  106 + contentDescription = "",
  107 + tint = Color.Red,
  108 + modifier = Modifier.constrainAs(muteIndicator) {
  109 + top.linkTo(identityBar.top)
  110 + bottom.linkTo(identityBar.bottom)
  111 + end.linkTo(identityBar.end, margin = identityBarPadding)
72 } 112 }
73 - }  
74 - val existingTrack = getVideoTrack()  
75 - if (existingTrack != null) {  
76 - setupVideoIfNeeded(existingTrack, view)  
77 - } 113 + )
78 } 114 }
79 - ) 115 + }
80 } 116 }
  1 +package io.livekit.android.composesample
  2 +
  3 +import androidx.compose.foundation.layout.fillMaxSize
  4 +import androidx.compose.runtime.*
  5 +import androidx.compose.ui.Modifier
  6 +import androidx.compose.ui.layout.onGloballyPositioned
  7 +import androidx.compose.ui.viewinterop.AndroidView
  8 +import io.livekit.android.renderer.TextureViewRenderer
  9 +import io.livekit.android.room.Room
  10 +import io.livekit.android.room.participant.Participant
  11 +import io.livekit.android.room.track.RemoteVideoTrack
  12 +import io.livekit.android.room.track.Track
  13 +import io.livekit.android.room.track.TrackPublication
  14 +import io.livekit.android.room.track.VideoTrack
  15 +import io.livekit.android.room.track.video.ComposeVisibility
  16 +
  17 +@Composable
  18 +fun VideoItem(
  19 + room: Room,
  20 + videoTrack: VideoTrack,
  21 + modifier: Modifier = Modifier
  22 +) {
  23 + val videoSinkVisibility = remember(room, videoTrack) { ComposeVisibility() }
  24 + var boundVideoTrack by remember { mutableStateOf<VideoTrack?>(null) }
  25 + var view: TextureViewRenderer? by remember { mutableStateOf(null) }
  26 + fun cleanupVideoTrack() {
  27 + view?.let { boundVideoTrack?.removeRenderer(it) }
  28 + boundVideoTrack = null
  29 + }
  30 +
  31 + fun setupVideoIfNeeded(videoTrack: VideoTrack, view: TextureViewRenderer) {
  32 + if (boundVideoTrack == videoTrack) {
  33 + return
  34 + }
  35 +
  36 + cleanupVideoTrack()
  37 +
  38 + boundVideoTrack = videoTrack
  39 + if (videoTrack is RemoteVideoTrack) {
  40 + videoTrack.addRenderer(view, videoSinkVisibility)
  41 + } else {
  42 + videoTrack.addRenderer(view)
  43 + }
  44 + }
  45 + DisposableEffect(room, videoTrack) {
  46 + onDispose {
  47 + videoSinkVisibility.onDispose()
  48 + cleanupVideoTrack()
  49 + }
  50 + }
  51 + AndroidView(
  52 + factory = { context ->
  53 + TextureViewRenderer(context).apply {
  54 + room.initVideoRenderer(this)
  55 + setupVideoIfNeeded(videoTrack, this)
  56 +
  57 + view = this
  58 + }
  59 + },
  60 + update = { view ->
  61 + setupVideoIfNeeded(videoTrack, view)
  62 + },
  63 + modifier = modifier
  64 + .onGloballyPositioned { videoSinkVisibility.onGloballyPositioned(it) },
  65 + )
  66 +}
  67 +
  68 +@Composable
  69 +fun VideoItemTrackSelector(
  70 + room: Room,
  71 + participant: Participant,
  72 + videoTracks: Map<String, TrackPublication>,
  73 + modifier: Modifier = Modifier
  74 +) {
  75 +
  76 + val videoTrack = participant.getTrackPublication(Track.Source.SCREEN_SHARE)?.track as? VideoTrack
  77 + ?: participant.getTrackPublication(Track.Source.CAMERA)?.track as? VideoTrack
  78 + ?: videoTracks.values.firstOrNull()?.track as? VideoTrack
  79 +
  80 + if (videoTrack != null) {
  81 + VideoItem(
  82 + room = room,
  83 + videoTrack = videoTrack,
  84 + modifier = modifier
  85 + )
  86 + }
  87 +}
@@ -2,7 +2,8 @@ package io.livekit.android.composesample.ui.theme @@ -2,7 +2,8 @@ package io.livekit.android.composesample.ui.theme
2 2
3 import androidx.compose.ui.graphics.Color 3 import androidx.compose.ui.graphics.Color
4 4
5 -val Purple200 = Color(0xFFBB86FC)  
6 -val Purple500 = Color(0xFF6200EE)  
7 -val Purple700 = Color(0xFF3700B3)  
8 -val Teal200 = Color(0xFF03DAC5)  
  5 +val BlueMain = Color(0xFF007DFF)
  6 +val BlueDark = Color(0xFF0058B3)
  7 +val BlueLight = Color(0xFF66B1FF)
  8 +val NoVideoIconTint = Color(0xFF5A8BFF)
  9 +val NoVideoBackground = Color(0xFF00153C)
1 package io.livekit.android.composesample.ui.theme 1 package io.livekit.android.composesample.ui.theme
2 2
3 -import androidx.compose.foundation.isSystemInDarkTheme  
4 import androidx.compose.material.MaterialTheme 3 import androidx.compose.material.MaterialTheme
5 import androidx.compose.material.darkColors 4 import androidx.compose.material.darkColors
6 import androidx.compose.material.lightColors 5 import androidx.compose.material.lightColors
@@ -8,17 +7,21 @@ import androidx.compose.runtime.Composable @@ -8,17 +7,21 @@ import androidx.compose.runtime.Composable
8 import androidx.compose.ui.graphics.Color 7 import androidx.compose.ui.graphics.Color
9 8
10 private val DarkColorPalette = darkColors( 9 private val DarkColorPalette = darkColors(
11 - primary = Purple200,  
12 - primaryVariant = Purple700,  
13 - secondary = Teal200,  
14 - background = Color.Black 10 + primary = BlueMain,
  11 + primaryVariant = BlueDark,
  12 + secondary = BlueMain,
  13 + background = Color.Black,
  14 + surface = Color.Transparent,
  15 + onPrimary = Color.White,
  16 + onSecondary = Color.White,
  17 + onBackground = Color.White,
  18 + onSurface = Color.White,
15 ) 19 )
16 20
17 private val LightColorPalette = lightColors( 21 private val LightColorPalette = lightColors(
18 - primary = Purple500,  
19 - primaryVariant = Purple700,  
20 - secondary = Teal200  
21 - 22 + primary = BlueMain,
  23 + primaryVariant = BlueDark,
  24 + secondary = BlueMain,
22 /* Other default colors to override 25 /* Other default colors to override
23 background = Color.White, 26 background = Color.White,
24 surface = Color.White, 27 surface = Color.White,
@@ -31,7 +34,7 @@ private val LightColorPalette = lightColors( @@ -31,7 +34,7 @@ private val LightColorPalette = lightColors(
31 34
32 @Composable 35 @Composable
33 fun AppTheme( 36 fun AppTheme(
34 - darkTheme: Boolean = isSystemInDarkTheme(), 37 + darkTheme: Boolean = true,
35 content: @Composable() () -> Unit 38 content: @Composable() () -> Unit
36 ) { 39 ) {
37 val colors = if (darkTheme) { 40 val colors = if (darkTheme) {
1 -<resources xmlns:tools="http://schemas.android.com/tools">  
2 - <!-- Base application theme. -->  
3 - <style name="Theme.Livekitandroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">  
4 - <!-- Primary brand color. -->  
5 - <item name="colorPrimary">@color/purple_200</item>  
6 - <item name="colorPrimaryVariant">@color/purple_700</item>  
7 - <item name="colorOnPrimary">@color/black</item>  
8 - <!-- Secondary brand color. -->  
9 - <item name="colorSecondary">@color/teal_200</item>  
10 - <item name="colorSecondaryVariant">@color/teal_200</item>  
11 - <item name="colorOnSecondary">@color/black</item>  
12 - <!-- Status bar color. -->  
13 - <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>  
14 - <!-- Customize your theme here. -->  
15 - </style>  
16 -</resources>  
@@ -2,13 +2,9 @@ @@ -2,13 +2,9 @@
2 <!-- Base application theme. --> 2 <!-- Base application theme. -->
3 <style name="Theme.Livekitandroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> 3 <style name="Theme.Livekitandroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
4 <!-- Primary brand color. --> 4 <!-- Primary brand color. -->
5 - <item name="colorPrimary">@color/purple_500</item>  
6 - <item name="colorPrimaryVariant">@color/purple_700</item> 5 + <item name="colorPrimary">@color/colorPrimary</item>
  6 + <item name="colorPrimaryVariant">@color/colorPrimaryDark</item>
7 <item name="colorOnPrimary">@color/white</item> 7 <item name="colorOnPrimary">@color/white</item>
8 - <!-- Secondary brand color. -->  
9 - <item name="colorSecondary">@color/teal_200</item>  
10 - <item name="colorSecondaryVariant">@color/teal_700</item>  
11 - <item name="colorOnSecondary">@color/black</item>  
12 <!-- Status bar color. --> 8 <!-- Status bar color. -->
13 <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item> 9 <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
14 <!-- Customize your theme here. --> 10 <!-- Customize your theme here. -->