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 b787b7afe..d0d7084fe 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -42,13 +42,14 @@ 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.collectAsState +import androidx.compose.runtime.mutableStateListOf import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.toColorInt import androidx.core.net.toUri @@ -59,7 +60,6 @@ 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 @@ -74,6 +74,7 @@ import com.nextcloud.talk.call.MessageSenderMcu import com.nextcloud.talk.call.MessageSenderNoMcu import com.nextcloud.talk.call.MutableLocalCallParticipantModel 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 @@ -303,8 +304,8 @@ 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 var binding: CallActivityBinding? = null private var audioOutputDialog: AudioOutputDialog? = null private var moreCallActionsDialog: MoreCallActionsDialog? = null @@ -399,7 +400,6 @@ class CallActivity : CallBaseActivity() { .setRepeatCount(PulseAnimation.INFINITE) .setRepeatMode(PulseAnimation.REVERSE) callParticipants = HashMap() - participantDisplayItems = HashMap() reactionAnimator = ReactionAnimator(context, binding!!.reactionAnimationWrapper, viewThemeUtils) checkInitialDevicePermissions() @@ -734,10 +734,7 @@ class CallActivity : CallBaseActivity() { } binding!!.switchSelfVideoButton.setOnClickListener { switchCamera() } - binding!!.gridview.onItemClickListener = - AdapterView.OnItemClickListener { _: AdapterView<*>?, _: View?, _: Int, _: Long -> - animateCallControls(true, 0) - } + binding!!.lowerHandButton.setOnClickListener { l: View? -> raiseHandViewModel!!.lowerHand() } binding!!.pictureInPictureButton.setOnClickListener { enterPipMode() } } @@ -890,20 +887,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 +917,8 @@ class CallActivity : CallBaseActivity() { false } animateCallControls(true, 0) - initGridAdapter() + initGrid() + binding!!.composeParticipantGrid.z = 0f } @SuppressLint("ClickableViewAccessibility") @@ -935,72 +933,28 @@ class CallActivity : CallBaseActivity() { binding!!.selfVideoRenderer.setEnableHardwareScaler(false) binding!!.selfVideoRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT) binding!!.selfVideoRenderer.setOnTouchListener(SelfVideoTouchListener()) + + binding!!.pipSelfVideoRenderer.clearImage() + binding!!.pipSelfVideoRenderer.release() } - private fun initSelfVideoViewForPipMode() { - try { - binding!!.pipSelfVideoRenderer.init(rootEglBase!!.eglBaseContext, null) - } catch (e: IllegalStateException) { - Log.d(TAG, "pipGroupVideoRenderer already initialized", e) - } - binding!!.pipSelfVideoRenderer.setZOrderMediaOverlay(true) - // disabled because it causes some devices to crash - binding!!.pipSelfVideoRenderer.setEnableHardwareScaler(false) - binding!!.pipSelfVideoRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT) - - 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 - } - } 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 + private fun initGrid() { + Log.d(TAG, "initGrid") + binding!!.composeParticipantGrid.visibility = View.VISIBLE + binding!!.composeParticipantGrid.setContent { + MaterialTheme { + val participantUiStates = participantItems.map { it.uiStateFlow.collectAsState().value } + ParticipantGrid( + participantUiStates = participantUiStates, + eglBase = rootEglBase!!, + isVoiceOnlyCall = isVoiceOnlyCall, + isInPipMode = isInPipMode + ) { + animateCallControls(true, 0) + } } } - 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() - } - participantsAdapter = ParticipantsAdapter( - this, - participantDisplayItems, - binding!!.conversationRelativeLayout, - binding!!.callInfosLinearLayout, - columns, - isVoiceOnlyCall - ) - binding!!.gridview.adapter = participantsAdapter + if (isInPipMode) { updateUiForPipMode() } @@ -2116,7 +2070,11 @@ class CallActivity : CallBaseActivity() { videoCapturer!!.dispose() videoCapturer = null } + binding!!.selfVideoRenderer.clearImage() binding!!.selfVideoRenderer.release() + + binding!!.pipSelfVideoRenderer.clearImage() + binding!!.pipSelfVideoRenderer.release() if (audioSource != null) { audioSource!!.dispose() audioSource = null @@ -2219,6 +2177,7 @@ class CallActivity : CallBaseActivity() { startVideoCapture(true) } } + in ANGLE_LANDSCAPE_RIGHT_THRESHOLD_MIN..ANGLE_LANDSCAPE_RIGHT_THRESHOLD_MAX, in ANGLE_LANDSCAPE_LEFT_THRESHOLD_MIN..ANGLE_LANDSCAPE_LEFT_THRESHOLD_MAX -> { if (lastAspectRatio != RATIO_16_TO_9) { @@ -2571,18 +2530,17 @@ class CallActivity : CallBaseActivity() { } 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 } + 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 +2635,26 @@ 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) + } + + initGrid() } private fun setCallState(callState: CallStatus) { @@ -2712,6 +2674,7 @@ class CallActivity : CallBaseActivity() { handler!!.postDelayed({ setCallState(CallStatus.CALLING_TIMEOUT) }, CALLING_TIMEOUT) handler!!.post { handleCallStateJoined() } } + CallStatus.IN_CONVERSATION -> handler!!.post { handleCallStateInConversation() } CallStatus.OFFLINE -> handler!!.post { handleCallStateOffline() } CallStatus.LEAVING -> handler!!.post { handleCallStateLeaving() } @@ -2725,7 +2688,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 +2700,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 +2727,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 +2748,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 +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.VISIBLE) { binding!!.callStates.callStateProgressBar.visibility = View.VISIBLE @@ -2818,8 +2781,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 +2802,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 +2823,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 +2984,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") } } @@ -3225,30 +3188,46 @@ class CallActivity : CallBaseActivity() { override fun updateUiForPipMode() { Log.d(TAG, "updateUiForPipMode") - val params = RelativeLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - params.setMargins(0, 0, 0, 0) - binding!!.gridview.layoutParams = params binding!!.callControls.visibility = View.GONE binding!!.callInfosLinearLayout.visibility = View.GONE binding!!.selfVideoViewWrapper.visibility = View.GONE binding!!.callStates.callStateRelativeLayout.visibility = View.GONE + binding!!.pipCallConversationNameTextView.text = conversationName + binding!!.selfVideoRenderer.clearImage() binding!!.selfVideoRenderer.release() - if (participantDisplayItems!!.size > 1) { - binding!!.pipCallConversationNameTextView.text = conversationName - binding!!.pipSelfVideoOverlay.visibility = View.VISIBLE - initSelfVideoViewForPipMode() + + if (participantItems.size == 1) { + binding!!.pipOverlay.visibility = View.GONE } else { - binding!!.pipSelfVideoOverlay.visibility = View.GONE + binding!!.composeParticipantGrid.visibility = View.GONE + + if (localVideoTrack?.enabled() == true) { + binding!!.pipOverlay.visibility = View.VISIBLE + binding!!.pipSelfVideoRenderer.visibility = View.VISIBLE + + try { + binding!!.pipSelfVideoRenderer.init(rootEglBase!!.eglBaseContext, null) + } catch (e: IllegalStateException) { + Log.d(TAG, "pipGroupVideoRenderer already initialized", e) + } + binding!!.pipSelfVideoRenderer.setZOrderMediaOverlay(true) + // disabled because it causes some devices to crash + binding!!.pipSelfVideoRenderer.setEnableHardwareScaler(false) + binding!!.pipSelfVideoRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT) + + localVideoTrack?.addSink(binding?.pipSelfVideoRenderer) + } else { + binding!!.pipOverlay.visibility = View.VISIBLE + binding!!.pipSelfVideoRenderer.visibility = View.GONE + } } } override fun updateUiForNormalMode() { Log.d(TAG, "updateUiForNormalMode") - binding!!.pipSelfVideoOverlay.visibility = View.GONE + binding!!.pipOverlay.visibility = View.GONE + binding!!.composeParticipantGrid.visibility = View.VISIBLE if (isVoiceOnlyCall) { binding!!.callControls.visibility = View.VISIBLE @@ -3353,10 +3332,10 @@ class CallActivity : CallBaseActivity() { private const val SELFVIDEO_WIDTH_16_TO_9_RATIO = 136 private const val SELFVIDEO_HEIGHT_16_TO_9_RATIO = 80 - private const val SELFVIDEO_POSITION_X_LANDSCAPE = 100F - private const val SELFVIDEO_POSITION_Y_LANDSCAPE = 100F + private const val SELFVIDEO_POSITION_X_LANDSCAPE = 50F + private const val SELFVIDEO_POSITION_Y_LANDSCAPE = 50F private const val SELFVIDEO_POSITION_X_PORTRAIT = 300F - private const val SELFVIDEO_POSITION_Y_PORTRAIT = 100F + private const val SELFVIDEO_POSITION_Y_PORTRAIT = 50F private const val FIVE_SECONDS: Long = 5000 private const val CALLING_TIMEOUT: Long = 45000 @@ -3368,20 +3347,8 @@ class CallActivity : CallBaseActivity() { private const val SPOTLIGHT_HEADING_SIZE: Int = 20 private const val SPOTLIGHT_SUBHEADING_SIZE: Int = 16 - private const val GRID_MAX_COLUMN_COUNT_PORTRAIT: Int = 2 - private const val GRID_MIN_COLUMN_COUNT_PORTRAIT: Int = 1 - private const val GRID_MAX_COLUMN_COUNT_LANDSCAPE: Int = 3 - private const val GRID_MIN_GROUP_COLUMN_COUNT_LANDSCAPE: Int = 2 - private const val GRID_MIN_COLUMN_COUNT_LANDSCAPE: Int = 1 - private const val DELAY_ON_ERROR_STOP_THRESHOLD: Int = 16 - private const val BY_50_PERCENT = 0.5 - private const val BY_80_PERCENT = 0.8 - - private const val Y_POS_CALL_INFO: Float = 250f - private const val Y_POS_NO_CALL_INFO: Float = 20f - private const val SESSION_ID_PREFFIX_END: Int = 4 } } diff --git a/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.java b/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.java deleted file mode 100644 index 122fd090a..000000000 --- a/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.java +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2023 Andy Scherzinger - * SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez - * SPDX-FileCopyrightText: 2021 Marcel Hibbe - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.adapters; - -import android.content.Context; -import android.os.Handler; -import android.os.Looper; -import android.text.TextUtils; - -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; - -import org.webrtc.EglBase; -import org.webrtc.MediaStream; -import org.webrtc.PeerConnection; - -public class ParticipantDisplayItem { - - /** - * Shared handler to receive change notifications from the model on the main thread. - */ - private static final Handler handler = new Handler(Looper.getMainLooper()); - - private final ParticipantDisplayItemNotifier participantDisplayItemNotifier = new ParticipantDisplayItemNotifier(); - - private final Context context; - - 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(); - } - - private final CallParticipantModel.Observer callParticipantModelObserver = new CallParticipantModel.Observer() { - @Override - public void onChange() { - updateFromModel(); - } - - @Override - public void onReaction(String reaction) { - } - }; - - 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); - } - - private void updateFromModel() { - actorType = callParticipantModel.getActorType(); - actorId = callParticipantModel.getActorId(); - userId = callParticipantModel.getUserId(); - nick = callParticipantModel.getNick(); - - this.updateUrlForAvatar(); - - if ("screen".equals(streamType)) { - iceConnectionState = callParticipantModel.getScreenIceConnectionState(); - mediaStream = callParticipantModel.getScreenMediaStream(); - isAudioEnabled = true; - streamEnabled = true; - } else { - iceConnectionState = callParticipantModel.getIceConnectionState(); - mediaStream = callParticipantModel.getMediaStream(); - isAudioEnabled = callParticipantModel.isAudioAvailable() != null ? - callParticipantModel.isAudioAvailable() : false; - streamEnabled = callParticipantModel.isVideoAvailable() != null ? - callParticipantModel.isVideoAvailable() : false; - } - - raisedHand = callParticipantModel.getRaisedHand(); - - 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); - } else if (!TextUtils.isEmpty(userId)) { - urlForAvatar = ApiUtils.getUrlForAvatar(baseUrl, userId, true); - } else { - urlForAvatar = ApiUtils.getUrlForGuestAvatar(baseUrl, getNick(), 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; - } - - public String getNick() { - if (TextUtils.isEmpty(userId) && TextUtils.isEmpty(nick)) { - return defaultGuestNick; - } - - return nick; - } - - 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() { - return "ParticipantSession{" + - "userId='" + userId + '\'' + - ", actorType='" + actorType + '\'' + - ", actorId='" + actorId + '\'' + - ", session='" + session + '\'' + - ", nick='" + nick + '\'' + - ", urlForAvatar='" + urlForAvatar + '\'' + - ", mediaStream=" + mediaStream + - ", streamType='" + streamType + '\'' + - ", streamEnabled=" + streamEnabled + - ", rootEglBase=" + rootEglBase + - ", raisedHand=" + raisedHand + - '}'; - } -} diff --git a/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.kt b/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.kt new file mode 100644 index 000000000..299505afd --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.kt @@ -0,0 +1,217 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2023 Andy Scherzinger + * SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez + * SPDX-FileCopyrightText: 2021-2025 Marcel Hibbe + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.adapters + +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.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 + +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 mediaStream: MediaStream? +) + +@Suppress("LongParameterList") +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() + + private val _uiStateFlow = MutableStateFlow(buildUiState()) + val uiStateFlow: StateFlow = _uiStateFlow.asStateFlow() + + private val session: String = callParticipantModel.sessionId + + 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) + + 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 + + val sessionKey: String + get() = "$session-$streamType" + + interface Observer { + fun onChange() + } + + private val callParticipantModelObserver: CallParticipantModel.Observer = object : CallParticipantModel.Observer { + override fun onChange() { + updateFromModel() + } + + override fun onReaction(reaction: String) { + // unused + } + } + + init { + callParticipantModel.addObserver(callParticipantModelObserver, handler) + + updateFromModel() + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun destroy() { + callParticipantModel.removeObserver(callParticipantModelObserver) + + surfaceViewRenderer?.let { renderer -> + try { + mediaStream?.videoTracks?.firstOrNull()?.removeSink(renderer) + renderer.clearImage() + renderer.release() + (renderer.parent as? ViewGroup)?.removeView(renderer) + } catch (e: Exception) { + Log.w("ParticipantDisplayItem", "Error releasing renderer", e) + } + } + surfaceViewRenderer = null + } + + 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.iceConnectionState + mediaStream = callParticipantModel.mediaStream + isAudioEnabled = callParticipantModel.isAudioAvailable ?: false + isStreamEnabled = callParticipantModel.isVideoAvailable ?: false + } + + raisedHand = callParticipantModel.raisedHand + + 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 fun buildUiState(): ParticipantUiState { + return ParticipantUiState( + sessionKey = sessionKey, + nick = nick ?: "Guest", + isConnected = isConnected, + isAudioEnabled = isAudioEnabled, + isStreamEnabled = isStreamEnabled, + raisedHand = raisedHand?.state == true, + avatarUrl = urlForAvatar, + mediaStream = mediaStream + ) + } + + 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 = getUrlForAvatar(baseUrl, userId, true) + } else { + urlForAvatar = getUrlForGuestAvatar(baseUrl, nick, true) + } + } + + 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) + } + + fun removeObserver(observer: Observer?) { + participantDisplayItemNotifier.removeObserver(observer) + } + + override fun toString(): String { + return "ParticipantSession{" + + "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.java b/app/src/main/java/com/nextcloud/talk/adapters/ParticipantsAdapter.java deleted file mode 100644 index c6c49773d..000000000 --- a/app/src/main/java/com/nextcloud/talk/adapters/ParticipantsAdapter.java +++ /dev/null @@ -1,217 +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.ImageViewExtensionsKt; -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 org.webrtc.VideoTrack; - -import java.util.ArrayList; -import java.util.Map; - -public class ParticipantsAdapter extends BaseAdapter { - - private static final String TAG = "ParticipantsAdapter"; - - private final ParticipantDisplayItem.Observer participantDisplayItemObserver = this::notifyDataSetChanged; - - private final Context mContext; - private final ArrayList participantDisplayItems; - private final RelativeLayout gridViewWrapper; - private final LinearLayout callInfosLinearLayout; - private final int columns; - private final boolean isVoiceOnlyCall; - - public ParticipantsAdapter(Context mContext, - Map participantDisplayItems, - RelativeLayout gridViewWrapper, - LinearLayout callInfosLinearLayout, - int columns, - boolean isVoiceOnlyCall) { - this.mContext = mContext; - this.gridViewWrapper = gridViewWrapper; - this.callInfosLinearLayout = callInfosLinearLayout; - this.columns = columns; - this.isVoiceOnlyCall = isVoiceOnlyCall; - - this.participantDisplayItems = new ArrayList<>(); - this.participantDisplayItems.addAll(participantDisplayItems.values()); - - for (ParticipantDisplayItem participantDisplayItem : this.participantDisplayItems) { - participantDisplayItem.addObserver(participantDisplayItemObserver); - } - } - - public void destroy() { - for (ParticipantDisplayItem participantDisplayItem : participantDisplayItems) { - participantDisplayItem.removeObserver(participantDisplayItemObserver); - } - } - - @Override - public int getCount() { - return participantDisplayItems.size(); - } - - @Override - public ParticipantDisplayItem getItem(int position) { - return participantDisplayItems.get(position); - } - - @Override - public long getItemId(int position) { - return 0; - } - - @Override - public View getView(int position, View convertView, ViewGroup parent) { - ParticipantDisplayItem participantDisplayItem = getItem(position); - - SurfaceViewRenderer surfaceViewRenderer; - if (convertView == null) { - convertView = LayoutInflater.from(mContext).inflate(R.layout.call_item, parent, false); - convertView.setVisibility(View.VISIBLE); - - surfaceViewRenderer = convertView.findViewById(R.id.surface_view); - try { - Log.d(TAG, "hasSurface: " + participantDisplayItem.getRootEglBase().hasSurface()); - - surfaceViewRenderer.setMirror(false); - surfaceViewRenderer.init(participantDisplayItem.getRootEglBase().getEglBaseContext(), null); - surfaceViewRenderer.setZOrderMediaOverlay(false); - // disabled because it causes some devices to crash - surfaceViewRenderer.setEnableHardwareScaler(false); - surfaceViewRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT); - } catch (Exception e) { - Log.e(TAG, "error while initializing surfaceViewRenderer", e); - } - } else { - surfaceViewRenderer = convertView.findViewById(R.id.surface_view); - } - - ProgressBar progressBar = convertView.findViewById(R.id.participant_progress_bar); - if (!participantDisplayItem.isConnected()) { - progressBar.setVisibility(View.VISIBLE); - } else { - progressBar.setVisibility(View.GONE); - } - - ViewGroup.LayoutParams layoutParams = convertView.getLayoutParams(); - layoutParams.height = scaleGridViewItemHeight(); - convertView.setLayoutParams(layoutParams); - - TextView nickTextView = convertView.findViewById(R.id.peer_nick_text_view); - ImageView imageView = convertView.findViewById(R.id.avatarImageView); - - MediaStream mediaStream = participantDisplayItem.getMediaStream(); - if (hasVideoStream(participantDisplayItem, mediaStream)) { - VideoTrack videoTrack = mediaStream.videoTracks.get(0); - videoTrack.addSink(surfaceViewRenderer); - imageView.setVisibility(View.INVISIBLE); - surfaceViewRenderer.setVisibility(View.VISIBLE); - nickTextView.setVisibility(View.GONE); - } else { - imageView.setVisibility(View.VISIBLE); - surfaceViewRenderer.setVisibility(View.INVISIBLE); - - if (((CallActivity) mContext).isInPipMode) { - nickTextView.setVisibility(View.GONE); - } else { - nickTextView.setVisibility(View.VISIBLE); - nickTextView.setText(participantDisplayItem.getNick()); - } - if (participantDisplayItem.getActorType() == Participant.ActorType.GUESTS || - participantDisplayItem.getActorType() == Participant.ActorType.EMAILS) { - ImageViewExtensionsKt.loadFirstLetterAvatar( - imageView, - String.valueOf(participantDisplayItem.getNick()) - ); - } else { - ImageViewExtensionsKt.loadAvatarWithUrl(imageView,null, participantDisplayItem.getUrlForAvatar()); - } - } - - ImageView audioOffView = convertView.findViewById(R.id.remote_audio_off); - if (!participantDisplayItem.isAudioEnabled()) { - audioOffView.setVisibility(View.VISIBLE); - } else { - audioOffView.setVisibility(View.GONE); - } - - ImageView raisedHandView = convertView.findViewById(R.id.raised_hand); - if (participantDisplayItem.getRaisedHand() != null && participantDisplayItem.getRaisedHand().getState()) { - raisedHandView.setVisibility(View.VISIBLE); - } else { - raisedHandView.setVisibility(View.GONE); - } - - return convertView; - } - - private boolean hasVideoStream(ParticipantDisplayItem participantDisplayItem, MediaStream mediaStream) { - if (!participantDisplayItem.isStreamEnabled()) { - return false; - } - - if (mediaStream == null || mediaStream.videoTracks == null) { - return false; - } - - for (VideoTrack t : mediaStream.videoTracks) { - if (MediaStreamTrack.State.LIVE == t.state()) { - return true; - } - } - - return false; - } - - private int scaleGridViewItemHeight() { - int headerHeight = 0; - int callControlsHeight = 0; - if (callInfosLinearLayout.getVisibility() == View.VISIBLE && isVoiceOnlyCall) { - headerHeight = callInfosLinearLayout.getHeight(); - } - if (isVoiceOnlyCall) { - callControlsHeight = Math.round(mContext.getResources().getDimension(R.dimen.call_controls_height)); - } - int itemHeight = (gridViewWrapper.getHeight() - headerHeight - callControlsHeight) / getRowsCount(getCount()); - int itemMinHeight = Math.round(mContext.getResources().getDimension(R.dimen.call_grid_item_min_height)); - if (itemHeight < itemMinHeight) { - itemHeight = itemMinHeight; - } - return itemHeight; - } - - private int getRowsCount(int items) { - int rows = (int) Math.ceil((double) items / (double) columns); - if (rows == 0) { - rows = 1; - } - return rows; - } -} 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..fa96171f1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt @@ -0,0 +1,62 @@ +/* + * 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.foundation.shape.CircleShape +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.layout.ContentScale +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.nextcloud.talk.adapters.ParticipantUiState + +@Composable +fun AvatarWithFallback(participant: ParticipantUiState, modifier: Modifier = Modifier) { + val initials = participant.nick + .split(" ") + .mapNotNull { it.firstOrNull()?.uppercase() } + .take(2) + .joinToString("") + + Box( + modifier = modifier + .clip(CircleShape), + contentAlignment = Alignment.Center + ) { + if (!participant.avatarUrl.isNullOrEmpty()) { + AsyncImage( + model = participant.avatarUrl, + contentDescription = "Avatar", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White, CircleShape), + contentAlignment = Alignment.Center + ) { + Text( + text = initials.ifEmpty { "?" }, + color = Color.Black, + fontSize = 24.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..724b0fad7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/components/ParticipantGrid.kt @@ -0,0 +1,291 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Marcel Hibbe + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +@file:Suppress("MagicNumber", "TooManyFunctions") + +package com.nextcloud.talk.call.components + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.platform.LocalConfiguration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextcloud.talk.adapters.ParticipantUiState +import org.webrtc.EglBase +import kotlin.math.ceil + +@Suppress("LongParameterList") +@Composable +fun ParticipantGrid( + modifier: Modifier = Modifier, + eglBase: EglBase?, + participantUiStates: List, + isVoiceOnlyCall: Boolean, + isInPipMode: Boolean, + onClick: () -> Unit +) { + val configuration = LocalConfiguration.current + val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT + + val minItemHeight = 100.dp + + val columns = + if (isPortrait) { + when (participantUiStates.size) { + 1, 2, 3 -> 1 + else -> 2 + } + } else { + when (participantUiStates.size) { + 1 -> 1 + 2, 4 -> 2 + else -> 3 + } + } + + val rows = ceil(participantUiStates.size / columns.toFloat()).toInt() + + val heightForNonGridComponents = if (isVoiceOnlyCall && !isInPipMode) { + // this is a workaround for now. It should ~summarize the height of callInfosLinearLayout and callControls + // Once everything is migrated to jetpack, this workaround should be obsolete or solved in a better way + 240.dp + } else { + 0.dp + } + + val gridHeight = LocalConfiguration.current.screenHeightDp.dp - heightForNonGridComponents + val itemSpacing = 8.dp + val edgePadding = 8.dp + + val totalVerticalSpacing = itemSpacing * (rows - 1) + val totalVerticalPadding = edgePadding * 2 + val availableHeight = gridHeight - totalVerticalSpacing - totalVerticalPadding + + val rawItemHeight = availableHeight / rows + val itemHeight = maxOf(rawItemHeight, minItemHeight) + + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = edgePadding) + .clickable { onClick() }, + verticalArrangement = Arrangement.spacedBy(itemSpacing), + horizontalArrangement = Arrangement.spacedBy(itemSpacing), + contentPadding = PaddingValues(vertical = edgePadding) + ) { + items( + participantUiStates, + key = { it.sessionKey } + ) { participant -> + ParticipantTile( + participantUiState = participant, + modifier = Modifier + .height(itemHeight) + .fillMaxWidth(), + eglBase = eglBase, + isVoiceOnlyCall = isVoiceOnlyCall + ) + } + } +} + +@Preview +@Composable +fun ParticipantGridPreview() { + ParticipantGrid( + participantUiStates = getTestParticipants(1), + eglBase = null, + isVoiceOnlyCall = false, + isInPipMode = false + ) {} +} + +@Preview +@Composable +fun TwoParticipants() { + ParticipantGrid( + participantUiStates = getTestParticipants(2), + eglBase = null, + isVoiceOnlyCall = false, + isInPipMode = false + ) {} +} + +@Preview +@Composable +fun ThreeParticipants() { + ParticipantGrid( + participantUiStates = getTestParticipants(3), + eglBase = null, + isVoiceOnlyCall = false, + isInPipMode = false + ) {} +} + +@Preview +@Composable +fun FourParticipants() { + ParticipantGrid( + participantUiStates = getTestParticipants(4), + eglBase = null, + isVoiceOnlyCall = false, + isInPipMode = false + ) {} +} + +@Preview +@Composable +fun FiveParticipants() { + ParticipantGrid( + participantUiStates = getTestParticipants(5), + eglBase = null, + isVoiceOnlyCall = false, + isInPipMode = false + ) {} +} + +@Preview +@Composable +fun SevenParticipants() { + ParticipantGrid( + participantUiStates = getTestParticipants(7), + eglBase = null, + isVoiceOnlyCall = false, + isInPipMode = false + ) {} +} + +@Preview +@Composable +fun FiftyParticipants() { + ParticipantGrid( + participantUiStates = getTestParticipants(50), + eglBase = null, + isVoiceOnlyCall = false, + isInPipMode = false + ) {} +} + +@Preview( + showBackground = false, + heightDp = 360, + widthDp = 800 +) +@Composable +fun OneParticipantLandscape() { + ParticipantGrid( + participantUiStates = getTestParticipants(1), + eglBase = null, + isVoiceOnlyCall = false, + isInPipMode = false + ) {} +} + +@Preview( + showBackground = false, + heightDp = 360, + widthDp = 800 +) +@Composable +fun TwoParticipantsLandscape() { + ParticipantGrid( + participantUiStates = getTestParticipants(2), + eglBase = null, + isVoiceOnlyCall = false, + isInPipMode = false + ) {} +} + +@Preview( + showBackground = false, + heightDp = 360, + widthDp = 800 +) +@Composable +fun ThreeParticipantsLandscape() { + ParticipantGrid( + participantUiStates = getTestParticipants(3), + eglBase = null, + isVoiceOnlyCall = false, + isInPipMode = false + ) {} +} + +@Preview( + showBackground = false, + heightDp = 360, + widthDp = 800 +) +@Composable +fun FourParticipantsLandscape() { + ParticipantGrid( + participantUiStates = getTestParticipants(4), + eglBase = null, + isVoiceOnlyCall = false, + isInPipMode = false + ) {} +} + +@Preview( + showBackground = false, + heightDp = 360, + widthDp = 800 +) +@Composable +fun SevenParticipantsLandscape() { + ParticipantGrid( + participantUiStates = getTestParticipants(7), + eglBase = null, + isVoiceOnlyCall = false, + isInPipMode = false + ) {} +} + +@Preview( + showBackground = false, + heightDp = 360, + widthDp = 800 +) +@Composable +fun FiftyParticipantsLandscape() { + ParticipantGrid( + participantUiStates = getTestParticipants(50), + eglBase = null, + isVoiceOnlyCall = false, + isInPipMode = false + ) {} +} + +fun getTestParticipants(numberOfParticipants: Int): List { + val participantList = mutableListOf() + for (i: Int in 1..numberOfParticipants) { + val participant = ParticipantUiState( + sessionKey = i.toString(), + nick = "test$i user", + isConnected = true, + isAudioEnabled = false, + isStreamEnabled = true, + raisedHand = true, + avatarUrl = "", + mediaStream = null + ) + participantList.add(participant) + } + return participantList +} 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..c15edf292 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/components/ParticipantTile.kt @@ -0,0 +1,144 @@ +/* + * 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.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import com.nextcloud.talk.R +import com.nextcloud.talk.adapters.ParticipantUiState +import com.nextcloud.talk.utils.ColorGenerator +import org.webrtc.EglBase + +const val NICK_OFFSET = 4f +const val NICK_BLUR_RADIUS = 4f +const val AVATAR_SIZE_FACTOR = 0.6f + +@Suppress("Detekt.LongMethod") +@Composable +fun ParticipantTile( + participantUiState: ParticipantUiState, + eglBase: EglBase?, + modifier: Modifier = Modifier, + isVoiceOnlyCall: Boolean +) { + val colorInt = ColorGenerator.shared.usernameToColor(participantUiState.nick) + + BoxWithConstraints( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color(colorInt)) + ) { + val avatarSize = min(maxWidth, maxHeight) * AVATAR_SIZE_FACTOR + + if (!isVoiceOnlyCall && participantUiState.isStreamEnabled && participantUiState.mediaStream != null) { + WebRTCVideoView(participantUiState, eglBase) + } else { + AvatarWithFallback( + participant = participantUiState, + modifier = Modifier + .size(avatarSize) + .align(Alignment.Center) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + ) { + if (participantUiState.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 (!participantUiState.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 = participantUiState.nick, + color = Color.White, + modifier = Modifier + .align(Alignment.BottomStart), + style = MaterialTheme.typography.bodyMedium.copy( + shadow = Shadow( + color = Color.Black, + offset = Offset(NICK_OFFSET, NICK_OFFSET), + blurRadius = NICK_BLUR_RADIUS + ) + ) + ) + + if (!participantUiState.isConnected) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + } + } +} + +@Preview(showBackground = false) +@Composable +fun ParticipantTilePreview() { + val participant = ParticipantUiState( + sessionKey = "", + nick = "testuser one", + isConnected = true, + isAudioEnabled = false, + isStreamEnabled = true, + raisedHand = true, + avatarUrl = "", + mediaStream = null + ) + ParticipantTile( + participantUiState = participant, + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + eglBase = null, + isVoiceOnlyCall = false + ) +} 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..375627481 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/components/WebRTCVideoView.kt @@ -0,0 +1,35 @@ +/* + * 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 com.nextcloud.talk.adapters.ParticipantUiState +import org.webrtc.EglBase +import org.webrtc.SurfaceViewRenderer + +@Composable +fun WebRTCVideoView(participant: ParticipantUiState, eglBase: EglBase?) { + AndroidView( + factory = { context -> + SurfaceViewRenderer(context).apply { + init(eglBase?.eglBaseContext, null) + setEnableHardwareScaler(true) + setMirror(false) + participant.mediaStream?.videoTracks?.firstOrNull()?.addSink(this) + } + }, + modifier = Modifier.fillMaxSize(), + onRelease = { + participant.mediaStream?.videoTracks?.firstOrNull()?.removeSink(it) + it.release() + } + ) +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ColorGenerator.kt b/app/src/main/java/com/nextcloud/talk/utils/ColorGenerator.kt new file mode 100644 index 000000000..468f72374 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/ColorGenerator.kt @@ -0,0 +1,107 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2025 Marcel Hibbe + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.utils + +import android.graphics.Color +import java.security.MessageDigest +import kotlin.math.abs + +// See https://github.com/nextcloud/nextcloud-vue/blob/56b79afae93f4701a0cb933bfeb7b4a2fbd590fb/src/functions/usernameToColor/usernameToColor.js +// and https://github.com/nextcloud/nextcloud-vue/blob/56b79afae93f4701a0cb933bfeb7b4a2fbd590fb/src/utils/GenColors.js + +@Suppress("MagicNumber") +class ColorGenerator private constructor() { + + private val steps = 6 + private val finalPalette: List = genColors(steps) + + companion object { + val shared = ColorGenerator() + private const val MULTIPLIER = 256.0f + + private fun stepCalc(steps: Int, colorStart: Int, colorEnd: Int): List { + val r0 = Color.red(colorStart) / MULTIPLIER + val g0 = Color.green(colorStart) / MULTIPLIER + val b0 = Color.blue(colorStart) / MULTIPLIER + + val r1 = Color.red(colorEnd) / MULTIPLIER + val g1 = Color.green(colorEnd) / MULTIPLIER + val b1 = Color.blue(colorEnd) / MULTIPLIER + + return listOf( + (r1 - r0) / steps, + (g1 - g0) / steps, + (b1 - b0) / steps + ) + } + + private fun mixPalette(steps: Int, color1: Int, color2: Int): List { + val palette = mutableListOf(color1) + val step = stepCalc(steps, color1, color2) + + val rStart = Color.red(color1) / MULTIPLIER + val gStart = Color.green(color1) / MULTIPLIER + val bStart = Color.blue(color1) / MULTIPLIER + + for (i in 1 until steps) { + val r = (abs(rStart + step[0] * i) * MULTIPLIER).toInt().coerceIn(0, 255) + val g = (abs(gStart + step[1] * i) * MULTIPLIER).toInt().coerceIn(0, 255) + val b = (abs(bStart + step[2] * i) * MULTIPLIER).toInt().coerceIn(0, 255) + + palette.add(Color.rgb(r, g, b)) + } + + return palette + } + + fun genColors(steps: Int): List { + val red = Color.rgb( + (182 / MULTIPLIER * 255).toInt(), + (70 / MULTIPLIER * 255).toInt(), + (157 / MULTIPLIER * 255).toInt() + ) + val yellow = Color.rgb( + (221 / MULTIPLIER * 255).toInt(), + (203 / MULTIPLIER * 255).toInt(), + (85 / MULTIPLIER * 255).toInt() + ) + val blue = Color.rgb( + 0, + (130 / MULTIPLIER * 255).toInt(), + (201 / MULTIPLIER * 255).toInt() + ) + + val palette1 = mixPalette(steps, red, yellow).toMutableList() + val palette2 = mixPalette(steps, yellow, blue) + val palette3 = mixPalette(steps, blue, red) + + palette1.addAll(palette2) + palette1.addAll(palette3) + + return palette1 + } + } + + fun usernameToColor(username: String): Int { + val hash = username.lowercase() + var hashInt: Int + + val bytes = hash.toByteArray(Charsets.UTF_8) + val md = MessageDigest.getInstance("MD5") + val digest = md.digest(bytes) + val hex = digest.joinToString("") { "%02x".format(it) } + + hashInt = hex.map { it.digitToInt(16) % 16 }.sum() + + val maximum = steps * 3 + hashInt %= maximum + + return finalPalette[hashInt] + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt index eee95b293..9ed9e524c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt @@ -9,13 +9,13 @@ package com.nextcloud.talk.utils.message import android.content.Context import android.content.Intent import android.graphics.Typeface -import android.net.Uri import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.StyleSpan import android.util.Log import android.view.View +import androidx.core.net.toUri import com.nextcloud.talk.R import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.ui.theme.ViewThemeUtils @@ -145,7 +145,7 @@ class MessageUtils(val context: Context) { "file" -> { itemView?.setOnClickListener { v -> - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(individualHashMap["link"])) + val browserIntent = Intent(Intent.ACTION_VIEW, individualHashMap["link"]?.toUri()) context.startActivity(browserIntent) } } diff --git a/app/src/main/res/layout/call_activity.xml b/app/src/main/res/layout/call_activity.xml index df7bca34f..ca67beaa2 100644 --- a/app/src/main/res/layout/call_activity.xml +++ b/app/src/main/res/layout/call_activity.xml @@ -33,15 +33,10 @@ android:visibility="visible" tools:visibility="visible"> - + android:layout_height="match_parent" /> - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 768c7c388..f9ccd0c99 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -54,7 +54,6 @@ 24dp 18dp - 180dp 110dp 48dp 48dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec6a5d518..27f94ca7b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -175,7 +175,6 @@ How to translate with transifex: Notifications are granted Notifications are declined - Notifications are declined. Please allow notifications in Android settings Notification troubleshooting @@ -373,7 +372,6 @@ How to translate with transifex: Do not disturb Away Invisible - 😃 👍 👎 @@ -398,9 +396,6 @@ How to translate with transifex: There was a problem loading your chats Close Close Icon - Refresh - Please check your internet connection - Visible Enter a message … @@ -438,9 +433,7 @@ How to translate with transifex: Failed Sending Failed to send message: - Remote audio off Add attachment - Recent See %d similar message See %d similar messages @@ -461,7 +454,6 @@ How to translate with transifex: Guest access password Enter a password Error during setting/disabling the password. - Weak password Share conversation link Resend invitations Invitations were sent out again. @@ -477,11 +469,8 @@ How to translate with transifex: %s characters limit has been hit - Email Group Team - Groups - Teams Participants Add participants Start group chat