Skip to content

Commit

Permalink
Update scroll order (#859)
Browse files Browse the repository at this point in the history
Update sort order based on UI visibility of the participants.
  • Loading branch information
aleksandar-apostolov authored Oct 13, 2023
1 parent 530574f commit c5c7257
Show file tree
Hide file tree
Showing 12 changed files with 394 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand Down Expand Up @@ -73,6 +74,7 @@ import io.getstream.video.android.core.ParticipantState
import io.getstream.video.android.core.model.NetworkQuality
import io.getstream.video.android.core.model.Reaction
import io.getstream.video.android.core.model.ReactionState
import io.getstream.video.android.core.model.VisibilityOnScreenState
import io.getstream.video.android.mock.StreamMockUtils
import io.getstream.video.android.mock.mockCall
import io.getstream.video.android.mock.mockParticipantList
Expand Down Expand Up @@ -122,6 +124,19 @@ public fun ParticipantVideo(
val connectionQuality by participant.networkQuality.collectAsStateWithLifecycle()
val participants by call.state.participants.collectAsStateWithLifecycle()

DisposableEffect(call, participant.sessionId) {
// Inform the call of this participant visibility on screen, affects sorting order.
updateParticipantVisibility(participant.sessionId, call, VisibilityOnScreenState.VISIBLE)

onDispose {
updateParticipantVisibility(
participant.sessionId,
call,
VisibilityOnScreenState.INVISIBLE,
)
}
}

val containerModifier = if (style.isFocused && participants.size > 1) {
modifier.border(
border = if (style.isScreenSharing) {
Expand Down Expand Up @@ -370,6 +385,17 @@ private fun BoxScope.DefaultReaction(
}
}

private fun updateParticipantVisibility(
sessionId: String,
call: Call,
visibilityOnScreenState: VisibilityOnScreenState,
) {
call.state.updateParticipantVisibility(
sessionId,
visibilityOnScreenState,
)
}

@Preview
@Composable
private fun CallParticipantPreview() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,8 +299,10 @@ internal fun BoxScope.LandscapeVideoRenderer(

else -> {
BoxWithConstraints(modifier = Modifier.fillMaxHeight()) {
val gridState = lazyGridStateWithVisibilityNotification(call = call)
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
state = gridState,
columns = GridCells.Fixed(3),
content = {
items(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ package io.getstream.video.android.compose.ui.components.call.renderer.internal

import android.content.res.Configuration.ORIENTATION_LANDSCAPE
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.IntSize
Expand Down Expand Up @@ -63,7 +67,7 @@ internal fun BoxScope.OrientationVideoRenderer(
) {
val dominantSpeaker by call.state.dominantSpeaker.collectAsStateWithLifecycle()
val participants by call.state.participants.collectAsStateWithLifecycle()
val sortedParticipants by call.state.sortedParticipants.collectAsStateWithLifecycle()
val sortedParticipants by call.state.sortedParticipants.collectAsStateWithLifecycle(emptyList())
val callParticipants by remember(participants) {
derivedStateOf {
if (sortedParticipants.size > 6) {
Expand Down Expand Up @@ -98,3 +102,27 @@ internal fun BoxScope.OrientationVideoRenderer(
)
}
}

/**
* Creates a [LazyGridState] which also monitors the visibility of items on the UI and exposes
* a snapshot flow to the [Call].
*
* @param call the current call.
*/
@Composable
internal fun lazyGridStateWithVisibilityNotification(call: Call): LazyGridState {
val gridState = rememberLazyGridState()
val snapshotFlow = snapshotFlow {
gridState.layoutInfo.visibleItemsInfo.map {
it.key as String
}
}
DisposableEffect(key1 = call, effect = {
call.state.updateParticipantVisibilityFlow(snapshotFlow)

onDispose {
call.state.updateParticipantVisibilityFlow(null)
}
})
return gridState
}
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,11 @@ internal fun BoxScope.PortraitVideoRenderer(

else -> {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val gridState = lazyGridStateWithVisibilityNotification(call = call)
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
columns = GridCells.Fixed(2),
state = gridState,
content = {
items(
count = callParticipants.size,
Expand Down
14 changes: 13 additions & 1 deletion stream-video-android-core/api/stream-video-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ public final class io/getstream/video/android/core/CallState {
public final fun getScreenSharingSession ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getSession ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getSettings ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getSortedParticipants ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getSortedParticipants ()Lkotlinx/coroutines/flow/Flow;
public final fun getSpeakingWhileMuted ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getStartedAt ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getStartsAt ()Lkotlinx/coroutines/flow/StateFlow;
Expand All @@ -166,6 +166,8 @@ public final class io/getstream/video/android/core/CallState {
public final fun updateFromResponse (Lorg/openapitools/client/models/StopLiveResponse;)V
public final fun updateFromResponse (Lorg/openapitools/client/models/UpdateCallResponse;)V
public final fun updateParticipant (Lio/getstream/video/android/core/ParticipantState;)V
public final fun updateParticipantVisibility (Ljava/lang/String;Lio/getstream/video/android/core/model/VisibilityOnScreenState;)V
public final fun updateParticipantVisibilityFlow (Lkotlinx/coroutines/flow/Flow;)V
public final fun upsertParticipants (Ljava/util/List;)V
}

Expand Down Expand Up @@ -483,6 +485,7 @@ public final class io/getstream/video/android/core/ParticipantState {
public final fun getVideo ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getVideoEnabled ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getVideoTrack ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getVisibleOnScreen ()Lkotlinx/coroutines/flow/StateFlow;
public fun hashCode ()I
public final fun isLocal ()Z
public final fun muteAudio (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Expand Down Expand Up @@ -3956,6 +3959,15 @@ public final class io/getstream/video/android/core/model/VideoTrack : io/getstre
public fun toString ()Ljava/lang/String;
}

public final class io/getstream/video/android/core/model/VisibilityOnScreenState : java/lang/Enum {
public static final field INVISIBLE Lio/getstream/video/android/core/model/VisibilityOnScreenState;
public static final field UNKNOWN Lio/getstream/video/android/core/model/VisibilityOnScreenState;
public static final field VISIBLE Lio/getstream/video/android/core/model/VisibilityOnScreenState;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lio/getstream/video/android/core/model/VisibilityOnScreenState;
public static fun values ()[Lio/getstream/video/android/core/model/VisibilityOnScreenState;
}

public class io/getstream/video/android/core/notifications/DefaultNotificationHandler : io/getstream/android/push/permissions/NotificationPermissionHandler, io/getstream/video/android/core/notifications/NotificationHandler {
public static final field Companion Lio/getstream/video/android/core/notifications/DefaultNotificationHandler$Companion;
public fun <init> (Landroid/app/Application;Lio/getstream/android/push/permissions/NotificationPermissionHandler;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ import io.getstream.video.android.core.model.NetworkQuality
import io.getstream.video.android.core.model.RTMP
import io.getstream.video.android.core.model.Reaction
import io.getstream.video.android.core.model.ScreenSharingSession
import io.getstream.video.android.core.model.VisibilityOnScreenState
import io.getstream.video.android.core.permission.PermissionRequest
import io.getstream.video.android.core.sorting.SortedParticipantsState
import io.getstream.video.android.core.utils.mapState
import io.getstream.video.android.core.utils.toUser
import io.getstream.video.android.model.User
Expand All @@ -51,6 +53,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
Expand Down Expand Up @@ -173,6 +176,7 @@ public class CallState(
) {

private val logger by taggedLogger("CallState")
private var participantsVisibilityMonitor: Job? = null

internal val _connection = MutableStateFlow<RealtimeConnection>(RealtimeConnection.PreJoin)
public val connection: StateFlow<RealtimeConnection> = _connection
Expand Down Expand Up @@ -330,8 +334,12 @@ public class CallState(
*
* Debounced 100ms to avoid rapid changes
*/
val sortedParticipants = sortedParticipantsFlow.debounce(100)
.stateIn(scope, SharingStarted.WhileSubscribed(10000L), emptyList())
val sortedParticipants = SortedParticipantsState(
scope,
call,
_participants,
_pinnedParticipants,
).asFlow().debounce(100)

/** Members contains the list of users who are permanently associated with this call. This includes users who are currently not active in the call
* As an example if you invite "john", "bob" and "jane" to a call and only Jane joins.
Expand Down Expand Up @@ -1132,6 +1140,65 @@ public class CallState(
_egress.value = newEgress
_broadcasting.value = true
}

/**
* Update participants visibility on the UI.
*
* @param sessionId the session ID of the participant.
* @param visibilityOnScreenState the visibility state.
*
* @see VisibilityOnScreenState
* @see CallState.updateParticipantVisibilityFlow
*/
fun updateParticipantVisibility(
sessionId: String,
visibilityOnScreenState: VisibilityOnScreenState,
) {
_participants.value[sessionId]?._visibleOnScreen?.value = visibilityOnScreenState
}

/**
* Set a flow to update the participants visibility.
* The flow should emit lists with currently visible participant session IDs.
*
* Note: If you pass null to the parameter it will just cancel the currently observing flow.
*
* E.g. Grid visible items info can be used to update the [CallState]
* ```
* val gridState = rememberLazyGridState()
* val updateFlow = snapshotFlow {
* gridState.layoutInfo.visibleItemsInfo.map {
* it.key // Assuming keys are sessionId
* }
* }
*
* call.state.updateParticipantVisibilityFlow(updateFlow)
* ```
*
* @param flow a flow that emits updates with list of visible participants.
*
* @see CallState.updateParticipantVisibility
*/
fun updateParticipantVisibilityFlow(flow: Flow<List<String>>?) {
// Cancel any previous job.
participantsVisibilityMonitor?.cancel()

if (flow != null) {
participantsVisibilityMonitor = scope.launch {
flow.collectLatest { visibleParticipantIds ->
_participants.value.forEach {
if (visibleParticipantIds.contains(it.key)) {
// If participant is in the lists its visible
it.value._visibleOnScreen.value = VisibilityOnScreenState.VISIBLE
} else {
// Participant is not in the list, thus invisible
it.value._visibleOnScreen.value = VisibilityOnScreenState.INVISIBLE
}
}
}
}
}
}
}

private fun MemberResponse.toMemberState(): MemberState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import io.getstream.video.android.core.model.MediaTrack
import io.getstream.video.android.core.model.NetworkQuality
import io.getstream.video.android.core.model.Reaction
import io.getstream.video.android.core.model.VideoTrack
import io.getstream.video.android.core.model.VisibilityOnScreenState
import io.getstream.video.android.core.utils.combineStates
import io.getstream.video.android.core.utils.mapState
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -61,6 +62,9 @@ public data class ParticipantState(
internal val _videoTrack = MutableStateFlow<VideoTrack?>(null)
val videoTrack: StateFlow<VideoTrack?> = _videoTrack

internal val _visibleOnScreen = MutableStateFlow(VisibilityOnScreenState.UNKNOWN)
val visibleOnScreen: StateFlow<VisibilityOnScreenState> = _visibleOnScreen

/**
* State that indicates whether the camera is capturing and sending video or not.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2014-2023 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-video-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.getstream.video.android.core.model

/**
* Participant visibility on the screen.
*/
enum class VisibilityOnScreenState {
/** The participant is visible on the screen. */
VISIBLE,

/** The participant is not visible on the screen. */
INVISIBLE,

/** It is unknown if the participant is visible. */
UNKNOWN,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2014-2023 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-video-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.getstream.video.android.core.sorting

import io.getstream.video.android.core.ParticipantState
import io.getstream.video.android.core.model.VisibilityOnScreenState
import io.getstream.video.android.core.utils.combineComparators

/**
* Default comparator for the participants.
* Returns a function that takes a set of the pinned participants before starting the sorting.
*/
internal val defaultComparator: (pinned: Set<String>) -> Comparator<ParticipantState> = { pinned ->
combineComparators(
onlyIfInvisibleOrUnknown(
{ it.userId.value },
{ it.joinedAt.value },
{ it.audioEnabled.value },
{ it.videoEnabled.value },
{ it.dominantSpeaker.value },
),
compareBy(
{ it.screenSharingEnabled.value },
{ pinned.contains(it.sessionId) },
),
)
}

/**
* Conditional comparator for visibility.
*/
private fun onlyIfInvisibleOrUnknown(
vararg selectors: (ParticipantState) -> Comparable<*>?,
): Comparator<ParticipantState> {
return Comparator { p1, p2 ->
if (p1.visibleOnScreen.value != VisibilityOnScreenState.VISIBLE ||
p2.visibleOnScreen.value != VisibilityOnScreenState.VISIBLE
) {
var comparisonResult = 0
for (selector in selectors) {
val valueToCompare1 = selector(p1)
val valueToCompare2 = selector(p2)
val diff = compareValues(valueToCompare1, valueToCompare2)
if (diff != 0) {
comparisonResult = diff
break
}
}
comparisonResult
} else {
0
}
}
}
Loading

0 comments on commit c5c7257

Please sign in to comment.