davidliu
Committed by GitHub

android automatic track management (#23)

* basic event bus implementation

* automatic video track management

* fix tests

* clean up import
正在显示 22 个修改的文件 包含 494 行增加37 行删除
@@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
2 2
3 buildscript { 3 buildscript {
4 ext { 4 ext {
5 - compose_version = '1.0.4' 5 + compose_version = '1.0.5'
6 kotlin_version = '1.5.31' 6 kotlin_version = '1.5.31'
7 java_version = JavaVersion.VERSION_1_8 7 java_version = JavaVersion.VERSION_1_8
8 dokka_version = '1.5.0' 8 dokka_version = '1.5.0'
@@ -104,6 +104,7 @@ dependencies { @@ -104,6 +104,7 @@ dependencies {
104 implementation "androidx.core:core:${versions.androidx_core}" 104 implementation "androidx.core:core:${versions.androidx_core}"
105 implementation "com.google.protobuf:protobuf-java:${versions.protobuf}" 105 implementation "com.google.protobuf:protobuf-java:${versions.protobuf}"
106 implementation "com.google.protobuf:protobuf-java-util:${versions.protobuf}" 106 implementation "com.google.protobuf:protobuf-java-util:${versions.protobuf}"
  107 + implementation "androidx.compose.ui:ui:$compose_version"
107 108
108 implementation 'com.google.dagger:dagger:2.38' 109 implementation 'com.google.dagger:dagger:2.38'
109 kapt 'com.google.dagger:dagger-compiler:2.38' 110 kapt 'com.google.dagger:dagger-compiler:2.38'
@@ -8,7 +8,18 @@ import org.webrtc.PeerConnection @@ -8,7 +8,18 @@ import org.webrtc.PeerConnection
8 8
9 9
10 data class ConnectOptions( 10 data class ConnectOptions(
  11 + /** Auto subscribe to room tracks upon connect, defaults to true */
11 val autoSubscribe: Boolean = true, 12 val autoSubscribe: Boolean = true,
  13 + /**
  14 + * Automatically manage quality of subscribed video tracks, subscribe to the
  15 + * an appropriate resolution based on the size of the video elements that tracks
  16 + * are attached to.
  17 + *
  18 + * Also observes the visibility of attached tracks and pauses receiving data
  19 + * if they are not visible.
  20 + */
  21 + val autoManageVideo: Boolean = false,
  22 +
12 val iceServers: List<PeerConnection.IceServer>? = null, 23 val iceServers: List<PeerConnection.IceServer>? = null,
13 val rtcConfig: PeerConnection.RTCConfiguration? = null, 24 val rtcConfig: PeerConnection.RTCConfiguration? = null,
14 /** 25 /**
@@ -61,6 +61,7 @@ class LiveKit { @@ -61,6 +61,7 @@ class LiveKit {
61 options?.videoTrackPublishDefaults?.let { 61 options?.videoTrackPublishDefaults?.let {
62 room.localParticipant.videoTrackPublishDefaults = it 62 room.localParticipant.videoTrackPublishDefaults = it
63 } 63 }
  64 + room.autoManageVideo = options?.autoManageVideo ?: false
64 65
65 if (options?.audio == true) { 66 if (options?.audio == true) {
66 val audioTrack = room.localParticipant.createAudioTrack() 67 val audioTrack = room.localParticipant.createAudioTrack()
  1 +package io.livekit.android.events
  2 +
  3 +import kotlinx.coroutines.flow.MutableSharedFlow
  4 +import kotlinx.coroutines.flow.asSharedFlow
  5 +import kotlinx.coroutines.flow.collect
  6 +
  7 +class BroadcastEventBus<T> : EventListenable<T> {
  8 + private val mutableEvents = MutableSharedFlow<T>()
  9 + override val events = mutableEvents.asSharedFlow()
  10 +
  11 + suspend fun postEvent(event: T) {
  12 + mutableEvents.emit(event)
  13 + }
  14 +
  15 + suspend fun postEvents(eventsToPost: Collection<T>) {
  16 + eventsToPost.forEach { event ->
  17 + mutableEvents.emit(event)
  18 + }
  19 + }
  20 +
  21 + fun readOnly(): EventListenable<T> = this
  22 +}
  1 +package io.livekit.android.events
  2 +
  3 +sealed class Event
  1 +package io.livekit.android.events
  2 +
  3 +import kotlinx.coroutines.flow.SharedFlow
  4 +import kotlinx.coroutines.flow.collect
  5 +
  6 +interface EventListenable<out T> {
  7 + val events: SharedFlow<T>
  8 +}
  9 +
  10 +suspend inline fun <T> EventListenable<T>.collect(crossinline action: suspend (value: T) -> Unit) {
  11 + return events.collect(action)
  12 +}
  1 +package io.livekit.android.events
  2 +
  3 +import io.livekit.android.room.track.Track
  4 +
  5 +sealed class TrackEvent : Event() {
  6 + class VisibilityChanged(val isVisible: Boolean) : TrackEvent()
  7 + class VideoDimensionsChanged(val newDimensions: Track.Dimensions) : TrackEvent()
  8 +}
@@ -10,13 +10,16 @@ import dagger.assisted.AssistedFactory @@ -10,13 +10,16 @@ import dagger.assisted.AssistedFactory
10 import dagger.assisted.AssistedInject 10 import dagger.assisted.AssistedInject
11 import io.livekit.android.ConnectOptions 11 import io.livekit.android.ConnectOptions
12 import io.livekit.android.Version 12 import io.livekit.android.Version
  13 +import io.livekit.android.dagger.InjectionNames
13 import io.livekit.android.renderer.TextureViewRenderer 14 import io.livekit.android.renderer.TextureViewRenderer
14 import io.livekit.android.room.participant.* 15 import io.livekit.android.room.participant.*
15 import io.livekit.android.room.track.* 16 import io.livekit.android.room.track.*
16 import io.livekit.android.util.LKLog 17 import io.livekit.android.util.LKLog
  18 +import kotlinx.coroutines.CoroutineDispatcher
17 import livekit.LivekitModels 19 import livekit.LivekitModels
18 import livekit.LivekitRtc 20 import livekit.LivekitRtc
19 import org.webrtc.* 21 import org.webrtc.*
  22 +import javax.inject.Named
20 23
21 class Room 24 class Room
22 @AssistedInject 25 @AssistedInject
@@ -26,6 +29,8 @@ constructor( @@ -26,6 +29,8 @@ constructor(
26 private val eglBase: EglBase, 29 private val eglBase: EglBase,
27 private val localParticipantFactory: LocalParticipant.Factory, 30 private val localParticipantFactory: LocalParticipant.Factory,
28 private val defaultsManager: DefaultsManager, 31 private val defaultsManager: DefaultsManager,
  32 + @Named(InjectionNames.DISPATCHER_IO)
  33 + private val ioDispatcher: CoroutineDispatcher,
29 ) : RTCEngine.Listener, ParticipantListener, ConnectivityManager.NetworkCallback() { 34 ) : RTCEngine.Listener, ParticipantListener, ConnectivityManager.NetworkCallback() {
30 init { 35 init {
31 engine.listener = this 36 engine.listener = this
@@ -52,6 +57,7 @@ constructor( @@ -52,6 +57,7 @@ constructor(
52 var metadata: String? = null 57 var metadata: String? = null
53 private set 58 private set
54 59
  60 + var autoManageVideo: Boolean = false
55 var audioTrackCaptureDefaults: LocalAudioTrackOptions by defaultsManager::audioTrackCaptureDefaults 61 var audioTrackCaptureDefaults: LocalAudioTrackOptions by defaultsManager::audioTrackCaptureDefaults
56 var audioTrackPublishDefaults: AudioTrackPublishDefaults by defaultsManager::audioTrackPublishDefaults 62 var audioTrackPublishDefaults: AudioTrackPublishDefaults by defaultsManager::audioTrackPublishDefaults
57 var videoTrackCaptureDefaults: LocalVideoTrackOptions by defaultsManager::videoTrackCaptureDefaults 63 var videoTrackCaptureDefaults: LocalVideoTrackOptions by defaultsManager::videoTrackCaptureDefaults
@@ -121,9 +127,9 @@ constructor( @@ -121,9 +127,9 @@ constructor(
121 } 127 }
122 128
123 participant = if (info != null) { 129 participant = if (info != null) {
124 - RemoteParticipant(engine.client, info) 130 + RemoteParticipant(info, engine.client, ioDispatcher)
125 } else { 131 } else {
126 - RemoteParticipant(engine.client, sid, null) 132 + RemoteParticipant(sid, null, engine.client, ioDispatcher)
127 } 133 }
128 participant.internalListener = this 134 participant.internalListener = this
129 mutableRemoteParticipants[sid] = participant 135 mutableRemoteParticipants[sid] = participant
@@ -282,7 +288,7 @@ constructor( @@ -282,7 +288,7 @@ constructor(
282 trackSid = track.id() 288 trackSid = track.id()
283 } 289 }
284 val participant = getOrCreateRemoteParticipant(participantSid) 290 val participant = getOrCreateRemoteParticipant(participantSid)
285 - participant.addSubscribedMediaTrack(track, trackSid!!) 291 + participant.addSubscribedMediaTrack(track, trackSid!!, autoManageVideo)
286 } 292 }
287 293
288 /** 294 /**
@@ -26,6 +26,7 @@ import org.webrtc.PeerConnection @@ -26,6 +26,7 @@ import org.webrtc.PeerConnection
26 import org.webrtc.SessionDescription 26 import org.webrtc.SessionDescription
27 import javax.inject.Inject 27 import javax.inject.Inject
28 import javax.inject.Named 28 import javax.inject.Named
  29 +import javax.inject.Singleton
29 import kotlin.coroutines.Continuation 30 import kotlin.coroutines.Continuation
30 import kotlin.coroutines.suspendCoroutine 31 import kotlin.coroutines.suspendCoroutine
31 32
@@ -33,6 +34,7 @@ import kotlin.coroutines.suspendCoroutine @@ -33,6 +34,7 @@ import kotlin.coroutines.suspendCoroutine
33 * SignalClient to LiveKit WS servers 34 * SignalClient to LiveKit WS servers
34 * @suppress 35 * @suppress
35 */ 36 */
  37 +@Singleton
36 class SignalClient 38 class SignalClient
37 @Inject 39 @Inject
38 constructor( 40 constructor(
@@ -288,11 +290,26 @@ constructor( @@ -288,11 +290,26 @@ constructor(
288 sendRequest(request) 290 sendRequest(request)
289 } 291 }
290 292
291 - fun sendUpdateTrackSettings(sid: String, disabled: Boolean, videoQuality: LivekitRtc.VideoQuality) { 293 + fun sendUpdateTrackSettings(
  294 + sid: String,
  295 + disabled: Boolean,
  296 + videoDimensions: Track.Dimensions?,
  297 + videoQuality: LivekitRtc.VideoQuality?,
  298 + ) {
292 val trackSettings = LivekitRtc.UpdateTrackSettings.newBuilder() 299 val trackSettings = LivekitRtc.UpdateTrackSettings.newBuilder()
293 .addTrackSids(sid) 300 .addTrackSids(sid)
294 .setDisabled(disabled) 301 .setDisabled(disabled)
295 - .setQuality(videoQuality) 302 + .apply {
  303 + if(videoDimensions != null) {
  304 + width = videoDimensions.width
  305 + height = videoDimensions.height
  306 + } else if(videoQuality != null) {
  307 + quality = videoQuality
  308 + } else {
  309 + // default to HIGH
  310 + quality = LivekitRtc.VideoQuality.HIGH
  311 + }
  312 + }
296 313
297 val request = LivekitRtc.SignalRequest.newBuilder() 314 val request = LivekitRtc.SignalRequest.newBuilder()
298 .setTrackSetting(trackSettings) 315 .setTrackSetting(trackSettings)
@@ -4,6 +4,7 @@ import io.livekit.android.room.SignalClient @@ -4,6 +4,7 @@ import io.livekit.android.room.SignalClient
4 import io.livekit.android.room.track.* 4 import io.livekit.android.room.track.*
5 import io.livekit.android.util.CloseableCoroutineScope 5 import io.livekit.android.util.CloseableCoroutineScope
6 import io.livekit.android.util.LKLog 6 import io.livekit.android.util.LKLog
  7 +import kotlinx.coroutines.CoroutineDispatcher
7 import kotlinx.coroutines.SupervisorJob 8 import kotlinx.coroutines.SupervisorJob
8 import kotlinx.coroutines.delay 9 import kotlinx.coroutines.delay
9 import kotlinx.coroutines.launch 10 import kotlinx.coroutines.launch
@@ -13,14 +14,24 @@ import org.webrtc.MediaStreamTrack @@ -13,14 +14,24 @@ import org.webrtc.MediaStreamTrack
13 import org.webrtc.VideoTrack 14 import org.webrtc.VideoTrack
14 15
15 class RemoteParticipant( 16 class RemoteParticipant(
16 - val signalClient: SignalClient,  
17 sid: String, 17 sid: String,
18 identity: String? = null, 18 identity: String? = null,
  19 + val signalClient: SignalClient,
  20 + private val ioDispatcher: CoroutineDispatcher,
19 ) : Participant(sid, identity) { 21 ) : Participant(sid, identity) {
20 /** 22 /**
21 * @suppress 23 * @suppress
22 */ 24 */
23 - constructor(signalClient: SignalClient, info: LivekitModels.ParticipantInfo) : this(signalClient, info.sid, info.identity) { 25 + constructor(
  26 + info: LivekitModels.ParticipantInfo,
  27 + signalClient: SignalClient,
  28 + ioDispatcher: CoroutineDispatcher
  29 + ) : this(
  30 + info.sid,
  31 + info.identity,
  32 + signalClient,
  33 + ioDispatcher,
  34 + ) {
24 updateFromInfo(info) 35 updateFromInfo(info)
25 } 36 }
26 37
@@ -43,7 +54,11 @@ class RemoteParticipant( @@ -43,7 +54,11 @@ class RemoteParticipant(
43 var publication = getTrackPublication(trackSid) 54 var publication = getTrackPublication(trackSid)
44 55
45 if (publication == null) { 56 if (publication == null) {
46 - publication = RemoteTrackPublication(trackInfo, participant = this) 57 + publication = RemoteTrackPublication(
  58 + trackInfo,
  59 + participant = this,
  60 + ioDispatcher = ioDispatcher
  61 + )
47 62
48 newTrackPublications[trackSid] = publication 63 newTrackPublications[trackSid] = publication
49 addTrackPublication(publication) 64 addTrackPublication(publication)
@@ -71,11 +86,21 @@ class RemoteParticipant( @@ -71,11 +86,21 @@ class RemoteParticipant(
71 /** 86 /**
72 * @suppress 87 * @suppress
73 */ 88 */
74 - fun addSubscribedMediaTrack(mediaTrack: MediaStreamTrack, sid: String, triesLeft: Int = 20) { 89 + fun addSubscribedMediaTrack(
  90 + mediaTrack: MediaStreamTrack,
  91 + sid: String,
  92 + autoManageVideo: Boolean = false,
  93 + triesLeft: Int = 20
  94 + ) {
75 val publication = getTrackPublication(sid) 95 val publication = getTrackPublication(sid)
76 val track: Track = when (val kind = mediaTrack.kind()) { 96 val track: Track = when (val kind = mediaTrack.kind()) {
77 KIND_AUDIO -> AudioTrack(rtcTrack = mediaTrack as AudioTrack, name = "") 97 KIND_AUDIO -> AudioTrack(rtcTrack = mediaTrack as AudioTrack, name = "")
78 - KIND_VIDEO -> VideoTrack(rtcTrack = mediaTrack as VideoTrack, name = "") 98 + KIND_VIDEO -> RemoteVideoTrack(
  99 + rtcTrack = mediaTrack as VideoTrack,
  100 + name = "",
  101 + autoManageVideo = autoManageVideo,
  102 + dispatcher = ioDispatcher
  103 + )
79 else -> throw TrackException.InvalidTrackTypeException("invalid track type: $kind") 104 else -> throw TrackException.InvalidTrackTypeException("invalid track type: $kind")
80 } 105 }
81 106
@@ -90,7 +115,7 @@ class RemoteParticipant( @@ -90,7 +115,7 @@ class RemoteParticipant(
90 } else { 115 } else {
91 coroutineScope.launch { 116 coroutineScope.launch {
92 delay(150) 117 delay(150)
93 - addSubscribedMediaTrack(mediaTrack, sid, triesLeft - 1) 118 + addSubscribedMediaTrack(mediaTrack, sid, autoManageVideo, triesLeft - 1)
94 } 119 }
95 } 120 }
96 return 121 return
1 package io.livekit.android.room.track 1 package io.livekit.android.room.track
2 2
  3 +import io.livekit.android.dagger.InjectionNames
  4 +import io.livekit.android.events.TrackEvent
  5 +import io.livekit.android.events.collect
3 import io.livekit.android.room.participant.RemoteParticipant 6 import io.livekit.android.room.participant.RemoteParticipant
  7 +import io.livekit.android.util.debounce
  8 +import io.livekit.android.util.invoke
  9 +import kotlinx.coroutines.*
4 import livekit.LivekitModels 10 import livekit.LivekitModels
5 import livekit.LivekitRtc 11 import livekit.LivekitRtc
  12 +import javax.inject.Named
6 13
7 class RemoteTrackPublication( 14 class RemoteTrackPublication(
8 info: LivekitModels.TrackInfo, 15 info: LivekitModels.TrackInfo,
9 track: Track? = null, 16 track: Track? = null,
10 - participant: RemoteParticipant 17 + participant: RemoteParticipant,
  18 + @Named(InjectionNames.DISPATCHER_IO)
  19 + private val ioDispatcher: CoroutineDispatcher,
11 ) : TrackPublication(info, track, participant) { 20 ) : TrackPublication(info, track, participant) {
12 21
  22 + @OptIn(FlowPreview::class)
  23 + override var track: Track?
  24 + get() = super.track
  25 + set(value) {
  26 + if (value != super.track) {
  27 + trackJob?.cancel()
  28 + trackJob = null
  29 + }
  30 +
  31 + super.track = value
  32 +
  33 + if (value != null) {
  34 + trackJob = CoroutineScope(ioDispatcher).launch {
  35 + value.events.collect {
  36 + when (it) {
  37 + is TrackEvent.VisibilityChanged -> handleVisibilityChanged(it)
  38 + is TrackEvent.VideoDimensionsChanged -> handleVideoDimensionsChanged(it)
  39 + }
  40 + }
  41 + }
  42 + }
  43 + }
  44 +
  45 + private fun handleVisibilityChanged(trackEvent: TrackEvent.VisibilityChanged) {
  46 + disabled = !trackEvent.isVisible
  47 + sendUpdateTrackSettings.invoke()
  48 + }
  49 +
  50 + private fun handleVideoDimensionsChanged(trackEvent: TrackEvent.VideoDimensionsChanged) {
  51 + videoDimensions = trackEvent.newDimensions
  52 + sendUpdateTrackSettings.invoke()
  53 + }
  54 +
  55 + private var trackJob: Job? = null
  56 +
13 private var unsubscribed: Boolean = false 57 private var unsubscribed: Boolean = false
14 private var disabled: Boolean = false 58 private var disabled: Boolean = false
15 - private var videoQuality: LivekitRtc.VideoQuality = LivekitRtc.VideoQuality.HIGH 59 + private var videoQuality: LivekitRtc.VideoQuality? = LivekitRtc.VideoQuality.HIGH
  60 + private var videoDimensions: Track.Dimensions? = null
  61 +
  62 + val isAutoManaged: Boolean
  63 + get() = (track as? RemoteVideoTrack)?.autoManageVideo ?: false
16 64
17 override val subscribed: Boolean 65 override val subscribed: Boolean
18 get() { 66 get() {
@@ -54,24 +102,62 @@ class RemoteTrackPublication( @@ -54,24 +102,62 @@ class RemoteTrackPublication(
54 * video to reduce bandwidth requirements 102 * video to reduce bandwidth requirements
55 */ 103 */
56 fun setEnabled(enabled: Boolean) { 104 fun setEnabled(enabled: Boolean) {
  105 + if (isAutoManaged || !subscribed || enabled == !disabled) {
  106 + return
  107 + }
57 disabled = !enabled 108 disabled = !enabled
58 - sendUpdateTrackSettings() 109 + sendUpdateTrackSettings.invoke()
59 } 110 }
60 111
61 /** 112 /**
62 - * for tracks that support simulcasting, adjust subscribed quality 113 + * for tracks that support simulcasting, directly adjust subscribed quality
63 * 114 *
64 * this indicates the highest quality the client can accept. if network bandwidth does not 115 * this indicates the highest quality the client can accept. if network bandwidth does not
65 * allow, server will automatically reduce quality to optimize for uninterrupted video 116 * allow, server will automatically reduce quality to optimize for uninterrupted video
66 */ 117 */
67 fun setVideoQuality(quality: LivekitRtc.VideoQuality) { 118 fun setVideoQuality(quality: LivekitRtc.VideoQuality) {
  119 + if (isAutoManaged
  120 + || !subscribed
  121 + || quality == videoQuality
  122 + || track !is VideoTrack
  123 + ) {
  124 + return
  125 + }
68 videoQuality = quality 126 videoQuality = quality
69 - sendUpdateTrackSettings() 127 + videoDimensions = null
  128 + sendUpdateTrackSettings.invoke()
  129 + }
  130 +
  131 + /**
  132 + * Update the dimensions that the server will use for determining the video quality to send down.
  133 + */
  134 + fun setVideoDimensions(dimensions: Track.Dimensions) {
  135 + if (isAutoManaged
  136 + || !subscribed
  137 + || videoDimensions == dimensions
  138 + || track !is VideoTrack
  139 + ) {
  140 + return
  141 + }
  142 +
  143 + videoQuality = null
  144 + videoDimensions = dimensions
  145 + sendUpdateTrackSettings.invoke()
  146 + }
  147 +
  148 + // Debounce just in case multiple settings get changed at once.
  149 + private val sendUpdateTrackSettings = debounce<Unit, Unit>(100L, CoroutineScope(ioDispatcher)) {
  150 + sendUpdateTrackSettingsImpl()
70 } 151 }
71 152
72 - private fun sendUpdateTrackSettings() { 153 + private fun sendUpdateTrackSettingsImpl() {
73 val participant = this.participant.get() as? RemoteParticipant ?: return 154 val participant = this.participant.get() as? RemoteParticipant ?: return
74 155
75 - participant.signalClient.sendUpdateTrackSettings(sid, disabled, videoQuality) 156 + participant.signalClient.sendUpdateTrackSettings(
  157 + sid,
  158 + disabled,
  159 + videoDimensions,
  160 + videoQuality
  161 + )
76 } 162 }
77 } 163 }
  1 +package io.livekit.android.room.track
  2 +
  3 +import android.view.View
  4 +import io.livekit.android.dagger.InjectionNames
  5 +import io.livekit.android.events.TrackEvent
  6 +import io.livekit.android.room.track.video.VideoSinkVisibility
  7 +import io.livekit.android.room.track.video.ViewVisibility
  8 +import io.livekit.android.util.LKLog
  9 +import kotlinx.coroutines.CoroutineDispatcher
  10 +import kotlinx.coroutines.CoroutineScope
  11 +import kotlinx.coroutines.SupervisorJob
  12 +import kotlinx.coroutines.launch
  13 +import org.webrtc.VideoSink
  14 +import javax.inject.Named
  15 +import kotlin.math.max
  16 +
  17 +class RemoteVideoTrack(
  18 + name: String,
  19 + rtcTrack: org.webrtc.VideoTrack,
  20 + val autoManageVideo: Boolean = false,
  21 + @Named(InjectionNames.DISPATCHER_DEFAULT)
  22 + private val dispatcher: CoroutineDispatcher,
  23 +) : VideoTrack(name, rtcTrack) {
  24 +
  25 + private var coroutineScope = CoroutineScope(dispatcher + SupervisorJob())
  26 + private val sinkVisibilityMap = mutableMapOf<VideoSink, VideoSinkVisibility>()
  27 + private val visibilities = sinkVisibilityMap.values
  28 +
  29 + private var lastVisibility = false
  30 + private var lastDimensions: Dimensions = Dimensions(0, 0)
  31 +
  32 + override fun addRenderer(renderer: VideoSink) {
  33 + if (autoManageVideo && renderer is View) {
  34 + addRenderer(renderer, ViewVisibility(renderer))
  35 + } else {
  36 + super.addRenderer(renderer)
  37 + }
  38 + }
  39 +
  40 + fun addRenderer(renderer: VideoSink, visibility: VideoSinkVisibility) {
  41 + super.addRenderer(renderer)
  42 + if (autoManageVideo) {
  43 + sinkVisibilityMap[renderer] = visibility
  44 + visibility.addObserver { _, _ -> recalculateVisibility() }
  45 + recalculateVisibility()
  46 + } else {
  47 + LKLog.w { "attempted to tracking video sink visibility on an non auto managed video track." }
  48 + }
  49 + }
  50 +
  51 + override fun removeRenderer(renderer: VideoSink) {
  52 + super.removeRenderer(renderer)
  53 + val visibility = sinkVisibilityMap.remove(renderer)
  54 + visibility?.close()
  55 + }
  56 +
  57 + override fun stop() {
  58 + super.stop()
  59 + sinkVisibilityMap.values.forEach { it.close() }
  60 + sinkVisibilityMap.clear()
  61 + }
  62 +
  63 + private fun hasVisibleSinks(): Boolean {
  64 + return visibilities.any { it.isVisible() }
  65 + }
  66 +
  67 + private fun largestVideoViewSize(): Dimensions {
  68 + var maxWidth = 0
  69 + var maxHeight = 0
  70 + visibilities.forEach { visibility ->
  71 + val size = visibility.size()
  72 + maxWidth = max(maxWidth, size.width)
  73 + maxHeight = max(maxHeight, size.height)
  74 + }
  75 +
  76 + return Dimensions(maxWidth, maxHeight)
  77 + }
  78 +
  79 + private fun recalculateVisibility() {
  80 + val isVisible = hasVisibleSinks()
  81 + val newDimensions = largestVideoViewSize()
  82 +
  83 + val eventsToPost = mutableListOf<TrackEvent>()
  84 + if (isVisible != lastVisibility) {
  85 + lastVisibility = isVisible
  86 + eventsToPost.add(TrackEvent.VisibilityChanged(isVisible))
  87 + }
  88 + if (newDimensions != lastDimensions) {
  89 + lastDimensions = newDimensions
  90 + eventsToPost.add(TrackEvent.VideoDimensionsChanged(newDimensions))
  91 + }
  92 +
  93 + if (eventsToPost.any()) {
  94 + coroutineScope.launch {
  95 + eventBus.postEvents(eventsToPost)
  96 + }
  97 + }
  98 + }
  99 +}
1 package io.livekit.android.room.track 1 package io.livekit.android.room.track
2 2
  3 +import io.livekit.android.events.BroadcastEventBus
  4 +import io.livekit.android.events.TrackEvent
3 import livekit.LivekitModels 5 import livekit.LivekitModels
4 import org.webrtc.MediaStreamTrack 6 import org.webrtc.MediaStreamTrack
5 7
@@ -8,6 +10,9 @@ open class Track( @@ -8,6 +10,9 @@ open class Track(
8 kind: Kind, 10 kind: Kind,
9 open val rtcTrack: MediaStreamTrack 11 open val rtcTrack: MediaStreamTrack
10 ) { 12 ) {
  13 + protected val eventBus = BroadcastEventBus<TrackEvent>()
  14 + val events = eventBus.readOnly()
  15 +
11 var name = name 16 var name = name
12 internal set 17 internal set
13 var kind = kind 18 var kind = kind
@@ -72,7 +77,7 @@ open class Track( @@ -72,7 +77,7 @@ open class Track(
72 } 77 }
73 } 78 }
74 79
75 - data class Dimensions(var width: Int, var height: Int) 80 + data class Dimensions(val width: Int, val height: Int)
76 81
77 open fun start() { 82 open fun start() {
78 rtcTrack.setEnabled(true) 83 rtcTrack.setEnabled(true)
@@ -83,6 +88,7 @@ open class Track( @@ -83,6 +88,7 @@ open class Track(
83 } 88 }
84 } 89 }
85 90
  91 +
86 sealed class TrackException(message: String? = null, cause: Throwable? = null) : 92 sealed class TrackException(message: String? = null, cause: Throwable? = null) :
87 Exception(message, cause) { 93 Exception(message, cause) {
88 class InvalidTrackTypeException(message: String? = null, cause: Throwable? = null) : 94 class InvalidTrackTypeException(message: String? = null, cause: Throwable? = null) :
@@ -9,7 +9,7 @@ open class TrackPublication( @@ -9,7 +9,7 @@ open class TrackPublication(
9 track: Track?, 9 track: Track?,
10 participant: Participant 10 participant: Participant
11 ) { 11 ) {
12 - var track: Track? = track 12 + open var track: Track? = track
13 internal set 13 internal set
14 var name: String 14 var name: String
15 internal set 15 internal set
@@ -5,7 +5,7 @@ import org.webrtc.VideoTrack @@ -5,7 +5,7 @@ import org.webrtc.VideoTrack
5 5
6 open class VideoTrack(name: String, override val rtcTrack: VideoTrack) : 6 open class VideoTrack(name: String, override val rtcTrack: VideoTrack) :
7 Track(name, Kind.VIDEO, rtcTrack) { 7 Track(name, Kind.VIDEO, rtcTrack) {
8 - internal val sinks: MutableList<VideoSink> = ArrayList(); 8 + protected val sinks: MutableList<VideoSink> = ArrayList();
9 9
10 var enabled: Boolean 10 var enabled: Boolean
11 get() = rtcTrack.enabled() 11 get() = rtcTrack.enabled()
@@ -13,12 +13,12 @@ open class VideoTrack(name: String, override val rtcTrack: VideoTrack) : @@ -13,12 +13,12 @@ open class VideoTrack(name: String, override val rtcTrack: VideoTrack) :
13 rtcTrack.setEnabled(value) 13 rtcTrack.setEnabled(value)
14 } 14 }
15 15
16 - fun addRenderer(renderer: VideoSink) { 16 + open fun addRenderer(renderer: VideoSink) {
17 sinks.add(renderer) 17 sinks.add(renderer)
18 rtcTrack.addSink(renderer) 18 rtcTrack.addSink(renderer)
19 } 19 }
20 20
21 - fun removeRenderer(renderer: VideoSink) { 21 + open fun removeRenderer(renderer: VideoSink) {
22 rtcTrack.removeSink(renderer) 22 rtcTrack.removeSink(renderer)
23 sinks.remove(renderer) 23 sinks.remove(renderer)
24 } 24 }
  1 +package io.livekit.android.room.track.video
  2 +
  3 +import android.graphics.Rect
  4 +import android.os.Handler
  5 +import android.os.Looper
  6 +import android.view.View
  7 +import android.view.ViewTreeObserver
  8 +import androidx.annotation.CallSuper
  9 +import androidx.compose.ui.layout.LayoutCoordinates
  10 +import io.livekit.android.room.track.Track
  11 +import java.util.*
  12 +
  13 +abstract class VideoSinkVisibility : Observable() {
  14 + abstract fun isVisible(): Boolean
  15 + abstract fun size(): Track.Dimensions
  16 +
  17 + /**
  18 + * This should be called whenever the visibility or size has changed.
  19 + */
  20 + fun notifyChanged() {
  21 + setChanged()
  22 + notifyObservers()
  23 + }
  24 +
  25 + /**
  26 + * Called when this object is no longer needed and should clean up any unused resources.
  27 + */
  28 + @CallSuper
  29 + open fun close() {
  30 + deleteObservers()
  31 + }
  32 +}
  33 +
  34 +class ComposeVisibility : VideoSinkVisibility() {
  35 + private var lastCoordinates: LayoutCoordinates? = null
  36 +
  37 + override fun isVisible(): Boolean {
  38 + return (lastCoordinates?.isAttached == true && lastCoordinates?.size?.width != 0 && lastCoordinates?.size?.height != 0)
  39 + }
  40 +
  41 + override fun size(): Track.Dimensions {
  42 + val width = lastCoordinates?.size?.width ?: 0
  43 + val height = lastCoordinates?.size?.height ?: 0
  44 + return Track.Dimensions(width, height)
  45 + }
  46 +
  47 + fun onGloballyPositioned(layoutCoordinates: LayoutCoordinates) {
  48 + val lastVisible = isVisible()
  49 + val lastSize = size()
  50 + lastCoordinates = layoutCoordinates
  51 +
  52 + if (lastVisible != isVisible() || lastSize != size()) {
  53 + notifyChanged()
  54 + }
  55 + }
  56 +
  57 + fun onDispose() {
  58 + if (lastCoordinates == null) {
  59 + return
  60 + }
  61 + lastCoordinates = null
  62 + notifyChanged()
  63 + }
  64 +}
  65 +
  66 +class ViewVisibility(private val view: View) : VideoSinkVisibility() {
  67 +
  68 + private val handler = Handler(Looper.getMainLooper())
  69 + private val globalLayoutListener = object : ViewTreeObserver.OnGlobalLayoutListener {
  70 + val lastVisibility = false
  71 + val lastSize = Track.Dimensions(0, 0)
  72 +
  73 + override fun onGlobalLayout() {
  74 + handler.removeCallbacksAndMessages(null)
  75 + handler.postDelayed({
  76 + var shouldNotify = false
  77 + val newVisibility = isVisible()
  78 + val newSize = size()
  79 + if (newVisibility != lastVisibility) {
  80 + shouldNotify = true
  81 + }
  82 + if (newSize != lastSize) {
  83 + shouldNotify = true
  84 + }
  85 +
  86 + if (shouldNotify) {
  87 + notifyChanged()
  88 + }
  89 + }, 2000)
  90 + }
  91 + }
  92 +
  93 + init {
  94 + view.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)
  95 + }
  96 +
  97 + private val loc = IntArray(2)
  98 + private val viewRect = Rect()
  99 + private val windowRect = Rect()
  100 +
  101 + private fun isViewAncestorsVisible(view: View): Boolean {
  102 + if (view.visibility != View.VISIBLE) {
  103 + return false
  104 + }
  105 + val parent = view.parent as? View
  106 + if (parent != null) {
  107 + return isViewAncestorsVisible(parent)
  108 + }
  109 + return true
  110 + }
  111 +
  112 + override fun isVisible(): Boolean {
  113 + if (view.windowVisibility != View.VISIBLE || !isViewAncestorsVisible(view)) {
  114 + return false
  115 + }
  116 +
  117 + view.getLocationInWindow(loc)
  118 + viewRect.set(loc[0], loc[1], loc[0] + view.width, loc[1] + view.height)
  119 +
  120 + view.getWindowVisibleDisplayFrame(windowRect)
  121 + // Ensure window rect origin is at 0,0
  122 + windowRect.offset(-windowRect.left, -windowRect.top)
  123 +
  124 + return viewRect.intersect(windowRect)
  125 + }
  126 +
  127 + override fun size(): Track.Dimensions {
  128 + return Track.Dimensions(view.width, view.height)
  129 + }
  130 +
  131 + override fun close() {
  132 + super.close()
  133 + handler.removeCallbacksAndMessages(null)
  134 + view.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener)
  135 + }
  136 +}
@@ -15,4 +15,8 @@ fun <T, R> debounce( @@ -15,4 +15,8 @@ fun <T, R> debounce(
15 return@async destinationFunction(param) 15 return@async destinationFunction(param)
16 } 16 }
17 } 17 }
  18 +}
  19 +
  20 +fun <R> ((Unit) -> R).invoke() {
  21 + this.invoke(Unit)
18 } 22 }
@@ -55,7 +55,8 @@ class RoomTest { @@ -55,7 +55,8 @@ class RoomTest {
55 rtcEngine, 55 rtcEngine,
56 eglBase, 56 eglBase,
57 localParticantFactory, 57 localParticantFactory,
58 - DefaultsManager() 58 + DefaultsManager(),
  59 + coroutineRule.dispatcher
59 ) 60 )
60 } 61 }
61 62
1 package io.livekit.android.room.participant 1 package io.livekit.android.room.participant
2 2
  3 +import io.livekit.android.coroutines.TestCoroutineRule
3 import io.livekit.android.room.SignalClient 4 import io.livekit.android.room.SignalClient
4 import livekit.LivekitModels 5 import livekit.LivekitModels
5 import org.junit.Assert.* 6 import org.junit.Assert.*
6 import org.junit.Before 7 import org.junit.Before
  8 +import org.junit.Rule
7 import org.junit.Test 9 import org.junit.Test
8 import org.mockito.Mockito 10 import org.mockito.Mockito
9 11
10 class RemoteParticipantTest { 12 class RemoteParticipantTest {
11 13
  14 + @get:Rule
  15 + var coroutineRule = TestCoroutineRule()
  16 +
12 lateinit var signalClient: SignalClient 17 lateinit var signalClient: SignalClient
13 lateinit var participant: RemoteParticipant 18 lateinit var participant: RemoteParticipant
14 19
15 @Before 20 @Before
16 fun setup() { 21 fun setup() {
17 signalClient = Mockito.mock(SignalClient::class.java) 22 signalClient = Mockito.mock(SignalClient::class.java)
18 - participant = RemoteParticipant(signalClient, "sid") 23 + participant = RemoteParticipant(
  24 + "sid",
  25 + signalClient = signalClient,
  26 + ioDispatcher = coroutineRule.dispatcher
  27 + )
19 } 28 }
20 29
21 @Test 30 @Test
@@ -24,7 +33,7 @@ class RemoteParticipantTest { @@ -24,7 +33,7 @@ class RemoteParticipantTest {
24 .addTracks(TRACK_INFO) 33 .addTracks(TRACK_INFO)
25 .build() 34 .build()
26 35
27 - participant = RemoteParticipant(signalClient, info) 36 + participant = RemoteParticipant(info, signalClient, ioDispatcher = coroutineRule.dispatcher)
28 37
29 assertEquals(1, participant.tracks.values.size) 38 assertEquals(1, participant.tracks.values.size)
30 assertNotNull(participant.getTrackPublication(TRACK_INFO.sid)) 39 assertNotNull(participant.getTrackPublication(TRACK_INFO.sid))
@@ -46,7 +46,9 @@ class CallViewModel( @@ -46,7 +46,9 @@ class CallViewModel(
46 application, 46 application,
47 url, 47 url,
48 token, 48 token,
49 - ConnectOptions(), 49 + ConnectOptions(
  50 + autoManageVideo = true,
  51 + ),
50 this@CallViewModel 52 this@CallViewModel
51 ) 53 )
52 54
@@ -3,6 +3,7 @@ package io.livekit.android.composesample @@ -3,6 +3,7 @@ package io.livekit.android.composesample
3 import androidx.compose.foundation.layout.fillMaxSize 3 import androidx.compose.foundation.layout.fillMaxSize
4 import androidx.compose.runtime.* 4 import androidx.compose.runtime.*
5 import androidx.compose.ui.Modifier 5 import androidx.compose.ui.Modifier
  6 +import androidx.compose.ui.layout.onGloballyPositioned
6 import androidx.compose.ui.viewinterop.AndroidView 7 import androidx.compose.ui.viewinterop.AndroidView
7 import com.github.ajalt.timberkt.Timber 8 import com.github.ajalt.timberkt.Timber
8 import io.livekit.android.renderer.TextureViewRenderer 9 import io.livekit.android.renderer.TextureViewRenderer
@@ -10,39 +11,46 @@ import io.livekit.android.room.Room @@ -10,39 +11,46 @@ import io.livekit.android.room.Room
10 import io.livekit.android.room.participant.ParticipantListener 11 import io.livekit.android.room.participant.ParticipantListener
11 import io.livekit.android.room.participant.RemoteParticipant 12 import io.livekit.android.room.participant.RemoteParticipant
12 import io.livekit.android.room.track.RemoteTrackPublication 13 import io.livekit.android.room.track.RemoteTrackPublication
  14 +import io.livekit.android.room.track.RemoteVideoTrack
13 import io.livekit.android.room.track.Track 15 import io.livekit.android.room.track.Track
14 -import io.livekit.android.room.track.VideoTrack 16 +import io.livekit.android.room.track.video.ComposeVisibility
15 17
16 @Composable 18 @Composable
17 fun ParticipantItem( 19 fun ParticipantItem(
18 room: Room, 20 room: Room,
19 participant: RemoteParticipant, 21 participant: RemoteParticipant,
20 ) { 22 ) {
  23 + val videoSinkVisibility = remember(room, participant) { ComposeVisibility() }
21 var videoBound by remember(room, participant) { mutableStateOf(false) } 24 var videoBound by remember(room, participant) { mutableStateOf(false) }
22 - fun getVideoTrack(): VideoTrack? { 25 + fun getVideoTrack(): RemoteVideoTrack? {
23 return participant 26 return participant
24 .videoTracks.values 27 .videoTracks.values
25 - .firstOrNull()?.track as? VideoTrack 28 + .firstOrNull()?.track as? RemoteVideoTrack
26 } 29 }
27 30
28 - fun setupVideoIfNeeded(videoTrack: VideoTrack, view: TextureViewRenderer) { 31 + fun setupVideoIfNeeded(videoTrack: RemoteVideoTrack, view: TextureViewRenderer) {
29 if (videoBound) { 32 if (videoBound) {
30 return 33 return
31 } 34 }
32 35
33 videoBound = true 36 videoBound = true
34 Timber.v { "adding renderer to $videoTrack" } 37 Timber.v { "adding renderer to $videoTrack" }
35 - videoTrack.addRenderer(view) 38 + videoTrack.addRenderer(view, videoSinkVisibility)
  39 + }
  40 + DisposableEffect(room, participant) {
  41 + onDispose {
  42 + videoSinkVisibility.onDispose()
  43 + }
36 } 44 }
37 -  
38 AndroidView( 45 AndroidView(
39 factory = { context -> 46 factory = { context ->
40 TextureViewRenderer(context).apply { 47 TextureViewRenderer(context).apply {
41 room.initVideoRenderer(this) 48 room.initVideoRenderer(this)
42 -  
43 } 49 }
44 }, 50 },
45 - modifier = Modifier.fillMaxSize(), 51 + modifier = Modifier
  52 + .fillMaxSize()
  53 + .onGloballyPositioned { videoSinkVisibility.onGloballyPositioned(it) },
46 update = { view -> 54 update = { view ->
47 participant.listener = object : ParticipantListener { 55 participant.listener = object : ParticipantListener {
48 override fun onTrackSubscribed( 56 override fun onTrackSubscribed(
@@ -50,7 +58,7 @@ fun ParticipantItem( @@ -50,7 +58,7 @@ fun ParticipantItem(
50 publication: RemoteTrackPublication, 58 publication: RemoteTrackPublication,
51 participant: RemoteParticipant 59 participant: RemoteParticipant
52 ) { 60 ) {
53 - if (track is VideoTrack) { 61 + if (track is RemoteVideoTrack) {
54 setupVideoIfNeeded(track, view) 62 setupVideoIfNeeded(track, view)
55 } 63 }
56 } 64 }