davidliu
Committed by GitHub

Make LocalVideoTrack options FlowObservable (#220)

* Change track options to be FlowObservable

* View based sample update
@@ -5,17 +5,19 @@ import android.content.Context @@ -5,17 +5,19 @@ import android.content.Context
5 import android.content.pm.PackageManager 5 import android.content.pm.PackageManager
6 import android.hardware.camera2.CameraManager 6 import android.hardware.camera2.CameraManager
7 import androidx.core.content.ContextCompat 7 import androidx.core.content.ContextCompat
  8 +import com.github.ajalt.timberkt.Timber
8 import dagger.assisted.Assisted 9 import dagger.assisted.Assisted
9 import dagger.assisted.AssistedFactory 10 import dagger.assisted.AssistedFactory
10 import dagger.assisted.AssistedInject 11 import dagger.assisted.AssistedInject
11 import io.livekit.android.memory.CloseableManager 12 import io.livekit.android.memory.CloseableManager
12 import io.livekit.android.memory.SurfaceTextureHelperCloser 13 import io.livekit.android.memory.SurfaceTextureHelperCloser
13 import io.livekit.android.room.DefaultsManager 14 import io.livekit.android.room.DefaultsManager
14 -import io.livekit.android.room.track.video.Camera1CapturerWithSize  
15 -import io.livekit.android.room.track.video.Camera2CapturerWithSize  
16 -import io.livekit.android.room.track.video.VideoCapturerWithSize 15 +import io.livekit.android.room.track.video.*
  16 +import io.livekit.android.util.FlowObservable
17 import io.livekit.android.util.LKLog 17 import io.livekit.android.util.LKLog
  18 +import io.livekit.android.util.flowDelegate
18 import org.webrtc.* 19 import org.webrtc.*
  20 +import org.webrtc.CameraVideoCapturer.CameraEventsHandler
19 import java.util.* 21 import java.util.*
20 22
21 23
@@ -30,7 +32,7 @@ constructor( @@ -30,7 +32,7 @@ constructor(
30 @Assisted private var capturer: VideoCapturer, 32 @Assisted private var capturer: VideoCapturer,
31 @Assisted private var source: VideoSource, 33 @Assisted private var source: VideoSource,
32 @Assisted name: String, 34 @Assisted name: String,
33 - @Assisted var options: LocalVideoTrackOptions, 35 + @Assisted options: LocalVideoTrackOptions,
34 @Assisted rtcTrack: org.webrtc.VideoTrack, 36 @Assisted rtcTrack: org.webrtc.VideoTrack,
35 private val peerConnectionFactory: PeerConnectionFactory, 37 private val peerConnectionFactory: PeerConnectionFactory,
36 private val context: Context, 38 private val context: Context,
@@ -42,6 +44,10 @@ constructor( @@ -42,6 +44,10 @@ constructor(
42 override var rtcTrack: org.webrtc.VideoTrack = rtcTrack 44 override var rtcTrack: org.webrtc.VideoTrack = rtcTrack
43 internal set 45 internal set
44 46
  47 + @FlowObservable
  48 + @get:FlowObservable
  49 + var options: LocalVideoTrackOptions by flowDelegate(options)
  50 +
45 val dimensions: Dimensions 51 val dimensions: Dimensions
46 get() { 52 get() {
47 (capturer as? VideoCapturerWithSize)?.let { capturerWithSize -> 53 (capturer as? VideoCapturerWithSize)?.let { capturerWithSize ->
@@ -116,13 +122,46 @@ constructor( @@ -116,13 +122,46 @@ constructor(
116 targetDeviceId = deviceNames[(currentIndex + 1) % deviceNames.size] 122 targetDeviceId = deviceNames[(currentIndex + 1) % deviceNames.size]
117 } 123 }
118 124
  125 + fun updateCameraOptions() {
  126 + val newOptions = options.copy(
  127 + deviceId = targetDeviceId,
  128 + position = enumerator.getCameraPosition(targetDeviceId)
  129 + )
  130 + options = newOptions
  131 + }
  132 +
119 val cameraSwitchHandler = object : CameraVideoCapturer.CameraSwitchHandler { 133 val cameraSwitchHandler = object : CameraVideoCapturer.CameraSwitchHandler {
120 override fun onCameraSwitchDone(isFrontFacing: Boolean) { 134 override fun onCameraSwitchDone(isFrontFacing: Boolean) {
121 - val newOptions = options.copy(  
122 - deviceId = targetDeviceId,  
123 - position = enumerator.getCameraPosition(targetDeviceId)  
124 - )  
125 - options = newOptions 135 + // For cameras we control, wait until the first frame to ensure everything is okay.
  136 + if (cameraCapturer is CameraCapturerWithSize) {
  137 + cameraCapturer.cameraEventsDispatchHandler
  138 + .registerHandler(object : CameraEventsHandler {
  139 + override fun onFirstFrameAvailable() {
  140 + updateCameraOptions()
  141 + cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
  142 + }
  143 +
  144 + override fun onCameraError(p0: String?) {
  145 + cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
  146 + }
  147 +
  148 + override fun onCameraDisconnected() {
  149 + cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
  150 + }
  151 +
  152 + override fun onCameraFreezed(p0: String?) {
  153 + }
  154 +
  155 + override fun onCameraOpening(p0: String?) {
  156 + }
  157 +
  158 + override fun onCameraClosed() {
  159 + cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
  160 + }
  161 + })
  162 + } else {
  163 + updateCameraOptions()
  164 + }
126 } 165 }
127 166
128 override fun onCameraSwitchError(errorDescription: String?) { 167 override fun onCameraSwitchError(errorDescription: String?) {
@@ -154,6 +193,7 @@ constructor( @@ -154,6 +193,7 @@ constructor(
154 // sender owns rtcTrack, so it'll take care of disposing it. 193 // sender owns rtcTrack, so it'll take care of disposing it.
155 oldRtcTrack.setEnabled(false) 194 oldRtcTrack.setEnabled(false)
156 195
  196 + // Close resources associated to the old track. new track resources is registered in createTrack.
157 val oldCloseable = closeableManager.unregisterResource(oldRtcTrack) 197 val oldCloseable = closeableManager.unregisterResource(oldRtcTrack)
158 oldCloseable?.close() 198 oldCloseable?.close()
159 199
@@ -298,8 +338,9 @@ constructor( @@ -298,8 +338,9 @@ constructor(
298 enumerator: CameraEnumerator, 338 enumerator: CameraEnumerator,
299 options: LocalVideoTrackOptions 339 options: LocalVideoTrackOptions
300 ): Pair<VideoCapturer, LocalVideoTrackOptions>? { 340 ): Pair<VideoCapturer, LocalVideoTrackOptions>? {
  341 + val cameraEventsDispatchHandler = CameraEventsDispatchHandler()
301 val targetDeviceName = enumerator.findCamera(options.deviceId, options.position) ?: return null 342 val targetDeviceName = enumerator.findCamera(options.deviceId, options.position) ?: return null
302 - val targetVideoCapturer = enumerator.createCapturer(targetDeviceName, null) 343 + val targetVideoCapturer = enumerator.createCapturer(targetDeviceName, cameraEventsDispatchHandler)
303 344
304 // back fill any missing information 345 // back fill any missing information
305 val newOptions = options.copy( 346 val newOptions = options.copy(
@@ -310,7 +351,11 @@ constructor( @@ -310,7 +351,11 @@ constructor(
310 // Cache supported capture formats ahead of time to avoid future camera locks. 351 // Cache supported capture formats ahead of time to avoid future camera locks.
311 Camera1Helper.getSupportedFormats(Camera1Helper.getCameraId(newOptions.deviceId)) 352 Camera1Helper.getSupportedFormats(Camera1Helper.getCameraId(newOptions.deviceId))
312 return Pair( 353 return Pair(
313 - Camera1CapturerWithSize(targetVideoCapturer, targetDeviceName), 354 + Camera1CapturerWithSize(
  355 + targetVideoCapturer,
  356 + targetDeviceName,
  357 + cameraEventsDispatchHandler
  358 + ),
314 newOptions 359 newOptions
315 ) 360 )
316 } 361 }
@@ -320,7 +365,8 @@ constructor( @@ -320,7 +365,8 @@ constructor(
320 Camera2CapturerWithSize( 365 Camera2CapturerWithSize(
321 targetVideoCapturer, 366 targetVideoCapturer,
322 context.getSystemService(Context.CAMERA_SERVICE) as CameraManager, 367 context.getSystemService(Context.CAMERA_SERVICE) as CameraManager,
323 - targetDeviceName 368 + targetDeviceName,
  369 + cameraEventsDispatchHandler
324 ), 370 ),
325 newOptions 371 newOptions
326 ) 372 )
@@ -370,10 +416,7 @@ constructor( @@ -370,10 +416,7 @@ constructor(
370 private fun CameraEnumerator.findCamera(predicate: (deviceName: String) -> Boolean): String? { 416 private fun CameraEnumerator.findCamera(predicate: (deviceName: String) -> Boolean): String? {
371 for (deviceName in deviceNames) { 417 for (deviceName in deviceNames) {
372 if (predicate(deviceName)) { 418 if (predicate(deviceName)) {
373 - val videoCapturer = createCapturer(deviceName, null)  
374 - if (videoCapturer != null) {  
375 - return deviceName  
376 - } 419 + return deviceName
377 } 420 }
378 } 421 }
379 return null 422 return null
  1 +package io.livekit.android.room.track.video
  2 +
  3 +import org.webrtc.CameraVideoCapturer.CameraEventsHandler
  4 +
  5 +/**
  6 + * Dispatches CameraEventsHandler callbacks to registered handlers.
  7 + *
  8 + * @suppress
  9 + */
  10 +internal class CameraEventsDispatchHandler : CameraEventsHandler {
  11 + private val handlers = mutableSetOf<CameraEventsHandler>()
  12 +
  13 + @Synchronized
  14 + fun registerHandler(handler: CameraEventsHandler) {
  15 + handlers.add(handler)
  16 + }
  17 +
  18 + @Synchronized
  19 + fun unregisterHandler(handler: CameraEventsHandler) {
  20 + handlers.remove(handler)
  21 + }
  22 +
  23 + override fun onCameraError(errorDescription: String) {
  24 + val handlersCopy = handlers.toMutableSet()
  25 + for (handler in handlersCopy) {
  26 + handler.onCameraError(errorDescription)
  27 + }
  28 + }
  29 +
  30 + override fun onCameraDisconnected() {
  31 + val handlersCopy = handlers.toMutableSet()
  32 + for (handler in handlersCopy) {
  33 + handler.onCameraDisconnected()
  34 + }
  35 + }
  36 +
  37 + override fun onCameraFreezed(errorDescription: String) {
  38 + val handlersCopy = handlers.toMutableSet()
  39 + for (handler in handlersCopy) {
  40 + handler.onCameraFreezed(errorDescription)
  41 + }
  42 + }
  43 +
  44 + override fun onCameraOpening(cameraName: String) {
  45 + val handlersCopy = handlers.toMutableSet()
  46 + for (handler in handlersCopy) {
  47 + handler.onCameraOpening(cameraName)
  48 + }
  49 + }
  50 +
  51 + override fun onFirstFrameAvailable() {
  52 + val handlersCopy = handlers.toMutableSet()
  53 + for (handler in handlersCopy) {
  54 + handler.onFirstFrameAvailable()
  55 + }
  56 + }
  57 +
  58 + override fun onCameraClosed() {
  59 + val handlersCopy = handlers.toMutableSet()
  60 + for (handler in handlersCopy) {
  61 + handler.onCameraClosed()
  62 + }
  63 + }
  64 +
  65 +}
@@ -13,10 +13,19 @@ internal interface VideoCapturerWithSize : VideoCapturer { @@ -13,10 +13,19 @@ internal interface VideoCapturerWithSize : VideoCapturer {
13 /** 13 /**
14 * @suppress 14 * @suppress
15 */ 15 */
  16 +
  17 +internal abstract class CameraCapturerWithSize(
  18 + val cameraEventsDispatchHandler: CameraEventsDispatchHandler
  19 +) : VideoCapturerWithSize
  20 +
  21 +/**
  22 + * @suppress
  23 + */
16 internal class Camera1CapturerWithSize( 24 internal class Camera1CapturerWithSize(
17 private val capturer: Camera1Capturer, 25 private val capturer: Camera1Capturer,
18 - private val deviceName: String?  
19 -) : CameraVideoCapturer by capturer, VideoCapturerWithSize { 26 + private val deviceName: String?,
  27 + cameraEventsDispatchHandler: CameraEventsDispatchHandler,
  28 +) : CameraCapturerWithSize(cameraEventsDispatchHandler), CameraVideoCapturer by capturer {
20 override fun findCaptureFormat(width: Int, height: Int): Size { 29 override fun findCaptureFormat(width: Int, height: Int): Size {
21 val cameraId = Camera1Helper.getCameraId(deviceName) 30 val cameraId = Camera1Helper.getCameraId(deviceName)
22 return Camera1Helper.findClosestCaptureFormat(cameraId, width, height) 31 return Camera1Helper.findClosestCaptureFormat(cameraId, width, height)
@@ -29,8 +38,9 @@ internal class Camera1CapturerWithSize( @@ -29,8 +38,9 @@ internal class Camera1CapturerWithSize(
29 internal class Camera2CapturerWithSize( 38 internal class Camera2CapturerWithSize(
30 private val capturer: Camera2Capturer, 39 private val capturer: Camera2Capturer,
31 private val cameraManager: CameraManager, 40 private val cameraManager: CameraManager,
32 - private val deviceName: String?  
33 -) : CameraVideoCapturer by capturer, VideoCapturerWithSize { 41 + private val deviceName: String?,
  42 + cameraEventsDispatchHandler: CameraEventsDispatchHandler,
  43 +) : CameraCapturerWithSize(cameraEventsDispatchHandler), CameraVideoCapturer by capturer {
34 override fun findCaptureFormat(width: Int, height: Int): Size { 44 override fun findCaptureFormat(width: Int, height: Int): Size {
35 return Camera2Helper.findClosestCaptureFormat(cameraManager, deviceName, width, height) 45 return Camera2Helper.findClosestCaptureFormat(cameraManager, deviceName, width, height)
36 } 46 }
@@ -69,9 +69,6 @@ class CallViewModel( @@ -69,9 +69,6 @@ class CallViewModel(
69 private val mutableCameraEnabled = MutableLiveData(true) 69 private val mutableCameraEnabled = MutableLiveData(true)
70 val cameraEnabled = mutableCameraEnabled.hide() 70 val cameraEnabled = mutableCameraEnabled.hide()
71 71
72 - private val mutableFlipVideoButtonEnabled = MutableLiveData(true)  
73 - val flipButtonVideoEnabled = mutableFlipVideoButtonEnabled.hide()  
74 -  
75 private val mutableScreencastEnabled = MutableLiveData(false) 72 private val mutableScreencastEnabled = MutableLiveData(false)
76 val screenshareEnabled = mutableScreencastEnabled.hide() 73 val screenshareEnabled = mutableScreencastEnabled.hide()
77 74
@@ -102,8 +99,8 @@ class CallViewModel( @@ -102,8 +99,8 @@ class CallViewModel(
102 } 99 }
103 } 100 }
104 101
  102 + // Handle room events.
105 launch { 103 launch {
106 - // Handle room events.  
107 room.events.collect { 104 room.events.collect {
108 when (it) { 105 when (it) {
109 is RoomEvent.FailedToConnect -> mutableError.value = it.error 106 is RoomEvent.FailedToConnect -> mutableError.value = it.error
@@ -118,6 +115,7 @@ class CallViewModel( @@ -118,6 +115,7 @@ class CallViewModel(
118 } 115 }
119 } 116 }
120 } 117 }
  118 +
121 connectToRoom() 119 connectToRoom()
122 } 120 }
123 121
@@ -69,7 +69,6 @@ class CallActivity : AppCompatActivity() { @@ -69,7 +69,6 @@ class CallActivity : AppCompatActivity() {
69 val activeSpeakers by viewModel.activeSpeakers.collectAsState(initial = emptyList()) 69 val activeSpeakers by viewModel.activeSpeakers.collectAsState(initial = emptyList())
70 val micEnabled by viewModel.micEnabled.observeAsState(true) 70 val micEnabled by viewModel.micEnabled.observeAsState(true)
71 val videoEnabled by viewModel.cameraEnabled.observeAsState(true) 71 val videoEnabled by viewModel.cameraEnabled.observeAsState(true)
72 - val flipButtonEnabled by viewModel.flipButtonVideoEnabled.observeAsState(true)  
73 val screencastEnabled by viewModel.screenshareEnabled.observeAsState(false) 72 val screencastEnabled by viewModel.screenshareEnabled.observeAsState(false)
74 val permissionAllowed by viewModel.permissionAllowed.collectAsState() 73 val permissionAllowed by viewModel.permissionAllowed.collectAsState()
75 Content( 74 Content(
@@ -79,7 +78,6 @@ class CallActivity : AppCompatActivity() { @@ -79,7 +78,6 @@ class CallActivity : AppCompatActivity() {
79 activeSpeakers, 78 activeSpeakers,
80 micEnabled, 79 micEnabled,
81 videoEnabled, 80 videoEnabled,
82 - flipButtonEnabled,  
83 screencastEnabled, 81 screencastEnabled,
84 audioSwitchHandler = viewModel.audioHandler, 82 audioSwitchHandler = viewModel.audioHandler,
85 permissionAllowed = permissionAllowed, 83 permissionAllowed = permissionAllowed,
@@ -128,7 +126,6 @@ class CallActivity : AppCompatActivity() { @@ -128,7 +126,6 @@ class CallActivity : AppCompatActivity() {
128 activeSpeakers: List<Participant> = listOf(previewParticipant), 126 activeSpeakers: List<Participant> = listOf(previewParticipant),
129 micEnabled: Boolean = true, 127 micEnabled: Boolean = true,
130 videoEnabled: Boolean = true, 128 videoEnabled: Boolean = true,
131 - flipButtonEnabled: Boolean = true,  
132 screencastEnabled: Boolean = false, 129 screencastEnabled: Boolean = false,
133 permissionAllowed: Boolean = true, 130 permissionAllowed: Boolean = true,
134 audioSwitchHandler: AudioSwitchHandler? = null, 131 audioSwitchHandler: AudioSwitchHandler? = null,
@@ -10,6 +10,8 @@ import androidx.compose.ui.res.painterResource @@ -10,6 +10,8 @@ import androidx.compose.ui.res.painterResource
10 import io.livekit.android.compose.VideoRenderer 10 import io.livekit.android.compose.VideoRenderer
11 import io.livekit.android.room.Room 11 import io.livekit.android.room.Room
12 import io.livekit.android.room.participant.Participant 12 import io.livekit.android.room.participant.Participant
  13 +import io.livekit.android.room.track.CameraPosition
  14 +import io.livekit.android.room.track.LocalVideoTrack
13 import io.livekit.android.room.track.Track 15 import io.livekit.android.room.track.Track
14 import io.livekit.android.room.track.VideoTrack 16 import io.livekit.android.room.track.VideoTrack
15 import io.livekit.android.util.flow 17 import io.livekit.android.util.flow
@@ -22,7 +24,6 @@ fun VideoItemTrackSelector( @@ -22,7 +24,6 @@ fun VideoItemTrackSelector(
22 room: Room, 24 room: Room,
23 participant: Participant, 25 participant: Participant,
24 modifier: Modifier = Modifier, 26 modifier: Modifier = Modifier,
25 - mirror: Boolean = false,  
26 ) { 27 ) {
27 val videoTrackMap by participant::videoTracks.flow.collectAsState(initial = emptyList()) 28 val videoTrackMap by participant::videoTracks.flow.collectAsState(initial = emptyList())
28 val videoPubs = videoTrackMap.filter { (pub) -> pub.subscribed } 29 val videoPubs = videoTrackMap.filter { (pub) -> pub.subscribed }
@@ -35,12 +36,22 @@ fun VideoItemTrackSelector( @@ -35,12 +36,22 @@ fun VideoItemTrackSelector(
35 ?: videoPubs.firstOrNull() 36 ?: videoPubs.firstOrNull()
36 37
37 val videoTrack = videoPub?.track as? VideoTrack 38 val videoTrack = videoPub?.track as? VideoTrack
38 - val videoMuted by  
39 - if (videoPub != null) {  
40 - videoPub::muted.flow.collectAsState()  
41 - } else {  
42 - remember(videoPub) {  
43 - derivedStateOf { false } 39 + var videoMuted by remember { mutableStateOf(false) }
  40 + var cameraFacingFront by remember { mutableStateOf(false) }
  41 +
  42 + // monitor muted state
  43 + LaunchedEffect(videoPub) {
  44 + if (videoPub != null) {
  45 + videoPub::muted.flow.collect { muted -> videoMuted = muted }
  46 + }
  47 + }
  48 +
  49 + // monitor camera facing for local participant
  50 + LaunchedEffect(participant, videoTrack) {
  51 + if (room.localParticipant == participant && videoTrack as? LocalVideoTrack != null) {
  52 + videoTrack::options.flow.collect { options ->
  53 + cameraFacingFront = options.position == CameraPosition.FRONT
  54 + }
44 } 55 }
45 } 56 }
46 57
@@ -48,7 +59,7 @@ fun VideoItemTrackSelector( @@ -48,7 +59,7 @@ fun VideoItemTrackSelector(
48 VideoRenderer( 59 VideoRenderer(
49 room = room, 60 room = room,
50 videoTrack = videoTrack, 61 videoTrack = videoTrack,
51 - mirror = mirror, 62 + mirror = room.localParticipant == participant && cameraFacingFront,
52 modifier = modifier 63 modifier = modifier
53 ) 64 )
54 } else { 65 } else {
  1 +@file:OptIn(ExperimentalCoroutinesApi::class)
  2 +
1 package io.livekit.android.sample 3 package io.livekit.android.sample
2 4
3 5
4 -import android.graphics.Color  
5 -import android.graphics.drawable.GradientDrawable  
6 import android.view.View 6 import android.view.View
7 import com.github.ajalt.timberkt.Timber 7 import com.github.ajalt.timberkt.Timber
8 import com.xwray.groupie.viewbinding.BindableItem 8 import com.xwray.groupie.viewbinding.BindableItem
@@ -10,6 +10,8 @@ import com.xwray.groupie.viewbinding.GroupieViewHolder @@ -10,6 +10,8 @@ import com.xwray.groupie.viewbinding.GroupieViewHolder
10 import io.livekit.android.room.Room 10 import io.livekit.android.room.Room
11 import io.livekit.android.room.participant.ConnectionQuality 11 import io.livekit.android.room.participant.ConnectionQuality
12 import io.livekit.android.room.participant.Participant 12 import io.livekit.android.room.participant.Participant
  13 +import io.livekit.android.room.track.CameraPosition
  14 +import io.livekit.android.room.track.LocalVideoTrack
13 import io.livekit.android.room.track.Track 15 import io.livekit.android.room.track.Track
14 import io.livekit.android.room.track.VideoTrack 16 import io.livekit.android.room.track.VideoTrack
15 import io.livekit.android.sample.databinding.ParticipantItemBinding 17 import io.livekit.android.sample.databinding.ParticipantItemBinding
@@ -17,7 +19,6 @@ import io.livekit.android.util.flow @@ -17,7 +19,6 @@ import io.livekit.android.util.flow
17 import kotlinx.coroutines.* 19 import kotlinx.coroutines.*
18 import kotlinx.coroutines.flow.* 20 import kotlinx.coroutines.flow.*
19 21
20 -@OptIn(ExperimentalCoroutinesApi::class)  
21 class ParticipantItem( 22 class ParticipantItem(
22 private val room: Room, 23 private val room: Room,
23 private val participant: Participant, 24 private val participant: Participant,
@@ -92,29 +93,34 @@ class ParticipantItem( @@ -92,29 +93,34 @@ class ParticipantItem(
92 } 93 }
93 94
94 coroutineScope?.launch { 95 coroutineScope?.launch {
95 - videoTrackPubFlow  
96 - .flatMapLatest { pub ->  
97 - if (pub != null) {  
98 - pub::track.flow  
99 - } else {  
100 - flowOf(null)  
101 - }  
102 - }  
103 - .collectLatest { videoTrack -> 96 + val videoTrackFlow = videoTrackPubFlow
  97 + .flatMapLatestOrNull { pub -> pub::track.flow }
  98 +
  99 + // Configure video view with track
  100 + launch {
  101 + videoTrackFlow.collectLatest { videoTrack ->
104 setupVideoIfNeeded(videoTrack as? VideoTrack, viewBinding) 102 setupVideoIfNeeded(videoTrack as? VideoTrack, viewBinding)
105 } 103 }
  104 + }
  105 +
  106 + // For local participants, mirror camera if using front camera.
  107 + if (participant == room.localParticipant) {
  108 + launch {
  109 + videoTrackFlow
  110 + .flatMapLatestOrNull { track -> (track as LocalVideoTrack)::options.flow }
  111 + .collectLatest { options ->
  112 + viewBinding.renderer.setMirror(options?.position == CameraPosition.FRONT)
  113 + }
  114 + }
  115 + }
106 } 116 }
  117 +
  118 + // Handle muted changes
107 coroutineScope?.launch { 119 coroutineScope?.launch {
108 videoTrackPubFlow 120 videoTrackPubFlow
109 - .flatMapLatest { pub ->  
110 - if (pub != null) {  
111 - pub::muted.flow  
112 - } else {  
113 - flowOf(true)  
114 - }  
115 - } 121 + .flatMapLatestOrNull { pub -> pub::muted.flow }
116 .collectLatest { muted -> 122 .collectLatest { muted ->
117 - viewBinding.renderer.visibleOrInvisible(!muted) 123 + viewBinding.renderer.visibleOrInvisible(!(muted ?: true))
118 } 124 }
119 } 125 }
120 val existingTrack = getVideoTrack() 126 val existingTrack = getVideoTrack()
@@ -175,3 +181,15 @@ private fun showFocus(binding: ParticipantItemBinding) { @@ -175,3 +181,15 @@ private fun showFocus(binding: ParticipantItemBinding) {
175 private fun hideFocus(binding: ParticipantItemBinding) { 181 private fun hideFocus(binding: ParticipantItemBinding) {
176 binding.speakingIndicator.visibility = View.INVISIBLE 182 binding.speakingIndicator.visibility = View.INVISIBLE
177 } 183 }
  184 +
  185 +private inline fun <T, R> Flow<T?>.flatMapLatestOrNull(
  186 + crossinline transform: suspend (value: T) -> Flow<R>
  187 +): Flow<R?> {
  188 + return flatMapLatest {
  189 + if (it == null) {
  190 + flowOf(null)
  191 + } else {
  192 + transform(it)
  193 + }
  194 + }
  195 +}