Merge pull request #4947 from nextcloud/improveCallGrid

Improve call grid & Picture-inPicture view
This commit is contained in:
Marcel Hibbe 2025-05-14 08:01:12 +00:00 committed by GitHub
commit f8bfa0485c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 970 additions and 665 deletions

View File

@ -42,13 +42,14 @@ 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.collectAsState
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
@ -59,7 +60,6 @@ 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
@ -74,6 +74,7 @@ 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.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
@ -303,8 +304,8 @@ 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 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 +400,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 +734,7 @@ class CallActivity : CallBaseActivity() {
} }
binding!!.switchSelfVideoButton.setOnClickListener { switchCamera() } binding!!.switchSelfVideoButton.setOnClickListener { switchCamera() }
binding!!.gridview.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 +887,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 +917,8 @@ class CallActivity : CallBaseActivity() {
false false
} }
animateCallControls(true, 0) animateCallControls(true, 0)
initGridAdapter() initGrid()
binding!!.composeParticipantGrid.z = 0f
} }
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
@ -935,72 +933,28 @@ class CallActivity : CallBaseActivity() {
binding!!.selfVideoRenderer.setEnableHardwareScaler(false) binding!!.selfVideoRenderer.setEnableHardwareScaler(false)
binding!!.selfVideoRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT) binding!!.selfVideoRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
binding!!.selfVideoRenderer.setOnTouchListener(SelfVideoTouchListener()) binding!!.selfVideoRenderer.setOnTouchListener(SelfVideoTouchListener())
binding!!.pipSelfVideoRenderer.clearImage()
binding!!.pipSelfVideoRenderer.release()
} }
private fun initSelfVideoViewForPipMode() { private fun initGrid() {
try { Log.d(TAG, "initGrid")
binding!!.pipSelfVideoRenderer.init(rootEglBase!!.eglBaseContext, null) binding!!.composeParticipantGrid.visibility = View.VISIBLE
} catch (e: IllegalStateException) { binding!!.composeParticipantGrid.setContent {
Log.d(TAG, "pipGroupVideoRenderer already initialized", e) MaterialTheme {
} val participantUiStates = participantItems.map { it.uiStateFlow.collectAsState().value }
binding!!.pipSelfVideoRenderer.setZOrderMediaOverlay(true) ParticipantGrid(
// disabled because it causes some devices to crash participantUiStates = participantUiStates,
binding!!.pipSelfVideoRenderer.setEnableHardwareScaler(false) eglBase = rootEglBase!!,
binding!!.pipSelfVideoRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT) isVoiceOnlyCall = isVoiceOnlyCall,
isInPipMode = isInPipMode
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) { animateCallControls(true, 0)
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
} }
} }
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) { if (isInPipMode) {
updateUiForPipMode() updateUiForPipMode()
} }
@ -2116,7 +2070,11 @@ class CallActivity : CallBaseActivity() {
videoCapturer!!.dispose() videoCapturer!!.dispose()
videoCapturer = null videoCapturer = null
} }
binding!!.selfVideoRenderer.clearImage()
binding!!.selfVideoRenderer.release() binding!!.selfVideoRenderer.release()
binding!!.pipSelfVideoRenderer.clearImage()
binding!!.pipSelfVideoRenderer.release()
if (audioSource != null) { if (audioSource != null) {
audioSource!!.dispose() audioSource!!.dispose()
audioSource = null audioSource = null
@ -2219,6 +2177,7 @@ class CallActivity : CallBaseActivity() {
startVideoCapture(true) startVideoCapture(true)
} }
} }
in ANGLE_LANDSCAPE_RIGHT_THRESHOLD_MIN..ANGLE_LANDSCAPE_RIGHT_THRESHOLD_MAX, in ANGLE_LANDSCAPE_RIGHT_THRESHOLD_MIN..ANGLE_LANDSCAPE_RIGHT_THRESHOLD_MAX,
in ANGLE_LANDSCAPE_LEFT_THRESHOLD_MIN..ANGLE_LANDSCAPE_LEFT_THRESHOLD_MAX -> { in ANGLE_LANDSCAPE_LEFT_THRESHOLD_MIN..ANGLE_LANDSCAPE_LEFT_THRESHOLD_MAX -> {
if (lastAspectRatio != RATIO_16_TO_9) { if (lastAspectRatio != RATIO_16_TO_9) {
@ -2571,18 +2530,17 @@ class CallActivity : CallBaseActivity() {
} }
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 val participant = participantItems.find { it.sessionKey == key }
participantDisplayItem.destroy() participant?.destroy()
if (!isDestroyed) { participantItems.removeAll { it.sessionKey == key }
initGridAdapter() 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 +2635,26 @@ 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)
}
initGrid()
} }
private fun setCallState(callState: CallStatus) { private fun setCallState(callState: CallStatus) {
@ -2712,6 +2674,7 @@ class CallActivity : CallBaseActivity() {
handler!!.postDelayed({ setCallState(CallStatus.CALLING_TIMEOUT) }, CALLING_TIMEOUT) handler!!.postDelayed({ setCallState(CallStatus.CALLING_TIMEOUT) }, CALLING_TIMEOUT)
handler!!.post { handleCallStateJoined() } handler!!.post { handleCallStateJoined() }
} }
CallStatus.IN_CONVERSATION -> handler!!.post { handleCallStateInConversation() } CallStatus.IN_CONVERSATION -> handler!!.post { handleCallStateInConversation() }
CallStatus.OFFLINE -> handler!!.post { handleCallStateOffline() } CallStatus.OFFLINE -> handler!!.post { handleCallStateOffline() }
CallStatus.LEAVING -> handler!!.post { handleCallStateLeaving() } CallStatus.LEAVING -> handler!!.post { handleCallStateLeaving() }
@ -2725,7 +2688,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 +2700,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 +2727,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 +2748,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 +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.VISIBLE) { if (binding!!.callStates.callStateProgressBar.visibility != View.VISIBLE) {
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) { 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 +2802,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 +2823,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 +2984,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")
} }
} }
@ -3225,30 +3188,46 @@ class CallActivity : CallBaseActivity() {
override fun updateUiForPipMode() { override fun updateUiForPipMode() {
Log.d(TAG, "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!!.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.release()
if (participantDisplayItems!!.size > 1) {
binding!!.pipCallConversationNameTextView.text = conversationName binding!!.pipCallConversationNameTextView.text = conversationName
binding!!.pipSelfVideoOverlay.visibility = View.VISIBLE
initSelfVideoViewForPipMode() binding!!.selfVideoRenderer.clearImage()
binding!!.selfVideoRenderer.release()
if (participantItems.size == 1) {
binding!!.pipOverlay.visibility = View.GONE
} else { } 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() { override fun updateUiForNormalMode() {
Log.d(TAG, "updateUiForNormalMode") Log.d(TAG, "updateUiForNormalMode")
binding!!.pipSelfVideoOverlay.visibility = View.GONE binding!!.pipOverlay.visibility = View.GONE
binding!!.composeParticipantGrid.visibility = View.VISIBLE
if (isVoiceOnlyCall) { if (isVoiceOnlyCall) {
binding!!.callControls.visibility = View.VISIBLE 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_WIDTH_16_TO_9_RATIO = 136
private const val SELFVIDEO_HEIGHT_16_TO_9_RATIO = 80 private const val SELFVIDEO_HEIGHT_16_TO_9_RATIO = 80
private const val SELFVIDEO_POSITION_X_LANDSCAPE = 100F private const val SELFVIDEO_POSITION_X_LANDSCAPE = 50F
private const val SELFVIDEO_POSITION_Y_LANDSCAPE = 100F private const val SELFVIDEO_POSITION_Y_LANDSCAPE = 50F
private const val SELFVIDEO_POSITION_X_PORTRAIT = 300F 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 FIVE_SECONDS: Long = 5000
private const val CALLING_TIMEOUT: Long = 45000 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_HEADING_SIZE: Int = 20
private const val SPOTLIGHT_SUBHEADING_SIZE: Int = 16 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 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 private const val SESSION_ID_PREFFIX_END: Int = 4
} }
} }

View File

@ -1,203 +0,0 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
* SPDX-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 +
'}';
}
}

View File

@ -0,0 +1,217 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-FileCopyrightText: 2021-2025 Marcel Hibbe <dev@mhibbe.de>
* 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<ParticipantUiState> = _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())
}
}

