davidliu

Revert "Revert "Update samples (#102)""

This reverts commit facd5b6d.
@@ -30,22 +30,20 @@ class CallViewModel( @@ -30,22 +30,20 @@ class CallViewModel(
30 val token: String, 30 val token: String,
31 application: Application 31 application: Application
32 ) : AndroidViewModel(application) { 32 ) : AndroidViewModel(application) {
33 - private val mutableRoom = MutableStateFlow<Room?>(null)  
34 - val room: MutableStateFlow<Room?> = mutableRoom  
35 - val participants = mutableRoom.flatMapLatest { room ->  
36 - if (room != null) {  
37 - room::remoteParticipants.flow  
38 - .map { remoteParticipants ->  
39 - listOf<Participant>(room.localParticipant) +  
40 - remoteParticipants  
41 - .keys  
42 - .sortedBy { it }  
43 - .mapNotNull { remoteParticipants[it] }  
44 - }  
45 - } else {  
46 - flowOf(emptyList()) 33 +
  34 + val room = LiveKit.create(
  35 + appContext = application,
  36 + options = RoomOptions(adaptiveStream = true, dynacast = true),
  37 + )
  38 +
  39 + val participants = room::remoteParticipants.flow
  40 + .map { remoteParticipants ->
  41 + listOf<Participant>(room.localParticipant) +
  42 + remoteParticipants
  43 + .keys
  44 + .sortedBy { it }
  45 + .mapNotNull { remoteParticipants[it] }
47 } 46 }
48 - }  
49 47
50 private val mutableError = MutableStateFlow<Throwable?>(null) 48 private val mutableError = MutableStateFlow<Throwable?>(null)
51 val error = mutableError.hide() 49 val error = mutableError.hide()
@@ -53,13 +51,7 @@ class CallViewModel( @@ -53,13 +51,7 @@ class CallViewModel(
53 private val mutablePrimarySpeaker = MutableStateFlow<Participant?>(null) 51 private val mutablePrimarySpeaker = MutableStateFlow<Participant?>(null)
54 val primarySpeaker: StateFlow<Participant?> = mutablePrimarySpeaker 52 val primarySpeaker: StateFlow<Participant?> = mutablePrimarySpeaker
55 53
56 - val activeSpeakers = mutableRoom.flatMapLatest { room ->  
57 - if (room != null) {  
58 - room::activeSpeakers.flow  
59 - } else {  
60 - flowOf(emptyList())  
61 - }  
62 - } 54 + val activeSpeakers = room::activeSpeakers.flow
63 55
64 private var localScreencastTrack: LocalScreencastVideoTrack? = null 56 private var localScreencastTrack: LocalScreencastVideoTrack? = null
65 57
@@ -84,61 +76,60 @@ class CallViewModel( @@ -84,61 +76,60 @@ class CallViewModel(
84 val audioHandler = AudioSwitchHandler(application) 76 val audioHandler = AudioSwitchHandler(application)
85 init { 77 init {
86 viewModelScope.launch { 78 viewModelScope.launch {
87 -  
88 launch { 79 launch {
89 error.collect { Timber.e(it) } 80 error.collect { Timber.e(it) }
90 } 81 }
91 82
92 - try {  
93 - val room = LiveKit.connect(  
94 - application,  
95 - url,  
96 - token,  
97 - roomOptions = RoomOptions(adaptiveStream = true, dynacast = true),  
98 - overrides = LiveKitOverrides(audioHandler = audioHandler)  
99 - )  
100 -  
101 - // Create and publish audio/video tracks  
102 - val localParticipant = room.localParticipant  
103 - localParticipant.setMicrophoneEnabled(true)  
104 - mutableMicEnabled.postValue(localParticipant.isMicrophoneEnabled())  
105 -  
106 - localParticipant.setCameraEnabled(true)  
107 - mutableCameraEnabled.postValue(localParticipant.isCameraEnabled())  
108 - mutableRoom.value = room  
109 -  
110 - handlePrimarySpeaker(emptyList(), emptyList(), room)  
111 -  
112 - launch {  
113 - combine(participants, activeSpeakers) { participants, speakers -> participants to speakers }  
114 - .collect { (participantsList, speakers) ->  
115 - handlePrimarySpeaker(  
116 - participantsList,  
117 - speakers,  
118 - room  
119 - )  
120 - }  
121 - } 83 + launch {
  84 + combine(participants, activeSpeakers) { participants, speakers -> participants to speakers }
  85 + .collect { (participantsList, speakers) ->
  86 + handlePrimarySpeaker(
  87 + participantsList,
  88 + speakers,
  89 + room
  90 + )
  91 + }
  92 + }
122 93
123 - launch {  
124 - room.events.collect {  
125 - when (it) {  
126 - is RoomEvent.FailedToConnect -> mutableError.value = it.error  
127 - is RoomEvent.DataReceived -> {  
128 - val identity = it.participant.identity ?: ""  
129 - val message = it.data.toString(Charsets.UTF_8)  
130 - mutableDataReceived.emit("$identity: $message")  
131 - } 94 + launch {
  95 + room.events.collect {
  96 + when (it) {
  97 + is RoomEvent.FailedToConnect -> mutableError.value = it.error
  98 + is RoomEvent.DataReceived -> {
  99 + val identity = it.participant.identity ?: ""
  100 + val message = it.data.toString(Charsets.UTF_8)
  101 + mutableDataReceived.emit("$identity: $message")
132 } 102 }
  103 + else -> {}
133 } 104 }
134 } 105 }
135 - } catch (e: Throwable) {  
136 - mutableError.value = e  
137 } 106 }
  107 + connectToRoom()
138 } 108 }
139 } 109 }
140 110
141 - private fun handlePrimarySpeaker(participantsList: List<Participant>, speakers: List<Participant>, room: Room) { 111 + private suspend fun connectToRoom() {
  112 + try {
  113 + room.connect(
  114 + url = url,
  115 + token = token,
  116 + )
  117 +
  118 + // Create and publish audio/video tracks
  119 + val localParticipant = room.localParticipant
  120 + localParticipant.setMicrophoneEnabled(true)
  121 + mutableMicEnabled.postValue(localParticipant.isMicrophoneEnabled())
  122 +
  123 + localParticipant.setCameraEnabled(true)
  124 + mutableCameraEnabled.postValue(localParticipant.isCameraEnabled())
  125 +
  126 + handlePrimarySpeaker(emptyList(), emptyList(), room)
  127 + } catch (e: Throwable) {
  128 + mutableError.value = e
  129 + }
  130 + }
  131 +
  132 + private fun handlePrimarySpeaker(participantsList: List<Participant>, speakers: List<Participant>, room: Room?) {
142 133
143 var speaker = mutablePrimarySpeaker.value 134 var speaker = mutablePrimarySpeaker.value
144 135
@@ -159,7 +150,7 @@ class CallViewModel( @@ -159,7 +150,7 @@ class CallViewModel(
159 // Default to another person in room, or local participant. 150 // Default to another person in room, or local participant.
160 speaker = participantsList.filterIsInstance<RemoteParticipant>() 151 speaker = participantsList.filterIsInstance<RemoteParticipant>()
161 .firstOrNull() 152 .firstOrNull()
162 - ?: room.localParticipant 153 + ?: room?.localParticipant
163 } 154 }
164 155
165 if (speakers.isNotEmpty() && !speakers.contains(speaker)) { 156 if (speakers.isNotEmpty() && !speakers.contains(speaker)) {
@@ -176,7 +167,7 @@ class CallViewModel( @@ -176,7 +167,7 @@ class CallViewModel(
176 } 167 }
177 168
178 fun startScreenCapture(mediaProjectionPermissionResultData: Intent) { 169 fun startScreenCapture(mediaProjectionPermissionResultData: Intent) {
179 - val localParticipant = room.value?.localParticipant ?: return 170 + val localParticipant = room.localParticipant
180 viewModelScope.launch { 171 viewModelScope.launch {
181 val screencastTrack = 172 val screencastTrack =
182 localParticipant.createScreencastTrack(mediaProjectionPermissionResultData = mediaProjectionPermissionResultData) 173 localParticipant.createScreencastTrack(mediaProjectionPermissionResultData = mediaProjectionPermissionResultData)
@@ -197,7 +188,7 @@ class CallViewModel( @@ -197,7 +188,7 @@ class CallViewModel(
197 viewModelScope.launch { 188 viewModelScope.launch {
198 localScreencastTrack?.let { localScreencastVideoTrack -> 189 localScreencastTrack?.let { localScreencastVideoTrack ->
199 localScreencastVideoTrack.stop() 190 localScreencastVideoTrack.stop()
200 - room.value?.localParticipant?.unpublishTrack(localScreencastVideoTrack) 191 + room.localParticipant.unpublishTrack(localScreencastVideoTrack)
201 mutableScreencastEnabled.postValue(localScreencastTrack?.enabled ?: false) 192 mutableScreencastEnabled.postValue(localScreencastTrack?.enabled ?: false)
202 } 193 }
203 } 194 }
@@ -205,39 +196,35 @@ class CallViewModel( @@ -205,39 +196,35 @@ class CallViewModel(
205 196
206 override fun onCleared() { 197 override fun onCleared() {
207 super.onCleared() 198 super.onCleared()
208 - mutableRoom.value?.disconnect() 199 + room.disconnect()
209 } 200 }
210 201
211 fun setMicEnabled(enabled: Boolean) { 202 fun setMicEnabled(enabled: Boolean) {
212 viewModelScope.launch { 203 viewModelScope.launch {
213 - val localParticipant = room.value?.localParticipant ?: return@launch  
214 - localParticipant.setMicrophoneEnabled(enabled) 204 + room.localParticipant.setMicrophoneEnabled(enabled)
215 mutableMicEnabled.postValue(enabled) 205 mutableMicEnabled.postValue(enabled)
216 } 206 }
217 } 207 }
218 208
219 fun setCameraEnabled(enabled: Boolean) { 209 fun setCameraEnabled(enabled: Boolean) {
220 viewModelScope.launch { 210 viewModelScope.launch {
221 - val localParticipant = room.value?.localParticipant ?: return@launch  
222 - localParticipant.setCameraEnabled(enabled) 211 + room.localParticipant.setCameraEnabled(enabled)
223 mutableCameraEnabled.postValue(enabled) 212 mutableCameraEnabled.postValue(enabled)
224 } 213 }
225 } 214 }
226 215
227 fun flipCamera() { 216 fun flipCamera() {
228 - room.value?.localParticipant?.let { participant ->  
229 - val videoTrack = participant.getTrackPublication(Track.Source.CAMERA)  
230 - ?.track as? LocalVideoTrack  
231 - ?: return@let  
232 -  
233 - val newOptions = when (videoTrack.options.position) {  
234 - CameraPosition.FRONT -> LocalVideoTrackOptions(position = CameraPosition.BACK)  
235 - CameraPosition.BACK -> LocalVideoTrackOptions(position = CameraPosition.FRONT)  
236 - else -> LocalVideoTrackOptions()  
237 - }  
238 -  
239 - videoTrack.restartTrack(newOptions) 217 + val videoTrack = room.localParticipant.getTrackPublication(Track.Source.CAMERA)
  218 + ?.track as? LocalVideoTrack
  219 + ?: return
  220 +
  221 + val newOptions = when (videoTrack.options.position) {
  222 + CameraPosition.FRONT -> LocalVideoTrackOptions(position = CameraPosition.BACK)
  223 + CameraPosition.BACK -> LocalVideoTrackOptions(position = CameraPosition.FRONT)
  224 + else -> LocalVideoTrackOptions()
240 } 225 }
  226 +
  227 + videoTrack.restartTrack(newOptions)
241 } 228 }
242 229
243 fun dismissError() { 230 fun dismissError() {
@@ -246,17 +233,17 @@ class CallViewModel( @@ -246,17 +233,17 @@ class CallViewModel(
246 233
247 fun sendData(message: String) { 234 fun sendData(message: String) {
248 viewModelScope.launch { 235 viewModelScope.launch {
249 - room.value?.localParticipant?.publishData(message.toByteArray(Charsets.UTF_8)) 236 + room.localParticipant.publishData(message.toByteArray(Charsets.UTF_8))
250 } 237 }
251 } 238 }
252 239
253 fun toggleSubscriptionPermissions() { 240 fun toggleSubscriptionPermissions() {
254 mutablePermissionAllowed.value = !mutablePermissionAllowed.value 241 mutablePermissionAllowed.value = !mutablePermissionAllowed.value
255 - room.value?.localParticipant?.setTrackSubscriptionPermissions(mutablePermissionAllowed.value) 242 + room.localParticipant.setTrackSubscriptionPermissions(mutablePermissionAllowed.value)
256 } 243 }
257 244
258 fun simulateMigration() { 245 fun simulateMigration() {
259 - room.value?.sendSimulateScenario( 246 + room.sendSimulateScenario(
260 LivekitRtc.SimulateScenario.newBuilder() 247 LivekitRtc.SimulateScenario.newBuilder()
261 .setMigration(true) 248 .setMigration(true)
262 .build() 249 .build()
@@ -265,21 +252,14 @@ class CallViewModel( @@ -265,21 +252,14 @@ class CallViewModel(
265 252
266 fun reconnect() { 253 fun reconnect() {
267 Timber.e { "Reconnecting." } 254 Timber.e { "Reconnecting." }
268 - val room = mutableRoom.value ?: return  
269 - mutableRoom.value = null  
270 mutablePrimarySpeaker.value = null 255 mutablePrimarySpeaker.value = null
271 room.disconnect() 256 room.disconnect()
272 viewModelScope.launch { 257 viewModelScope.launch {
273 - room.connect(  
274 - url,  
275 - token  
276 - )  
277 - mutableRoom.value = room 258 + connectToRoom()
278 } 259 }
279 } 260 }
280 } 261 }
281 262
282 private fun <T> LiveData<T>.hide(): LiveData<T> = this 263 private fun <T> LiveData<T>.hide(): LiveData<T> = this
283 -  
284 private fun <T> MutableStateFlow<T>.hide(): StateFlow<T> = this 264 private fun <T> MutableStateFlow<T>.hide(): StateFlow<T> = this
285 private fun <T> Flow<T>.hide(): Flow<T> = this 265 private fun <T> Flow<T>.hide(): Flow<T> = this
@@ -62,7 +62,7 @@ class CallActivity : AppCompatActivity() { @@ -62,7 +62,7 @@ class CallActivity : AppCompatActivity() {
62 62
63 // Setup compose view. 63 // Setup compose view.
64 setContent { 64 setContent {
65 - val room by viewModel.room.collectAsState() 65 + val room = viewModel.room
66 val participants by viewModel.participants.collectAsState(initial = emptyList()) 66 val participants by viewModel.participants.collectAsState(initial = emptyList())
67 val primarySpeaker by viewModel.primarySpeaker.collectAsState() 67 val primarySpeaker by viewModel.primarySpeaker.collectAsState()
68 val activeSpeakers by viewModel.activeSpeakers.collectAsState(initial = emptyList()) 68 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,13 +12,10 @@ import androidx.appcompat.app.AppCompatActivity @@ -13,13 +12,10 @@ 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.sample.dialog.showDebugMenuDialog 16 import io.livekit.android.sample.dialog.showDebugMenuDialog
20 import io.livekit.android.sample.dialog.showSelectAudioDeviceDialog 17 import io.livekit.android.sample.dialog.showSelectAudioDeviceDialog
21 -import io.livekit.android.util.flow  
22 -import kotlinx.coroutines.flow.* 18 +import kotlinx.coroutines.flow.collectLatest
23 import kotlinx.parcelize.Parcelize 19 import kotlinx.parcelize.Parcelize
24 20
25 class CallActivity : AppCompatActivity() { 21 class CallActivity : AppCompatActivity() {
@@ -50,87 +46,32 @@ class CallActivity : AppCompatActivity() { @@ -50,87 +46,32 @@ class CallActivity : AppCompatActivity() {
50 setContentView(binding.root) 46 setContentView(binding.root)
51 47
52 // Audience row setup 48 // Audience row setup
53 - binding.audienceRow.layoutManager =  
54 - LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)  
55 - val adapter = GroupieAdapter()  
56 - 49 + val audienceAdapter = GroupieAdapter()
57 binding.audienceRow.apply { 50 binding.audienceRow.apply {
58 - this.adapter = adapter 51 + layoutManager = LinearLayoutManager(this@CallActivity, LinearLayoutManager.HORIZONTAL, false)
  52 + adapter = audienceAdapter
59 } 53 }
60 54
61 lifecycleScope.launchWhenCreated { 55 lifecycleScope.launchWhenCreated {
62 - viewModel.room  
63 - .combine(viewModel.participants) { room, participants -> room to participants }  
64 - .collect { (room, participants) ->  
65 - if (room != null) {  
66 - val items = participants.map { participant -> ParticipantItem(room, participant) }  
67 - adapter.update(items)  
68 - } 56 + viewModel.participants
  57 + .collect { participants ->
  58 + val items = participants.map { participant -> ParticipantItem(viewModel.room, participant) }
  59 + audienceAdapter.update(items)
69 } 60 }
70 } 61 }
71 62
72 // speaker view setup 63 // speaker view setup
  64 + val speakerAdapter = GroupieAdapter()
  65 + binding.speakerView.apply {
  66 + layoutManager = LinearLayoutManager(this@CallActivity, LinearLayoutManager.HORIZONTAL, false)
  67 + adapter = speakerAdapter
  68 + }
73 lifecycleScope.launchWhenCreated { 69 lifecycleScope.launchWhenCreated {
74 - viewModel.room.filterNotNull().take(1)  
75 - .transform { room ->  
76 - // Initialize video renderer  
77 - room.initVideoRenderer(binding.speakerVideoView)  
78 -  
79 - // Observe primary speaker changes  
80 - emitAll(viewModel.primarySpeaker)  
81 - }.flatMapLatest { primarySpeaker ->  
82 - if (primarySpeaker != null) {  
83 - flowOf(primarySpeaker)  
84 - } else {  
85 - emptyFlow()  
86 - }  
87 - }.collect { participant ->  
88 - // Update new primary speaker identity  
89 - binding.identityText.text = participant.identity  
90 -  
91 - // observe videoTracks changes.  
92 - val videoTrackFlow = participant::videoTracks.flow  
93 - .map { participant to it }  
94 - .flatMapLatest { (participant, videoTracks) ->  
95 - // Prioritize any screenshare streams.  
96 - val trackPublication = participant.getTrackPublication(Track.Source.SCREEN_SHARE)  
97 - ?: participant.getTrackPublication(Track.Source.CAMERA)  
98 - ?: videoTracks.firstOrNull()?.first  
99 - ?: return@flatMapLatest emptyFlow()  
100 -  
101 - trackPublication::track.flow  
102 - }  
103 -  
104 - // observe audioTracks changes.  
105 - val mutedFlow = participant::audioTracks.flow  
106 - .flatMapLatest { tracks ->  
107 - val audioTrack = tracks.firstOrNull()?.first  
108 - if (audioTrack != null) {  
109 - audioTrack::muted.flow  
110 - } else {  
111 - flowOf(true)  
112 - }  
113 - }  
114 -  
115 - combine(videoTrackFlow, mutedFlow) { videoTrack, muted ->  
116 - videoTrack to muted  
117 - }.collect { (videoTrack, muted) ->  
118 - // Cleanup old video track  
119 - val oldVideoTrack = binding.speakerVideoView.tag as? VideoTrack  
120 - oldVideoTrack?.removeRenderer(binding.speakerVideoView)  
121 -  
122 - // Bind new video track to video view.  
123 - if (videoTrack is VideoTrack) {  
124 - videoTrack.addRenderer(binding.speakerVideoView)  
125 - binding.speakerVideoView.visibility = View.VISIBLE  
126 - } else {  
127 - binding.speakerVideoView.visibility = View.INVISIBLE  
128 - }  
129 - binding.speakerVideoView.tag = videoTrack  
130 -  
131 - binding.muteIndicator.visibility = if (muted) View.VISIBLE else View.INVISIBLE  
132 - }  
133 - } 70 + viewModel.primarySpeaker.collectLatest { speaker ->
  71 + val items = listOfNotNull(speaker)
  72 + .map { participant -> ParticipantItem(viewModel.room, participant, speakerView = true) }
  73 + speakerAdapter.update(items)
  74 + }
134 } 75 }
135 76
136 // Controls setup 77 // Controls setup
@@ -224,10 +165,9 @@ class CallActivity : AppCompatActivity() { @@ -224,10 +165,9 @@ class CallActivity : AppCompatActivity() {
224 } 165 }
225 166
226 override fun onDestroy() { 167 override fun onDestroy() {
  168 + binding.audienceRow.adapter = null
  169 + binding.speakerView.adapter = null
227 super.onDestroy() 170 super.onDestroy()
228 -  
229 - // Release video views  
230 - binding.speakerVideoView.release()  
231 } 171 }
232 172
233 companion object { 173 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>