David Liu

fix sample app not handling track subscribing late

1 package io.livekit.android.room.track 1 package io.livekit.android.room.track
2 2
3 import io.livekit.android.room.participant.Participant 3 import io.livekit.android.room.participant.Participant
  4 +import io.livekit.android.util.flowDelegate
4 import livekit.LivekitModels 5 import livekit.LivekitModels
5 import java.lang.ref.WeakReference 6 import java.lang.ref.WeakReference
6 7
@@ -9,7 +10,7 @@ open class TrackPublication( @@ -9,7 +10,7 @@ open class TrackPublication(
9 track: Track?, 10 track: Track?,
10 participant: Participant 11 participant: Participant
11 ) { 12 ) {
12 - open var track: Track? = track 13 + open var track: Track? by flowDelegate(track)
13 internal set 14 internal set
14 var name: String 15 var name: String
15 internal set 16 internal set
@@ -32,9 +32,15 @@ android { @@ -32,9 +32,15 @@ android {
32 32
33 dependencies { 33 dependencies {
34 34
35 - implementation 'androidx.core:core-ktx:1.7.0'  
36 - implementation 'androidx.appcompat:appcompat:1.3.1'  
37 - implementation 'com.google.android.material:material:1.4.0' 35 + api "androidx.core:core-ktx:${versions.androidx_core}"
  36 + api 'androidx.appcompat:appcompat:1.4.0'
  37 + api 'com.google.android.material:material:1.4.0'
  38 + api deps.kotlinx_coroutines
  39 + api deps.timber
  40 + api "androidx.lifecycle:lifecycle-runtime-ktx:${versions.androidx_lifecycle}"
  41 + api "androidx.lifecycle:lifecycle-viewmodel-ktx:${versions.androidx_lifecycle}"
  42 + api "androidx.lifecycle:lifecycle-common-java8:${versions.androidx_lifecycle}"
  43 + api project(":livekit-android-sdk")
38 testImplementation 'junit:junit:4.+' 44 testImplementation 'junit:junit:4.+'
39 androidTestImplementation 'androidx.test.ext:junit:1.1.3' 45 androidTestImplementation 'androidx.test.ext:junit:1.1.3'
40 androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 46 androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
1 -package io.livekit.android.composesample 1 +package io.livekit.android.sample
2 2
3 import android.app.Application 3 import android.app.Application
4 import android.content.Intent 4 import android.content.Intent
@@ -62,7 +62,7 @@ class CallViewModel( @@ -62,7 +62,7 @@ class CallViewModel(
62 val flipButtonVideoEnabled = mutableFlipVideoButtonEnabled.hide() 62 val flipButtonVideoEnabled = mutableFlipVideoButtonEnabled.hide()
63 63
64 private val mutableScreencastEnabled = MutableLiveData(false) 64 private val mutableScreencastEnabled = MutableLiveData(false)
65 - val screencastEnabled = mutableScreencastEnabled.hide() 65 + val screenshareEnabled = mutableScreencastEnabled.hide()
66 66
67 init { 67 init {
68 viewModelScope.launch { 68 viewModelScope.launch {
@@ -152,7 +152,7 @@ class CallViewModel( @@ -152,7 +152,7 @@ class CallViewModel(
152 } 152 }
153 } 153 }
154 154
155 - fun flipVideo() { 155 + fun flipCamera() {
156 room.value?.localParticipant?.let { participant -> 156 room.value?.localParticipant?.let { participant ->
157 val videoTrack = participant.getTrackPublication(Track.Source.CAMERA) 157 val videoTrack = participant.getTrackPublication(Track.Source.CAMERA)
158 ?.track as? LocalVideoTrack 158 ?.track as? LocalVideoTrack
@@ -27,6 +27,7 @@ import com.google.accompanist.pager.ExperimentalPagerApi @@ -27,6 +27,7 @@ import com.google.accompanist.pager.ExperimentalPagerApi
27 import io.livekit.android.composesample.ui.theme.AppTheme 27 import io.livekit.android.composesample.ui.theme.AppTheme
28 import io.livekit.android.room.Room 28 import io.livekit.android.room.Room
29 import io.livekit.android.room.participant.Participant 29 import io.livekit.android.room.participant.Participant
  30 +import io.livekit.android.sample.CallViewModel
30 import kotlinx.coroutines.Dispatchers 31 import kotlinx.coroutines.Dispatchers
31 import kotlinx.parcelize.Parcelize 32 import kotlinx.parcelize.Parcelize
32 33
@@ -87,7 +88,7 @@ class CallActivity : AppCompatActivity() { @@ -87,7 +88,7 @@ class CallActivity : AppCompatActivity() {
87 val micEnabled by viewModel.micEnabled.observeAsState(true) 88 val micEnabled by viewModel.micEnabled.observeAsState(true)
88 val videoEnabled by viewModel.cameraEnabled.observeAsState(true) 89 val videoEnabled by viewModel.cameraEnabled.observeAsState(true)
89 val flipButtonEnabled by viewModel.flipButtonVideoEnabled.observeAsState(true) 90 val flipButtonEnabled by viewModel.flipButtonVideoEnabled.observeAsState(true)
90 - val screencastEnabled by viewModel.screencastEnabled.observeAsState(false) 91 + val screencastEnabled by viewModel.screenshareEnabled.observeAsState(false)
91 Content( 92 Content(
92 room, 93 room,
93 participants, 94 participants,
@@ -209,7 +210,7 @@ class CallActivity : AppCompatActivity() { @@ -209,7 +210,7 @@ class CallActivity : AppCompatActivity() {
209 ) 210 )
210 } 211 }
211 Surface( 212 Surface(
212 - onClick = { viewModel.flipVideo() }, 213 + onClick = { viewModel.flipCamera() },
213 ) { 214 ) {
214 Icon( 215 Icon(
215 painterResource(id = R.drawable.outline_flip_camera_android_24), 216 painterResource(id = R.drawable.outline_flip_camera_android_24),
@@ -37,7 +37,7 @@ dependencies { @@ -37,7 +37,7 @@ dependencies {
37 implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 37 implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
38 implementation deps.kotlinx_coroutines 38 implementation deps.kotlinx_coroutines
39 implementation 'com.google.android.material:material:1.4.0' 39 implementation 'com.google.android.material:material:1.4.0'
40 - implementation 'androidx.appcompat:appcompat:1.3.1' 40 + implementation 'androidx.appcompat:appcompat:1.4.0'
41 implementation "androidx.core:core-ktx:${versions.androidx_core}" 41 implementation "androidx.core:core-ktx:${versions.androidx_core}"
42 implementation "androidx.activity:activity-ktx:1.4.0" 42 implementation "androidx.activity:activity-ktx:1.4.0"
43 implementation 'androidx.fragment:fragment-ktx:1.3.6' 43 implementation 'androidx.fragment:fragment-ktx:1.3.6'
@@ -52,7 +52,6 @@ dependencies { @@ -52,7 +52,6 @@ dependencies {
52 implementation 'com.snakydesign.livedataextensions:lives:1.3.0' 52 implementation 'com.snakydesign.livedataextensions:lives:1.3.0'
53 implementation deps.timber 53 implementation deps.timber
54 implementation project(":sample-app-common") 54 implementation project(":sample-app-common")
55 - implementation project(":livekit-android-sdk")  
56 testImplementation 'junit:junit:4.12' 55 testImplementation 'junit:junit:4.12'
57 androidTestImplementation 'androidx.test.ext:junit:1.1.2' 56 androidTestImplementation 'androidx.test.ext:junit:1.1.2'
58 androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 57 androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
@@ -8,15 +8,15 @@ import android.os.Parcelable @@ -8,15 +8,15 @@ import android.os.Parcelable
8 import android.view.View 8 import android.view.View
9 import androidx.activity.result.contract.ActivityResultContracts 9 import androidx.activity.result.contract.ActivityResultContracts
10 import androidx.appcompat.app.AppCompatActivity 10 import androidx.appcompat.app.AppCompatActivity
  11 +import androidx.lifecycle.lifecycleScope
11 import androidx.recyclerview.widget.LinearLayoutManager 12 import androidx.recyclerview.widget.LinearLayoutManager
12 import com.github.ajalt.timberkt.Timber 13 import com.github.ajalt.timberkt.Timber
13 -import com.snakydesign.livedataextensions.combineLatest  
14 -import com.snakydesign.livedataextensions.scan  
15 -import com.snakydesign.livedataextensions.take  
16 import com.xwray.groupie.GroupieAdapter 14 import com.xwray.groupie.GroupieAdapter
17 -import io.livekit.android.room.participant.Participant 15 +import io.livekit.android.room.track.Track
18 import io.livekit.android.room.track.VideoTrack 16 import io.livekit.android.room.track.VideoTrack
19 import io.livekit.android.sample.databinding.CallActivityBinding 17 import io.livekit.android.sample.databinding.CallActivityBinding
  18 +import io.livekit.android.util.flow
  19 +import kotlinx.coroutines.flow.*
20 import kotlinx.parcelize.Parcelize 20 import kotlinx.parcelize.Parcelize
21 21
22 class CallActivity : AppCompatActivity() { 22 class CallActivity : AppCompatActivity() {
@@ -41,7 +41,7 @@ class CallActivity : AppCompatActivity() { @@ -41,7 +41,7 @@ class CallActivity : AppCompatActivity() {
41 if (resultCode != Activity.RESULT_OK || data == null) { 41 if (resultCode != Activity.RESULT_OK || data == null) {
42 return@registerForActivityResult 42 return@registerForActivityResult
43 } 43 }
44 - viewModel.setScreenshare(true, data) 44 + viewModel.startScreenCapture(data)
45 } 45 }
46 46
47 override fun onCreate(savedInstanceState: Bundle?) { 47 override fun onCreate(savedInstanceState: Bundle?) {
@@ -60,48 +60,67 @@ class CallActivity : AppCompatActivity() { @@ -60,48 +60,67 @@ class CallActivity : AppCompatActivity() {
60 this.adapter = adapter 60 this.adapter = adapter
61 } 61 }
62 62
63 - combineLatest(  
64 - viewModel.room,  
65 - viewModel.participants  
66 - ) { room, participants -> room to participants }  
67 - .observe(this) {  
68 -  
69 - val (room, participants) = it  
70 - val items = participants.map { participant -> ParticipantItem(room, participant) }  
71 - adapter.update(items)  
72 - } 63 + lifecycleScope.launchWhenCreated {
  64 + viewModel.room
  65 + .combine(viewModel.participants) { room, participants -> room to participants }
  66 + .collect { (room, participants) ->
  67 + if (room != null) {
  68 + val items = participants.map { participant -> ParticipantItem(room, participant) }
  69 + adapter.update(items)
  70 + }
  71 + }
  72 + }
73 73
74 // speaker view setup 74 // speaker view setup
75 - viewModel.room.take(1).observe(this) { room ->  
76 - room.initVideoRenderer(binding.speakerVideoView)  
77 - viewModel.activeSpeaker  
78 - .scan(Pair<Participant?, Participant?>(null, null)) { pair, participant ->  
79 - // old participant is first  
80 - // latest active participant is second  
81 - Pair(pair.second, participant)  
82 - }.observe(this) { (oldSpeaker, newSpeaker) ->  
83 - // Remove any renderering from the old speaker  
84 - oldSpeaker?.videoTracks  
85 - ?.values  
86 - ?.forEach { trackPublication ->  
87 - (trackPublication.track as? VideoTrack)?.removeRenderer(binding.speakerVideoView)  
88 - }  
89 -  
90 - binding.identityText.text = newSpeaker?.identity  
91 - val videoTrack = newSpeaker?.videoTracks?.values  
92 - ?.firstOrNull()  
93 - ?.track as? VideoTrack  
94 - if (videoTrack != null) { 75 + lifecycleScope.launchWhenCreated {
  76 + viewModel.room.filterNotNull().take(1)
  77 + .transform { room ->
  78 + // Initialize video renderer
  79 + room.initVideoRenderer(binding.speakerVideoView)
  80 +
  81 + // Observe primary speaker changes
  82 + emitAll(viewModel.primarySpeaker)
  83 + }.flatMapLatest { primarySpeaker ->
  84 + // Update new primary speaker identity
  85 + binding.identityText.text = primarySpeaker?.identity
  86 +
  87 + // observe videoTracks changes.
  88 + if (primarySpeaker != null) {
  89 + primarySpeaker::videoTracks.flow
  90 + .map { primarySpeaker to it }
  91 + } else {
  92 + emptyFlow()
  93 + }
  94 + }.flatMapLatest { (participant, videoTracks) ->
  95 +
  96 + for (videoTrack in videoTracks.values) {
  97 + Timber.e { "videoTrack is ${videoTrack.track}" }
  98 + }
  99 + // Prioritize any screenshare streams.
  100 + val trackPublication = participant.getTrackPublication(Track.Source.SCREEN_SHARE)
  101 + ?: participant.getTrackPublication(Track.Source.CAMERA)
  102 + ?: videoTracks.values.firstOrNull()
  103 + ?: return@flatMapLatest emptyFlow()
  104 +
  105 + trackPublication::track.flow
  106 + }.collect { videoTrack ->
  107 + // Cleanup old video track
  108 + val oldVideoTrack = binding.speakerVideoView.tag as? VideoTrack
  109 + oldVideoTrack?.removeRenderer(binding.speakerVideoView)
  110 +
  111 + // Bind new video track to video view.
  112 + if (videoTrack is VideoTrack) {
95 videoTrack.addRenderer(binding.speakerVideoView) 113 videoTrack.addRenderer(binding.speakerVideoView)
96 binding.speakerVideoView.visibility = View.VISIBLE 114 binding.speakerVideoView.visibility = View.VISIBLE
97 } else { 115 } else {
98 binding.speakerVideoView.visibility = View.INVISIBLE 116 binding.speakerVideoView.visibility = View.INVISIBLE
99 } 117 }
  118 + binding.speakerVideoView.tag = videoTrack
100 } 119 }
101 } 120 }
102 121
103 // Controls setup 122 // Controls setup
104 - viewModel.videoEnabled.observe(this) { enabled -> 123 + viewModel.cameraEnabled.observe(this) { enabled ->
105 binding.camera.setOnClickListener { viewModel.setCameraEnabled(!enabled) } 124 binding.camera.setOnClickListener { viewModel.setCameraEnabled(!enabled) }
106 binding.camera.setImageResource( 125 binding.camera.setImageResource(
107 if (enabled) R.drawable.outline_videocam_24 126 if (enabled) R.drawable.outline_videocam_24
@@ -121,7 +140,7 @@ class CallActivity : AppCompatActivity() { @@ -121,7 +140,7 @@ class CallActivity : AppCompatActivity() {
121 viewModel.screenshareEnabled.observe(this) { enabled -> 140 viewModel.screenshareEnabled.observe(this) { enabled ->
122 binding.screenShare.setOnClickListener { 141 binding.screenShare.setOnClickListener {
123 if (enabled) { 142 if (enabled) {
124 - viewModel.setScreenshare(!enabled) 143 + viewModel.stopScreenCapture()
125 } else { 144 } else {
126 requestMediaProjection() 145 requestMediaProjection()
127 } 146 }
1 -package io.livekit.android.sample  
2 -  
3 -import android.app.Application  
4 -import android.content.Intent  
5 -import androidx.lifecycle.AndroidViewModel  
6 -import androidx.lifecycle.MutableLiveData  
7 -import androidx.lifecycle.viewModelScope  
8 -import com.snakydesign.livedataextensions.distinctUntilChanged  
9 -import io.livekit.android.ConnectOptions  
10 -import io.livekit.android.LiveKit  
11 -import io.livekit.android.events.RoomEvent  
12 -import io.livekit.android.events.collect  
13 -import io.livekit.android.room.Room  
14 -import io.livekit.android.room.participant.Participant  
15 -import io.livekit.android.room.participant.RemoteParticipant  
16 -import io.livekit.android.room.track.CameraPosition  
17 -import io.livekit.android.room.track.LocalVideoTrack  
18 -import io.livekit.android.room.track.Track  
19 -import io.livekit.android.sample.util.hide  
20 -import kotlinx.coroutines.launch  
21 -  
22 -class CallViewModel(  
23 - val url: String,  
24 - val token: String,  
25 - application: Application  
26 -) : AndroidViewModel(application) {  
27 - private val mutableRoom = MutableLiveData<Room>()  
28 - val room = mutableRoom.hide()  
29 - private val mutableParticipants = MutableLiveData<List<Participant>>()  
30 - val participants = mutableParticipants.hide()  
31 - private val mutableActiveSpeaker = MutableLiveData<Participant>()  
32 - val activeSpeaker = mutableActiveSpeaker.hide().distinctUntilChanged()  
33 -  
34 - private val mutableVideoEnabled = MutableLiveData<Boolean>()  
35 - val videoEnabled = mutableVideoEnabled.hide().distinctUntilChanged()  
36 - private val mutableMicEnabled = MutableLiveData<Boolean>()  
37 - val micEnabled = mutableMicEnabled.hide().distinctUntilChanged()  
38 - private val mutableScreenshareEnabled = MutableLiveData<Boolean>()  
39 - val screenshareEnabled = mutableScreenshareEnabled.hide().distinctUntilChanged()  
40 -  
41 - init {  
42 - viewModelScope.launch {  
43 - val room = LiveKit.connect(  
44 - application,  
45 - url,  
46 - token,  
47 - ConnectOptions(),  
48 - null  
49 - )  
50 -  
51 - launch {  
52 - room.events.collect {  
53 - handleRoomEvent(it)  
54 - }  
55 - }  
56 -  
57 - val localParticipant = room.localParticipant  
58 - val audioTrack = localParticipant.createAudioTrack()  
59 - localParticipant.publishAudioTrack(audioTrack)  
60 - val videoTrack = localParticipant.createVideoTrack()  
61 - localParticipant.publishVideoTrack(videoTrack)  
62 - videoTrack.startCapture()  
63 -  
64 - updateParticipants(room)  
65 - mutableActiveSpeaker.value = localParticipant  
66 - mutableRoom.value = room  
67 -  
68 - mutableVideoEnabled.value =  
69 - !(localParticipant.getTrackPublication(Track.Source.CAMERA)?.muted ?: false)  
70 - mutableMicEnabled.value =  
71 - !(localParticipant.getTrackPublication(Track.Source.MICROPHONE)?.muted ?: false)  
72 - mutableScreenshareEnabled.value = false  
73 - }  
74 - }  
75 -  
76 - private fun handleRoomEvent(event: RoomEvent) {  
77 - when (event) {  
78 - is RoomEvent.ParticipantConnected -> updateParticipants(event.room)  
79 - is RoomEvent.ParticipantDisconnected -> updateParticipants(event.room)  
80 - is RoomEvent.ActiveSpeakersChanged -> handleActiveSpeakersChanged(event.speakers)  
81 - }  
82 - }  
83 -  
84 - private fun updateParticipants(room: Room) {  
85 -  
86 - val participantList = listOf(room.localParticipant) +  
87 - room.remoteParticipants  
88 - .keys  
89 - .sortedBy { it }  
90 - .mapNotNull { room.remoteParticipants[it] }  
91 - mutableParticipants.postValue(participantList)  
92 -  
93 - if (!participantList.contains(mutableActiveSpeaker.value) || mutableActiveSpeaker.value == null) {  
94 - // active speaker has left, choose someone else at random.  
95 - mutableActiveSpeaker.postValue(participantList.last())  
96 - }  
97 - }  
98 -  
99 - fun handleActiveSpeakersChanged(speakers: List<Participant>) {  
100 - // If old active speaker is still active, don't change.  
101 - if (speakers.isEmpty() || speakers.contains(mutableActiveSpeaker.value)) {  
102 - return  
103 - }  
104 - val newSpeaker = speakers  
105 - .filter { it is RemoteParticipant } // Try not to display local participant as speaker.  
106 - .firstOrNull() ?: return  
107 - mutableActiveSpeaker.postValue(newSpeaker)  
108 - }  
109 -  
110 - override fun onCleared() {  
111 - super.onCleared()  
112 - mutableRoom.value?.disconnect()  
113 - }  
114 -  
115 - fun setCameraEnabled(enabled: Boolean) {  
116 - val localParticipant = room.value?.localParticipant ?: return  
117 -  
118 - viewModelScope.launch {  
119 - localParticipant.setCameraEnabled(enabled)  
120 - mutableVideoEnabled.postValue(enabled)  
121 - }  
122 - }  
123 -  
124 - fun setMicEnabled(enabled: Boolean) {  
125 - val localParticipant = room.value?.localParticipant ?: return  
126 -  
127 - viewModelScope.launch {  
128 - localParticipant.setMicrophoneEnabled(enabled)  
129 - mutableMicEnabled.postValue(enabled)  
130 - }  
131 - }  
132 -  
133 - fun setScreenshare(  
134 - enabled: Boolean,  
135 - mediaProjectionPermissionResultData: Intent? = null  
136 - ) {  
137 - val localParticipant = room.value?.localParticipant ?: return  
138 -  
139 - viewModelScope.launch {  
140 - localParticipant.setScreenShareEnabled(enabled, mediaProjectionPermissionResultData)  
141 - mutableScreenshareEnabled.postValue(enabled)  
142 - }  
143 - }  
144 -  
145 - fun flipCamera() {  
146 - val localParticipant = room.value?.localParticipant ?: return  
147 - val localVideoTrack = localParticipant  
148 - .getTrackPublication(Track.Source.CAMERA)  
149 - ?.track as? LocalVideoTrack  
150 - ?: return  
151 -  
152 - val currentOptions = localVideoTrack.options  
153 - val newPosition = when (currentOptions.position) {  
154 - CameraPosition.FRONT -> CameraPosition.BACK  
155 - CameraPosition.BACK -> CameraPosition.FRONT  
156 - null -> null  
157 - }  
158 -  
159 - if (newPosition != null) {  
160 - localVideoTrack.restartTrack(options = currentOptions.copy(position = newPosition))  
161 - }  
162 - }  
163 -}  
@@ -23,12 +23,8 @@ @@ -23,12 +23,8 @@
23 23
24 <io.livekit.android.renderer.TextureViewRenderer 24 <io.livekit.android.renderer.TextureViewRenderer
25 android:id="@+id/speaker_video_view" 25 android:id="@+id/speaker_video_view"
26 - android:layout_width="0dp"  
27 - android:layout_height="0dp"  
28 - app:layout_constraintBottom_toTopOf="@id/audience_row"  
29 - app:layout_constraintEnd_toEndOf="parent"  
30 - app:layout_constraintStart_toStartOf="parent"  
31 - app:layout_constraintTop_toTopOf="parent" /> 26 + android:layout_width="match_parent"
  27 + android:layout_height="match_parent" />
32 </FrameLayout> 28 </FrameLayout>
33 29
34 <FrameLayout 30 <FrameLayout