davidliu
Committed by GitHub

Adaptive stream fixes (#73)

* tests

* update visibility immediately

* handle visibility change from hidden

* surface view version

* clean up and readme update
@@ -82,7 +82,8 @@ screenCaptureIntentLauncher.launch(mediaProjectionManager.createScreenCaptureInt @@ -82,7 +82,8 @@ screenCaptureIntentLauncher.launch(mediaProjectionManager.createScreenCaptureInt
82 82
83 ### Rendering subscribed tracks 83 ### Rendering subscribed tracks
84 84
85 -LiveKit uses WebRTC-provided `org.webrtc.SurfaceViewRenderer` to render video tracks. A `TextureView` implementation is also provided through `TextureViewRenderer`. Subscribed audio tracks are automatically played. 85 +LiveKit uses `SurfaceViewRenderer` to render video tracks. A `TextureView` implementation is also
  86 +provided through `TextureViewRenderer`. Subscribed audio tracks are automatically played.
86 87
87 ```kt 88 ```kt
88 class MainActivity : AppCompatActivity(), RoomListener { 89 class MainActivity : AppCompatActivity(), RoomListener {
@@ -121,7 +122,7 @@ class MainActivity : AppCompatActivity(), RoomListener { @@ -121,7 +122,7 @@ class MainActivity : AppCompatActivity(), RoomListener {
121 } 122 }
122 123
123 private fun attachVideo(videoTrack: VideoTrack) { 124 private fun attachVideo(videoTrack: VideoTrack) {
124 - // viewBinding.renderer is a `org.webrtc.SurfaceViewRenderer` in your 125 + // viewBinding.renderer is a `io.livekit.android.renderer.SurfaceViewRenderer` in your
125 // layout 126 // layout
126 videoTrack.addRenderer(viewBinding.renderer) 127 videoTrack.addRenderer(viewBinding.renderer)
127 } 128 }
  1 +package io.livekit.android.renderer
  2 +
  3 +import android.content.Context
  4 +import android.util.AttributeSet
  5 +import android.view.View
  6 +import io.livekit.android.room.track.video.ViewVisibility
  7 +import org.webrtc.SurfaceViewRenderer
  8 +
  9 +class SurfaceViewRenderer : SurfaceViewRenderer, ViewVisibility.Notifier {
  10 + constructor(context: Context) : super(context)
  11 + constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
  12 +
  13 + override var viewVisibility: ViewVisibility? = null
  14 + override fun onVisibilityChanged(changedView: View, visibility: Int) {
  15 + super.onVisibilityChanged(changedView, visibility)
  16 + viewVisibility?.recalculate()
  17 + }
  18 +}
@@ -10,30 +10,26 @@ @@ -10,30 +10,26 @@
10 package io.livekit.android.renderer 10 package io.livekit.android.renderer
11 11
12 import android.content.Context 12 import android.content.Context
13 -import android.view.SurfaceView  
14 -import android.view.SurfaceHolder  
15 -import org.webrtc.RendererCommon.RendererEvents  
16 -import org.webrtc.RendererCommon.VideoLayoutMeasure  
17 -import kotlin.jvm.JvmOverloads  
18 -import org.webrtc.RendererCommon.GlDrawer  
19 -import org.webrtc.RendererCommon.ScalingType  
20 import android.content.res.Resources.NotFoundException 13 import android.content.res.Resources.NotFoundException
21 import android.graphics.Matrix 14 import android.graphics.Matrix
  15 +import android.graphics.SurfaceTexture
22 import android.os.Looper 16 import android.os.Looper
23 import android.util.AttributeSet 17 import android.util.AttributeSet
  18 +import android.view.Surface
  19 +import android.view.SurfaceHolder
24 import android.view.TextureView 20 import android.view.TextureView
  21 +import android.view.View
  22 +import io.livekit.android.room.track.video.ViewVisibility
25 import org.webrtc.* 23 import org.webrtc.*
26 -import android.graphics.SurfaceTexture  
27 -import android.view.Surface  
28 -  
29 -import org.webrtc.ThreadUtils 24 +import org.webrtc.RendererCommon.*
30 import java.util.concurrent.CountDownLatch 25 import java.util.concurrent.CountDownLatch
31 26
32 27
33 /** 28 /**
34 - * Display the video stream on a SurfaceView. 29 + * Display the video stream on a TextureView.
35 */ 30 */
36 -class TextureViewRenderer : TextureView, SurfaceHolder.Callback, TextureView.SurfaceTextureListener, VideoSink, RendererEvents { 31 +class TextureViewRenderer : TextureView, SurfaceHolder.Callback, TextureView.SurfaceTextureListener, VideoSink,
  32 + RendererEvents, ViewVisibility.Notifier {
37 // Cached resource name. 33 // Cached resource name.
38 private val resourceName: String 34 private val resourceName: String
39 private val videoLayoutMeasure = VideoLayoutMeasure() 35 private val videoLayoutMeasure = VideoLayoutMeasure()
@@ -359,4 +355,10 @@ class TextureViewRenderer : TextureView, SurfaceHolder.Callback, TextureView.Sur @@ -359,4 +355,10 @@ class TextureViewRenderer : TextureView, SurfaceHolder.Callback, TextureView.Sur
359 companion object { 355 companion object {
360 private const val TAG = "SurfaceViewRenderer" 356 private const val TAG = "SurfaceViewRenderer"
361 } 357 }
  358 +
  359 + override var viewVisibility: ViewVisibility? = null
  360 + override fun onVisibilityChanged(changedView: View, visibility: Int) {
  361 + super.onVisibilityChanged(changedView, visibility)
  362 + viewVisibility?.recalculate()
  363 + }
362 } 364 }
@@ -33,12 +33,17 @@ class RemoteTrackPublication( @@ -33,12 +33,17 @@ class RemoteTrackPublication(
33 trackJob = CoroutineScope(ioDispatcher).launch { 33 trackJob = CoroutineScope(ioDispatcher).launch {
34 value.events.collect { 34 value.events.collect {
35 when (it) { 35 when (it) {
36 - is TrackEvent.VisibilityChanged -> handleVisibilityChanged(it)  
37 - is TrackEvent.VideoDimensionsChanged -> handleVideoDimensionsChanged(it) 36 + is TrackEvent.VisibilityChanged -> handleVisibilityChanged(it.isVisible)
  37 + is TrackEvent.VideoDimensionsChanged -> handleVideoDimensionsChanged(it.newDimensions)
38 is TrackEvent.StreamStateChanged -> handleStreamStateChanged(it) 38 is TrackEvent.StreamStateChanged -> handleStreamStateChanged(it)
39 } 39 }
40 } 40 }
41 } 41 }
  42 +
  43 + if (value is RemoteVideoTrack) {
  44 + handleVideoDimensionsChanged(value.lastDimensions)
  45 + handleVisibilityChanged(value.lastVisibility)
  46 + }
42 } 47 }
43 } 48 }
44 49
@@ -158,13 +163,13 @@ class RemoteTrackPublication( @@ -158,13 +163,13 @@ class RemoteTrackPublication(
158 sendUpdateTrackSettings.invoke() 163 sendUpdateTrackSettings.invoke()
159 } 164 }
160 165
161 - private fun handleVisibilityChanged(trackEvent: TrackEvent.VisibilityChanged) {  
162 - disabled = !trackEvent.isVisible 166 + private fun handleVisibilityChanged(isVisible: Boolean) {
  167 + disabled = !isVisible
163 sendUpdateTrackSettings.invoke() 168 sendUpdateTrackSettings.invoke()
164 } 169 }
165 170
166 - private fun handleVideoDimensionsChanged(trackEvent: TrackEvent.VideoDimensionsChanged) {  
167 - videoDimensions = trackEvent.newDimensions 171 + private fun handleVideoDimensionsChanged(newDimensions: Track.Dimensions) {
  172 + videoDimensions = newDimensions
168 sendUpdateTrackSettings.invoke() 173 sendUpdateTrackSettings.invoke()
169 } 174 }
170 175
@@ -23,8 +23,10 @@ class RemoteVideoTrack( @@ -23,8 +23,10 @@ class RemoteVideoTrack(
23 private val sinkVisibilityMap = mutableMapOf<VideoSink, VideoSinkVisibility>() 23 private val sinkVisibilityMap = mutableMapOf<VideoSink, VideoSinkVisibility>()
24 private val visibilities = sinkVisibilityMap.values 24 private val visibilities = sinkVisibilityMap.values
25 25
26 - private var lastVisibility = false  
27 - private var lastDimensions: Dimensions = Dimensions(0, 0) 26 + internal var lastVisibility = false
  27 + private set
  28 + internal var lastDimensions: Dimensions = Dimensions(0, 0)
  29 + private set
28 30
29 override fun addRenderer(renderer: VideoSink) { 31 override fun addRenderer(renderer: VideoSink) {
30 if (autoManageVideo && renderer is View) { 32 if (autoManageVideo && renderer is View) {
@@ -49,6 +51,9 @@ class RemoteVideoTrack( @@ -49,6 +51,9 @@ class RemoteVideoTrack(
49 super.removeRenderer(renderer) 51 super.removeRenderer(renderer)
50 val visibility = sinkVisibilityMap.remove(renderer) 52 val visibility = sinkVisibilityMap.remove(renderer)
51 visibility?.close() 53 visibility?.close()
  54 + if (autoManageVideo && visibility != null) {
  55 + recalculateVisibility()
  56 + }
52 } 57 }
53 58
54 override fun stop() { 59 override fun stop() {
@@ -8,6 +8,7 @@ import android.view.ViewTreeObserver @@ -8,6 +8,7 @@ import android.view.ViewTreeObserver
8 import androidx.annotation.CallSuper 8 import androidx.annotation.CallSuper
9 import androidx.compose.ui.layout.LayoutCoordinates 9 import androidx.compose.ui.layout.LayoutCoordinates
10 import io.livekit.android.room.track.Track 10 import io.livekit.android.room.track.Track
  11 +import io.livekit.android.room.track.video.ViewVisibility.Notifier
11 import java.util.* 12 import java.util.*
12 13
13 abstract class VideoSinkVisibility : Observable() { 14 abstract class VideoSinkVisibility : Observable() {
@@ -63,41 +64,55 @@ class ComposeVisibility : VideoSinkVisibility() { @@ -63,41 +64,55 @@ class ComposeVisibility : VideoSinkVisibility() {
63 } 64 }
64 } 65 }
65 66
  67 +/**
  68 + * A [VideoSinkVisibility] for views. If using a custom view other than the sdk provided renderers,
  69 + * you must implement [Notifier], override [View.onVisibilityChanged] and call through to [recalculate], or
  70 + * the visibility may not be calculated correctly.
  71 + */
66 class ViewVisibility(private val view: View) : VideoSinkVisibility() { 72 class ViewVisibility(private val view: View) : VideoSinkVisibility() {
67 73
  74 + private val lastVisibility = false
  75 + private val lastSize = Track.Dimensions(0, 0)
  76 +
68 private val handler = Handler(Looper.getMainLooper()) 77 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 - } 78 + private val globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
  79 + handler.removeCallbacksAndMessages(null)
  80 + handler.postDelayed({
  81 + recalculate()
  82 + }, 2000)
  83 + }
  84 +
  85 + interface Notifier {
  86 + var viewVisibility: ViewVisibility?
91 } 87 }
92 88
93 init { 89 init {
94 view.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener) 90 view.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)
  91 + if (view is Notifier) {
  92 + view.viewVisibility = this
  93 + }
95 } 94 }
96 95
97 private val loc = IntArray(2) 96 private val loc = IntArray(2)
98 private val viewRect = Rect() 97 private val viewRect = Rect()
99 private val windowRect = Rect() 98 private val windowRect = Rect()
100 99
  100 + fun recalculate() {
  101 + var shouldNotify = false
  102 + val newVisibility = isVisible()
  103 + val newSize = size()
  104 + if (newVisibility != lastVisibility) {
  105 + shouldNotify = true
  106 + }
  107 + if (newSize != lastSize) {
  108 + shouldNotify = true
  109 + }
  110 +
  111 + if (shouldNotify) {
  112 + notifyChanged()
  113 + }
  114 + }
  115 +
101 private fun isViewAncestorsVisible(view: View): Boolean { 116 private fun isViewAncestorsVisible(view: View): Boolean {
102 if (view.visibility != View.VISIBLE) { 117 if (view.visibility != View.VISIBLE) {
103 return false 118 return false
@@ -132,5 +147,8 @@ class ViewVisibility(private val view: View) : VideoSinkVisibility() { @@ -132,5 +147,8 @@ class ViewVisibility(private val view: View) : VideoSinkVisibility() {
132 super.close() 147 super.close()
133 handler.removeCallbacksAndMessages(null) 148 handler.removeCallbacksAndMessages(null)
134 view.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener) 149 view.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener)
  150 + if (view is Notifier && view.viewVisibility == this) {
  151 + view.viewVisibility = null
  152 + }
135 } 153 }
136 } 154 }
  1 +package io.livekit.android.mock
  2 +
  3 +import org.webrtc.VideoSink
  4 +import org.webrtc.VideoTrack
  5 +
  6 +class MockVideoStreamTrack(
  7 + val id: String = "id",
  8 + val kind: String = AUDIO_TRACK_KIND,
  9 + var enabled: Boolean = true,
  10 + var state: State = State.LIVE,
  11 +) : VideoTrack(1L) {
  12 + val sinks = mutableSetOf<VideoSink>()
  13 + override fun id(): String = id
  14 +
  15 + override fun kind(): String = kind
  16 +
  17 + override fun enabled(): Boolean = enabled
  18 +
  19 + override fun setEnabled(enable: Boolean): Boolean {
  20 + enabled = enable
  21 + return true
  22 + }
  23 +
  24 + override fun state(): State {
  25 + return state
  26 + }
  27 +
  28 + override fun dispose() {
  29 + }
  30 +
  31 + override fun addSink(sink: VideoSink) {
  32 + sinks.add(sink)
  33 + }
  34 +
  35 + override fun removeSink(sink: VideoSink) {
  36 + sinks.remove(sink)
  37 + }
  38 +}
  1 +package io.livekit.android.room.track
  2 +
  3 +import io.livekit.android.BaseTest
  4 +import io.livekit.android.events.EventCollector
  5 +import io.livekit.android.events.TrackEvent
  6 +import io.livekit.android.mock.MockVideoStreamTrack
  7 +import io.livekit.android.room.track.video.VideoSinkVisibility
  8 +import kotlinx.coroutines.ExperimentalCoroutinesApi
  9 +import org.junit.Assert
  10 +import org.junit.Before
  11 +import org.junit.Test
  12 +import org.webrtc.VideoFrame
  13 +import org.webrtc.VideoSink
  14 +
  15 +@OptIn(ExperimentalCoroutinesApi::class)
  16 +class RemoteVideoTrackTest : BaseTest() {
  17 +
  18 + lateinit var track: RemoteVideoTrack
  19 +
  20 + @Before
  21 + fun setup() {
  22 + track = RemoteVideoTrack(
  23 + name = "track",
  24 + rtcTrack = MockVideoStreamTrack(),
  25 + autoManageVideo = true,
  26 + dispatcher = coroutineRule.dispatcher
  27 + )
  28 + }
  29 +
  30 + @Test
  31 + fun defaultVisibility() = runTest {
  32 + Assert.assertFalse(track.lastVisibility)
  33 + Assert.assertEquals(Track.Dimensions(0, 0), track.lastDimensions)
  34 + }
  35 +
  36 + @Test
  37 + fun addVisibility() = runTest {
  38 + val visible = true
  39 + val size = Track.Dimensions(100, 100)
  40 + val visibility = CustomVisibility(visible = visible, size = size)
  41 + val eventCollector = EventCollector(track.events, coroutineRule.scope)
  42 + track.addRenderer(EmptyVideoSink(), visibility)
  43 + val events = eventCollector.stopCollecting()
  44 +
  45 + Assert.assertEquals(2, events.size)
  46 + val visibilityEvent = events.first { it is TrackEvent.VisibilityChanged } as TrackEvent.VisibilityChanged
  47 + val sizeEvent = events.first { it is TrackEvent.VideoDimensionsChanged } as TrackEvent.VideoDimensionsChanged
  48 +
  49 + Assert.assertEquals(visible, visibilityEvent.isVisible)
  50 + Assert.assertEquals(size, sizeEvent.newDimensions)
  51 + }
  52 +
  53 + @Test
  54 + fun removeVisibility() = runTest {
  55 + val sink = EmptyVideoSink()
  56 + val visibility = CustomVisibility(visible = true, size = Track.Dimensions(100, 100))
  57 + track.addRenderer(sink, visibility)
  58 + val eventCollector = EventCollector(track.events, coroutineRule.scope)
  59 + track.removeRenderer(sink)
  60 + val events = eventCollector.stopCollecting()
  61 +
  62 + Assert.assertEquals(2, events.size)
  63 + val visibilityEvent = events.first { it is TrackEvent.VisibilityChanged } as TrackEvent.VisibilityChanged
  64 + val sizeEvent = events.first { it is TrackEvent.VideoDimensionsChanged } as TrackEvent.VideoDimensionsChanged
  65 +
  66 + Assert.assertEquals(false, visibilityEvent.isVisible)
  67 + Assert.assertEquals(Track.Dimensions(0, 0), sizeEvent.newDimensions)
  68 + }
  69 +
  70 + @Test
  71 + fun changeVisibility() = runTest {
  72 + val visibility = CustomVisibility(visible = true, size = Track.Dimensions(100, 100))
  73 + track.addRenderer(EmptyVideoSink(), visibility)
  74 +
  75 + val visible = false
  76 + val size = Track.Dimensions(200, 200)
  77 +
  78 + val eventCollector = EventCollector(track.events, coroutineRule.scope)
  79 + visibility.visible = visible
  80 + visibility.size = size
  81 + val events = eventCollector.stopCollecting()
  82 +
  83 + Assert.assertEquals(2, events.size)
  84 + val visibilityEvent = events.first { it is TrackEvent.VisibilityChanged } as TrackEvent.VisibilityChanged
  85 + val sizeEvent = events.first { it is TrackEvent.VideoDimensionsChanged } as TrackEvent.VideoDimensionsChanged
  86 + Assert.assertEquals(visible, visibilityEvent.isVisible)
  87 + Assert.assertEquals(size, sizeEvent.newDimensions)
  88 + }
  89 +
  90 + @Test
  91 + fun multipleVisibility() = runTest {
  92 + val visible = true
  93 + val size = Track.Dimensions(100, 100)
  94 + val hiddenVisibility = CustomVisibility(visible = false, size = Track.Dimensions(0, 0))
  95 + val visibility = CustomVisibility(visible = visible, size = size)
  96 + track.addRenderer(EmptyVideoSink(), hiddenVisibility)
  97 +
  98 + val eventCollector = EventCollector(track.events, coroutineRule.scope)
  99 + track.addRenderer(EmptyVideoSink(), visibility)
  100 + val events = eventCollector.stopCollecting()
  101 +
  102 + // Make sure we're grabbing the max of visibility and dimensions
  103 + Assert.assertEquals(2, events.size)
  104 + val visibilityEvent = events.first { it is TrackEvent.VisibilityChanged } as TrackEvent.VisibilityChanged
  105 + val sizeEvent = events.first { it is TrackEvent.VideoDimensionsChanged } as TrackEvent.VideoDimensionsChanged
  106 +
  107 + Assert.assertEquals(visible, visibilityEvent.isVisible)
  108 + Assert.assertEquals(size, sizeEvent.newDimensions)
  109 + }
  110 +}
  111 +
  112 +private class EmptyVideoSink : VideoSink {
  113 + override fun onFrame(p0: VideoFrame?) {
  114 + }
  115 +}
  116 +
  117 +private class CustomVisibility(
  118 + visible: Boolean = false,
  119 + size: Track.Dimensions = Track.Dimensions(0, 0)
  120 +) : VideoSinkVisibility() {
  121 +
  122 + var visible = visible
  123 + set(value) {
  124 + field = value
  125 + notifyChanged()
  126 + }
  127 +
  128 + var size = size
  129 + set(value) {
  130 + field = value
  131 + notifyChanged()
  132 + }
  133 +
  134 + override fun isVisible() = visible
  135 +
  136 + override fun size() = size
  137 +
  138 +}