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