davidliu

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

This reverts commit facd5b6d.
... ... @@ -30,22 +30,20 @@ class CallViewModel(
val token: String,
application: Application
) : AndroidViewModel(application) {
private val mutableRoom = MutableStateFlow<Room?>(null)
val room: MutableStateFlow<Room?> = mutableRoom
val participants = mutableRoom.flatMapLatest { room ->
if (room != null) {
room::remoteParticipants.flow
.map { remoteParticipants ->
listOf<Participant>(room.localParticipant) +
remoteParticipants
.keys
.sortedBy { it }
.mapNotNull { remoteParticipants[it] }
}
} else {
flowOf(emptyList())
val room = LiveKit.create(
appContext = application,
options = RoomOptions(adaptiveStream = true, dynacast = true),
)
val participants = room::remoteParticipants.flow
.map { remoteParticipants ->
listOf<Participant>(room.localParticipant) +
remoteParticipants
.keys
.sortedBy { it }
.mapNotNull { remoteParticipants[it] }
}
}
private val mutableError = MutableStateFlow<Throwable?>(null)
val error = mutableError.hide()
... ... @@ -53,13 +51,7 @@ class CallViewModel(
private val mutablePrimarySpeaker = MutableStateFlow<Participant?>(null)
val primarySpeaker: StateFlow<Participant?> = mutablePrimarySpeaker
val activeSpeakers = mutableRoom.flatMapLatest { room ->
if (room != null) {
room::activeSpeakers.flow
} else {
flowOf(emptyList())
}
}
val activeSpeakers = room::activeSpeakers.flow
private var localScreencastTrack: LocalScreencastVideoTrack? = null
... ... @@ -84,61 +76,60 @@ class CallViewModel(
val audioHandler = AudioSwitchHandler(application)
init {
viewModelScope.launch {
launch {
error.collect { Timber.e(it) }
}
try {
val room = LiveKit.connect(
application,
url,
token,
roomOptions = RoomOptions(adaptiveStream = true, dynacast = true),
overrides = LiveKitOverrides(audioHandler = audioHandler)
)
// Create and publish audio/video tracks
val localParticipant = room.localParticipant
localParticipant.setMicrophoneEnabled(true)
mutableMicEnabled.postValue(localParticipant.isMicrophoneEnabled())
localParticipant.setCameraEnabled(true)
mutableCameraEnabled.postValue(localParticipant.isCameraEnabled())
mutableRoom.value = room
handlePrimarySpeaker(emptyList(), emptyList(), room)
launch {
combine(participants, activeSpeakers) { participants, speakers -> participants to speakers }
.collect { (participantsList, speakers) ->
handlePrimarySpeaker(
participantsList,
speakers,
room
)
}
}
launch {
combine(participants, activeSpeakers) { participants, speakers -> participants to speakers }
.collect { (participantsList, speakers) ->
handlePrimarySpeaker(
participantsList,
speakers,
room
)
}
}
launch {
room.events.collect {
when (it) {
is RoomEvent.FailedToConnect -> mutableError.value = it.error
is RoomEvent.DataReceived -> {
val identity = it.participant.identity ?: ""
val message = it.data.toString(Charsets.UTF_8)
mutableDataReceived.emit("$identity: $message")
}
launch {
room.events.collect {
when (it) {
is RoomEvent.FailedToConnect -> mutableError.value = it.error
is RoomEvent.DataReceived -> {
val identity = it.participant.identity ?: ""
val message = it.data.toString(Charsets.UTF_8)
mutableDataReceived.emit("$identity: $message")
}
else -> {}
}
}
} catch (e: Throwable) {
mutableError.value = e
}
connectToRoom()
}
}
private fun handlePrimarySpeaker(participantsList: List<Participant>, speakers: List<Participant>, room: Room) {
private suspend fun connectToRoom() {
try {
room.connect(
url = url,
token = token,
)
// Create and publish audio/video tracks
val localParticipant = room.localParticipant
localParticipant.setMicrophoneEnabled(true)
mutableMicEnabled.postValue(localParticipant.isMicrophoneEnabled())
localParticipant.setCameraEnabled(true)
mutableCameraEnabled.postValue(localParticipant.isCameraEnabled())
handlePrimarySpeaker(emptyList(), emptyList(), room)
} catch (e: Throwable) {
mutableError.value = e
}
}
private fun handlePrimarySpeaker(participantsList: List<Participant>, speakers: List<Participant>, room: Room?) {
var speaker = mutablePrimarySpeaker.value
... ... @@ -159,7 +150,7 @@ class CallViewModel(
// Default to another person in room, or local participant.
speaker = participantsList.filterIsInstance<RemoteParticipant>()
.firstOrNull()
?: room.localParticipant
?: room?.localParticipant
}
if (speakers.isNotEmpty() && !speakers.contains(speaker)) {
... ... @@ -176,7 +167,7 @@ class CallViewModel(
}
fun startScreenCapture(mediaProjectionPermissionResultData: Intent) {
val localParticipant = room.value?.localParticipant ?: return
val localParticipant = room.localParticipant
viewModelScope.launch {
val screencastTrack =
localParticipant.createScreencastTrack(mediaProjectionPermissionResultData = mediaProjectionPermissionResultData)
... ... @@ -197,7 +188,7 @@ class CallViewModel(
viewModelScope.launch {
localScreencastTrack?.let { localScreencastVideoTrack ->
localScreencastVideoTrack.stop()
room.value?.localParticipant?.unpublishTrack(localScreencastVideoTrack)
room.localParticipant.unpublishTrack(localScreencastVideoTrack)
mutableScreencastEnabled.postValue(localScreencastTrack?.enabled ?: false)
}
}
... ... @@ -205,39 +196,35 @@ class CallViewModel(
override fun onCleared() {
super.onCleared()
mutableRoom.value?.disconnect()
room.disconnect()
}
fun setMicEnabled(enabled: Boolean) {
viewModelScope.launch {
val localParticipant = room.value?.localParticipant ?: return@launch
localParticipant.setMicrophoneEnabled(enabled)
room.localParticipant.setMicrophoneEnabled(enabled)
mutableMicEnabled.postValue(enabled)
}
}
fun setCameraEnabled(enabled: Boolean) {
viewModelScope.launch {
val localParticipant = room.value?.localParticipant ?: return@launch
localParticipant.setCameraEnabled(enabled)
room.localParticipant.setCameraEnabled(enabled)
mutableCameraEnabled.postValue(enabled)
}
}
fun flipCamera() {
room.value?.localParticipant?.let { participant ->
val videoTrack = participant.getTrackPublication(Track.Source.CAMERA)
?.track as? LocalVideoTrack
?: return@let
val newOptions = when (videoTrack.options.position) {
CameraPosition.FRONT -> LocalVideoTrackOptions(position = CameraPosition.BACK)
CameraPosition.BACK -> LocalVideoTrackOptions(position = CameraPosition.FRONT)
else -> LocalVideoTrackOptions()
}
videoTrack.restartTrack(newOptions)
val videoTrack = room.localParticipant.getTrackPublication(Track.Source.CAMERA)
?.track as? LocalVideoTrack
?: return
val newOptions = when (videoTrack.options.position) {
CameraPosition.FRONT -> LocalVideoTrackOptions(position = CameraPosition.BACK)
CameraPosition.BACK -> LocalVideoTrackOptions(position = CameraPosition.FRONT)
else -> LocalVideoTrackOptions()
}
videoTrack.restartTrack(newOptions)
}
fun dismissError() {
... ... @@ -246,17 +233,17 @@ class CallViewModel(
fun sendData(message: String) {
viewModelScope.launch {
room.value?.localParticipant?.publishData(message.toByteArray(Charsets.UTF_8))
room.localParticipant.publishData(message.toByteArray(Charsets.UTF_8))
}
}
fun toggleSubscriptionPermissions() {
mutablePermissionAllowed.value = !mutablePermissionAllowed.value
room.value?.localParticipant?.setTrackSubscriptionPermissions(mutablePermissionAllowed.value)
room.localParticipant.setTrackSubscriptionPermissions(mutablePermissionAllowed.value)
}
fun simulateMigration() {
room.value?.sendSimulateScenario(
room.sendSimulateScenario(
LivekitRtc.SimulateScenario.newBuilder()
.setMigration(true)
.build()
... ... @@ -265,21 +252,14 @@ class CallViewModel(
fun reconnect() {
Timber.e { "Reconnecting." }
val room = mutableRoom.value ?: return
mutableRoom.value = null
mutablePrimarySpeaker.value = null
room.disconnect()
viewModelScope.launch {
room.connect(
url,
token
)
mutableRoom.value = room
connectToRoom()
}
}
}
private fun <T> LiveData<T>.hide(): LiveData<T> = this
private fun <T> MutableStateFlow<T>.hide(): StateFlow<T> = this
private fun <T> Flow<T>.hide(): Flow<T> = this
\ No newline at end of file
... ...
... ... @@ -62,7 +62,7 @@ class CallActivity : AppCompatActivity() {
// Setup compose view.
setContent {
val room by viewModel.room.collectAsState()
val room = viewModel.room
val participants by viewModel.participants.collectAsState(initial = emptyList())
val primarySpeaker by viewModel.primarySpeaker.collectAsState()
val activeSpeakers by viewModel.activeSpeakers.collectAsState(initial = emptyList())
... ...
... ... @@ -4,7 +4,6 @@ import android.app.Activity
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.os.Parcelable
import android.view.View
import android.widget.EditText
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
... ... @@ -13,13 +12,10 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.xwray.groupie.GroupieAdapter
import io.livekit.android.room.track.Track
import io.livekit.android.room.track.VideoTrack
import io.livekit.android.sample.databinding.CallActivityBinding
import io.livekit.android.sample.dialog.showDebugMenuDialog
import io.livekit.android.sample.dialog.showSelectAudioDeviceDialog
import io.livekit.android.util.flow
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.collectLatest
import kotlinx.parcelize.Parcelize
class CallActivity : AppCompatActivity() {
... ... @@ -50,87 +46,32 @@ class CallActivity : AppCompatActivity() {
setContentView(binding.root)
// Audience row setup
binding.audienceRow.layoutManager =
LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
val adapter = GroupieAdapter()
val audienceAdapter = GroupieAdapter()
binding.audienceRow.apply {
this.adapter = adapter
layoutManager = LinearLayoutManager(this@CallActivity, LinearLayoutManager.HORIZONTAL, false)
adapter = audienceAdapter
}
lifecycleScope.launchWhenCreated {
viewModel.room
.combine(viewModel.participants) { room, participants -> room to participants }
.collect { (room, participants) ->
if (room != null) {
val items = participants.map { participant -> ParticipantItem(room, participant) }
adapter.update(items)
}
viewModel.participants
.collect { participants ->
val items = participants.map { participant -> ParticipantItem(viewModel.room, participant) }
audienceAdapter.update(items)
}
}
// speaker view setup
val speakerAdapter = GroupieAdapter()
binding.speakerView.apply {
layoutManager = LinearLayoutManager(this@CallActivity, LinearLayoutManager.HORIZONTAL, false)
adapter = speakerAdapter
}
lifecycleScope.launchWhenCreated {
viewModel.room.filterNotNull().take(1)
.transform { room ->
// Initialize video renderer
room.initVideoRenderer(binding.speakerVideoView)
// Observe primary speaker changes
emitAll(viewModel.primarySpeaker)
}.flatMapLatest { primarySpeaker ->
if (primarySpeaker != null) {
flowOf(primarySpeaker)
} else {
emptyFlow()
}
}.collect { participant ->
// Update new primary speaker identity
binding.identityText.text = participant.identity
// observe videoTracks changes.
val videoTrackFlow = participant::videoTracks.flow
.map { participant to it }
.flatMapLatest { (participant, videoTracks) ->
// Prioritize any screenshare streams.
val trackPublication = participant.getTrackPublication(Track.Source.SCREEN_SHARE)
?: participant.getTrackPublication(Track.Source.CAMERA)
?: videoTracks.firstOrNull()?.first
?: return@flatMapLatest emptyFlow()
trackPublication::track.flow
}
// observe audioTracks changes.
val mutedFlow = participant::audioTracks.flow
.flatMapLatest { tracks ->
val audioTrack = tracks.firstOrNull()?.first
if (audioTrack != null) {
audioTrack::muted.flow
} else {
flowOf(true)
}
}
combine(videoTrackFlow, mutedFlow) { videoTrack, muted ->
videoTrack to muted
}.collect { (videoTrack, muted) ->
// Cleanup old video track
val oldVideoTrack = binding.speakerVideoView.tag as? VideoTrack
oldVideoTrack?.removeRenderer(binding.speakerVideoView)
// Bind new video track to video view.
if (videoTrack is VideoTrack) {
videoTrack.addRenderer(binding.speakerVideoView)
binding.speakerVideoView.visibility = View.VISIBLE
} else {
binding.speakerVideoView.visibility = View.INVISIBLE
}
binding.speakerVideoView.tag = videoTrack
binding.muteIndicator.visibility = if (muted) View.VISIBLE else View.INVISIBLE
}
}
viewModel.primarySpeaker.collectLatest { speaker ->
val items = listOfNotNull(speaker)
.map { participant -> ParticipantItem(viewModel.room, participant, speakerView = true) }
speakerAdapter.update(items)
}
}
// Controls setup
... ... @@ -224,10 +165,9 @@ class CallActivity : AppCompatActivity() {
}
override fun onDestroy() {
binding.audienceRow.adapter = null
binding.speakerView.adapter = null
super.onDestroy()
// Release video views
binding.speakerVideoView.release()
}
companion object {
... ...
... ... @@ -7,20 +7,18 @@ import com.xwray.groupie.viewbinding.GroupieViewHolder
import io.livekit.android.room.Room
import io.livekit.android.room.participant.ConnectionQuality
import io.livekit.android.room.participant.Participant
import io.livekit.android.room.participant.ParticipantListener
import io.livekit.android.room.participant.RemoteParticipant
import io.livekit.android.room.track.RemoteTrackPublication
import io.livekit.android.room.track.Track
import io.livekit.android.room.track.VideoTrack
import io.livekit.android.sample.databinding.ParticipantItemBinding
import io.livekit.android.util.flow
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.*
@OptIn(ExperimentalCoroutinesApi::class)
class ParticipantItem(
private val room: Room,
private val participant: Participant
private val participant: Participant,
private val speakerView: Boolean = false,
) : BindableItem<ParticipantItemBinding>() {
private var boundVideoTrack: VideoTrack? = null
... ... @@ -67,25 +65,44 @@ class ParticipantItem(
if (quality == ConnectionQuality.POOR) View.VISIBLE else View.INVISIBLE
}
}
participant.listener = object : ParticipantListener {
override fun onTrackSubscribed(
track: Track,
publication: RemoteTrackPublication,
participant: RemoteParticipant
) {
if (track !is VideoTrack) return
if (publication.source == Track.Source.CAMERA) {
setupVideoIfNeeded(track, viewBinding)
}
}
override fun onTrackUnpublished(
publication: RemoteTrackPublication,
participant: RemoteParticipant
) {
super.onTrackUnpublished(publication, participant)
Timber.e { "Track unpublished" }
// observe videoTracks changes.
val videoTrackPubFlow = participant::videoTracks.flow
.map { participant to it }
.flatMapLatest { (participant, videoTracks) ->
// Prioritize any screenshare streams.
val trackPublication = participant.getTrackPublication(Track.Source.SCREEN_SHARE)
?: participant.getTrackPublication(Track.Source.CAMERA)
?: videoTracks.firstOrNull()?.first
flowOf(trackPublication)
}
coroutineScope?.launch {
videoTrackPubFlow
.flatMapLatest { pub ->
if (pub != null) {
pub::track.flow
} else {
flowOf(null)
}
}
.collectLatest { videoTrack ->
setupVideoIfNeeded(videoTrack as? VideoTrack, viewBinding)
}
}
coroutineScope?.launch {
videoTrackPubFlow
.flatMapLatest { pub ->
if (pub != null) {
pub::muted.flow
} else {
flowOf(true)
}
}
.collectLatest { muted ->
viewBinding.renderer.visibleOrInvisible(!muted)
}
}
val existingTrack = getVideoTrack()
if (existingTrack != null) {
... ... @@ -97,14 +114,14 @@ class ParticipantItem(
return participant.getTrackPublication(Track.Source.CAMERA)?.track as? VideoTrack
}
internal fun setupVideoIfNeeded(videoTrack: VideoTrack, viewBinding: ParticipantItemBinding) {
if (boundVideoTrack != null) {
private fun setupVideoIfNeeded(videoTrack: VideoTrack?, viewBinding: ParticipantItemBinding) {
if (boundVideoTrack == videoTrack) {
return
}
boundVideoTrack?.removeRenderer(viewBinding.renderer)
boundVideoTrack = videoTrack
Timber.v { "adding renderer to $videoTrack" }
videoTrack.addRenderer(viewBinding.renderer)
videoTrack?.addRenderer(viewBinding.renderer)
}
override fun unbind(viewHolder: GroupieViewHolder<ParticipantItemBinding>) {
... ... @@ -115,5 +132,25 @@ class ParticipantItem(
boundVideoTrack = null
}
override fun getLayout(): Int = R.layout.participant_item
override fun getLayout(): Int =
if (speakerView)
R.layout.speaker_view
else
R.layout.participant_item
}
private fun View.visibleOrGone(visible: Boolean) {
visibility = if (visible) {
View.VISIBLE
} else {
View.GONE
}
}
private fun View.visibleOrInvisible(visible: Boolean) {
visibility = if (visible) {
View.VISIBLE
} else {
View.INVISIBLE
}
}
\ No newline at end of file
... ...
... ... @@ -4,7 +4,7 @@
android:layout_height="match_parent"
android:keepScreenOn="true">
<FrameLayout
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/speaker_view"
android:layout_width="0dp"
android:layout_height="0dp"
... ... @@ -14,49 +14,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_gravity="center"
android:src="@drawable/outline_videocam_off_24"
app:tint="@color/no_video_participant" />
<io.livekit.android.renderer.TextureViewRenderer
android:id="@+id/speaker_video_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
<FrameLayout
android:id="@+id/identity_bar"
android:layout_width="0dp"
android:layout_height="30dp"
android:background="#80000000"
app:layout_constraintBottom_toBottomOf="@id/speaker_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageView
android:id="@+id/mute_indicator"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="@dimen/identity_bar_padding"
android:src="@drawable/outline_mic_off_24"
app:layout_constraintBottom_toBottomOf="@id/identity_bar"
app:layout_constraintEnd_toEndOf="@id/identity_bar"
app:layout_constraintTop_toTopOf="@id/identity_bar"
app:tint="#BB0000" />
<TextView
android:id="@+id/identity_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/identity_bar_padding"
android:ellipsize="end"
app:layout_constraintBottom_toBottomOf="@id/identity_bar"
app:layout_constraintEnd_toStartOf="@id/mute_indicator"
app:layout_constraintStart_toStartOf="@id/identity_bar"
app:layout_constraintTop_toTopOf="@id/identity_bar" />
</androidx.recyclerview.widget.RecyclerView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/audience_row"
... ...
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/no_video_background">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/no_video_background"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_gravity="center"
android:src="@drawable/outline_videocam_off_24"
app:tint="@color/no_video_participant" />
<io.livekit.android.renderer.TextureViewRenderer
android:id="@+id/renderer"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ImageView
android:id="@+id/connection_quality"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="top|end"
android:layout_marginTop="@dimen/identity_bar_padding"
android:layout_marginEnd="@dimen/identity_bar_padding"
android:alpha="0.5"
android:src="@drawable/wifi_strength_1"
android:visibility="invisible"
app:tint="#FF0000" />
</FrameLayout>
<FrameLayout
android:id="@+id/identity_bar"
android:layout_width="0dp"
android:layout_height="30dp"
android:background="#80000000"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageView
android:id="@+id/mute_indicator"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="@dimen/identity_bar_padding"
android:src="@drawable/outline_mic_off_24"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/identity_bar"
app:layout_constraintEnd_toEndOf="@id/identity_bar"
app:layout_constraintTop_toTopOf="@id/identity_bar"
app:tint="#BB0000" />
<TextView
android:id="@+id/identity_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/identity_bar_padding"
android:ellipsize="end"
app:layout_constraintBottom_toBottomOf="@id/identity_bar"
app:layout_constraintEnd_toStartOf="@id/mute_indicator"
app:layout_constraintStart_toStartOf="@id/identity_bar"
app:layout_constraintTop_toTopOf="@id/identity_bar" />
</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
... ...