WIP migrate call grid to compose

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

View File

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

View File

@ -3,189 +3,182 @@
* *
* SPDX-FileCopyrightText: 2023 Andy Scherzinger <info@andy-scherzinger.de> * SPDX-FileCopyrightText: 2023 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com> * SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de> * SPDX-FileCopyrightText: 2021-2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
package com.nextcloud.talk.adapters; package com.nextcloud.talk.adapters
import android.content.Context; import android.content.Context
import android.os.Handler; import android.os.Handler
import android.os.Looper; import android.os.Looper
import android.text.TextUtils; 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; class ParticipantDisplayItem(
import com.nextcloud.talk.call.RaisedHand; private val context: Context,
import com.nextcloud.talk.models.json.participants.Participant; private val baseUrl: String,
import com.nextcloud.talk.utils.ApiUtils; private val defaultGuestNick: String,
import com.nextcloud.talk.utils.DisplayUtils; val rootEglBase: EglBase,
private val streamType: String,
private val roomToken: String,
private val callParticipantModel: CallParticipantModel
) {
private val participantDisplayItemNotifier = ParticipantDisplayItemNotifier()
import org.webrtc.EglBase; private val _uiStateFlow = MutableStateFlow(buildUiState())
import org.webrtc.MediaStream; val uiStateFlow: StateFlow<ParticipantUiState> = _uiStateFlow.asStateFlow()
import org.webrtc.PeerConnection;
public class ParticipantDisplayItem { private val session: String = callParticipantModel.sessionId
/** var actorType: ActorType? = null
* Shared handler to receive change notifications from the model on the main thread. private set
*/ private var actorId: String? = null
private static final Handler handler = new Handler(Looper.getMainLooper()); 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; interface Observer {
private final String defaultGuestNick; fun onChange()
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() { private val callParticipantModelObserver: CallParticipantModel.Observer = object : CallParticipantModel.Observer {
@Override override fun onChange() {
public void onChange() { updateFromModel()
updateFromModel();
} }
@Override override fun onReaction(reaction: String) {
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() { init {
this.callParticipantModel.removeObserver(callParticipantModelObserver); callParticipantModel.addObserver(callParticipantModelObserver, handler)
updateFromModel()
} }
private void updateFromModel() { fun destroy() {
actorType = callParticipantModel.getActorType(); callParticipantModel.removeObserver(callParticipantModelObserver)
actorId = callParticipantModel.getActorId();
userId = callParticipantModel.getUserId();
nick = callParticipantModel.getNick();
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)) { private fun updateFromModel() {
iceConnectionState = callParticipantModel.getScreenIceConnectionState(); actorType = callParticipantModel.actorType
mediaStream = callParticipantModel.getScreenMediaStream(); actorId = callParticipantModel.actorId
isAudioEnabled = true; userId = callParticipantModel.userId
streamEnabled = true; nick = callParticipantModel.nick
updateUrlForAvatar()
if (streamType == "screen") {
iceConnectionState = callParticipantModel.screenIceConnectionState
mediaStream = callParticipantModel.screenMediaStream
isAudioEnabled = true
isStreamEnabled = true
} else { } else {
iceConnectionState = callParticipantModel.getIceConnectionState(); iceConnectionState = callParticipantModel.iceConnectionState
mediaStream = callParticipantModel.getMediaStream(); mediaStream = callParticipantModel.mediaStream
isAudioEnabled = callParticipantModel.isAudioAvailable() != null ? isAudioEnabled = callParticipantModel.isAudioAvailable ?: false
callParticipantModel.isAudioAvailable() : false; isStreamEnabled = callParticipantModel.isVideoAvailable ?: false
streamEnabled = callParticipantModel.isVideoAvailable() != null ?
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)
} }
private void updateUrlForAvatar() { _uiStateFlow.value = buildUiState()
if (actorType == Participant.ActorType.FEDERATED) { participantDisplayItemNotifier.notifyChange()
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)) { } else if (!TextUtils.isEmpty(userId)) {
urlForAvatar = ApiUtils.getUrlForAvatar(baseUrl, userId, true); urlForAvatar = getUrlForAvatar(baseUrl, userId, true)
} else { } else {
urlForAvatar = ApiUtils.getUrlForGuestAvatar(baseUrl, getNick(), true); urlForAvatar = getUrlForGuestAvatar(baseUrl, nick, true)
} }
} }
public boolean isConnected() { val isConnected: Boolean
return iceConnectionState == PeerConnection.IceConnectionState.CONNECTED || get() = iceConnectionState == IceConnectionState.CONNECTED ||
iceConnectionState == PeerConnection.IceConnectionState.COMPLETED || iceConnectionState == IceConnectionState.COMPLETED ||
// If there is no connection state that means that no connection is needed, so it is a special case that is // If there is no connection state that means that no connection is needed,
// also seen as "connected". // so it is a special case that is also seen as "connected".
iceConnectionState == null; iceConnectionState == null
fun addObserver(observer: Observer?) {
participantDisplayItemNotifier.addObserver(observer)
} }
public String getNick() { fun removeObserver(observer: Observer?) {
if (TextUtils.isEmpty(userId) && TextUtils.isEmpty(nick)) { participantDisplayItemNotifier.removeObserver(observer)
return defaultGuestNick;
} }
return nick; override fun toString(): String {
}
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{" + return "ParticipantSession{" +
"userId='" + userId + '\'' + "userId='" + userId + '\'' +
", actorType='" + actorType + '\'' + ", actorType='" + actorType + '\'' +
@ -195,9 +188,16 @@ public class ParticipantDisplayItem {
", urlForAvatar='" + urlForAvatar + '\'' + ", urlForAvatar='" + urlForAvatar + '\'' +
", mediaStream=" + mediaStream + ", mediaStream=" + mediaStream +
", streamType='" + streamType + '\'' + ", streamType='" + streamType + '\'' +
", streamEnabled=" + streamEnabled + ", streamEnabled=" + isStreamEnabled +
", rootEglBase=" + rootEglBase + ", rootEglBase=" + rootEglBase +
", raisedHand=" + raisedHand + ", raisedHand=" + raisedHand +
'}'; '}'
}
companion object {
/**
* Shared handler to receive change notifications from the model on the main thread.
*/
private val handler = Handler(Looper.getMainLooper())
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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