davidliu
Committed by GitHub

FlowObservable isTrackEnabled variables (#685)

* Change isMicrophoneEnabled, isCameraEnabled, isScreenshareEnabled to FlowObservable variables

* Compile fixes

* spotless
  1 +---
  2 +"client-sdk-android": minor
  3 +---
  4 +
  5 +Change isMicrophoneEnabled, isCameraEnabled, isScreenshareEnabled to FlowObservable variables
@@ -62,6 +62,7 @@ import kotlinx.coroutines.Job @@ -62,6 +62,7 @@ import kotlinx.coroutines.Job
62 import kotlinx.coroutines.async 62 import kotlinx.coroutines.async
63 import kotlinx.coroutines.coroutineScope 63 import kotlinx.coroutines.coroutineScope
64 import kotlinx.coroutines.delay 64 import kotlinx.coroutines.delay
  65 +import kotlinx.coroutines.flow.map
65 import kotlinx.coroutines.launch 66 import kotlinx.coroutines.launch
66 import kotlinx.coroutines.suspendCancellableCoroutine 67 import kotlinx.coroutines.suspendCancellableCoroutine
67 import kotlinx.coroutines.sync.Mutex 68 import kotlinx.coroutines.sync.Mutex
@@ -32,6 +32,7 @@ import io.livekit.android.util.flow @@ -32,6 +32,7 @@ import io.livekit.android.util.flow
32 import io.livekit.android.util.flowDelegate 32 import io.livekit.android.util.flowDelegate
33 import kotlinx.coroutines.CoroutineDispatcher 33 import kotlinx.coroutines.CoroutineDispatcher
34 import kotlinx.coroutines.CoroutineScope 34 import kotlinx.coroutines.CoroutineScope
  35 +import kotlinx.coroutines.ExperimentalCoroutinesApi
35 import kotlinx.coroutines.SupervisorJob 36 import kotlinx.coroutines.SupervisorJob
36 import kotlinx.coroutines.cancel 37 import kotlinx.coroutines.cancel
37 import kotlinx.coroutines.flow.Flow 38 import kotlinx.coroutines.flow.Flow
@@ -270,6 +271,7 @@ open class Participant( @@ -270,6 +271,7 @@ open class Participant(
270 var trackPublications by flowDelegate(emptyMap<String, TrackPublication>()) 271 var trackPublications by flowDelegate(emptyMap<String, TrackPublication>())
271 protected set 272 protected set
272 273
  274 + @OptIn(ExperimentalCoroutinesApi::class)
273 private fun Flow<Map<String, TrackPublication>>.trackUpdateFlow(): Flow<List<Pair<TrackPublication, Track?>>> { 275 private fun Flow<Map<String, TrackPublication>>.trackUpdateFlow(): Flow<List<Pair<TrackPublication, Track?>>> {
274 return flatMapLatest { videoTracks -> 276 return flatMapLatest { videoTracks ->
275 if (videoTracks.isEmpty()) { 277 if (videoTracks.isEmpty()) {
@@ -365,23 +367,43 @@ open class Participant( @@ -365,23 +367,43 @@ open class Participant(
365 return null 367 return null
366 } 368 }
367 369
368 - fun isCameraEnabled(): Boolean {  
369 - val pub = getTrackPublication(Track.Source.CAMERA)  
370 - return isTrackPublicationEnabled(pub)  
371 - } 370 + @FlowObservable
  371 + @get:FlowObservable
  372 + val isMicrophoneEnabled by flowDelegate(
  373 + stateFlow = ::audioTrackPublications.flow
  374 + .map { it.firstOrNull { (pub, _) -> pub.source == Track.Source.MICROPHONE } ?: (null to null) }
  375 + .isTrackEnabledDetector()
  376 + .stateIn(delegateScope, SharingStarted.Eagerly, false),
  377 + )
372 378
373 - fun isMicrophoneEnabled(): Boolean {  
374 - val pub = getTrackPublication(Track.Source.MICROPHONE)  
375 - return isTrackPublicationEnabled(pub)  
376 - } 379 + @FlowObservable
  380 + @get:FlowObservable
  381 + val isCameraEnabled by flowDelegate(
  382 + stateFlow = ::videoTrackPublications.flow
  383 + .map { it.firstOrNull { (pub, _) -> pub.source == Track.Source.CAMERA } ?: (null to null) }
  384 + .isTrackEnabledDetector()
  385 + .stateIn(delegateScope, SharingStarted.Eagerly, false),
  386 + )
377 387
378 - fun isScreenShareEnabled(): Boolean {  
379 - val pub = getTrackPublication(Track.Source.SCREEN_SHARE)  
380 - return isTrackPublicationEnabled(pub)  
381 - } 388 + @FlowObservable
  389 + @get:FlowObservable
  390 + val isScreenShareEnabled by flowDelegate(
  391 + stateFlow = ::videoTrackPublications.flow
  392 + .map { it.firstOrNull { (pub, _) -> pub.source == Track.Source.SCREEN_SHARE } ?: (null to null) }
  393 + .isTrackEnabledDetector()
  394 + .stateIn(delegateScope, SharingStarted.Eagerly, false),
  395 + )
382 396
383 - private fun isTrackPublicationEnabled(pub: TrackPublication?): Boolean {  
384 - return !(pub?.muted ?: true) 397 + @OptIn(ExperimentalCoroutinesApi::class)
  398 + private fun Flow<Pair<TrackPublication?, Track?>>.isTrackEnabledDetector(): Flow<Boolean> {
  399 + return this.flatMapLatest { (pub, track) ->
  400 + if (pub == null) {
  401 + flowOf(false to track)
  402 + } else {
  403 + pub::muted.flow
  404 + .map { muted -> muted to track }
  405 + }
  406 + }.map { (muted, track) -> (!muted && track != null) }
385 } 407 }
386 408
387 /** 409 /**
@@ -32,6 +32,7 @@ import io.livekit.android.room.track.VideoCaptureParameter @@ -32,6 +32,7 @@ import io.livekit.android.room.track.VideoCaptureParameter
32 import io.livekit.android.room.track.VideoCodec 32 import io.livekit.android.room.track.VideoCodec
33 import io.livekit.android.test.MockE2ETest 33 import io.livekit.android.test.MockE2ETest
34 import io.livekit.android.test.assert.assertIsClassList 34 import io.livekit.android.test.assert.assertIsClassList
  35 +import io.livekit.android.test.coroutines.toListUntilSignal
35 import io.livekit.android.test.events.EventCollector 36 import io.livekit.android.test.events.EventCollector
36 import io.livekit.android.test.mock.MockAudioProcessingController 37 import io.livekit.android.test.mock.MockAudioProcessingController
37 import io.livekit.android.test.mock.MockEglBase 38 import io.livekit.android.test.mock.MockEglBase
@@ -41,11 +42,14 @@ import io.livekit.android.test.mock.TestData @@ -41,11 +42,14 @@ import io.livekit.android.test.mock.TestData
41 import io.livekit.android.test.mock.camera.MockCameraProvider 42 import io.livekit.android.test.mock.camera.MockCameraProvider
42 import io.livekit.android.test.mock.room.track.createMockLocalAudioTrack 43 import io.livekit.android.test.mock.room.track.createMockLocalAudioTrack
43 import io.livekit.android.test.util.toPBByteString 44 import io.livekit.android.test.util.toPBByteString
  45 +import io.livekit.android.util.flow
44 import io.livekit.android.util.toOkioByteString 46 import io.livekit.android.util.toOkioByteString
45 import kotlinx.coroutines.CoroutineScope 47 import kotlinx.coroutines.CoroutineScope
46 import kotlinx.coroutines.ExperimentalCoroutinesApi 48 import kotlinx.coroutines.ExperimentalCoroutinesApi
47 import kotlinx.coroutines.Job 49 import kotlinx.coroutines.Job
  50 +import kotlinx.coroutines.async
48 import kotlinx.coroutines.cancel 51 import kotlinx.coroutines.cancel
  52 +import kotlinx.coroutines.flow.MutableStateFlow
49 import kotlinx.coroutines.launch 53 import kotlinx.coroutines.launch
50 import kotlinx.coroutines.test.StandardTestDispatcher 54 import kotlinx.coroutines.test.StandardTestDispatcher
51 import kotlinx.coroutines.test.advanceUntilIdle 55 import kotlinx.coroutines.test.advanceUntilIdle
@@ -608,4 +612,39 @@ class LocalParticipantMockE2ETest : MockE2ETest() { @@ -608,4 +612,39 @@ class LocalParticipantMockE2ETest : MockE2ETest() {
608 coroutineRule.dispatcher.scheduler.advanceUntilIdle() 612 coroutineRule.dispatcher.scheduler.advanceUntilIdle()
609 assertTrue(!didThrow && success == false) 613 assertTrue(!didThrow && success == false)
610 } 614 }
  615 +
  616 + @Test
  617 + fun isMicrophoneEnabled() = runTest {
  618 + connect()
  619 +
  620 + room.localParticipant.publishAudioTrack(
  621 + track = createMockLocalAudioTrack(),
  622 + )
  623 +
  624 + advanceUntilIdle()
  625 +
  626 + assertTrue(room.localParticipant.isMicrophoneEnabled)
  627 + }
  628 +
  629 + @Test
  630 + fun microphoneEnabledFlow() = runTest {
  631 + connect()
  632 +
  633 + val signal = MutableStateFlow<Unit?>(null)
  634 + val job = async {
  635 + room.localParticipant::isMicrophoneEnabled.flow
  636 + .toListUntilSignal(signal)
  637 + }
  638 + room.localParticipant.publishAudioTrack(
  639 + track = createMockLocalAudioTrack(),
  640 + )
  641 +
  642 + room.localParticipant.setMicrophoneEnabled(false)
  643 + signal.compareAndSet(null, Unit)
  644 + val collectedList = job.await()
  645 + assertEquals(3, collectedList.size)
  646 + assertFalse(collectedList[0])
  647 + assertTrue(collectedList[1])
  648 + assertFalse(collectedList[2])
  649 + }
611 } 650 }
@@ -52,6 +52,7 @@ import io.livekit.android.sample.model.StressTest @@ -52,6 +52,7 @@ import io.livekit.android.sample.model.StressTest
52 import io.livekit.android.sample.service.ForegroundService 52 import io.livekit.android.sample.service.ForegroundService
53 import io.livekit.android.util.LKLog 53 import io.livekit.android.util.LKLog
54 import io.livekit.android.util.flow 54 import io.livekit.android.util.flow
  55 +import kotlinx.coroutines.Dispatchers
55 import kotlinx.coroutines.coroutineScope 56 import kotlinx.coroutines.coroutineScope
56 import kotlinx.coroutines.delay 57 import kotlinx.coroutines.delay
57 import kotlinx.coroutines.flow.Flow 58 import kotlinx.coroutines.flow.Flow
@@ -125,14 +126,9 @@ class CallViewModel( @@ -125,14 +126,9 @@ class CallViewModel(
125 private var localScreencastTrack: LocalScreencastVideoTrack? = null 126 private var localScreencastTrack: LocalScreencastVideoTrack? = null
126 127
127 // Controls 128 // Controls
128 - private val mutableMicEnabled = MutableLiveData(true)  
129 - val micEnabled = mutableMicEnabled.hide()  
130 -  
131 - private val mutableCameraEnabled = MutableLiveData(true)  
132 - val cameraEnabled = mutableCameraEnabled.hide()  
133 -  
134 - private val mutableScreencastEnabled = MutableLiveData(false)  
135 - val screenshareEnabled = mutableScreencastEnabled.hide() 129 + val micEnabled = room.localParticipant::isMicrophoneEnabled.flow
  130 + val cameraEnabled = room.localParticipant::isCameraEnabled.flow
  131 + val screenshareEnabled = room.localParticipant::isScreenShareEnabled.flow
136 132
137 private val mutableEnhancedNsEnabled = MutableLiveData(false) 133 private val mutableEnhancedNsEnabled = MutableLiveData(false)
138 val enhancedNsEnabled = mutableEnhancedNsEnabled.hide() 134 val enhancedNsEnabled = mutableEnhancedNsEnabled.hide()
@@ -157,7 +153,7 @@ class CallViewModel( @@ -157,7 +153,7 @@ class CallViewModel(
157 } 153 }
158 } 154 }
159 155
160 - viewModelScope.launch { 156 + viewModelScope.launch(Dispatchers.Default) {
161 // Collect any errors. 157 // Collect any errors.
162 launch { 158 launch {
163 error.collect { Timber.e(it) } 159 error.collect { Timber.e(it) }
@@ -256,10 +252,8 @@ class CallViewModel( @@ -256,10 +252,8 @@ class CallViewModel(
256 // Create and publish audio/video tracks 252 // Create and publish audio/video tracks
257 val localParticipant = room.localParticipant 253 val localParticipant = room.localParticipant
258 localParticipant.setMicrophoneEnabled(true) 254 localParticipant.setMicrophoneEnabled(true)
259 - mutableMicEnabled.postValue(localParticipant.isMicrophoneEnabled())  
260 255
261 localParticipant.setCameraEnabled(true) 256 localParticipant.setCameraEnabled(true)
262 - mutableCameraEnabled.postValue(localParticipant.isCameraEnabled())  
263 257
264 // Update the speaker 258 // Update the speaker
265 handlePrimarySpeaker(emptyList(), emptyList(), room) 259 handlePrimarySpeaker(emptyList(), emptyList(), room)
@@ -310,20 +304,18 @@ class CallViewModel( @@ -310,20 +304,18 @@ class CallViewModel(
310 */ 304 */
311 fun startScreenCapture(mediaProjectionPermissionResultData: Intent) { 305 fun startScreenCapture(mediaProjectionPermissionResultData: Intent) {
312 val localParticipant = room.localParticipant 306 val localParticipant = room.localParticipant
313 - viewModelScope.launch { 307 + viewModelScope.launch(Dispatchers.IO) {
314 localParticipant.setScreenShareEnabled(true, ScreenCaptureParams(mediaProjectionPermissionResultData)) 308 localParticipant.setScreenShareEnabled(true, ScreenCaptureParams(mediaProjectionPermissionResultData))
315 val screencastTrack = localParticipant.getTrackPublication(Track.Source.SCREEN_SHARE)?.track as? LocalScreencastVideoTrack 309 val screencastTrack = localParticipant.getTrackPublication(Track.Source.SCREEN_SHARE)?.track as? LocalScreencastVideoTrack
316 this@CallViewModel.localScreencastTrack = screencastTrack 310 this@CallViewModel.localScreencastTrack = screencastTrack
317 - mutableScreencastEnabled.postValue(screencastTrack?.enabled)  
318 } 311 }
319 } 312 }
320 313
321 fun stopScreenCapture() { 314 fun stopScreenCapture() {
322 - viewModelScope.launch { 315 + viewModelScope.launch(Dispatchers.IO) {
323 localScreencastTrack?.let { localScreencastVideoTrack -> 316 localScreencastTrack?.let { localScreencastVideoTrack ->
324 localScreencastVideoTrack.stop() 317 localScreencastVideoTrack.stop()
325 room.localParticipant.unpublishTrack(localScreencastVideoTrack) 318 room.localParticipant.unpublishTrack(localScreencastVideoTrack)
326 - mutableScreencastEnabled.postValue(localScreencastTrack?.enabled ?: false)  
327 } 319 }
328 } 320 }
329 } 321 }
@@ -345,16 +337,14 @@ class CallViewModel( @@ -345,16 +337,14 @@ class CallViewModel(
345 } 337 }
346 338
347 fun setMicEnabled(enabled: Boolean) { 339 fun setMicEnabled(enabled: Boolean) {
348 - viewModelScope.launch { 340 + viewModelScope.launch(Dispatchers.IO) {
349 room.localParticipant.setMicrophoneEnabled(enabled) 341 room.localParticipant.setMicrophoneEnabled(enabled)
350 - mutableMicEnabled.postValue(enabled)  
351 } 342 }
352 } 343 }
353 344
354 fun setCameraEnabled(enabled: Boolean) { 345 fun setCameraEnabled(enabled: Boolean) {
355 - viewModelScope.launch { 346 + viewModelScope.launch(Dispatchers.IO) {
356 room.localParticipant.setCameraEnabled(enabled) 347 room.localParticipant.setCameraEnabled(enabled)
357 - mutableCameraEnabled.postValue(enabled)  
358 } 348 }
359 } 349 }
360 350
@@ -377,7 +367,7 @@ class CallViewModel( @@ -377,7 +367,7 @@ class CallViewModel(
377 } 367 }
378 368
379 fun sendData(message: String) { 369 fun sendData(message: String) {
380 - viewModelScope.launch { 370 + viewModelScope.launch(Dispatchers.IO) {
381 room.localParticipant.publishData(message.toByteArray(Charsets.UTF_8)) 371 room.localParticipant.publishData(message.toByteArray(Charsets.UTF_8))
382 } 372 }
383 } 373 }
@@ -408,13 +398,13 @@ class CallViewModel( @@ -408,13 +398,13 @@ class CallViewModel(
408 Timber.e { "Reconnecting." } 398 Timber.e { "Reconnecting." }
409 mutablePrimarySpeaker.value = null 399 mutablePrimarySpeaker.value = null
410 room.disconnect() 400 room.disconnect()
411 - viewModelScope.launch { 401 + viewModelScope.launch(Dispatchers.IO) {
412 connectToRoom() 402 connectToRoom()
413 } 403 }
414 } 404 }
415 405
416 private suspend fun StressTest.SwitchRoom.execute() = coroutineScope { 406 private suspend fun StressTest.SwitchRoom.execute() = coroutineScope {
417 - launch { 407 + launch(Dispatchers.Default) {
418 while (isActive) { 408 while (isActive) {
419 delay(2000) 409 delay(2000)
420 dumpReferenceTables() 410 dumpReferenceTables()
@@ -423,12 +413,12 @@ class CallViewModel( @@ -423,12 +413,12 @@ class CallViewModel(
423 413
424 while (isActive) { 414 while (isActive) {
425 Timber.d { "Stress test -> connect to first room" } 415 Timber.d { "Stress test -> connect to first room" }
426 - launch { quickConnectToRoom(firstToken) } 416 + launch(Dispatchers.IO) { quickConnectToRoom(firstToken) }
427 delay(200) 417 delay(200)
428 room.disconnect() 418 room.disconnect()
429 delay(50) 419 delay(50)
430 Timber.d { "Stress test -> connect to second room" } 420 Timber.d { "Stress test -> connect to second room" }
431 - launch { quickConnectToRoom(secondToken) } 421 + launch(Dispatchers.IO) { quickConnectToRoom(secondToken) }
432 delay(200) 422 delay(200)
433 room.disconnect() 423 room.disconnect()
434 delay(50) 424 delay(50)
1 /* 1 /*
2 - * Copyright 2023-2024 LiveKit, Inc. 2 + * Copyright 2023-2025 LiveKit, Inc.
3 * 3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License. 5 * you may not use this file except in compliance with the License.
@@ -53,7 +53,6 @@ import androidx.compose.material.rememberScaffoldState @@ -53,7 +53,6 @@ import androidx.compose.material.rememberScaffoldState
53 import androidx.compose.runtime.Composable 53 import androidx.compose.runtime.Composable
54 import androidx.compose.runtime.collectAsState 54 import androidx.compose.runtime.collectAsState
55 import androidx.compose.runtime.getValue 55 import androidx.compose.runtime.getValue
56 -import androidx.compose.runtime.livedata.observeAsState  
57 import androidx.compose.runtime.mutableStateOf 56 import androidx.compose.runtime.mutableStateOf
58 import androidx.compose.runtime.remember 57 import androidx.compose.runtime.remember
59 import androidx.compose.runtime.rememberCoroutineScope 58 import androidx.compose.runtime.rememberCoroutineScope
@@ -118,9 +117,9 @@ class CallActivity : AppCompatActivity() { @@ -118,9 +117,9 @@ class CallActivity : AppCompatActivity() {
118 val participants by viewModel.participants.collectAsState(initial = emptyList()) 117 val participants by viewModel.participants.collectAsState(initial = emptyList())
119 val primarySpeaker by viewModel.primarySpeaker.collectAsState() 118 val primarySpeaker by viewModel.primarySpeaker.collectAsState()
120 val activeSpeakers by viewModel.activeSpeakers.collectAsState(initial = emptyList()) 119 val activeSpeakers by viewModel.activeSpeakers.collectAsState(initial = emptyList())
121 - val micEnabled by viewModel.micEnabled.observeAsState(true)  
122 - val videoEnabled by viewModel.cameraEnabled.observeAsState(true)  
123 - val screencastEnabled by viewModel.screenshareEnabled.observeAsState(false) 120 + val micEnabled by viewModel.micEnabled.collectAsState(true)
  121 + val videoEnabled by viewModel.cameraEnabled.collectAsState(true)
  122 + val screencastEnabled by viewModel.screenshareEnabled.collectAsState(false)
124 val permissionAllowed by viewModel.permissionAllowed.collectAsState() 123 val permissionAllowed by viewModel.permissionAllowed.collectAsState()
125 Content( 124 Content(
126 room, 125 room,
1 /* 1 /*
2 - * Copyright 2023-2024 LiveKit, Inc. 2 + * Copyright 2023-2025 LiveKit, Inc.
3 * 3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License. 5 * you may not use this file except in compliance with the License.
@@ -53,7 +53,8 @@ fun ParticipantItem( @@ -53,7 +53,8 @@ fun ParticipantItem(
53 val audioTracks by participant::audioTrackPublications.flow.collectAsState() 53 val audioTracks by participant::audioTrackPublications.flow.collectAsState()
54 val identityBarPadding = 4.dp 54 val identityBarPadding = 4.dp
55 ConstraintLayout( 55 ConstraintLayout(
56 - modifier = modifier.background(NoVideoBackground) 56 + modifier = modifier
  57 + .background(NoVideoBackground)
57 .run { 58 .run {
58 if (isSpeaking) { 59 if (isSpeaking) {
59 border(2.dp, BlueMain) 60 border(2.dp, BlueMain)
@@ -101,9 +102,9 @@ fun ParticipantItem( @@ -101,9 +102,9 @@ fun ParticipantItem(
101 }, 102 },
102 ) 103 )
103 104
104 - val isMuted = audioTracks.none { (pub) -> pub.track != null && !pub.muted } 105 + val isMicEnabled by participant::isMicrophoneEnabled.flow.collectAsState()
105 106
106 - if (isMuted) { 107 + if (!isMicEnabled) {
107 Icon( 108 Icon(
108 painter = painterResource(id = R.drawable.outline_mic_off_24), 109 painter = painterResource(id = R.drawable.outline_mic_off_24),
109 contentDescription = "", 110 contentDescription = "",
1 /* 1 /*
2 - * Copyright 2023-2024 LiveKit, Inc. 2 + * Copyright 2023-2025 LiveKit, Inc.
3 * 3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License. 5 * you may not use this file except in compliance with the License.
@@ -26,7 +26,9 @@ import android.widget.Toast @@ -26,7 +26,9 @@ import android.widget.Toast
26 import androidx.activity.result.contract.ActivityResultContracts 26 import androidx.activity.result.contract.ActivityResultContracts
27 import androidx.appcompat.app.AlertDialog 27 import androidx.appcompat.app.AlertDialog
28 import androidx.appcompat.app.AppCompatActivity 28 import androidx.appcompat.app.AppCompatActivity
  29 +import androidx.lifecycle.Lifecycle
29 import androidx.lifecycle.lifecycleScope 30 import androidx.lifecycle.lifecycleScope
  31 +import androidx.lifecycle.repeatOnLifecycle
30 import androidx.recyclerview.widget.LinearLayoutManager 32 import androidx.recyclerview.widget.LinearLayoutManager
31 import com.xwray.groupie.GroupieAdapter 33 import com.xwray.groupie.GroupieAdapter
32 import io.livekit.android.sample.common.R 34 import io.livekit.android.sample.common.R
@@ -36,6 +38,7 @@ import io.livekit.android.sample.dialog.showDebugMenuDialog @@ -36,6 +38,7 @@ import io.livekit.android.sample.dialog.showDebugMenuDialog
36 import io.livekit.android.sample.dialog.showSelectAudioDeviceDialog 38 import io.livekit.android.sample.dialog.showSelectAudioDeviceDialog
37 import io.livekit.android.sample.model.StressTest 39 import io.livekit.android.sample.model.StressTest
38 import kotlinx.coroutines.flow.collectLatest 40 import kotlinx.coroutines.flow.collectLatest
  41 +import kotlinx.coroutines.launch
39 import kotlinx.parcelize.Parcelize 42 import kotlinx.parcelize.Parcelize
40 43
41 class CallActivity : AppCompatActivity() { 44 class CallActivity : AppCompatActivity() {
@@ -81,13 +84,15 @@ class CallActivity : AppCompatActivity() { @@ -81,13 +84,15 @@ class CallActivity : AppCompatActivity() {
81 adapter = audienceAdapter 84 adapter = audienceAdapter
82 } 85 }
83 86
84 - lifecycleScope.launchWhenCreated { 87 + lifecycleScope.launch {
  88 + repeatOnLifecycle(Lifecycle.State.CREATED) {
85 viewModel.participants 89 viewModel.participants
86 .collect { participants -> 90 .collect { participants ->
87 val items = participants.map { participant -> ParticipantItem(viewModel.room, participant) } 91 val items = participants.map { participant -> ParticipantItem(viewModel.room, participant) }
88 audienceAdapter.update(items) 92 audienceAdapter.update(items)
89 } 93 }
90 } 94 }
  95 + }
91 96
92 // speaker view setup 97 // speaker view setup
93 val speakerAdapter = GroupieAdapter() 98 val speakerAdapter = GroupieAdapter()
@@ -95,16 +100,20 @@ class CallActivity : AppCompatActivity() { @@ -95,16 +100,20 @@ class CallActivity : AppCompatActivity() {
95 layoutManager = LinearLayoutManager(this@CallActivity, LinearLayoutManager.HORIZONTAL, false) 100 layoutManager = LinearLayoutManager(this@CallActivity, LinearLayoutManager.HORIZONTAL, false)
96 adapter = speakerAdapter 101 adapter = speakerAdapter
97 } 102 }
98 - lifecycleScope.launchWhenCreated { 103 + lifecycleScope.launch {
  104 + repeatOnLifecycle(Lifecycle.State.CREATED) {
99 viewModel.primarySpeaker.collectLatest { speaker -> 105 viewModel.primarySpeaker.collectLatest { speaker ->
100 val items = listOfNotNull(speaker) 106 val items = listOfNotNull(speaker)
101 .map { participant -> ParticipantItem(viewModel.room, participant, speakerView = true) } 107 .map { participant -> ParticipantItem(viewModel.room, participant, speakerView = true) }
102 speakerAdapter.update(items) 108 speakerAdapter.update(items)
103 } 109 }
104 } 110 }
  111 + }
105 112
106 // Controls setup 113 // Controls setup
107 - viewModel.cameraEnabled.observe(this) { enabled -> 114 + lifecycleScope.launch {
  115 + repeatOnLifecycle(Lifecycle.State.CREATED) {
  116 + viewModel.cameraEnabled.collect { enabled ->
108 binding.camera.setOnClickListener { viewModel.setCameraEnabled(!enabled) } 117 binding.camera.setOnClickListener { viewModel.setCameraEnabled(!enabled) }
109 binding.camera.setImageResource( 118 binding.camera.setImageResource(
110 if (enabled) { 119 if (enabled) {
@@ -115,7 +124,12 @@ class CallActivity : AppCompatActivity() { @@ -115,7 +124,12 @@ class CallActivity : AppCompatActivity() {
115 ) 124 )
116 binding.flipCamera.isEnabled = enabled 125 binding.flipCamera.isEnabled = enabled
117 } 126 }
118 - viewModel.micEnabled.observe(this) { enabled -> 127 + }
  128 + }
  129 +
  130 + lifecycleScope.launch {
  131 + repeatOnLifecycle(Lifecycle.State.CREATED) {
  132 + viewModel.micEnabled.collect { enabled ->
119 binding.mic.setOnClickListener { viewModel.setMicEnabled(!enabled) } 133 binding.mic.setOnClickListener { viewModel.setMicEnabled(!enabled) }
120 binding.mic.setImageResource( 134 binding.mic.setImageResource(
121 if (enabled) { 135 if (enabled) {
@@ -125,9 +139,14 @@ class CallActivity : AppCompatActivity() { @@ -125,9 +139,14 @@ class CallActivity : AppCompatActivity() {
125 }, 139 },
126 ) 140 )
127 } 141 }
  142 + }
  143 + }
128 144
129 binding.flipCamera.setOnClickListener { viewModel.flipCamera() } 145 binding.flipCamera.setOnClickListener { viewModel.flipCamera() }
130 - viewModel.screenshareEnabled.observe(this) { enabled -> 146 +
  147 + lifecycleScope.launch {
  148 + repeatOnLifecycle(Lifecycle.State.CREATED) {
  149 + viewModel.screenshareEnabled.collect { enabled ->
131 binding.screenShare.setOnClickListener { 150 binding.screenShare.setOnClickListener {
132 if (enabled) { 151 if (enabled) {
133 viewModel.stopScreenCapture() 152 viewModel.stopScreenCapture()
@@ -143,6 +162,8 @@ class CallActivity : AppCompatActivity() { @@ -143,6 +162,8 @@ class CallActivity : AppCompatActivity() {
143 }, 162 },
144 ) 163 )
145 } 164 }
  165 + }
  166 + }
146 167
147 binding.message.setOnClickListener { 168 binding.message.setOnClickListener {
148 val editText = EditText(this) 169 val editText = EditText(this)
1 /* 1 /*
2 - * Copyright 2024 LiveKit, Inc. 2 + * Copyright 2024-2025 LiveKit, Inc.
3 * 3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License. 5 * you may not use this file except in compliance with the License.
@@ -82,17 +82,9 @@ class ParticipantItem( @@ -82,17 +82,9 @@ class ParticipantItem(
82 } 82 }
83 } 83 }
84 coroutineScope?.launch { 84 coroutineScope?.launch {
85 - participant::audioTrackPublications.flow  
86 - .flatMapLatest { tracks ->  
87 - val audioTrack = tracks.firstOrNull()?.first  
88 - if (audioTrack != null) {  
89 - audioTrack::muted.flow  
90 - } else {  
91 - flowOf(true)  
92 - }  
93 - }  
94 - .collect { muted ->  
95 - viewBinding.muteIndicator.visibility = if (muted) View.VISIBLE else View.INVISIBLE 85 + participant::isMicrophoneEnabled.flow
  86 + .collect { isMicEnabled ->
  87 + viewBinding.muteIndicator.visibility = if (isMicEnabled) View.VISIBLE else View.INVISIBLE
96 } 88 }
97 } 89 }
98 coroutineScope?.launch { 90 coroutineScope?.launch {