正在显示
7 个修改的文件
包含
248 行增加
和
236 行删除
| @@ -82,7 +82,6 @@ class LiveKit { | @@ -82,7 +82,6 @@ class LiveKit { | ||
| 82 | * @param url URL to LiveKit server (i.e. ws://mylivekitdeploy.io) | 82 | * @param url URL to LiveKit server (i.e. ws://mylivekitdeploy.io) |
| 83 | * @param listener Listener to Room events. LiveKit interactions take place with these callbacks | 83 | * @param listener Listener to Room events. LiveKit interactions take place with these callbacks |
| 84 | */ | 84 | */ |
| 85 | - @Deprecated("Use LiveKit.create() and Room.connect() instead.") | ||
| 86 | suspend fun connect( | 85 | suspend fun connect( |
| 87 | appContext: Context, | 86 | appContext: Context, |
| 88 | url: String, | 87 | url: String, |
| @@ -28,20 +28,22 @@ class CallViewModel( | @@ -28,20 +28,22 @@ class CallViewModel( | ||
| 28 | val token: String, | 28 | val token: String, |
| 29 | application: Application | 29 | application: Application |
| 30 | ) : AndroidViewModel(application) { | 30 | ) : AndroidViewModel(application) { |
| 31 | - | ||
| 32 | - val room = LiveKit.create( | ||
| 33 | - appContext = application, | ||
| 34 | - options = RoomOptions(adaptiveStream = true, dynacast = true), | ||
| 35 | - ) | ||
| 36 | - | ||
| 37 | - val participants = room::remoteParticipants.flow | ||
| 38 | - .map { remoteParticipants -> | ||
| 39 | - listOf<Participant>(room.localParticipant) + | ||
| 40 | - remoteParticipants | ||
| 41 | - .keys | ||
| 42 | - .sortedBy { it } | ||
| 43 | - .mapNotNull { remoteParticipants[it] } | 31 | + private val mutableRoom = MutableStateFlow<Room?>(null) |
| 32 | + val room: MutableStateFlow<Room?> = mutableRoom | ||
| 33 | + val participants = mutableRoom.flatMapLatest { room -> | ||
| 34 | + if (room != null) { | ||
| 35 | + room::remoteParticipants.flow | ||
| 36 | + .map { remoteParticipants -> | ||
| 37 | + listOf<Participant>(room.localParticipant) + | ||
| 38 | + remoteParticipants | ||
| 39 | + .keys | ||
| 40 | + .sortedBy { it } | ||
| 41 | + .mapNotNull { remoteParticipants[it] } | ||
| 42 | + } | ||
| 43 | + } else { | ||
| 44 | + flowOf(emptyList()) | ||
| 44 | } | 45 | } |
| 46 | + } | ||
| 45 | 47 | ||
| 46 | private val mutableError = MutableStateFlow<Throwable?>(null) | 48 | private val mutableError = MutableStateFlow<Throwable?>(null) |
| 47 | val error = mutableError.hide() | 49 | val error = mutableError.hide() |
| @@ -49,7 +51,13 @@ class CallViewModel( | @@ -49,7 +51,13 @@ class CallViewModel( | ||
| 49 | private val mutablePrimarySpeaker = MutableStateFlow<Participant?>(null) | 51 | private val mutablePrimarySpeaker = MutableStateFlow<Participant?>(null) |
| 50 | val primarySpeaker: StateFlow<Participant?> = mutablePrimarySpeaker | 52 | val primarySpeaker: StateFlow<Participant?> = mutablePrimarySpeaker |
| 51 | 53 | ||
| 52 | - val activeSpeakers = room::activeSpeakers.flow | 54 | + val activeSpeakers = mutableRoom.flatMapLatest { room -> |
| 55 | + if (room != null) { | ||
| 56 | + room::activeSpeakers.flow | ||
| 57 | + } else { | ||
| 58 | + flowOf(emptyList()) | ||
| 59 | + } | ||
| 60 | + } | ||
| 53 | 61 | ||
| 54 | private var localScreencastTrack: LocalScreencastVideoTrack? = null | 62 | private var localScreencastTrack: LocalScreencastVideoTrack? = null |
| 55 | 63 | ||
| @@ -73,60 +81,60 @@ class CallViewModel( | @@ -73,60 +81,60 @@ class CallViewModel( | ||
| 73 | 81 | ||
| 74 | init { | 82 | init { |
| 75 | viewModelScope.launch { | 83 | viewModelScope.launch { |
| 84 | + | ||
| 76 | launch { | 85 | launch { |
| 77 | error.collect { Timber.e(it) } | 86 | error.collect { Timber.e(it) } |
| 78 | } | 87 | } |
| 79 | 88 | ||
| 80 | - launch { | ||
| 81 | - combine(participants, activeSpeakers) { participants, speakers -> participants to speakers } | ||
| 82 | - .collect { (participantsList, speakers) -> | ||
| 83 | - handlePrimarySpeaker( | ||
| 84 | - participantsList, | ||
| 85 | - speakers, | ||
| 86 | - room | ||
| 87 | - ) | ||
| 88 | - } | ||
| 89 | - } | 89 | + try { |
| 90 | + val room = LiveKit.connect( | ||
| 91 | + application, | ||
| 92 | + url, | ||
| 93 | + token, | ||
| 94 | + roomOptions = RoomOptions(adaptiveStream = true, dynacast = true), | ||
| 95 | + ) | ||
| 96 | + | ||
| 97 | + // Create and publish audio/video tracks | ||
| 98 | + val localParticipant = room.localParticipant | ||
| 99 | + localParticipant.setMicrophoneEnabled(true) | ||
| 100 | + mutableMicEnabled.postValue(localParticipant.isMicrophoneEnabled()) | ||
| 101 | + | ||
| 102 | + localParticipant.setCameraEnabled(true) | ||
| 103 | + mutableCameraEnabled.postValue(localParticipant.isCameraEnabled()) | ||
| 104 | + mutableRoom.value = room | ||
| 105 | + | ||
| 106 | + handlePrimarySpeaker(emptyList(), emptyList(), room) | ||
| 107 | + | ||
| 108 | + launch { | ||
| 109 | + combine(participants, activeSpeakers) { participants, speakers -> participants to speakers } | ||
| 110 | + .collect { (participantsList, speakers) -> | ||
| 111 | + handlePrimarySpeaker( | ||
| 112 | + participantsList, | ||
| 113 | + speakers, | ||
| 114 | + room | ||
| 115 | + ) | ||
| 116 | + } | ||
| 117 | + } | ||
| 90 | 118 | ||
| 91 | - launch { | ||
| 92 | - room.events.collect { | ||
| 93 | - when (it) { | ||
| 94 | - is RoomEvent.FailedToConnect -> mutableError.value = it.error | ||
| 95 | - is RoomEvent.DataReceived -> { | ||
| 96 | - val identity = it.participant.identity ?: "" | ||
| 97 | - val message = it.data.toString(Charsets.UTF_8) | ||
| 98 | - mutableDataReceived.emit("$identity: $message") | 119 | + launch { |
| 120 | + room.events.collect { | ||
| 121 | + when (it) { | ||
| 122 | + is RoomEvent.FailedToConnect -> mutableError.value = it.error | ||
| 123 | + is RoomEvent.DataReceived -> { | ||
| 124 | + val identity = it.participant.identity ?: "" | ||
| 125 | + val message = it.data.toString(Charsets.UTF_8) | ||
| 126 | + mutableDataReceived.emit("$identity: $message") | ||
| 127 | + } | ||
| 99 | } | 128 | } |
| 100 | - else -> {} | ||
| 101 | } | 129 | } |
| 102 | } | 130 | } |
| 131 | + } catch (e: Throwable) { | ||
| 132 | + mutableError.value = e | ||
| 103 | } | 133 | } |
| 104 | - connectToRoom() | ||
| 105 | } | 134 | } |
| 106 | } | 135 | } |
| 107 | 136 | ||
| 108 | - private suspend fun connectToRoom() { | ||
| 109 | - try { | ||
| 110 | - room.connect( | ||
| 111 | - url = url, | ||
| 112 | - token = token, | ||
| 113 | - ) | ||
| 114 | - | ||
| 115 | - // Create and publish audio/video tracks | ||
| 116 | - val localParticipant = room.localParticipant | ||
| 117 | - localParticipant.setMicrophoneEnabled(true) | ||
| 118 | - mutableMicEnabled.postValue(localParticipant.isMicrophoneEnabled()) | ||
| 119 | - | ||
| 120 | - localParticipant.setCameraEnabled(true) | ||
| 121 | - mutableCameraEnabled.postValue(localParticipant.isCameraEnabled()) | ||
| 122 | - | ||
| 123 | - handlePrimarySpeaker(emptyList(), emptyList(), room) | ||
| 124 | - } catch (e: Throwable) { | ||
| 125 | - mutableError.value = e | ||
| 126 | - } | ||
| 127 | - } | ||
| 128 | - | ||
| 129 | - private fun handlePrimarySpeaker(participantsList: List<Participant>, speakers: List<Participant>, room: Room?) { | 137 | + private fun handlePrimarySpeaker(participantsList: List<Participant>, speakers: List<Participant>, room: Room) { |
| 130 | 138 | ||
| 131 | var speaker = mutablePrimarySpeaker.value | 139 | var speaker = mutablePrimarySpeaker.value |
| 132 | 140 | ||
| @@ -147,7 +155,7 @@ class CallViewModel( | @@ -147,7 +155,7 @@ class CallViewModel( | ||
| 147 | // Default to another person in room, or local participant. | 155 | // Default to another person in room, or local participant. |
| 148 | speaker = participantsList.filterIsInstance<RemoteParticipant>() | 156 | speaker = participantsList.filterIsInstance<RemoteParticipant>() |
| 149 | .firstOrNull() | 157 | .firstOrNull() |
| 150 | - ?: room?.localParticipant | 158 | + ?: room.localParticipant |
| 151 | } | 159 | } |
| 152 | 160 | ||
| 153 | if (speakers.isNotEmpty() && !speakers.contains(speaker)) { | 161 | if (speakers.isNotEmpty() && !speakers.contains(speaker)) { |
| @@ -164,7 +172,7 @@ class CallViewModel( | @@ -164,7 +172,7 @@ class CallViewModel( | ||
| 164 | } | 172 | } |
| 165 | 173 | ||
| 166 | fun startScreenCapture(mediaProjectionPermissionResultData: Intent) { | 174 | fun startScreenCapture(mediaProjectionPermissionResultData: Intent) { |
| 167 | - val localParticipant = room.localParticipant | 175 | + val localParticipant = room.value?.localParticipant ?: return |
| 168 | viewModelScope.launch { | 176 | viewModelScope.launch { |
| 169 | val screencastTrack = | 177 | val screencastTrack = |
| 170 | localParticipant.createScreencastTrack(mediaProjectionPermissionResultData = mediaProjectionPermissionResultData) | 178 | localParticipant.createScreencastTrack(mediaProjectionPermissionResultData = mediaProjectionPermissionResultData) |
| @@ -185,7 +193,7 @@ class CallViewModel( | @@ -185,7 +193,7 @@ class CallViewModel( | ||
| 185 | viewModelScope.launch { | 193 | viewModelScope.launch { |
| 186 | localScreencastTrack?.let { localScreencastVideoTrack -> | 194 | localScreencastTrack?.let { localScreencastVideoTrack -> |
| 187 | localScreencastVideoTrack.stop() | 195 | localScreencastVideoTrack.stop() |
| 188 | - room.localParticipant.unpublishTrack(localScreencastVideoTrack) | 196 | + room.value?.localParticipant?.unpublishTrack(localScreencastVideoTrack) |
| 189 | mutableScreencastEnabled.postValue(localScreencastTrack?.enabled ?: false) | 197 | mutableScreencastEnabled.postValue(localScreencastTrack?.enabled ?: false) |
| 190 | } | 198 | } |
| 191 | } | 199 | } |
| @@ -193,35 +201,39 @@ class CallViewModel( | @@ -193,35 +201,39 @@ class CallViewModel( | ||
| 193 | 201 | ||
| 194 | override fun onCleared() { | 202 | override fun onCleared() { |
| 195 | super.onCleared() | 203 | super.onCleared() |
| 196 | - room.disconnect() | 204 | + mutableRoom.value?.disconnect() |
| 197 | } | 205 | } |
| 198 | 206 | ||
| 199 | fun setMicEnabled(enabled: Boolean) { | 207 | fun setMicEnabled(enabled: Boolean) { |
| 200 | viewModelScope.launch { | 208 | viewModelScope.launch { |
| 201 | - room.localParticipant.setMicrophoneEnabled(enabled) | 209 | + val localParticipant = room.value?.localParticipant ?: return@launch |
| 210 | + localParticipant.setMicrophoneEnabled(enabled) | ||
| 202 | mutableMicEnabled.postValue(enabled) | 211 | mutableMicEnabled.postValue(enabled) |
| 203 | } | 212 | } |
| 204 | } | 213 | } |
| 205 | 214 | ||
| 206 | fun setCameraEnabled(enabled: Boolean) { | 215 | fun setCameraEnabled(enabled: Boolean) { |
| 207 | viewModelScope.launch { | 216 | viewModelScope.launch { |
| 208 | - room.localParticipant.setCameraEnabled(enabled) | 217 | + val localParticipant = room.value?.localParticipant ?: return@launch |
| 218 | + localParticipant.setCameraEnabled(enabled) | ||
| 209 | mutableCameraEnabled.postValue(enabled) | 219 | mutableCameraEnabled.postValue(enabled) |
| 210 | } | 220 | } |
| 211 | } | 221 | } |
| 212 | 222 | ||
| 213 | fun flipCamera() { | 223 | fun flipCamera() { |
| 214 | - val videoTrack = room.localParticipant.getTrackPublication(Track.Source.CAMERA) | ||
| 215 | - ?.track as? LocalVideoTrack | ||
| 216 | - ?: return | ||
| 217 | - | ||
| 218 | - val newOptions = when (videoTrack.options.position) { | ||
| 219 | - CameraPosition.FRONT -> LocalVideoTrackOptions(position = CameraPosition.BACK) | ||
| 220 | - CameraPosition.BACK -> LocalVideoTrackOptions(position = CameraPosition.FRONT) | ||
| 221 | - else -> LocalVideoTrackOptions() | ||
| 222 | - } | 224 | + room.value?.localParticipant?.let { participant -> |
| 225 | + val videoTrack = participant.getTrackPublication(Track.Source.CAMERA) | ||
| 226 | + ?.track as? LocalVideoTrack | ||
| 227 | + ?: return@let | ||
| 228 | + | ||
| 229 | + val newOptions = when (videoTrack.options.position) { | ||
| 230 | + CameraPosition.FRONT -> LocalVideoTrackOptions(position = CameraPosition.BACK) | ||
| 231 | + CameraPosition.BACK -> LocalVideoTrackOptions(position = CameraPosition.FRONT) | ||
| 232 | + else -> LocalVideoTrackOptions() | ||
| 233 | + } | ||
| 223 | 234 | ||
| 224 | - videoTrack.restartTrack(newOptions) | 235 | + videoTrack.restartTrack(newOptions) |
| 236 | + } | ||
| 225 | } | 237 | } |
| 226 | 238 | ||
| 227 | fun dismissError() { | 239 | fun dismissError() { |
| @@ -230,17 +242,17 @@ class CallViewModel( | @@ -230,17 +242,17 @@ class CallViewModel( | ||
| 230 | 242 | ||
| 231 | fun sendData(message: String) { | 243 | fun sendData(message: String) { |
| 232 | viewModelScope.launch { | 244 | viewModelScope.launch { |
| 233 | - room.localParticipant.publishData(message.toByteArray(Charsets.UTF_8)) | 245 | + room.value?.localParticipant?.publishData(message.toByteArray(Charsets.UTF_8)) |
| 234 | } | 246 | } |
| 235 | } | 247 | } |
| 236 | 248 | ||
| 237 | fun toggleSubscriptionPermissions() { | 249 | fun toggleSubscriptionPermissions() { |
| 238 | mutablePermissionAllowed.value = !mutablePermissionAllowed.value | 250 | mutablePermissionAllowed.value = !mutablePermissionAllowed.value |
| 239 | - room.localParticipant.setTrackSubscriptionPermissions(mutablePermissionAllowed.value) | 251 | + room.value?.localParticipant?.setTrackSubscriptionPermissions(mutablePermissionAllowed.value) |
| 240 | } | 252 | } |
| 241 | 253 | ||
| 242 | fun simulateMigration() { | 254 | fun simulateMigration() { |
| 243 | - room.sendSimulateScenario( | 255 | + room.value?.sendSimulateScenario( |
| 244 | LivekitRtc.SimulateScenario.newBuilder() | 256 | LivekitRtc.SimulateScenario.newBuilder() |
| 245 | .setMigration(true) | 257 | .setMigration(true) |
| 246 | .build() | 258 | .build() |
| @@ -249,14 +261,21 @@ class CallViewModel( | @@ -249,14 +261,21 @@ class CallViewModel( | ||
| 249 | 261 | ||
| 250 | fun reconnect() { | 262 | fun reconnect() { |
| 251 | Timber.e { "Reconnecting." } | 263 | Timber.e { "Reconnecting." } |
| 264 | + val room = mutableRoom.value ?: return | ||
| 265 | + mutableRoom.value = null | ||
| 252 | mutablePrimarySpeaker.value = null | 266 | mutablePrimarySpeaker.value = null |
| 253 | room.disconnect() | 267 | room.disconnect() |
| 254 | viewModelScope.launch { | 268 | viewModelScope.launch { |
| 255 | - connectToRoom() | 269 | + room.connect( |
| 270 | + url, | ||
| 271 | + token | ||
| 272 | + ) | ||
| 273 | + mutableRoom.value = room | ||
| 256 | } | 274 | } |
| 257 | } | 275 | } |
| 258 | } | 276 | } |
| 259 | 277 | ||
| 260 | private fun <T> LiveData<T>.hide(): LiveData<T> = this | 278 | private fun <T> LiveData<T>.hide(): LiveData<T> = this |
| 279 | + | ||
| 261 | private fun <T> MutableStateFlow<T>.hide(): StateFlow<T> = this | 280 | private fun <T> MutableStateFlow<T>.hide(): StateFlow<T> = this |
| 262 | private fun <T> Flow<T>.hide(): Flow<T> = this | 281 | 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 = viewModel.room | 63 | + val room by viewModel.room.collectAsState() |
| 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,6 +4,7 @@ import android.app.Activity | @@ -4,6 +4,7 @@ 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 | ||
| 7 | import android.widget.EditText | 8 | import android.widget.EditText |
| 8 | import android.widget.Toast | 9 | import android.widget.Toast |
| 9 | import androidx.activity.result.contract.ActivityResultContracts | 10 | import androidx.activity.result.contract.ActivityResultContracts |
| @@ -12,8 +13,11 @@ import androidx.appcompat.app.AppCompatActivity | @@ -12,8 +13,11 @@ import androidx.appcompat.app.AppCompatActivity | ||
| 12 | import androidx.lifecycle.lifecycleScope | 13 | import androidx.lifecycle.lifecycleScope |
| 13 | import androidx.recyclerview.widget.LinearLayoutManager | 14 | import androidx.recyclerview.widget.LinearLayoutManager |
| 14 | import com.xwray.groupie.GroupieAdapter | 15 | import com.xwray.groupie.GroupieAdapter |
| 16 | +import io.livekit.android.room.track.Track | ||
| 17 | +import io.livekit.android.room.track.VideoTrack | ||
| 15 | import io.livekit.android.sample.databinding.CallActivityBinding | 18 | import io.livekit.android.sample.databinding.CallActivityBinding |
| 16 | -import kotlinx.coroutines.flow.collectLatest | 19 | +import io.livekit.android.util.flow |
| 20 | +import kotlinx.coroutines.flow.* | ||
| 17 | import kotlinx.parcelize.Parcelize | 21 | import kotlinx.parcelize.Parcelize |
| 18 | 22 | ||
| 19 | class CallActivity : AppCompatActivity() { | 23 | class CallActivity : AppCompatActivity() { |
| @@ -44,32 +48,87 @@ class CallActivity : AppCompatActivity() { | @@ -44,32 +48,87 @@ class CallActivity : AppCompatActivity() { | ||
| 44 | setContentView(binding.root) | 48 | setContentView(binding.root) |
| 45 | 49 | ||
| 46 | // Audience row setup | 50 | // Audience row setup |
| 47 | - val audienceAdapter = GroupieAdapter() | 51 | + binding.audienceRow.layoutManager = |
| 52 | + LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) | ||
| 53 | + val adapter = GroupieAdapter() | ||
| 54 | + | ||
| 48 | binding.audienceRow.apply { | 55 | binding.audienceRow.apply { |
| 49 | - layoutManager = LinearLayoutManager(this@CallActivity, LinearLayoutManager.HORIZONTAL, false) | ||
| 50 | - adapter = audienceAdapter | 56 | + this.adapter = adapter |
| 51 | } | 57 | } |
| 52 | 58 | ||
| 53 | lifecycleScope.launchWhenCreated { | 59 | lifecycleScope.launchWhenCreated { |
| 54 | - viewModel.participants | ||
| 55 | - .collect { participants -> | ||
| 56 | - val items = participants.map { participant -> ParticipantItem(viewModel.room, participant) } | ||
| 57 | - audienceAdapter.update(items) | 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 | + } | ||
| 58 | } | 67 | } |
| 59 | } | 68 | } |
| 60 | 69 | ||
| 61 | // speaker view setup | 70 | // speaker view setup |
| 62 | - val speakerAdapter = GroupieAdapter() | ||
| 63 | - binding.speakerView.apply { | ||
| 64 | - layoutManager = LinearLayoutManager(this@CallActivity, LinearLayoutManager.HORIZONTAL, false) | ||
| 65 | - adapter = speakerAdapter | ||
| 66 | - } | ||
| 67 | lifecycleScope.launchWhenCreated { | 71 | lifecycleScope.launchWhenCreated { |
| 68 | - viewModel.primarySpeaker.collectLatest { speaker -> | ||
| 69 | - val items = listOfNotNull(speaker) | ||
| 70 | - .map { participant -> ParticipantItem(viewModel.room, participant, speakerView = true) } | ||
| 71 | - speakerAdapter.update(items) | ||
| 72 | - } | 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 | + } | ||
| 73 | } | 132 | } |
| 74 | 133 | ||
| 75 | // Controls setup | 134 | // Controls setup |
| @@ -145,9 +204,10 @@ class CallActivity : AppCompatActivity() { | @@ -145,9 +204,10 @@ class CallActivity : AppCompatActivity() { | ||
| 145 | } | 204 | } |
| 146 | 205 | ||
| 147 | override fun onDestroy() { | 206 | override fun onDestroy() { |
| 148 | - binding.audienceRow.adapter = null | ||
| 149 | - binding.speakerView.adapter = null | ||
| 150 | super.onDestroy() | 207 | super.onDestroy() |
| 208 | + | ||
| 209 | + // Release video views | ||
| 210 | + binding.speakerVideoView.release() | ||
| 151 | } | 211 | } |
| 152 | 212 | ||
| 153 | companion object { | 213 | companion object { |
| @@ -7,18 +7,20 @@ import com.xwray.groupie.viewbinding.GroupieViewHolder | @@ -7,18 +7,20 @@ 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 | ||
| 10 | import io.livekit.android.room.track.Track | 13 | import io.livekit.android.room.track.Track |
| 11 | import io.livekit.android.room.track.VideoTrack | 14 | import io.livekit.android.room.track.VideoTrack |
| 12 | import io.livekit.android.sample.databinding.ParticipantItemBinding | 15 | import io.livekit.android.sample.databinding.ParticipantItemBinding |
| 13 | import io.livekit.android.util.flow | 16 | import io.livekit.android.util.flow |
| 14 | import kotlinx.coroutines.* | 17 | import kotlinx.coroutines.* |
| 15 | -import kotlinx.coroutines.flow.* | 18 | +import kotlinx.coroutines.flow.flatMapLatest |
| 19 | +import kotlinx.coroutines.flow.flowOf | ||
| 16 | 20 | ||
| 17 | -@OptIn(ExperimentalCoroutinesApi::class) | ||
| 18 | class ParticipantItem( | 21 | class ParticipantItem( |
| 19 | private val room: Room, | 22 | private val room: Room, |
| 20 | - private val participant: Participant, | ||
| 21 | - private val speakerView: Boolean = false, | 23 | + private val participant: Participant |
| 22 | ) : BindableItem<ParticipantItemBinding>() { | 24 | ) : BindableItem<ParticipantItemBinding>() { |
| 23 | 25 | ||
| 24 | private var boundVideoTrack: VideoTrack? = null | 26 | private var boundVideoTrack: VideoTrack? = null |
| @@ -65,44 +67,25 @@ class ParticipantItem( | @@ -65,44 +67,25 @@ class ParticipantItem( | ||
| 65 | if (quality == ConnectionQuality.POOR) View.VISIBLE else View.INVISIBLE | 67 | if (quality == ConnectionQuality.POOR) View.VISIBLE else View.INVISIBLE |
| 66 | } | 68 | } |
| 67 | } | 69 | } |
| 68 | - | ||
| 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) | 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 | + } | ||
| 79 | } | 80 | } |
| 80 | 81 | ||
| 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 | - } | 82 | + override fun onTrackUnpublished( |
| 83 | + publication: RemoteTrackPublication, | ||
| 84 | + participant: RemoteParticipant | ||
| 85 | + ) { | ||
| 86 | + super.onTrackUnpublished(publication, participant) | ||
| 87 | + Timber.e { "Track unpublished" } | ||
| 88 | + } | ||
| 106 | } | 89 | } |
| 107 | val existingTrack = getVideoTrack() | 90 | val existingTrack = getVideoTrack() |
| 108 | if (existingTrack != null) { | 91 | if (existingTrack != null) { |
| @@ -114,14 +97,14 @@ class ParticipantItem( | @@ -114,14 +97,14 @@ class ParticipantItem( | ||
| 114 | return participant.getTrackPublication(Track.Source.CAMERA)?.track as? VideoTrack | 97 | return participant.getTrackPublication(Track.Source.CAMERA)?.track as? VideoTrack |
| 115 | } | 98 | } |
| 116 | 99 | ||
| 117 | - private fun setupVideoIfNeeded(videoTrack: VideoTrack?, viewBinding: ParticipantItemBinding) { | ||
| 118 | - if (boundVideoTrack == videoTrack) { | 100 | + internal fun setupVideoIfNeeded(videoTrack: VideoTrack, viewBinding: ParticipantItemBinding) { |
| 101 | + if (boundVideoTrack != null) { | ||
| 119 | return | 102 | return |
| 120 | } | 103 | } |
| 121 | - boundVideoTrack?.removeRenderer(viewBinding.renderer) | 104 | + |
| 122 | boundVideoTrack = videoTrack | 105 | boundVideoTrack = videoTrack |
| 123 | Timber.v { "adding renderer to $videoTrack" } | 106 | Timber.v { "adding renderer to $videoTrack" } |
| 124 | - videoTrack?.addRenderer(viewBinding.renderer) | 107 | + videoTrack.addRenderer(viewBinding.renderer) |
| 125 | } | 108 | } |
| 126 | 109 | ||
| 127 | override fun unbind(viewHolder: GroupieViewHolder<ParticipantItemBinding>) { | 110 | override fun unbind(viewHolder: GroupieViewHolder<ParticipantItemBinding>) { |
| @@ -132,25 +115,5 @@ class ParticipantItem( | @@ -132,25 +115,5 @@ class ParticipantItem( | ||
| 132 | boundVideoTrack = null | 115 | boundVideoTrack = null |
| 133 | } | 116 | } |
| 134 | 117 | ||
| 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 | - } | 118 | + override fun getLayout(): Int = R.layout.participant_item |
| 156 | } | 119 | } |
| @@ -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 | - <androidx.recyclerview.widget.RecyclerView | 7 | + <FrameLayout |
| 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,7 +14,49 @@ | @@ -14,7 +14,49 @@ | ||
| 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 | - </androidx.recyclerview.widget.RecyclerView> | 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" /> | ||
| 18 | 60 | ||
| 19 | <androidx.recyclerview.widget.RecyclerView | 61 | <androidx.recyclerview.widget.RecyclerView |
| 20 | android:id="@+id/audience_row" | 62 | 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> |
-
请 注册 或 登录 后发表评论