davidliu
Committed by GitHub

Update samples (#102)

* Switch sample to use LiveKit.create() and Room.connect()

* refactor view based sample
@@ -76,6 +76,7 @@ class LiveKit { @@ -76,6 +76,7 @@ class LiveKit {
76 * @param url URL to LiveKit server (i.e. ws://mylivekitdeploy.io) 76 * @param url URL to LiveKit server (i.e. ws://mylivekitdeploy.io)
77 * @param listener Listener to Room events. LiveKit interactions take place with these callbacks 77 * @param listener Listener to Room events. LiveKit interactions take place with these callbacks
78 */ 78 */
  79 + @Deprecated("Use LiveKit.create() and Room.connect() instead.")
79 suspend fun connect( 80 suspend fun connect(
80 appContext: Context, 81 appContext: Context,
81 url: String, 82 url: String,
@@ -29,22 +29,20 @@ class CallViewModel( @@ -29,22 +29,20 @@ class CallViewModel(
29 val token: String, 29 val token: String,
30 application: Application 30 application: Application
31 ) : AndroidViewModel(application) { 31 ) : AndroidViewModel(application) {
32 - private val mutableRoom = MutableStateFlow<Room?>(null)  
33 - val room: MutableStateFlow<Room?> = mutableRoom  
34 - val participants = mutableRoom.flatMapLatest { room ->  
35 - if (room != null) {  
36 - room::remoteParticipants.flow  
37 - .map { remoteParticipants ->  
38 - listOf<Participant>(room.localParticipant) +  
39 - remoteParticipants  
40 - .keys  
41 - .sortedBy { it }  
42 - .mapNotNull { remoteParticipants[it] }  
43 - }  
44 - } else {  
45 - flowOf(emptyList()) 32 +
  33 + val room = LiveKit.create(
  34 + appContext = application,
  35 + options = RoomOptions(adaptiveStream = true, dynacast = true),
  36 + )
  37 +
  38 + val participants = room::remoteParticipants.flow
  39 + .map { remoteParticipants ->
  40 + listOf<Participant>(room.localParticipant) +
  41 + remoteParticipants
  42 + .keys
  43 + .sortedBy { it }
  44 + .mapNotNull { remoteParticipants[it] }
46 } 45 }
47 - }  
48 46
49 private val mutableError = MutableStateFlow<Throwable?>(null) 47 private val mutableError = MutableStateFlow<Throwable?>(null)
50 val error = mutableError.hide() 48 val error = mutableError.hide()
@@ -52,13 +50,7 @@ class CallViewModel( @@ -52,13 +50,7 @@ class CallViewModel(
52 private val mutablePrimarySpeaker = MutableStateFlow<Participant?>(null) 50 private val mutablePrimarySpeaker = MutableStateFlow<Participant?>(null)
53 val primarySpeaker: StateFlow<Participant?> = mutablePrimarySpeaker 51 val primarySpeaker: StateFlow<Participant?> = mutablePrimarySpeaker
54 52
55 - val activeSpeakers = mutableRoom.flatMapLatest { room ->  
56 - if (room != null) {  
57 - room::activeSpeakers.flow  
58 - } else {  
59 - flowOf(emptyList())  
60 - }  
61 - } 53 + val activeSpeakers = room::activeSpeakers.flow
62 54
63 private var localScreencastTrack: LocalScreencastVideoTrack? = null 55 private var localScreencastTrack: LocalScreencastVideoTrack? = null
64 56
@@ -87,60 +79,60 @@ class CallViewModel( @@ -87,60 +79,60 @@ class CallViewModel(
87 audioManager.start(null) 79 audioManager.start(null)
88 80
89 viewModelScope.launch { 81 viewModelScope.launch {
90 -  
91 launch { 82 launch {
92 error.collect { Timber.e(it) } 83 error.collect { Timber.e(it) }
93 } 84 }
94 85
95 - try {  
96 - val room = LiveKit.connect(  
97 - application,  
98 - url,  
99 - token,  
100 - roomOptions = RoomOptions(adaptiveStream = true, dynacast = true),  
101 - )  
102 -  
103 - // Create and publish audio/video tracks  
104 - val localParticipant = room.localParticipant  
105 - localParticipant.setMicrophoneEnabled(true)  
106 - mutableMicEnabled.postValue(localParticipant.isMicrophoneEnabled())  
107 -  
108 - localParticipant.setCameraEnabled(true)  
109 - mutableCameraEnabled.postValue(localParticipant.isCameraEnabled())  
110 - mutableRoom.value = room  
111 -  
112 - handlePrimarySpeaker(emptyList(), emptyList(), room)  
113 -  
114 - launch {  
115 - combine(participants, activeSpeakers) { participants, speakers -> participants to speakers }  
116 - .collect { (participantsList, speakers) ->  
117 - handlePrimarySpeaker(  
118 - participantsList,  
119 - speakers,  
120 - room  
121 - )  
122 - }  
123 - } 86 + launch {
  87 + combine(participants, activeSpeakers) { participants, speakers -> participants to speakers }
  88 + .collect { (participantsList, speakers) ->
  89 + handlePrimarySpeaker(
  90 + participantsList,
  91 + speakers,
  92 + room
  93 + )
  94 + }
  95 + }
124 96
125 - launch {  
126 - room.events.collect {  
127 - when (it) {  
128 - is RoomEvent.FailedToConnect -> mutableError.value = it.error  
129 - is RoomEvent.DataReceived -> {  
130 - val identity = it.participant.identity ?: ""  
131 - val message = it.data.toString(Charsets.UTF_8)  
132 - mutableDataReceived.emit("$identity: $message")  
133 - } 97 + launch {
  98 + room.events.collect {
  99 + when (it) {
  100 + is RoomEvent.FailedToConnect -> mutableError.value = it.error
  101 + is RoomEvent.DataReceived -> {
  102 + val identity = it.participant.identity ?: ""
  103 + val message = it.data.toString(Charsets.UTF_8)
  104 + mutableDataReceived.emit("$identity: $message")
134 } 105 }
  106 + else -> {}
135 } 107 }
136 } 108 }
137 - } catch (e: Throwable) {  
138 - mutableError.value = e  
139 } 109 }
  110 + connectToRoom()
140 } 111 }
141 } 112 }
142 113
143 - private fun handlePrimarySpeaker(participantsList: List<Participant>, speakers: List<Participant>, room: Room) { 114 + private suspend fun connectToRoom() {
  115 + try {
  116 + room.connect(
  117 + url = url,
  118 + token = token,
  119 + )
  120 +
  121 + // Create and publish audio/video tracks
  122 + val localParticipant = room.localParticipant
  123 + localParticipant.setMicrophoneEnabled(true)
  124 + mutableMicEnabled.postValue(localParticipant.isMicrophoneEnabled())
  125 +
  126 + localParticipant.setCameraEnabled(true)
  127 + mutableCameraEnabled.postValue(localParticipant.isCameraEnabled())
  128 +
  129 + handlePrimarySpeaker(emptyList(), emptyList(), room)
  130 + } catch (e: Throwable) {
  131 + mutableError.value = e
  132 + }
  133 + }
  134 +
  135 + private fun handlePrimarySpeaker(participantsList: List<Participant>, speakers: List<Participant>, room: Room?) {
144 136
145 var speaker = mutablePrimarySpeaker.value 137 var speaker = mutablePrimarySpeaker.value
146 138
@@ -161,7 +153,7 @@ class CallViewModel( @@ -161,7 +153,7 @@ class CallViewModel(
161 // Default to another person in room, or local participant. 153 // Default to another person in room, or local participant.
162 speaker = participantsList.filterIsInstance<RemoteParticipant>() 154 speaker = participantsList.filterIsInstance<RemoteParticipant>()
163 .firstOrNull() 155 .firstOrNull()
164 - ?: room.localParticipant 156 + ?: room?.localParticipant
165 } 157 }
166 158
167 if (speakers.isNotEmpty() && !speakers.contains(speaker)) { 159 if (speakers.isNotEmpty() && !speakers.contains(speaker)) {
@@ -178,7 +170,7 @@ class CallViewModel( @@ -178,7 +170,7 @@ class CallViewModel(
178 } 170 }
179 171
180 fun startScreenCapture(mediaProjectionPermissionResultData: Intent) { 172 fun startScreenCapture(mediaProjectionPermissionResultData: Intent) {
181 - val localParticipant = room.value?.localParticipant ?: return 173 + val localParticipant = room.localParticipant
182 viewModelScope.launch { 174 viewModelScope.launch {
183 val screencastTrack = 175 val screencastTrack =
184 localParticipant.createScreencastTrack(mediaProjectionPermissionResultData = mediaProjectionPermissionResultData) 176 localParticipant.createScreencastTrack(mediaProjectionPermissionResultData = mediaProjectionPermissionResultData)
@@ -199,7 +191,7 @@ class CallViewModel( @@ -199,7 +191,7 @@ class CallViewModel(
199 viewModelScope.launch { 191 viewModelScope.launch {
200 localScreencastTrack?.let { localScreencastVideoTrack -> 192 localScreencastTrack?.let { localScreencastVideoTrack ->
201 localScreencastVideoTrack.stop() 193 localScreencastVideoTrack.stop()
202 - room.value?.localParticipant?.unpublishTrack(localScreencastVideoTrack) 194 + room.localParticipant.unpublishTrack(localScreencastVideoTrack)
203 mutableScreencastEnabled.postValue(localScreencastTrack?.enabled ?: false) 195 mutableScreencastEnabled.postValue(localScreencastTrack?.enabled ?: false)
204 } 196 }
205 } 197 }
@@ -207,40 +199,36 @@ class CallViewModel( @@ -207,40 +199,36 @@ class CallViewModel(
207 199
208 override fun onCleared() { 200 override fun onCleared() {
209 super.onCleared() 201 super.onCleared()
210 - mutableRoom.value?.disconnect() 202 + room.disconnect()
211 audioManager.stop() 203 audioManager.stop()
212 } 204 }
213 205
214 fun setMicEnabled(enabled: Boolean) { 206 fun setMicEnabled(enabled: Boolean) {
215 viewModelScope.launch { 207 viewModelScope.launch {
216 - val localParticipant = room.value?.localParticipant ?: return@launch  
217 - localParticipant.setMicrophoneEnabled(enabled) 208 + room.localParticipant.setMicrophoneEnabled(enabled)
218 mutableMicEnabled.postValue(enabled) 209 mutableMicEnabled.postValue(enabled)
219 } 210 }
220 } 211 }
221 212
222 fun setCameraEnabled(enabled: Boolean) { 213 fun setCameraEnabled(enabled: Boolean) {
223 viewModelScope.launch { 214 viewModelScope.launch {
224 - val localParticipant = room.value?.localParticipant ?: return@launch  
225 - localParticipant.setCameraEnabled(enabled) 215 + room.localParticipant.setCameraEnabled(enabled)
226 mutableCameraEnabled.postValue(enabled) 216 mutableCameraEnabled.postValue(enabled)
227 } 217 }
228 } 218 }
229 219
230 fun flipCamera() { 220 fun flipCamera() {
231 - room.value?.localParticipant?.let { participant ->  
232 - val videoTrack = participant.getTrackPublication(Track.Source.CAMERA)  
233 - ?.track as? LocalVideoTrack  
234 - ?: return@let  
235 -  
236 - val newOptions = when (videoTrack.options.position) {  
237 - CameraPosition.FRONT -> LocalVideoTrackOptions(position = CameraPosition.BACK)  
238 - CameraPosition.BACK -> LocalVideoTrackOptions(position = CameraPosition.FRONT)  
239 - else -> LocalVideoTrackOptions()  
240 - }  
241 -  
242 - videoTrack.restartTrack(newOptions) 221 + val videoTrack = room.localParticipant.getTrackPublication(Track.Source.CAMERA)
  222 + ?.track as? LocalVideoTrack
  223 + ?: return
  224 +
  225 + val newOptions = when (videoTrack.options.position) {
  226 + CameraPosition.FRONT -> LocalVideoTrackOptions(position = CameraPosition.BACK)
  227 + CameraPosition.BACK -> LocalVideoTrackOptions(position = CameraPosition.FRONT)
  228 + else -> LocalVideoTrackOptions()
243 } 229 }
  230 +
  231 + videoTrack.restartTrack(newOptions)
244 } 232 }
245 233
246 fun dismissError() { 234 fun dismissError() {
@@ -249,17 +237,17 @@ class CallViewModel( @@ -249,17 +237,17 @@ class CallViewModel(
249 237
250 fun sendData(message: String) { 238 fun sendData(message: String) {
251 viewModelScope.launch { 239 viewModelScope.launch {
252 - room.value?.localParticipant?.publishData(message.toByteArray(Charsets.UTF_8)) 240 + room.localParticipant.publishData(message.toByteArray(Charsets.UTF_8))
253 } 241 }
254 } 242 }
255 243
256 fun toggleSubscriptionPermissions() { 244 fun toggleSubscriptionPermissions() {
257 mutablePermissionAllowed.value = !mutablePermissionAllowed.value 245 mutablePermissionAllowed.value = !mutablePermissionAllowed.value
258 - room.value?.localParticipant?.setTrackSubscriptionPermissions(mutablePermissionAllowed.value) 246 + room.localParticipant.setTrackSubscriptionPermissions(mutablePermissionAllowed.value)
259 } 247 }
260 248
261 fun simulateMigration() { 249 fun simulateMigration() {
262 - room.value?.sendSimulateScenario( 250 + room.sendSimulateScenario(
263 LivekitRtc.SimulateScenario.newBuilder() 251 LivekitRtc.SimulateScenario.newBuilder()
264 .setMigration(true) 252 .setMigration(true)
265 .build() 253 .build()
@@ -268,21 +256,14 @@ class CallViewModel( @@ -268,21 +256,14 @@ class CallViewModel(
268 256
269 fun reconnect() { 257 fun reconnect() {
270 Timber.e { "Reconnecting." } 258 Timber.e { "Reconnecting." }
271 - val room = mutableRoom.value ?: return  
272 - mutableRoom.value = null  
273 mutablePrimarySpeaker.value = null 259 mutablePrimarySpeaker.value = null
274 room.disconnect() 260 room.disconnect()
275 viewModelScope.launch { 261 viewModelScope.launch {
276 - room.connect(  
277 - url,  
278 - token  
279 - )  
280 - mutableRoom.value = room 262 + connectToRoom()
281 } 263 }
282 } 264 }
283 } 265 }
284 266
285 private fun <T> LiveData<T>.hide(): LiveData<T> = this 267 private fun <T> LiveData<T>.hide(): LiveData<T> = this
286 -  
287 private fun <T> MutableStateFlow<T>.hide(): StateFlow<T> = this 268 private fun <T> MutableStateFlow<T>.hide(): StateFlow<T> = this
288 private fun <T> Flow<T>.hide(): Flow<T> = this 269 private fun <T> Flow<T>.hide(): Flow<T> = this
@@ -60,7 +60,7 @@ class CallActivity : AppCompatActivity() { @@ -60,7 +60,7 @@ class CallActivity : AppCompatActivity() {
60 60
61 // Setup compose view. 61 // Setup compose view.
62 setContent { 62 setContent {
63 - val room by viewModel.room.collectAsState() 63 + val room = viewModel.room
64 val participants by viewModel.participants.collectAsState(initial = emptyList()) 64 val participants by viewModel.participants.collectAsState(initial = emptyList())
65 val primarySpeaker by viewModel.primarySpeaker.collectAsState() 65 val primarySpeaker by viewModel.primarySpeaker.collectAsState()
66 val activeSpeakers by viewModel.activeSpeakers.collectAsState(initial = emptyList()) 66 val activeSpeakers by viewModel.activeSpeakers.collectAsState(initial = emptyList())
@@ -4,7 +4,6 @@ import android.app.Activity @@ -4,7 +4,6 @@ import android.app.Activity
4 import android.media.projection.MediaProjectionManager 4 import android.media.projection.MediaProjectionManager
5 import android.os.Bundle 5 import android.os.Bundle
6 import android.os.Parcelable 6 import android.os.Parcelable
7 -import android.view.View  
8 import android.widget.EditText 7 import android.widget.EditText
9 import android.widget.Toast 8 import android.widget.Toast
10 import androidx.activity.result.contract.ActivityResultContracts 9 import androidx.activity.result.contract.ActivityResultContracts
@@ -13,11 +12,8 @@ import androidx.appcompat.app.AppCompatActivity @@ -13,11 +12,8 @@ import androidx.appcompat.app.AppCompatActivity
13 import androidx.lifecycle.lifecycleScope 12 import androidx.lifecycle.lifecycleScope
14 import androidx.recyclerview.widget.LinearLayoutManager 13 import androidx.recyclerview.widget.LinearLayoutManager
15 import com.xwray.groupie.GroupieAdapter 14 import com.xwray.groupie.GroupieAdapter
16 -import io.livekit.android.room.track.Track  
17 -import io.livekit.android.room.track.VideoTrack  
18 import io.livekit.android.sample.databinding.CallActivityBinding 15 import io.livekit.android.sample.databinding.CallActivityBinding
19 -import io.livekit.android.util.flow  
20 -import kotlinx.coroutines.flow.* 16 +import kotlinx.coroutines.flow.collectLatest
21 import kotlinx.parcelize.Parcelize 17 import kotlinx.parcelize.Parcelize
22 18
23 class CallActivity : AppCompatActivity() { 19 class CallActivity : AppCompatActivity() {
@@ -48,87 +44,32 @@ class CallActivity : AppCompatActivity() { @@ -48,87 +44,32 @@ class CallActivity : AppCompatActivity() {
48 setContentView(binding.root) 44 setContentView(binding.root)
49 45
50 // Audience row setup 46 // Audience row setup
51 - binding.audienceRow.layoutManager =  
52 - LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)  
53 - val adapter = GroupieAdapter()  
54 - 47 + val audienceAdapter = GroupieAdapter()
55 binding.audienceRow.apply { 48 binding.audienceRow.apply {
56 - this.adapter = adapter 49 + layoutManager = LinearLayoutManager(this@CallActivity, LinearLayoutManager.HORIZONTAL, false)
  50 + adapter = audienceAdapter
57 } 51 }
58 52
59 lifecycleScope.launchWhenCreated { 53 lifecycleScope.launchWhenCreated {
60 - viewModel.room  
61 - .combine(viewModel.participants) { room, participants -> room to participants }  
62 - .collect { (room, participants) ->  
63 - if (room != null) {  
64 - val items = participants.map { participant -> ParticipantItem(room, participant) }  
65 - adapter.update(items)  
66 - } 54 + viewModel.participants
  55 + .collect { participants ->
  56 + val items = participants.map { participant -> ParticipantItem(viewModel.room, participant) }
  57 + audienceAdapter.update(items)
67 } 58 }
68 } 59 }
69 60
70 // speaker view setup 61 // speaker view setup
  62 + val speakerAdapter = GroupieAdapter()
  63 + binding.speakerView.apply {
  64 + layoutManager = LinearLayoutManager(this@CallActivity, LinearLayoutManager.HORIZONTAL, false)
  65 + adapter = speakerAdapter
  66 + }
71 lifecycleScope.launchWhenCreated { 67 lifecycleScope.launchWhenCreated {
72 - viewModel.room.filterNotNull().take(1)  
73 - .transform { room ->  
74 - // Initialize video renderer  
75 - room.initVideoRenderer(binding.speakerVideoView)  
76 -  
77 - // Observe primary speaker changes  
78 - emitAll(viewModel.primarySpeaker)  
79 - }.flatMapLatest { primarySpeaker ->  
80 - if (primarySpeaker != null) {  
81 - flowOf(primarySpeaker)  
82 - } else {  
83 - emptyFlow()  
84 - }  
85 - }.collect { participant ->  
86 - // Update new primary speaker identity  
87 - binding.identityText.text = participant.identity  
88 -  
89 - // observe videoTracks changes.  
90 - val videoTrackFlow = participant::videoTracks.flow  
91 - .map { participant to it }  
92 - .flatMapLatest { (participant, videoTracks) ->  
93 - // Prioritize any screenshare streams.  
94 - val trackPublication = participant.getTrackPublication(Track.Source.SCREEN_SHARE)  
95 - ?: participant.getTrackPublication(Track.Source.CAMERA)  
96 - ?: videoTracks.firstOrNull()?.first  
97 - ?: return@flatMapLatest emptyFlow()  
98 -  
99 - trackPublication::track.flow  
100 - }  
101 -  
102 - // observe audioTracks changes.  
103 - val mutedFlow = participant::audioTracks.flow  
104 - .flatMapLatest { tracks ->  
105 - val audioTrack = tracks.firstOrNull()?.first  
106 - if (audioTrack != null) {  
107 - audioTrack::muted.flow  
108 - } else {  
109 - flowOf(true)  
110 - }  
111 - }  
112 -  
113 - combine(videoTrackFlow, mutedFlow) { videoTrack, muted ->  
114 - videoTrack to muted  
115 - }.collect { (videoTrack, muted) ->  
116 - // Cleanup old video track  
117 - val oldVideoTrack = binding.speakerVideoView.tag as? VideoTrack  
118 - oldVideoTrack?.removeRenderer(binding.speakerVideoView)  
119 -  
120 - // Bind new video track to video view.  
121 - if (videoTrack is VideoTrack) {  
122 - videoTrack.addRenderer(binding.speakerVideoView)  
123 - binding.speakerVideoView.visibility = View.VISIBLE  
124 - } else {  
125 - binding.speakerVideoView.visibility = View.INVISIBLE  
126 - }  
127 - binding.speakerVideoView.tag = videoTrack  
128 -  
129 - binding.muteIndicator.visibility = if (muted) View.VISIBLE else View.INVISIBLE  
130 - }  
131 - } 68 + viewModel.primarySpeaker.collectLatest { speaker ->
  69 + val items = listOfNotNull(speaker)
  70 + .map { participant -> ParticipantItem(viewModel.room, participant, speakerView = true) }
  71 + speakerAdapter.update(items)
  72 + }
132 } 73 }
133 74
134 // Controls setup 75 // Controls setup
@@ -204,10 +145,9 @@ class CallActivity : AppCompatActivity() { @@ -204,10 +145,9 @@ class CallActivity : AppCompatActivity() {
204 } 145 }
205 146
206 override fun onDestroy() { 147 override fun onDestroy() {
  148 + binding.audienceRow.adapter = null
  149 + binding.speakerView.adapter = null
207 super.onDestroy() 150 super.onDestroy()
208 -  
209 - // Release video views  
210 - binding.speakerVideoView.release()  
211 } 151 }
212 152
213 companion object { 153 companion object {
@@ -7,20 +7,18 @@ import com.xwray.groupie.viewbinding.GroupieViewHolder @@ -7,20 +7,18 @@ import com.xwray.groupie.viewbinding.GroupieViewHolder
7 import io.livekit.android.room.Room 7 import io.livekit.android.room.Room
8 import io.livekit.android.room.participant.ConnectionQuality 8 import io.livekit.android.room.participant.ConnectionQuality
9 import io.livekit.android.room.participant.Participant 9 import io.livekit.android.room.participant.Participant
10 -import io.livekit.android.room.participant.ParticipantListener  
11 -import io.livekit.android.room.participant.RemoteParticipant  
12 -import io.livekit.android.room.track.RemoteTrackPublication  
13 import io.livekit.android.room.track.Track 10 import io.livekit.android.room.track.Track
14 import io.livekit.android.room.track.VideoTrack 11 import io.livekit.android.room.track.VideoTrack
15 import io.livekit.android.sample.databinding.ParticipantItemBinding 12 import io.livekit.android.sample.databinding.ParticipantItemBinding
16 import io.livekit.android.util.flow 13 import io.livekit.android.util.flow
17 import kotlinx.coroutines.* 14 import kotlinx.coroutines.*
18 -import kotlinx.coroutines.flow.flatMapLatest  
19 -import kotlinx.coroutines.flow.flowOf 15 +import kotlinx.coroutines.flow.*
20 16
  17 +@OptIn(ExperimentalCoroutinesApi::class)
21 class ParticipantItem( 18 class ParticipantItem(
22 private val room: Room, 19 private val room: Room,
23 - private val participant: Participant 20 + private val participant: Participant,
  21 + private val speakerView: Boolean = false,
24 ) : BindableItem<ParticipantItemBinding>() { 22 ) : BindableItem<ParticipantItemBinding>() {
25 23
26 private var boundVideoTrack: VideoTrack? = null 24 private var boundVideoTrack: VideoTrack? = null
@@ -67,25 +65,44 @@ class ParticipantItem( @@ -67,25 +65,44 @@ class ParticipantItem(
67 if (quality == ConnectionQuality.POOR) View.VISIBLE else View.INVISIBLE 65 if (quality == ConnectionQuality.POOR) View.VISIBLE else View.INVISIBLE
68 } 66 }
69 } 67 }
70 - participant.listener = object : ParticipantListener {  
71 - override fun onTrackSubscribed(  
72 - track: Track,  
73 - publication: RemoteTrackPublication,  
74 - participant: RemoteParticipant  
75 - ) {  
76 - if (track !is VideoTrack) return  
77 - if (publication.source == Track.Source.CAMERA) {  
78 - setupVideoIfNeeded(track, viewBinding)  
79 - }  
80 - }  
81 68
82 - override fun onTrackUnpublished(  
83 - publication: RemoteTrackPublication,  
84 - participant: RemoteParticipant  
85 - ) {  
86 - super.onTrackUnpublished(publication, participant)  
87 - Timber.e { "Track unpublished" } 69 + // observe videoTracks changes.
  70 + val videoTrackPubFlow = participant::videoTracks.flow
  71 + .map { participant to it }
  72 + .flatMapLatest { (participant, videoTracks) ->
  73 + // Prioritize any screenshare streams.
  74 + val trackPublication = participant.getTrackPublication(Track.Source.SCREEN_SHARE)
  75 + ?: participant.getTrackPublication(Track.Source.CAMERA)
  76 + ?: videoTracks.firstOrNull()?.first
  77 +
  78 + flowOf(trackPublication)
88 } 79 }
  80 +
  81 + coroutineScope?.launch {
  82 + videoTrackPubFlow
  83 + .flatMapLatest { pub ->
  84 + if (pub != null) {
  85 + pub::track.flow
  86 + } else {
  87 + flowOf(null)
  88 + }
  89 + }
  90 + .collectLatest { videoTrack ->
  91 + setupVideoIfNeeded(videoTrack as? VideoTrack, viewBinding)
  92 + }
  93 + }
  94 + coroutineScope?.launch {
  95 + videoTrackPubFlow
  96 + .flatMapLatest { pub ->
  97 + if (pub != null) {
  98 + pub::muted.flow
  99 + } else {
  100 + flowOf(true)
  101 + }
  102 + }
  103 + .collectLatest { muted ->
  104 + viewBinding.renderer.visibleOrInvisible(!muted)
  105 + }
89 } 106 }
90 val existingTrack = getVideoTrack() 107 val existingTrack = getVideoTrack()
91 if (existingTrack != null) { 108 if (existingTrack != null) {
@@ -97,14 +114,14 @@ class ParticipantItem( @@ -97,14 +114,14 @@ class ParticipantItem(
97 return participant.getTrackPublication(Track.Source.CAMERA)?.track as? VideoTrack 114 return participant.getTrackPublication(Track.Source.CAMERA)?.track as? VideoTrack
98 } 115 }
99 116
100 - internal fun setupVideoIfNeeded(videoTrack: VideoTrack, viewBinding: ParticipantItemBinding) {  
101 - if (boundVideoTrack != null) { 117 + private fun setupVideoIfNeeded(videoTrack: VideoTrack?, viewBinding: ParticipantItemBinding) {
  118 + if (boundVideoTrack == videoTrack) {
102 return 119 return
103 } 120 }
104 - 121 + boundVideoTrack?.removeRenderer(viewBinding.renderer)
105 boundVideoTrack = videoTrack 122 boundVideoTrack = videoTrack
106 Timber.v { "adding renderer to $videoTrack" } 123 Timber.v { "adding renderer to $videoTrack" }
107 - videoTrack.addRenderer(viewBinding.renderer) 124 + videoTrack?.addRenderer(viewBinding.renderer)
108 } 125 }
109 126
110 override fun unbind(viewHolder: GroupieViewHolder<ParticipantItemBinding>) { 127 override fun unbind(viewHolder: GroupieViewHolder<ParticipantItemBinding>) {
@@ -115,5 +132,25 @@ class ParticipantItem( @@ -115,5 +132,25 @@ class ParticipantItem(
115 boundVideoTrack = null 132 boundVideoTrack = null
116 } 133 }
117 134
118 - override fun getLayout(): Int = R.layout.participant_item 135 + override fun getLayout(): Int =
  136 + if (speakerView)
  137 + R.layout.speaker_view
  138 + else
  139 + R.layout.participant_item
  140 +}
  141 +
  142 +private fun View.visibleOrGone(visible: Boolean) {
  143 + visibility = if (visible) {
  144 + View.VISIBLE
  145 + } else {
  146 + View.GONE
  147 + }
  148 +}
  149 +
  150 +private fun View.visibleOrInvisible(visible: Boolean) {
  151 + visibility = if (visible) {
  152 + View.VISIBLE
  153 + } else {
  154 + View.INVISIBLE
  155 + }
119 } 156 }
@@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
4 android:layout_height="match_parent" 4 android:layout_height="match_parent"
5 android:keepScreenOn="true"> 5 android:keepScreenOn="true">
6 6
7 - <FrameLayout 7 + <androidx.recyclerview.widget.RecyclerView
8 android:id="@+id/speaker_view" 8 android:id="@+id/speaker_view"
9 android:layout_width="0dp" 9 android:layout_width="0dp"
10 android:layout_height="0dp" 10 android:layout_height="0dp"
@@ -14,49 +14,7 @@ @@ -14,49 +14,7 @@
14 app:layout_constraintStart_toStartOf="parent" 14 app:layout_constraintStart_toStartOf="parent"
15 app:layout_constraintTop_toTopOf="parent"> 15 app:layout_constraintTop_toTopOf="parent">
16 16
17 - <ImageView  
18 - android:layout_width="120dp"  
19 - android:layout_height="120dp"  
20 - android:layout_gravity="center"  
21 - android:src="@drawable/outline_videocam_off_24"  
22 - app:tint="@color/no_video_participant" />  
23 -  
24 - <io.livekit.android.renderer.TextureViewRenderer  
25 - android:id="@+id/speaker_video_view"  
26 - android:layout_width="match_parent"  
27 - android:layout_height="match_parent" />  
28 - </FrameLayout>  
29 -  
30 - <FrameLayout  
31 - android:id="@+id/identity_bar"  
32 - android:layout_width="0dp"  
33 - android:layout_height="30dp"  
34 - android:background="#80000000"  
35 - app:layout_constraintBottom_toBottomOf="@id/speaker_view"  
36 - app:layout_constraintEnd_toEndOf="parent"  
37 - app:layout_constraintStart_toStartOf="parent" />  
38 -  
39 - <ImageView  
40 - android:id="@+id/mute_indicator"  
41 - android:layout_width="24dp"  
42 - android:layout_height="24dp"  
43 - android:layout_marginEnd="@dimen/identity_bar_padding"  
44 - android:src="@drawable/outline_mic_off_24"  
45 - app:layout_constraintBottom_toBottomOf="@id/identity_bar"  
46 - app:layout_constraintEnd_toEndOf="@id/identity_bar"  
47 - app:layout_constraintTop_toTopOf="@id/identity_bar"  
48 - app:tint="#BB0000" />  
49 -  
50 - <TextView  
51 - android:id="@+id/identity_text"  
52 - android:layout_width="0dp"  
53 - android:layout_height="wrap_content"  
54 - android:layout_marginStart="@dimen/identity_bar_padding"  
55 - android:ellipsize="end"  
56 - app:layout_constraintBottom_toBottomOf="@id/identity_bar"  
57 - app:layout_constraintEnd_toStartOf="@id/mute_indicator"  
58 - app:layout_constraintStart_toStartOf="@id/identity_bar"  
59 - app:layout_constraintTop_toTopOf="@id/identity_bar" /> 17 + </androidx.recyclerview.widget.RecyclerView>
60 18
61 <androidx.recyclerview.widget.RecyclerView 19 <androidx.recyclerview.widget.RecyclerView
62 android:id="@+id/audience_row" 20 android:id="@+id/audience_row"
  1 +<?xml version="1.0" encoding="utf-8"?>
  2 +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3 + xmlns:app="http://schemas.android.com/apk/res-auto"
  4 + android:layout_width="match_parent"
  5 + android:layout_height="match_parent"
  6 + android:background="@color/no_video_background">
  7 +
  8 + <FrameLayout
  9 + android:layout_width="match_parent"
  10 + android:layout_height="match_parent"
  11 + android:background="@color/no_video_background"
  12 + app:layout_constraintStart_toStartOf="parent"
  13 + app:layout_constraintTop_toTopOf="parent">
  14 +
  15 + <ImageView
  16 + android:layout_width="60dp"
  17 + android:layout_height="60dp"
  18 + android:layout_gravity="center"
  19 + android:src="@drawable/outline_videocam_off_24"
  20 + app:tint="@color/no_video_participant" />
  21 +
  22 + <io.livekit.android.renderer.TextureViewRenderer
  23 + android:id="@+id/renderer"
  24 + android:layout_width="match_parent"
  25 + android:layout_height="match_parent" />
  26 +
  27 + <ImageView
  28 + android:id="@+id/connection_quality"
  29 + android:layout_width="24dp"
  30 + android:layout_height="24dp"
  31 + android:layout_gravity="top|end"
  32 + android:layout_marginTop="@dimen/identity_bar_padding"
  33 + android:layout_marginEnd="@dimen/identity_bar_padding"
  34 + android:alpha="0.5"
  35 + android:src="@drawable/wifi_strength_1"
  36 + android:visibility="invisible"
  37 + app:tint="#FF0000" />
  38 + </FrameLayout>
  39 +
  40 + <FrameLayout
  41 + android:id="@+id/identity_bar"
  42 + android:layout_width="0dp"
  43 + android:layout_height="30dp"
  44 + android:background="#80000000"
  45 + app:layout_constraintBottom_toBottomOf="parent"
  46 + app:layout_constraintEnd_toEndOf="parent"
  47 + app:layout_constraintStart_toStartOf="parent" />
  48 +
  49 + <ImageView
  50 + android:id="@+id/mute_indicator"
  51 + android:layout_width="24dp"
  52 + android:layout_height="24dp"
  53 + android:layout_marginEnd="@dimen/identity_bar_padding"
  54 + android:src="@drawable/outline_mic_off_24"
  55 + android:visibility="gone"
  56 + app:layout_constraintBottom_toBottomOf="@id/identity_bar"
  57 + app:layout_constraintEnd_toEndOf="@id/identity_bar"
  58 + app:layout_constraintTop_toTopOf="@id/identity_bar"
  59 + app:tint="#BB0000" />
  60 +
  61 + <TextView
  62 + android:id="@+id/identity_text"
  63 + android:layout_width="0dp"
  64 + android:layout_height="wrap_content"
  65 + android:layout_marginStart="@dimen/identity_bar_padding"
  66 + android:ellipsize="end"
  67 + app:layout_constraintBottom_toBottomOf="@id/identity_bar"
  68 + app:layout_constraintEnd_toStartOf="@id/mute_indicator"
  69 + app:layout_constraintStart_toStartOf="@id/identity_bar"
  70 + app:layout_constraintTop_toTopOf="@id/identity_bar" />
  71 +</androidx.constraintlayout.widget.ConstraintLayout>