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. --> |
-
请 注册 或 登录 后发表评论