WIP migrate call grid to compose

Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
This commit is contained in:
Marcel Hibbe 2025-05-05 18:59:48 +02:00
parent 4b8b7630a9
commit feeec78ab4
No known key found for this signature in database
GPG Key ID: C793F8B59F43CE7B
9 changed files with 603 additions and 471 deletions

View File

@ -42,24 +42,24 @@ import android.view.OrientationEventListener
import android.view.View
import android.view.View.OnTouchListener
import android.view.ViewGroup
import android.view.ViewTreeObserver.OnGlobalLayoutListener
import android.widget.AdapterView
import android.widget.FrameLayout
import android.widget.RelativeLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateListOf
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.graphics.toColorInt
import androidx.core.net.toUri
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import autodagger.AutoInjector
import com.bluelinelabs.logansquare.LoganSquare
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.nextcloud.talk.R
import com.nextcloud.talk.adapters.ParticipantDisplayItem
import com.nextcloud.talk.adapters.ParticipantsAdapter
import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
@ -73,7 +73,9 @@ import com.nextcloud.talk.call.MessageSender
import com.nextcloud.talk.call.MessageSenderMcu
import com.nextcloud.talk.call.MessageSenderNoMcu
import com.nextcloud.talk.call.MutableLocalCallParticipantModel
import com.nextcloud.talk.call.ParticipantUiState
import com.nextcloud.talk.call.ReactionAnimator
import com.nextcloud.talk.call.components.ParticipantGrid
import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.databinding.CallActivityBinding
@ -152,6 +154,7 @@ import io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.launch
import okhttp3.Cache
import org.apache.commons.lang3.StringEscapeUtils
import org.greenrobot.eventbus.Subscribe
@ -303,8 +306,10 @@ class CallActivity : CallBaseActivity() {
private var handler: Handler? = null
private var currentCallStatus: CallStatus? = null
private var mediaPlayer: MediaPlayer? = null
private var participantDisplayItems: MutableMap<String, ParticipantDisplayItem>? = null
private var participantsAdapter: ParticipantsAdapter? = null
private val participantItems = mutableStateListOf<ParticipantDisplayItem>()
private val participantUiStates = mutableStateListOf<ParticipantUiState>()
private var binding: CallActivityBinding? = null
private var audioOutputDialog: AudioOutputDialog? = null
private var moreCallActionsDialog: MoreCallActionsDialog? = null
@ -399,7 +404,6 @@ class CallActivity : CallBaseActivity() {
.setRepeatCount(PulseAnimation.INFINITE)
.setRepeatMode(PulseAnimation.REVERSE)
callParticipants = HashMap()
participantDisplayItems = HashMap()
reactionAnimator = ReactionAnimator(context, binding!!.reactionAnimationWrapper, viewThemeUtils)
checkInitialDevicePermissions()
@ -734,10 +738,15 @@ class CallActivity : CallBaseActivity() {
}
binding!!.switchSelfVideoButton.setOnClickListener { switchCamera() }
binding!!.gridview.onItemClickListener =
AdapterView.OnItemClickListener { _: AdapterView<*>?, _: View?, _: Int, _: Long ->
animateCallControls(true, 0)
}
// binding!!.composeParticipantGrid.setOnClickListener {
// animateCallControls(true, 0)
// }
// binding!!.composeParticipantGrid.onItemClickListener =
// AdapterView.OnItemClickListener { _: AdapterView<*>?, _: View?, _: Int, _: Long ->
// animateCallControls(true, 0)
// }
binding!!.lowerHandButton.setOnClickListener { l: View? -> raiseHandViewModel!!.lowerHand() }
binding!!.pictureInPictureButton.setOnClickListener { enterPipMode() }
}
@ -890,20 +899,20 @@ class CallActivity : CallBaseActivity() {
val callControlsHeight =
applicationContext.resources.getDimension(R.dimen.call_controls_height).roundToInt()
params.setMargins(0, 0, 0, callControlsHeight)
binding!!.gridview.layoutParams = params
binding!!.composeParticipantGrid.layoutParams = params
} else {
val params = RelativeLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
params.setMargins(0, 0, 0, 0)
binding!!.gridview.layoutParams = params
binding!!.composeParticipantGrid.layoutParams = params
if (cameraEnumerator!!.deviceNames.size < 2) {
binding!!.switchSelfVideoButton.visibility = View.GONE
}
initSelfVideoViewForNormalMode()
}
binding!!.gridview.setOnTouchListener { _, me ->
binding!!.composeParticipantGrid.setOnTouchListener { _, me ->
val action = me.actionMasked
if (action == MotionEvent.ACTION_DOWN) {
animateCallControls(true, 0)
@ -920,7 +929,7 @@ class CallActivity : CallBaseActivity() {
false
}
animateCallControls(true, 0)
initGridAdapter()
initGrid()
}
@SuppressLint("ClickableViewAccessibility")
@ -951,56 +960,34 @@ class CallActivity : CallBaseActivity() {
localVideoTrack!!.addSink(binding!!.pipSelfVideoRenderer)
}
private fun initGridAdapter() {
Log.d(TAG, "initGridAdapter")
val columns: Int
val participantsInGrid = participantDisplayItems!!.size
columns = if (resources != null &&
resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
) {
if (participantsInGrid > 2) {
GRID_MAX_COLUMN_COUNT_PORTRAIT
} else {
GRID_MIN_COLUMN_COUNT_PORTRAIT
private fun initGrid() {
Log.d(TAG, "initGrid")
val participantsInGrid = participantUiStates.size
val columns = when {
resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT -> {
if (participantsInGrid > 2) GRID_MAX_COLUMN_COUNT_PORTRAIT else GRID_MIN_COLUMN_COUNT_PORTRAIT
}
} else {
if (participantsInGrid > 2) {
GRID_MAX_COLUMN_COUNT_LANDSCAPE
} else if (participantsInGrid > 1) {
GRID_MIN_GROUP_COLUMN_COUNT_LANDSCAPE
} else {
GRID_MIN_COLUMN_COUNT_LANDSCAPE
else -> {
if (participantsInGrid > 2) {
GRID_MAX_COLUMN_COUNT_LANDSCAPE
} else if (participantsInGrid > 1) {
GRID_MIN_GROUP_COLUMN_COUNT_LANDSCAPE
} else {
GRID_MIN_COLUMN_COUNT_LANDSCAPE
}
}
}
binding!!.gridview.numColumns = columns
binding!!.conversationRelativeLayout
.viewTreeObserver
.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
override fun onGlobalLayout() {
binding!!.conversationRelativeLayout.viewTreeObserver.removeOnGlobalLayoutListener(this)
val height = binding!!.conversationRelativeLayout.measuredHeight
binding!!.gridview.minimumHeight = height
}
})
binding!!.callInfosLinearLayout
.viewTreeObserver
.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
override fun onGlobalLayout() {
binding!!.callInfosLinearLayout.viewTreeObserver.removeOnGlobalLayoutListener(this)
}
})
if (participantsAdapter != null) {
participantsAdapter!!.destroy()
binding!!.composeParticipantGrid.setContent {
MaterialTheme {
ParticipantGrid(
participants = participantUiStates.toList(),
columns = columns
)
}
}
participantsAdapter = ParticipantsAdapter(
this,
participantDisplayItems!!.toMap(),
binding!!.conversationRelativeLayout,
binding!!.callInfosLinearLayout,
columns,
isVoiceOnlyCall
)
binding!!.gridview.adapter = participantsAdapter
if (isInPipMode) {
updateUiForPipMode()
}
@ -2116,6 +2103,7 @@ class CallActivity : CallBaseActivity() {
videoCapturer!!.dispose()
videoCapturer = null
}
binding!!.selfVideoRenderer.clearImage()
binding!!.selfVideoRenderer.release()
if (audioSource != null) {
audioSource!!.dispose()
@ -2570,19 +2558,42 @@ class CallActivity : CallBaseActivity() {
runOnUiThread { removeParticipantDisplayItem(sessionId, "video") }
}
// private fun removeParticipantDisplayItem(sessionId: String?, videoStreamType: String) {
// Log.d(TAG, "removeParticipantDisplayItem")
// val participantDisplayItem = participantDisplayItems!!.remove("$sessionId-$videoStreamType") ?: return
// participantDisplayItem.destroy()
// if (!isDestroyed) {
// initGridAdapter()
// }
// }
// private fun removeParticipantDisplayItem(sessionId: String?, videoStreamType: String) {
// val key = "$sessionId-$videoStreamType"
//
// val participant = participantItems.find { it.sessionKey == key }
// participant?.destroy()
// participantItems.remove(participant)
//
// initGrid()
// }
private fun removeParticipantDisplayItem(sessionId: String?, videoStreamType: String) {
Log.d(TAG, "removeParticipantDisplayItem")
val participantDisplayItem = participantDisplayItems!!.remove("$sessionId-$videoStreamType") ?: return
participantDisplayItem.destroy()
if (!isDestroyed) {
initGridAdapter()
}
val key = "$sessionId-$videoStreamType"
val participant = participantItems.find { it.sessionKey == key }
participant?.destroy()
participantItems.removeAll { it.sessionKey == key }
// Also remove UI state
participantUiStates.removeAll { it.sessionKey == key }
initGrid()
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onMessageEvent(configurationChangeEvent: ConfigurationChangeEvent?) {
powerManagerUtils!!.setOrientation(Objects.requireNonNull(resources).configuration.orientation)
initGridAdapter()
initGrid()
}
private fun updateSelfVideoViewIceConnectionState(iceConnectionState: IceConnectionState) {
@ -2677,22 +2688,37 @@ class CallActivity : CallBaseActivity() {
}
private fun addParticipantDisplayItem(callParticipantModel: CallParticipantModel, videoStreamType: String) {
if (callParticipantModel.isInternal != null && callParticipantModel.isInternal) {
return
}
if (callParticipantModel.isInternal == true) return
val defaultGuestNick = resources.getString(R.string.nc_nick_guest)
val participantDisplayItem = ParticipantDisplayItem(
context,
baseUrl,
defaultGuestNick,
rootEglBase,
videoStreamType,
roomToken,
callParticipantModel
context = context,
baseUrl = baseUrl!!,
defaultGuestNick = defaultGuestNick,
rootEglBase = rootEglBase!!,
streamType = videoStreamType,
roomToken = roomToken!!,
callParticipantModel = callParticipantModel
)
val sessionId = callParticipantModel.sessionId
participantDisplayItems!!["$sessionId-$videoStreamType"] = participantDisplayItem
initGridAdapter()
val sessionKey = participantDisplayItem.sessionKey
if (participantItems.none { it.sessionKey == sessionKey }) {
participantItems.add(participantDisplayItem)
lifecycleScope.launch {
participantDisplayItem.uiStateFlow.collect { uiState ->
val index = participantUiStates.indexOfFirst { it.sessionKey == sessionKey }
if (index >= 0) {
participantUiStates[index] = uiState
} else {
participantUiStates.add(uiState)
}
}
}
}
initGrid()
}
private fun setCallState(callState: CallStatus) {
@ -2725,7 +2751,7 @@ class CallActivity : CallBaseActivity() {
binding!!.callModeTextView.text = descriptionForCallType
binding!!.callStates.callStateTextView.setText(R.string.nc_leaving_call)
binding!!.callStates.callStateRelativeLayout.visibility = View.VISIBLE
binding!!.gridview.visibility = View.INVISIBLE
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
binding!!.callStates.callStateProgressBar.visibility = View.VISIBLE
binding!!.callStates.errorImageView.visibility = View.GONE
}
@ -2737,8 +2763,8 @@ class CallActivity : CallBaseActivity() {
if (binding!!.callStates.callStateRelativeLayout.visibility != View.VISIBLE) {
binding!!.callStates.callStateRelativeLayout.visibility = View.VISIBLE
}
if (binding!!.gridview.visibility != View.INVISIBLE) {
binding!!.gridview.visibility = View.INVISIBLE
if (binding!!.composeParticipantGrid.visibility != View.INVISIBLE) {
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
}
if (binding!!.callStates.callStateProgressBar.visibility != View.GONE) {
binding!!.callStates.callStateProgressBar.visibility = View.GONE
@ -2764,8 +2790,8 @@ class CallActivity : CallBaseActivity() {
if (binding!!.callStates.callStateProgressBar.visibility != View.GONE) {
binding!!.callStates.callStateProgressBar.visibility = View.GONE
}
if (binding!!.gridview.visibility != View.VISIBLE) {
binding!!.gridview.visibility = View.VISIBLE
if (binding!!.composeParticipantGrid.visibility != View.VISIBLE) {
binding!!.composeParticipantGrid.visibility = View.VISIBLE
}
if (binding!!.callStates.errorImageView.visibility != View.GONE) {
binding!!.callStates.errorImageView.visibility = View.GONE
@ -2785,8 +2811,8 @@ class CallActivity : CallBaseActivity() {
if (binding!!.callStates.callStateProgressBar.visibility != View.VISIBLE) {
binding!!.callStates.callStateProgressBar.visibility = View.VISIBLE
}
if (binding!!.gridview.visibility != View.INVISIBLE) {
binding!!.gridview.visibility = View.INVISIBLE
if (binding!!.composeParticipantGrid.visibility != View.INVISIBLE) {
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
}
if (binding!!.callStates.errorImageView.visibility != View.GONE) {
binding!!.callStates.errorImageView.visibility = View.GONE
@ -2800,8 +2826,8 @@ class CallActivity : CallBaseActivity() {
if (binding!!.callStates.callStateRelativeLayout.visibility != View.VISIBLE) {
binding!!.callStates.callStateRelativeLayout.visibility = View.VISIBLE
}
if (binding!!.gridview.visibility != View.INVISIBLE) {
binding!!.gridview.visibility = View.INVISIBLE
if (binding!!.composeParticipantGrid.visibility != View.INVISIBLE) {
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
}
if (binding!!.callStates.callStateProgressBar.visibility != View.VISIBLE) {
binding!!.callStates.callStateProgressBar.visibility = View.VISIBLE
@ -2818,8 +2844,8 @@ class CallActivity : CallBaseActivity() {
if (binding!!.callStates.callStateRelativeLayout.visibility != View.VISIBLE) {
binding!!.callStates.callStateRelativeLayout.visibility = View.VISIBLE
}
if (binding!!.gridview.visibility != View.INVISIBLE) {
binding!!.gridview.visibility = View.INVISIBLE
if (binding!!.composeParticipantGrid.visibility != View.INVISIBLE) {
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
}
if (binding!!.callStates.callStateProgressBar.visibility != View.VISIBLE) {
binding!!.callStates.callStateProgressBar.visibility = View.VISIBLE
@ -2839,8 +2865,8 @@ class CallActivity : CallBaseActivity() {
if (binding!!.callStates.callStateProgressBar.visibility != View.GONE) {
binding!!.callStates.callStateProgressBar.visibility = View.GONE
}
if (binding!!.gridview.visibility != View.INVISIBLE) {
binding!!.gridview.visibility = View.INVISIBLE
if (binding!!.composeParticipantGrid.visibility != View.INVISIBLE) {
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
}
binding!!.callStates.errorImageView.setImageResource(R.drawable.ic_av_timer_timer_24dp)
if (binding!!.callStates.errorImageView.visibility != View.VISIBLE) {
@ -2860,8 +2886,8 @@ class CallActivity : CallBaseActivity() {
if (binding!!.callStates.callStateRelativeLayout.visibility != View.VISIBLE) {
binding!!.callStates.callStateRelativeLayout.visibility = View.VISIBLE
}
if (binding!!.gridview.visibility != View.INVISIBLE) {
binding!!.gridview.visibility = View.INVISIBLE
if (binding!!.composeParticipantGrid.visibility != View.INVISIBLE) {
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
}
if (binding!!.callStates.callStateProgressBar.visibility != View.VISIBLE) {
binding!!.callStates.callStateProgressBar.visibility = View.VISIBLE
@ -3021,8 +3047,8 @@ class CallActivity : CallBaseActivity() {
removeParticipantDisplayItem(sessionId, "screen")
return
}
val hasScreenParticipantDisplayItem = participantDisplayItems!!["$sessionId-screen"] != null
if (!hasScreenParticipantDisplayItem) {
val screenParticipantDisplayItem = participantItems.find { it.sessionKey == "$sessionId-screen" }
if (screenParticipantDisplayItem != null) {
addParticipantDisplayItem(callParticipantModel, "screen")
}
}
@ -3230,14 +3256,15 @@ class CallActivity : CallBaseActivity() {
ViewGroup.LayoutParams.WRAP_CONTENT
)
params.setMargins(0, 0, 0, 0)
binding!!.gridview.layoutParams = params
binding!!.composeParticipantGrid.layoutParams = params
binding!!.callControls.visibility = View.GONE
binding!!.callInfosLinearLayout.visibility = View.GONE
binding!!.selfVideoViewWrapper.visibility = View.GONE
binding!!.callStates.callStateRelativeLayout.visibility = View.GONE
binding!!.selfVideoRenderer.clearImage()
binding!!.selfVideoRenderer.release()
if (participantDisplayItems!!.size > 1) {
if (participantItems.size > 1) {
binding!!.pipCallConversationNameTextView.text = conversationName
binding!!.pipSelfVideoOverlay.visibility = View.VISIBLE
initSelfVideoViewForPipMode()

View File

@ -3,201 +3,201 @@
*
* SPDX-FileCopyrightText: 2023 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2021-2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.adapters;
package com.nextcloud.talk.adapters
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import android.util.Log
import android.view.ViewGroup
import com.nextcloud.talk.call.CallParticipantModel
import com.nextcloud.talk.call.ParticipantUiState
import com.nextcloud.talk.call.RaisedHand
import com.nextcloud.talk.models.json.participants.Participant.ActorType
import com.nextcloud.talk.utils.ApiUtils.getUrlForAvatar
import com.nextcloud.talk.utils.ApiUtils.getUrlForFederatedAvatar
import com.nextcloud.talk.utils.ApiUtils.getUrlForGuestAvatar
import com.nextcloud.talk.utils.DisplayUtils.isDarkModeOn
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.webrtc.EglBase
import org.webrtc.MediaStream
import org.webrtc.PeerConnection.IceConnectionState
import org.webrtc.SurfaceViewRenderer
import com.nextcloud.talk.call.CallParticipantModel;
import com.nextcloud.talk.call.RaisedHand;
import com.nextcloud.talk.models.json.participants.Participant;
import com.nextcloud.talk.utils.ApiUtils;
import com.nextcloud.talk.utils.DisplayUtils;
class ParticipantDisplayItem(
private val context: Context,
private val baseUrl: String,
private val defaultGuestNick: String,
val rootEglBase: EglBase,
private val streamType: String,
private val roomToken: String,
private val callParticipantModel: CallParticipantModel
) {
private val participantDisplayItemNotifier = ParticipantDisplayItemNotifier()
import org.webrtc.EglBase;
import org.webrtc.MediaStream;
import org.webrtc.PeerConnection;
private val _uiStateFlow = MutableStateFlow(buildUiState())
val uiStateFlow: StateFlow<ParticipantUiState> = _uiStateFlow.asStateFlow()
public class ParticipantDisplayItem {
private val session: String = callParticipantModel.sessionId
/**
* Shared handler to receive change notifications from the model on the main thread.
*/
private static final Handler handler = new Handler(Looper.getMainLooper());
var actorType: ActorType? = null
private set
private var actorId: String? = null
private var userId: String? = null
private var iceConnectionState: IceConnectionState? = null
var nick: String? = null
get() = (if (TextUtils.isEmpty(userId) && TextUtils.isEmpty(field)) defaultGuestNick else field)
private final ParticipantDisplayItemNotifier participantDisplayItemNotifier = new ParticipantDisplayItemNotifier();
var urlForAvatar: String? = null
private set
var mediaStream: MediaStream? = null
private set
var isStreamEnabled: Boolean = false
private set
var isAudioEnabled: Boolean = false
private set
var raisedHand: RaisedHand? = null
private set
var surfaceViewRenderer: SurfaceViewRenderer? = null
private final Context context;
val sessionKey: String
get() = "$session-$streamType"
private final String baseUrl;
private final String defaultGuestNick;
private final EglBase rootEglBase;
private final String session;
private final String streamType;
private final String roomToken;
private final CallParticipantModel callParticipantModel;
private Participant.ActorType actorType;
private String actorId;
private String userId;
private PeerConnection.IceConnectionState iceConnectionState;
private String nick;
private String urlForAvatar;
private MediaStream mediaStream;
private boolean streamEnabled;
private boolean isAudioEnabled;
private RaisedHand raisedHand;
public interface Observer {
void onChange();
interface Observer {
fun onChange()
}
private final CallParticipantModel.Observer callParticipantModelObserver = new CallParticipantModel.Observer() {
@Override
public void onChange() {
updateFromModel();
private val callParticipantModelObserver: CallParticipantModel.Observer = object : CallParticipantModel.Observer {
override fun onChange() {
updateFromModel()
}
@Override
public void onReaction(String reaction) {
override fun onReaction(reaction: String) {
}
};
public ParticipantDisplayItem(Context context, String baseUrl, String defaultGuestNick, EglBase rootEglBase,
String streamType, String roomToken, CallParticipantModel callParticipantModel) {
this.context = context;
this.baseUrl = baseUrl;
this.defaultGuestNick = defaultGuestNick;
this.rootEglBase = rootEglBase;
this.session = callParticipantModel.getSessionId();
this.streamType = streamType;
this.roomToken = roomToken;
this.callParticipantModel = callParticipantModel;
this.callParticipantModel.addObserver(callParticipantModelObserver, handler);
updateFromModel();
}
public void destroy() {
this.callParticipantModel.removeObserver(callParticipantModelObserver);
init {
callParticipantModel.addObserver(callParticipantModelObserver, handler)
updateFromModel()
}
private void updateFromModel() {
actorType = callParticipantModel.getActorType();
actorId = callParticipantModel.getActorId();
userId = callParticipantModel.getUserId();
nick = callParticipantModel.getNick();
fun destroy() {
callParticipantModel.removeObserver(callParticipantModelObserver)
this.updateUrlForAvatar();
surfaceViewRenderer?.let { renderer ->
try {
mediaStream?.videoTracks?.firstOrNull()?.removeSink(renderer)
renderer.release()
(renderer.parent as? ViewGroup)?.removeView(renderer)
} catch (e: Exception) {
Log.w("ParticipantDisplayItem", "Error releasing renderer", e)
}
}
surfaceViewRenderer = null
}
if ("screen".equals(streamType)) {
iceConnectionState = callParticipantModel.getScreenIceConnectionState();
mediaStream = callParticipantModel.getScreenMediaStream();
isAudioEnabled = true;
streamEnabled = true;
private fun updateFromModel() {
actorType = callParticipantModel.actorType
actorId = callParticipantModel.actorId
userId = callParticipantModel.userId
nick = callParticipantModel.nick
updateUrlForAvatar()
if (streamType == "screen") {
iceConnectionState = callParticipantModel.screenIceConnectionState
mediaStream = callParticipantModel.screenMediaStream
isAudioEnabled = true
isStreamEnabled = true
} else {
iceConnectionState = callParticipantModel.getIceConnectionState();
mediaStream = callParticipantModel.getMediaStream();
isAudioEnabled = callParticipantModel.isAudioAvailable() != null ?
callParticipantModel.isAudioAvailable() : false;
streamEnabled = callParticipantModel.isVideoAvailable() != null ?
callParticipantModel.isVideoAvailable() : false;
iceConnectionState = callParticipantModel.iceConnectionState
mediaStream = callParticipantModel.mediaStream
isAudioEnabled = callParticipantModel.isAudioAvailable ?: false
isStreamEnabled = callParticipantModel.isVideoAvailable ?: false
}
raisedHand = callParticipantModel.getRaisedHand();
raisedHand = callParticipantModel.raisedHand
participantDisplayItemNotifier.notifyChange();
if (surfaceViewRenderer == null && mediaStream != null) {
val renderer = SurfaceViewRenderer(context).apply {
init(rootEglBase.eglBaseContext, null)
setEnableHardwareScaler(true)
setMirror(false)
}
surfaceViewRenderer = renderer
mediaStream?.videoTracks?.firstOrNull()?.addSink(renderer)
}
_uiStateFlow.value = buildUiState()
participantDisplayItemNotifier.notifyChange()
}
private void updateUrlForAvatar() {
if (actorType == Participant.ActorType.FEDERATED) {
int darkTheme = DisplayUtils.INSTANCE.isDarkModeOn(context) ? 1 : 0;
urlForAvatar = ApiUtils.getUrlForFederatedAvatar(baseUrl, roomToken, actorId, darkTheme, true);
private fun buildUiState(): ParticipantUiState {
return ParticipantUiState(
sessionKey = sessionKey,
nick = nick ?: "Guest",
isConnected = isConnected,
isAudioEnabled = isAudioEnabled,
isStreamEnabled = isStreamEnabled,
raisedHand = raisedHand?.state == true,
avatarUrl = urlForAvatar,
surfaceViewRenderer = surfaceViewRenderer
)
}
private fun updateUrlForAvatar() {
if (actorType == ActorType.FEDERATED) {
val darkTheme = if (isDarkModeOn(context)) 1 else 0
urlForAvatar = getUrlForFederatedAvatar(baseUrl, roomToken, actorId!!, darkTheme, true)
} else if (!TextUtils.isEmpty(userId)) {
urlForAvatar = ApiUtils.getUrlForAvatar(baseUrl, userId, true);
urlForAvatar = getUrlForAvatar(baseUrl, userId, true)
} else {
urlForAvatar = ApiUtils.getUrlForGuestAvatar(baseUrl, getNick(), true);
urlForAvatar = getUrlForGuestAvatar(baseUrl, nick, true)
}
}
public boolean isConnected() {
return iceConnectionState == PeerConnection.IceConnectionState.CONNECTED ||
iceConnectionState == PeerConnection.IceConnectionState.COMPLETED ||
// If there is no connection state that means that no connection is needed, so it is a special case that is
// also seen as "connected".
iceConnectionState == null;
val isConnected: Boolean
get() = iceConnectionState == IceConnectionState.CONNECTED ||
iceConnectionState == IceConnectionState.COMPLETED ||
// If there is no connection state that means that no connection is needed,
// so it is a special case that is also seen as "connected".
iceConnectionState == null
fun addObserver(observer: Observer?) {
participantDisplayItemNotifier.addObserver(observer)
}
public String getNick() {
if (TextUtils.isEmpty(userId) && TextUtils.isEmpty(nick)) {
return defaultGuestNick;
}
return nick;
fun removeObserver(observer: Observer?) {
participantDisplayItemNotifier.removeObserver(observer)
}
public String getUrlForAvatar() {
return urlForAvatar;
}
public MediaStream getMediaStream() {
return mediaStream;
}
public boolean isStreamEnabled() {
return streamEnabled;
}
public EglBase getRootEglBase() {
return rootEglBase;
}
public boolean isAudioEnabled() {
return isAudioEnabled;
}
public RaisedHand getRaisedHand() {
return raisedHand;
}
public Participant.ActorType getActorType() {
return actorType;
}
public void addObserver(Observer observer) {
participantDisplayItemNotifier.addObserver(observer);
}
public void removeObserver(Observer observer) {
participantDisplayItemNotifier.removeObserver(observer);
}
@Override
public String toString() {
override fun toString(): String {
return "ParticipantSession{" +
"userId='" + userId + '\'' +
", actorType='" + actorType + '\'' +
", actorId='" + actorId + '\'' +
", session='" + session + '\'' +
", nick='" + nick + '\'' +
", urlForAvatar='" + urlForAvatar + '\'' +
", mediaStream=" + mediaStream +
", streamType='" + streamType + '\'' +
", streamEnabled=" + streamEnabled +
", rootEglBase=" + rootEglBase +
", raisedHand=" + raisedHand +
'}';
"userId='" + userId + '\'' +
", actorType='" + actorType + '\'' +
", actorId='" + actorId + '\'' +
", session='" + session + '\'' +
", nick='" + nick + '\'' +
", urlForAvatar='" + urlForAvatar + '\'' +
", mediaStream=" + mediaStream +
", streamType='" + streamType + '\'' +
", streamEnabled=" + isStreamEnabled +
", rootEglBase=" + rootEglBase +
", raisedHand=" + raisedHand +
'}'
}
companion object {
/**
* Shared handler to receive change notifications from the model on the main thread.
*/
private val handler = Handler(Looper.getMainLooper())
}
}

View File

@ -1,204 +0,0 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2021-2023 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.adapters
import android.content.Context
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.RelativeLayout
import android.widget.TextView
import com.nextcloud.talk.R
import com.nextcloud.talk.activities.CallActivity
import com.nextcloud.talk.extensions.loadAvatarWithUrl
import com.nextcloud.talk.extensions.loadFirstLetterAvatar
import com.nextcloud.talk.models.json.participants.Participant
import org.webrtc.MediaStream
import org.webrtc.MediaStreamTrack
import org.webrtc.RendererCommon
import org.webrtc.SurfaceViewRenderer
import kotlin.math.ceil
class ParticipantsAdapter(
private val mContext: Context,
participantDisplayItems: Map<String?, ParticipantDisplayItem>,
private val gridViewWrapper: RelativeLayout,
private val callInfosLinearLayout: LinearLayout,
private val columns: Int,
private val isVoiceOnlyCall: Boolean
) : BaseAdapter() {
private val participantDisplayItemObserver = ParticipantDisplayItem.Observer { this.notifyDataSetChanged() }
private val participantDisplayItems = ArrayList<ParticipantDisplayItem>()
init {
this.participantDisplayItems.addAll(participantDisplayItems.values)
for (participantDisplayItem in this.participantDisplayItems) {
participantDisplayItem.addObserver(participantDisplayItemObserver)
}
}
fun destroy() {
for (participantDisplayItem in participantDisplayItems) {
participantDisplayItem.removeObserver(participantDisplayItemObserver)
}
}
override fun getCount(): Int {
return participantDisplayItems.size
}
override fun getItem(position: Int): ParticipantDisplayItem {
return participantDisplayItems[position]
}
override fun getItemId(position: Int): Long {
return 0
}
@Suppress("Detekt.LongMethod", "Detekt.TooGenericExceptionCaught")
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
var convertView = convertView
val participantDisplayItem = getItem(position)
val surfaceViewRenderer: SurfaceViewRenderer
if (convertView == null) {
convertView = LayoutInflater.from(mContext).inflate(R.layout.call_item, parent, false)
convertView.visibility = View.VISIBLE
surfaceViewRenderer = convertView.findViewById(R.id.surface_view)
try {
Log.d(TAG, "hasSurface: " + participantDisplayItem.rootEglBase.hasSurface())
surfaceViewRenderer.setMirror(false)
surfaceViewRenderer.init(participantDisplayItem.rootEglBase.eglBaseContext, null)
surfaceViewRenderer.setZOrderMediaOverlay(false)
// disabled because it causes some devices to crash
surfaceViewRenderer.setEnableHardwareScaler(false)
surfaceViewRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
} catch (e: Exception) {
Log.e(TAG, "error while initializing surfaceViewRenderer", e)
}
} else {
surfaceViewRenderer = convertView.findViewById(R.id.surface_view)
}
val progressBar = convertView!!.findViewById<ProgressBar>(R.id.participant_progress_bar)
if (!participantDisplayItem.isConnected) {
progressBar.visibility = View.VISIBLE
} else {
progressBar.visibility = View.GONE
}
val layoutParams = convertView.layoutParams
layoutParams.height = scaleGridViewItemHeight()
convertView.layoutParams = layoutParams
val nickTextView = convertView.findViewById<TextView>(R.id.peer_nick_text_view)
val imageView = convertView.findViewById<ImageView>(R.id.avatarImageView)
val mediaStream = participantDisplayItem.mediaStream
if (hasVideoStream(participantDisplayItem, mediaStream)) {
val videoTrack = mediaStream.videoTracks[0]
videoTrack.addSink(surfaceViewRenderer)
imageView.visibility = View.INVISIBLE
surfaceViewRenderer.visibility = View.VISIBLE
nickTextView.visibility = View.GONE
} else {
imageView.visibility = View.VISIBLE
surfaceViewRenderer.visibility = View.INVISIBLE
if ((mContext as CallActivity).isInPipMode) {
nickTextView.visibility = View.GONE
} else {
nickTextView.visibility = View.VISIBLE
nickTextView.text = participantDisplayItem.nick
}
if (participantDisplayItem.actorType == Participant.ActorType.GUESTS ||
participantDisplayItem.actorType == Participant.ActorType.EMAILS
) {
imageView
.loadFirstLetterAvatar(
participantDisplayItem.nick.toString()
)
} else {
imageView.loadAvatarWithUrl(null, participantDisplayItem.urlForAvatar)
}
}
val audioOffView = convertView.findViewById<ImageView>(R.id.remote_audio_off)
if (!participantDisplayItem.isAudioEnabled) {
audioOffView.visibility = View.VISIBLE
} else {
audioOffView.visibility = View.GONE
}
val raisedHandView = convertView.findViewById<ImageView>(R.id.raised_hand)
if (participantDisplayItem.raisedHand != null && participantDisplayItem.raisedHand.state) {
raisedHandView.visibility = View.VISIBLE
} else {
raisedHandView.visibility = View.GONE
}
return convertView
}
@Suppress("ReturnCount")
private fun hasVideoStream(participantDisplayItem: ParticipantDisplayItem, mediaStream: MediaStream?): Boolean {
if (!participantDisplayItem.isStreamEnabled) {
return false
}
if (mediaStream?.videoTracks == null) {
return false
}
for (t in mediaStream.videoTracks) {
if (MediaStreamTrack.State.LIVE == t.state()) {
return true
}
}
return false
}
private fun scaleGridViewItemHeight(): Int {
var headerHeight = 0
var callControlsHeight = 0
if (callInfosLinearLayout.visibility == View.VISIBLE && isVoiceOnlyCall) {
headerHeight = callInfosLinearLayout.height
}
if (isVoiceOnlyCall) {
callControlsHeight = Math.round(mContext.resources.getDimension(R.dimen.call_controls_height))
}
var itemHeight = (gridViewWrapper.height - headerHeight - callControlsHeight) / getRowsCount(count)
val itemMinHeight = Math.round(mContext.resources.getDimension(R.dimen.call_grid_item_min_height))
if (itemHeight < itemMinHeight) {
itemHeight = itemMinHeight
}
return itemHeight
}
private fun getRowsCount(items: Int): Int {
var rows = ceil(items.toDouble() / columns.toDouble()).toInt()
if (rows == 0) {
rows = 1
}
return rows
}
companion object {
private const val TAG = "ParticipantsAdapter"
}
}

View File

@ -0,0 +1,21 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.call
import org.webrtc.SurfaceViewRenderer
data class ParticipantUiState(
val sessionKey: String,
val nick: String,
val isConnected: Boolean,
val isAudioEnabled: Boolean,
val isStreamEnabled: Boolean,
val raisedHand: Boolean,
val avatarUrl: String?,
val surfaceViewRenderer: SurfaceViewRenderer? = null
)

View File

@ -0,0 +1,46 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.call.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.nextcloud.talk.call.ParticipantUiState
@Composable
fun AvatarWithFallback(participant: ParticipantUiState) {
if (!participant.avatarUrl.isNullOrEmpty()) {
AsyncImage(
model = participant.avatarUrl,
contentDescription = "Avatar",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Gray),
contentAlignment = Alignment.Center
) {
Text(
text = participant.nick.firstOrNull()?.uppercase() ?: "?",
color = Color.White,
fontSize = 40.sp
)
}
}
}

View File

@ -0,0 +1,34 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.call.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.nextcloud.talk.call.ParticipantUiState
@Composable
fun ParticipantGrid(participants: List<ParticipantUiState>, columns: Int = 2, modifier: Modifier = Modifier) {
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(participants, key = { it.sessionKey }) { participant ->
ParticipantTile(participant)
}
}
}

View File

@ -0,0 +1,180 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.call.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.nextcloud.talk.R
import com.nextcloud.talk.call.ParticipantUiState
@Composable
fun ParticipantTile(participant: ParticipantUiState) {
Box(
modifier = Modifier
.aspectRatio(3f / 4f)
.clip(RoundedCornerShape(12.dp))
.background(Color.DarkGray)
) {
if (participant.isStreamEnabled && participant.surfaceViewRenderer != null) {
WebRTCVideoView(participant.surfaceViewRenderer)
} else {
AvatarWithFallback(participant)
}
if (participant.raisedHand) {
Icon(
painter = painterResource(id = R.drawable.ic_hand_back_left),
contentDescription = "Raised Hand",
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.size(24.dp),
tint = Color.White
)
}
if (!participant.isAudioEnabled) {
Icon(
painter = painterResource(id = R.drawable.ic_mic_off_white_24px),
contentDescription = "Mic Off",
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(6.dp)
.size(24.dp),
tint = Color.White
)
}
Text(
text = participant.nick,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomStart)
.background(Color.Black.copy(alpha = 0.6f))
.padding(6.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
// @Composable
// fun ParticipantItem(participant: ParticipantDisplayItem) {
// val context = LocalContext.current
// val videoTrack = participant.mediaStream?.videoTracks?.firstOrNull()
//
// Box(
// modifier = Modifier
// .aspectRatio(1f)
// .background(Color.Black)
// .padding(4.dp)
// ) {
// // Renderer
// participant.surfaceViewRenderer?.let { renderer ->
// AndroidView(
// factory = {
// // If not yet initialized
// if (renderer.parent != null) {
// (renderer.parent as? ViewGroup)?.removeView(renderer)
// }
//
// // if (!renderer.isInitialized) { // TODO
// renderer.init(participant.rootEglBase.eglBaseContext, null)
// renderer.setMirror(false)
// renderer.setZOrderMediaOverlay(false)
// renderer.setEnableHardwareScaler(false)
// renderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
// // }
//
// // Attach sink
// try {
// videoTrack?.removeSink(renderer)
// } catch (_: Exception) {}
// videoTrack?.addSink(renderer)
//
// renderer
// },
// modifier = Modifier.fillMaxSize(),
// update = { view ->
// view.visibility =
// if (videoTrack != null && participant.isConnected) View.VISIBLE else View.INVISIBLE
// }
// )
// }
//
// // Overlay: Nick or Avatar
// if (videoTrack == null || !participant.isConnected) {
// Column(
// modifier = Modifier
// .fillMaxSize()
// .background(Color.DarkGray)
// .padding(8.dp),
// verticalArrangement = Arrangement.Center,
// horizontalAlignment = Alignment.CenterHorizontally
// ) {
// Text(
// text = participant.nick!!,
// color = Color.White,
// fontSize = 16.sp,
// modifier = Modifier.padding(bottom = 8.dp)
// )
// // Replace this with image loader like Coil if needed
// Icon(
// imageVector = Icons.Default.Person,
// contentDescription = null,
// tint = Color.White,
// modifier = Modifier.size(40.dp)
// )
// }
// }
//
// // Status indicators (audio muted / raised hand)
// Row(
// modifier = Modifier
// .align(Alignment.TopEnd)
// .padding(4.dp)
// ) {
// if (!participant.isAudioEnabled) {
// Icon(
// painter = painterResource(id = R.drawable.account_circle_96dp),
// contentDescription = "Mic Off",
// tint = Color.Red,
// modifier = Modifier.size(20.dp)
// )
// }
// if (participant.raisedHand?.state == true) {
// Icon(
// painter = painterResource(id = R.drawable.ic_hand_back_left),
// contentDescription = "Hand Raised",
// tint = Color.Yellow,
// modifier = Modifier.size(20.dp)
// )
// }
// }
//
// // Loading spinner
// if (!participant.isConnected) {
// CircularProgressIndicator(
// modifier = Modifier.align(Alignment.Center)
// )
// }
// }
// }

View File

@ -0,0 +1,23 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.call.components
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import org.webrtc.SurfaceViewRenderer
@Composable
fun WebRTCVideoView(surfaceViewRenderer: SurfaceViewRenderer) {
AndroidView(
factory = { surfaceViewRenderer },
update = { /* No-op, renderer is already initialized and reused */ },
modifier = Modifier.fillMaxSize()
)
}

View File

@ -33,15 +33,15 @@
android:visibility="visible"
tools:visibility="visible">
<GridView
android:id="@+id/gridview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:numColumns="2"
android:scrollbars="vertical"
android:stretchMode="columnWidth"
tools:listitem="@layout/call_item" />
<!-- <GridView-->
<!-- android:id="@+id/gridview"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="match_parent"-->
<!-- android:gravity="center"-->
<!-- android:numColumns="2"-->
<!-- android:scrollbars="vertical"-->
<!-- android:stretchMode="columnWidth"-->
<!-- tools:listitem="@layout/call_item" />-->
<FrameLayout
android:id="@+id/selfVideoViewWrapper"
@ -368,4 +368,9 @@
</RelativeLayout>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeParticipantGrid"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</RelativeLayout>