From feeec78ab442504b05e522ee4ad36f088f542b83 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 5 May 2025 18:59:48 +0200 Subject: [PATCH] WIP migrate call grid to compose Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/CallActivity.kt | 225 +++++++------ .../talk/adapters/ParticipantDisplayItem.kt | 318 +++++++++--------- .../talk/adapters/ParticipantsAdapter.kt | 204 ----------- .../nextcloud/talk/call/ParticipantUiState.kt | 21 ++ .../call/components/AvatarWithFallback.kt | 46 +++ .../talk/call/components/ParticipantGrid.kt | 34 ++ .../talk/call/components/ParticipantTile.kt | 180 ++++++++++ .../talk/call/components/WebRTCVideoView.kt | 23 ++ app/src/main/res/layout/call_activity.xml | 23 +- 9 files changed, 603 insertions(+), 471 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/talk/adapters/ParticipantsAdapter.kt create mode 100644 app/src/main/java/com/nextcloud/talk/call/ParticipantUiState.kt create mode 100644 app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt create mode 100644 app/src/main/java/com/nextcloud/talk/call/components/ParticipantGrid.kt create mode 100644 app/src/main/java/com/nextcloud/talk/call/components/ParticipantTile.kt create mode 100644 app/src/main/java/com/nextcloud/talk/call/components/WebRTCVideoView.kt diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 06a454703..879217a6f 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -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? = null - private var participantsAdapter: ParticipantsAdapter? = null + + private val participantItems = mutableStateListOf() + private val participantUiStates = mutableStateListOf() + 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() diff --git a/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.kt b/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.kt index 122fd090a..64c1a0839 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.kt @@ -3,201 +3,201 @@ * * SPDX-FileCopyrightText: 2023 Andy Scherzinger * SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez - * SPDX-FileCopyrightText: 2021 Marcel Hibbe + * SPDX-FileCopyrightText: 2021-2025 Marcel Hibbe * 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 = _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()) } } diff --git a/app/src/main/java/com/nextcloud/talk/adapters/ParticipantsAdapter.kt b/app/src/main/java/com/nextcloud/talk/adapters/ParticipantsAdapter.kt deleted file mode 100644 index 69dfead61..000000000 --- a/app/src/main/java/com/nextcloud/talk/adapters/ParticipantsAdapter.kt +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2021-2023 Marcel Hibbe - * SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez - * 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, - 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() - - 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(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(R.id.peer_nick_text_view) - val imageView = convertView.findViewById(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(R.id.remote_audio_off) - if (!participantDisplayItem.isAudioEnabled) { - audioOffView.visibility = View.VISIBLE - } else { - audioOffView.visibility = View.GONE - } - - val raisedHandView = convertView.findViewById(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" - } -} diff --git a/app/src/main/java/com/nextcloud/talk/call/ParticipantUiState.kt b/app/src/main/java/com/nextcloud/talk/call/ParticipantUiState.kt new file mode 100644 index 000000000..1804650d6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/ParticipantUiState.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Marcel Hibbe + * 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 +) diff --git a/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt b/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt new file mode 100644 index 000000000..b39c279cf --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt @@ -0,0 +1,46 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Marcel Hibbe + * 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 + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/components/ParticipantGrid.kt b/app/src/main/java/com/nextcloud/talk/call/components/ParticipantGrid.kt new file mode 100644 index 000000000..a81216cb7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/components/ParticipantGrid.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Marcel Hibbe + * 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, 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) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/components/ParticipantTile.kt b/app/src/main/java/com/nextcloud/talk/call/components/ParticipantTile.kt new file mode 100644 index 000000000..b1d98007e --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/components/ParticipantTile.kt @@ -0,0 +1,180 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Marcel Hibbe + * 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) +// ) +// } +// } +// } diff --git a/app/src/main/java/com/nextcloud/talk/call/components/WebRTCVideoView.kt b/app/src/main/java/com/nextcloud/talk/call/components/WebRTCVideoView.kt new file mode 100644 index 000000000..d7bb69984 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/components/WebRTCVideoView.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Marcel Hibbe + * 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() + ) +} diff --git a/app/src/main/res/layout/call_activity.xml b/app/src/main/res/layout/call_activity.xml index df7bca34f..ebc1287d5 100644 --- a/app/src/main/res/layout/call_activity.xml +++ b/app/src/main/res/layout/call_activity.xml @@ -33,15 +33,15 @@ android:visibility="visible" tools:visibility="visible"> - + + + + + + + + + + +