View File

@ -1,217 +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.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<ParticipantDisplayItem> participantDisplayItems;
private final RelativeLayout gridViewWrapper;
private final LinearLayout callInfosLinearLayout;
private final int columns;
private final boolean isVoiceOnlyCall;
public ParticipantsAdapter(Context mContext,
Map<String, ParticipantDisplayItem> 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;
}
}

View File

@ -0,0 +1,62 @@
/*
* 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.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
)
}
}
}
}

View File

@ -0,0 +1,291 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* 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<ParticipantUiState>,
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<ParticipantUiState> {
val participantList = mutableListOf<ParticipantUiState>()
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
}

View File

@ -0,0 +1,144 @@
/*
* 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.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
)
}

View File

@ -0,0 +1,35 @@
/*
* 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 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()
}
)
}

View File

@ -0,0 +1,107 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* 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<Int> = genColors(steps)
companion object {
val shared = ColorGenerator()
private const val MULTIPLIER = 256.0f
private fun stepCalc(steps: Int, colorStart: Int, colorEnd: Int): List<Float> {
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<Int> {
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<Int> {
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]
}
}

View File

@ -9,13 +9,13 @@ package com.nextcloud.talk.utils.message
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Typeface import android.graphics.Typeface
import android.net.Uri
import android.text.SpannableString import android.text.SpannableString
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import androidx.core.net.toUri
import com.nextcloud.talk.R import com.nextcloud.talk.R
import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.ui.theme.ViewThemeUtils
@ -145,7 +145,7 @@ class MessageUtils(val context: Context) {
"file" -> { "file" -> {
itemView?.setOnClickListener { v -> 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) context.startActivity(browserIntent)
} }
} }

View File

@ -33,15 +33,10 @@
android:visibility="visible" android:visibility="visible"
tools:visibility="visible"> tools:visibility="visible">
<GridView <androidx.compose.ui.platform.ComposeView
android:id="@+id/gridview" android:id="@+id/composeParticipantGrid"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent" />
android:gravity="center"
android:numColumns="2"
android:scrollbars="vertical"
android:stretchMode="columnWidth"
tools:listitem="@layout/call_item" />
<FrameLayout <FrameLayout
android:id="@+id/selfVideoViewWrapper" android:id="@+id/selfVideoViewWrapper"
@ -327,7 +322,7 @@
</LinearLayout> </LinearLayout>
<RelativeLayout <RelativeLayout
android:id="@+id/pipSelfVideoOverlay" android:id="@+id/pipOverlay"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/black" android:background="@color/black"
@ -341,9 +336,8 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_centerHorizontal="true" android:layout_centerHorizontal="true"
android:layout_marginStart="5dp" android:layout_marginStart="5dp"
android:layout_marginTop="40dp" android:layout_marginTop="0dp"
android:layout_marginEnd="5dp" android:layout_marginEnd="5dp"
android:layout_marginBottom="-30dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="3" android:maxLines="3"
android:textAlignment="center" android:textAlignment="center"

View File

@ -1,80 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Nextcloud Talk - Android Client
~
~ SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
~ SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
~ SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
~ SPDX-License-Identifier: GPL-3.0-or-later
-->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/relative_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/avatarImageView"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_centerInParent="true"
android:contentDescription="@string/avatar"/>
<org.webrtc.SurfaceViewRenderer
android:id="@+id/surface_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="invisible" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_alignParentBottom="true">
<TextView
android:id="@+id/peer_nick_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginBottom="6dp"
android:ellipsize="end"
android:maxEms="8"
android:maxLines="1"
android:textAlignment="viewStart"
android:textColor="@color/white"
tools:text="Bill Murray 12345678901234567890" />
<ImageView
android:id="@+id/remote_audio_off"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="10dp"
android:layout_marginBottom="6dp"
android:contentDescription="@string/nc_remote_audio_off"
android:src="@drawable/ic_mic_off_white_24px"
android:visibility="invisible"
tools:visibility="visible" />
<ImageView
android:id="@+id/raised_hand"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="10dp"
android:layout_marginBottom="6dp"
android:contentDescription="@string/raise_hand"
android:src="@drawable/ic_hand_back_left"
android:visibility="invisible"
tools:visibility="visible" />
</LinearLayout>
<ProgressBar
android:id="@+id/participant_progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="@dimen/call_participant_progress_bar_size"
android:layout_height="@dimen/call_participant_progress_bar_size"
android:layout_centerInParent="@bool/value_true" />
</RelativeLayout>

View File

@ -54,7 +54,6 @@
<dimen name="dialog_padding">24dp</dimen> <dimen name="dialog_padding">24dp</dimen>
<dimen name="dialog_padding_top_bottom">18dp</dimen> <dimen name="dialog_padding_top_bottom">18dp</dimen>
<dimen name="call_grid_item_min_height">180dp</dimen>
<dimen name="call_controls_height">110dp</dimen> <dimen name="call_controls_height">110dp</dimen>
<dimen name="call_participant_progress_bar_size">48dp</dimen> <dimen name="call_participant_progress_bar_size">48dp</dimen>
<dimen name="call_self_participant_progress_bar_size">48dp</dimen> <dimen name="call_self_participant_progress_bar_size">48dp</dimen>

View File

@ -175,7 +175,6 @@ How to translate with transifex:
<string name="nc_settings_notifications_granted">Notifications are granted</string> <string name="nc_settings_notifications_granted">Notifications are granted</string>
<string name="nc_settings_notifications_declined">Notifications are declined</string> <string name="nc_settings_notifications_declined">Notifications are declined</string>
<string name="nc_settings_notifications_declined_hint">Notifications are declined. Please allow notifications in Android settings</string>
<!-- Diagnose --> <!-- Diagnose -->
<string name="nc_notifications_troubleshooting_dialog_title">Notification troubleshooting</string> <string name="nc_notifications_troubleshooting_dialog_title">Notification troubleshooting</string>
@ -373,7 +372,6 @@ How to translate with transifex:
<string name="dnd">Do not disturb</string> <string name="dnd">Do not disturb</string>
<string name="away">Away</string> <string name="away">Away</string>
<string name="invisible">Invisible</string> <string name="invisible">Invisible</string>
<string name="divider" translatable="false"></string>
<string name="default_emoji" translatable="false">😃</string> <string name="default_emoji" translatable="false">😃</string>
<string name="emoji_thumbsUp" translatable="false">👍</string> <string name="emoji_thumbsUp" translatable="false">👍</string>
<string name="emoji_thumbsDown" translatable="false">👎</string> <string name="emoji_thumbsDown" translatable="false">👎</string>
@ -398,9 +396,6 @@ How to translate with transifex:
<string name="error_loading_chats">There was a problem loading your chats</string> <string name="error_loading_chats">There was a problem loading your chats</string>
<string name="close">Close</string> <string name="close">Close</string>
<string name="close_icon">Close Icon</string> <string name="close_icon">Close Icon</string>
<string name="nc_refresh">Refresh</string>
<string name="nc_check_your_internet">Please check your internet connection</string>
<string name="nc_visible">Visible</string>
<!-- Chat --> <!-- Chat -->
<string name="nc_hint_enter_a_message">Enter a message …</string> <string name="nc_hint_enter_a_message">Enter a message …</string>
@ -438,9 +433,7 @@ How to translate with transifex:
<string name="nc_message_failed">Failed</string> <string name="nc_message_failed">Failed</string>
<string name="nc_message_sending">Sending</string> <string name="nc_message_sending">Sending</string>
<string name="nc_message_failed_to_send">Failed to send message:</string> <string name="nc_message_failed_to_send">Failed to send message:</string>
<string name="nc_remote_audio_off">Remote audio off</string>
<string name="nc_add_attachment">Add attachment</string> <string name="nc_add_attachment">Add attachment</string>
<string name="emoji_category_recent">Recent</string>
<plurals name="see_similar_system_messages"> <plurals name="see_similar_system_messages">
<item quantity="one">See %d similar message</item> <item quantity="one">See %d similar message</item>
<item quantity="other">See %d similar messages</item> <item quantity="other">See %d similar messages</item>
@ -461,7 +454,6 @@ How to translate with transifex:
<string name="nc_guest_access_password_dialog_title">Guest access password</string> <string name="nc_guest_access_password_dialog_title">Guest access password</string>
<string name="nc_guest_access_password_dialog_hint">Enter a password</string> <string name="nc_guest_access_password_dialog_hint">Enter a password</string>
<string name="nc_guest_access_password_failed">Error during setting/disabling the password.</string> <string name="nc_guest_access_password_failed">Error during setting/disabling the password.</string>
<string name="nc_guest_access_password_weak_alert_title">Weak password</string>
<string name="nc_guest_access_share_link">Share conversation link</string> <string name="nc_guest_access_share_link">Share conversation link</string>
<string name="nc_guest_access_resend_invitations">Resend invitations</string> <string name="nc_guest_access_resend_invitations">Resend invitations</string>
<string name="nc_guest_access_resend_invitations_successful">Invitations were sent out again.</string> <string name="nc_guest_access_resend_invitations_successful">Invitations were sent out again.</string>
@ -477,11 +469,8 @@ How to translate with transifex:
<!-- Other --> <!-- Other -->
<string name="nc_limit_hit">%s characters limit has been hit</string> <string name="nc_limit_hit">%s characters limit has been hit</string>
<string name="nc_email">Email</string>
<string name="nc_group">Group</string> <string name="nc_group">Group</string>
<string name="nc_team">Team</string> <string name="nc_team">Team</string>
<string name="nc_groups">Groups</string>
<string name="nc_teams">Teams</string>
<string name="nc_participants">Participants</string> <string name="nc_participants">Participants</string>
<string name="nc_participants_add">Add participants</string> <string name="nc_participants_add">Add participants</string>
<string name="nc_start_group_chat">Start group chat</string> <string name="nc_start_group_chat">Start group chat</string>