mirror of
https://github.com/nextcloud/talk-android
synced 2025-06-19 03:29:28 +01:00
Merge pull request #4947 from nextcloud/improveCallGrid
Improve call grid & Picture-inPicture view
This commit is contained in:
commit
f8bfa0485c
@ -42,13 +42,14 @@ import android.view.OrientationEventListener
|
||||
import android.view.View
|
||||
import android.view.View.OnTouchListener
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver.OnGlobalLayoutListener
|
||||
import android.widget.AdapterView
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.graphics.toColorInt
|
||||
import androidx.core.net.toUri
|
||||
@ -59,7 +60,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.adapters.ParticipantDisplayItem
|
||||
import com.nextcloud.talk.adapters.ParticipantsAdapter
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
@ -74,6 +74,7 @@ import com.nextcloud.talk.call.MessageSenderMcu
|
||||
import com.nextcloud.talk.call.MessageSenderNoMcu
|
||||
import com.nextcloud.talk.call.MutableLocalCallParticipantModel
|
||||
import com.nextcloud.talk.call.ReactionAnimator
|
||||
import com.nextcloud.talk.call.components.ParticipantGrid
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.CallActivityBinding
|
||||
@ -303,8 +304,8 @@ class CallActivity : CallBaseActivity() {
|
||||
private var handler: Handler? = null
|
||||
private var currentCallStatus: CallStatus? = null
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private var participantDisplayItems: MutableMap<String, ParticipantDisplayItem?>? = null
|
||||
private var participantsAdapter: ParticipantsAdapter? = null
|
||||
|
||||
private val participantItems = mutableStateListOf<ParticipantDisplayItem>()
|
||||
private var binding: CallActivityBinding? = null
|
||||
private var audioOutputDialog: AudioOutputDialog? = null
|
||||
private var moreCallActionsDialog: MoreCallActionsDialog? = null
|
||||
@ -399,7 +400,6 @@ class CallActivity : CallBaseActivity() {
|
||||
.setRepeatCount(PulseAnimation.INFINITE)
|
||||
.setRepeatMode(PulseAnimation.REVERSE)
|
||||
callParticipants = HashMap()
|
||||
participantDisplayItems = HashMap()
|
||||
reactionAnimator = ReactionAnimator(context, binding!!.reactionAnimationWrapper, viewThemeUtils)
|
||||
|
||||
checkInitialDevicePermissions()
|
||||
@ -734,10 +734,7 @@ class CallActivity : CallBaseActivity() {
|
||||
}
|
||||
|
||||
binding!!.switchSelfVideoButton.setOnClickListener { switchCamera() }
|
||||
binding!!.gridview.onItemClickListener =
|
||||
AdapterView.OnItemClickListener { _: AdapterView<*>?, _: View?, _: Int, _: Long ->
|
||||
animateCallControls(true, 0)
|
||||
}
|
||||
|
||||
binding!!.lowerHandButton.setOnClickListener { l: View? -> raiseHandViewModel!!.lowerHand() }
|
||||
binding!!.pictureInPictureButton.setOnClickListener { enterPipMode() }
|
||||
}
|
||||
@ -890,20 +887,20 @@ class CallActivity : CallBaseActivity() {
|
||||
val callControlsHeight =
|
||||
applicationContext.resources.getDimension(R.dimen.call_controls_height).roundToInt()
|
||||
params.setMargins(0, 0, 0, callControlsHeight)
|
||||
binding!!.gridview.layoutParams = params
|
||||
binding!!.composeParticipantGrid.layoutParams = params
|
||||
} else {
|
||||
val params = RelativeLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
params.setMargins(0, 0, 0, 0)
|
||||
binding!!.gridview.layoutParams = params
|
||||
binding!!.composeParticipantGrid.layoutParams = params
|
||||
if (cameraEnumerator!!.deviceNames.size < 2) {
|
||||
binding!!.switchSelfVideoButton.visibility = View.GONE
|
||||
}
|
||||
initSelfVideoViewForNormalMode()
|
||||
}
|
||||
binding!!.gridview.setOnTouchListener { _, me ->
|
||||
binding!!.composeParticipantGrid.setOnTouchListener { _, me ->
|
||||
val action = me.actionMasked
|
||||
if (action == MotionEvent.ACTION_DOWN) {
|
||||
animateCallControls(true, 0)
|
||||
@ -920,7 +917,8 @@ class CallActivity : CallBaseActivity() {
|
||||
false
|
||||
}
|
||||
animateCallControls(true, 0)
|
||||
initGridAdapter()
|
||||
initGrid()
|
||||
binding!!.composeParticipantGrid.z = 0f
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@ -935,72 +933,28 @@ class CallActivity : CallBaseActivity() {
|
||||
binding!!.selfVideoRenderer.setEnableHardwareScaler(false)
|
||||
binding!!.selfVideoRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
|
||||
binding!!.selfVideoRenderer.setOnTouchListener(SelfVideoTouchListener())
|
||||
|
||||
binding!!.pipSelfVideoRenderer.clearImage()
|
||||
binding!!.pipSelfVideoRenderer.release()
|
||||
}
|
||||
|
||||
private fun initSelfVideoViewForPipMode() {
|
||||
try {
|
||||
binding!!.pipSelfVideoRenderer.init(rootEglBase!!.eglBaseContext, null)
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.d(TAG, "pipGroupVideoRenderer already initialized", e)
|
||||
}
|
||||
binding!!.pipSelfVideoRenderer.setZOrderMediaOverlay(true)
|
||||
// disabled because it causes some devices to crash
|
||||
binding!!.pipSelfVideoRenderer.setEnableHardwareScaler(false)
|
||||
binding!!.pipSelfVideoRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
|
||||
|
||||
localVideoTrack!!.addSink(binding!!.pipSelfVideoRenderer)
|
||||
}
|
||||
|
||||
private fun initGridAdapter() {
|
||||
Log.d(TAG, "initGridAdapter")
|
||||
val columns: Int
|
||||
val participantsInGrid = participantDisplayItems!!.size
|
||||
columns = if (resources != null &&
|
||||
resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
|
||||
) {
|
||||
if (participantsInGrid > 2) {
|
||||
GRID_MAX_COLUMN_COUNT_PORTRAIT
|
||||
} else {
|
||||
GRID_MIN_COLUMN_COUNT_PORTRAIT
|
||||
}
|
||||
} else {
|
||||
if (participantsInGrid > 2) {
|
||||
GRID_MAX_COLUMN_COUNT_LANDSCAPE
|
||||
} else if (participantsInGrid > 1) {
|
||||
GRID_MIN_GROUP_COLUMN_COUNT_LANDSCAPE
|
||||
} else {
|
||||
GRID_MIN_COLUMN_COUNT_LANDSCAPE
|
||||
private fun initGrid() {
|
||||
Log.d(TAG, "initGrid")
|
||||
binding!!.composeParticipantGrid.visibility = View.VISIBLE
|
||||
binding!!.composeParticipantGrid.setContent {
|
||||
MaterialTheme {
|
||||
val participantUiStates = participantItems.map { it.uiStateFlow.collectAsState().value }
|
||||
ParticipantGrid(
|
||||
participantUiStates = participantUiStates,
|
||||
eglBase = rootEglBase!!,
|
||||
isVoiceOnlyCall = isVoiceOnlyCall,
|
||||
isInPipMode = isInPipMode
|
||||
) {
|
||||
animateCallControls(true, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
binding!!.gridview.numColumns = columns
|
||||
binding!!.conversationRelativeLayout
|
||||
.viewTreeObserver
|
||||
.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
binding!!.conversationRelativeLayout.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
val height = binding!!.conversationRelativeLayout.measuredHeight
|
||||
binding!!.gridview.minimumHeight = height
|
||||
}
|
||||
})
|
||||
binding!!.callInfosLinearLayout
|
||||
.viewTreeObserver
|
||||
.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
binding!!.callInfosLinearLayout.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
}
|
||||
})
|
||||
if (participantsAdapter != null) {
|
||||
participantsAdapter!!.destroy()
|
||||
}
|
||||
participantsAdapter = ParticipantsAdapter(
|
||||
this,
|
||||
participantDisplayItems,
|
||||
binding!!.conversationRelativeLayout,
|
||||
binding!!.callInfosLinearLayout,
|
||||
columns,
|
||||
isVoiceOnlyCall
|
||||
)
|
||||
binding!!.gridview.adapter = participantsAdapter
|
||||
|
||||
if (isInPipMode) {
|
||||
updateUiForPipMode()
|
||||
}
|
||||
@ -2116,7 +2070,11 @@ class CallActivity : CallBaseActivity() {
|
||||
videoCapturer!!.dispose()
|
||||
videoCapturer = null
|
||||
}
|
||||
binding!!.selfVideoRenderer.clearImage()
|
||||
binding!!.selfVideoRenderer.release()
|
||||
|
||||
binding!!.pipSelfVideoRenderer.clearImage()
|
||||
binding!!.pipSelfVideoRenderer.release()
|
||||
if (audioSource != null) {
|
||||
audioSource!!.dispose()
|
||||
audioSource = null
|
||||
@ -2219,6 +2177,7 @@ class CallActivity : CallBaseActivity() {
|
||||
startVideoCapture(true)
|
||||
}
|
||||
}
|
||||
|
||||
in ANGLE_LANDSCAPE_RIGHT_THRESHOLD_MIN..ANGLE_LANDSCAPE_RIGHT_THRESHOLD_MAX,
|
||||
in ANGLE_LANDSCAPE_LEFT_THRESHOLD_MIN..ANGLE_LANDSCAPE_LEFT_THRESHOLD_MAX -> {
|
||||
if (lastAspectRatio != RATIO_16_TO_9) {
|
||||
@ -2571,18 +2530,17 @@ class CallActivity : CallBaseActivity() {
|
||||
}
|
||||
|
||||
private fun removeParticipantDisplayItem(sessionId: String?, videoStreamType: String) {
|
||||
Log.d(TAG, "removeParticipantDisplayItem")
|
||||
val participantDisplayItem = participantDisplayItems!!.remove("$sessionId-$videoStreamType") ?: return
|
||||
participantDisplayItem.destroy()
|
||||
if (!isDestroyed) {
|
||||
initGridAdapter()
|
||||
}
|
||||
val key = "$sessionId-$videoStreamType"
|
||||
val participant = participantItems.find { it.sessionKey == key }
|
||||
participant?.destroy()
|
||||
participantItems.removeAll { it.sessionKey == key }
|
||||
initGrid()
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onMessageEvent(configurationChangeEvent: ConfigurationChangeEvent?) {
|
||||
powerManagerUtils!!.setOrientation(Objects.requireNonNull(resources).configuration.orientation)
|
||||
initGridAdapter()
|
||||
initGrid()
|
||||
}
|
||||
|
||||
private fun updateSelfVideoViewIceConnectionState(iceConnectionState: IceConnectionState) {
|
||||
@ -2677,22 +2635,26 @@ class CallActivity : CallBaseActivity() {
|
||||
}
|
||||
|
||||
private fun addParticipantDisplayItem(callParticipantModel: CallParticipantModel, videoStreamType: String) {
|
||||
if (callParticipantModel.isInternal != null && callParticipantModel.isInternal) {
|
||||
return
|
||||
}
|
||||
if (callParticipantModel.isInternal == true) return
|
||||
|
||||
val defaultGuestNick = resources.getString(R.string.nc_nick_guest)
|
||||
val participantDisplayItem = ParticipantDisplayItem(
|
||||
context,
|
||||
baseUrl,
|
||||
defaultGuestNick,
|
||||
rootEglBase,
|
||||
videoStreamType,
|
||||
roomToken,
|
||||
callParticipantModel
|
||||
context = context,
|
||||
baseUrl = baseUrl!!,
|
||||
defaultGuestNick = defaultGuestNick,
|
||||
rootEglBase = rootEglBase!!,
|
||||
streamType = videoStreamType,
|
||||
roomToken = roomToken!!,
|
||||
callParticipantModel = callParticipantModel
|
||||
)
|
||||
val sessionId = callParticipantModel.sessionId
|
||||
participantDisplayItems!!["$sessionId-$videoStreamType"] = participantDisplayItem
|
||||
initGridAdapter()
|
||||
|
||||
val sessionKey = participantDisplayItem.sessionKey
|
||||
|
||||
if (participantItems.none { it.sessionKey == sessionKey }) {
|
||||
participantItems.add(participantDisplayItem)
|
||||
}
|
||||
|
||||
initGrid()
|
||||
}
|
||||
|
||||
private fun setCallState(callState: CallStatus) {
|
||||
@ -2712,6 +2674,7 @@ class CallActivity : CallBaseActivity() {
|
||||
handler!!.postDelayed({ setCallState(CallStatus.CALLING_TIMEOUT) }, CALLING_TIMEOUT)
|
||||
handler!!.post { handleCallStateJoined() }
|
||||
}
|
||||
|
||||
CallStatus.IN_CONVERSATION -> handler!!.post { handleCallStateInConversation() }
|
||||
CallStatus.OFFLINE -> handler!!.post { handleCallStateOffline() }
|
||||
CallStatus.LEAVING -> handler!!.post { handleCallStateLeaving() }
|
||||
@ -2725,7 +2688,7 @@ class CallActivity : CallBaseActivity() {
|
||||
binding!!.callModeTextView.text = descriptionForCallType
|
||||
binding!!.callStates.callStateTextView.setText(R.string.nc_leaving_call)
|
||||
binding!!.callStates.callStateRelativeLayout.visibility = View.VISIBLE
|
||||
binding!!.gridview.visibility = View.INVISIBLE
|
||||
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
|
||||
binding!!.callStates.callStateProgressBar.visibility = View.VISIBLE
|
||||
binding!!.callStates.errorImageView.visibility = View.GONE
|
||||
}
|
||||
@ -2737,8 +2700,8 @@ class CallActivity : CallBaseActivity() {
|
||||
if (binding!!.callStates.callStateRelativeLayout.visibility != View.VISIBLE) {
|
||||
binding!!.callStates.callStateRelativeLayout.visibility = View.VISIBLE
|
||||
}
|
||||
if (binding!!.gridview.visibility != View.INVISIBLE) {
|
||||
binding!!.gridview.visibility = View.INVISIBLE
|
||||
if (binding!!.composeParticipantGrid.visibility != View.INVISIBLE) {
|
||||
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
|
||||
}
|
||||
if (binding!!.callStates.callStateProgressBar.visibility != View.GONE) {
|
||||
binding!!.callStates.callStateProgressBar.visibility = View.GONE
|
||||
@ -2764,8 +2727,8 @@ class CallActivity : CallBaseActivity() {
|
||||
if (binding!!.callStates.callStateProgressBar.visibility != View.GONE) {
|
||||
binding!!.callStates.callStateProgressBar.visibility = View.GONE
|
||||
}
|
||||
if (binding!!.gridview.visibility != View.VISIBLE) {
|
||||
binding!!.gridview.visibility = View.VISIBLE
|
||||
if (binding!!.composeParticipantGrid.visibility != View.VISIBLE) {
|
||||
binding!!.composeParticipantGrid.visibility = View.VISIBLE
|
||||
}
|
||||
if (binding!!.callStates.errorImageView.visibility != View.GONE) {
|
||||
binding!!.callStates.errorImageView.visibility = View.GONE
|
||||
@ -2785,8 +2748,8 @@ class CallActivity : CallBaseActivity() {
|
||||
if (binding!!.callStates.callStateProgressBar.visibility != View.VISIBLE) {
|
||||
binding!!.callStates.callStateProgressBar.visibility = View.VISIBLE
|
||||
}
|
||||
if (binding!!.gridview.visibility != View.INVISIBLE) {
|
||||
binding!!.gridview.visibility = View.INVISIBLE
|
||||
if (binding!!.composeParticipantGrid.visibility != View.INVISIBLE) {
|
||||
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
|
||||
}
|
||||
if (binding!!.callStates.errorImageView.visibility != View.GONE) {
|
||||
binding!!.callStates.errorImageView.visibility = View.GONE
|
||||
@ -2800,8 +2763,8 @@ class CallActivity : CallBaseActivity() {
|
||||
if (binding!!.callStates.callStateRelativeLayout.visibility != View.VISIBLE) {
|
||||
binding!!.callStates.callStateRelativeLayout.visibility = View.VISIBLE
|
||||
}
|
||||
if (binding!!.gridview.visibility != View.INVISIBLE) {
|
||||
binding!!.gridview.visibility = View.INVISIBLE
|
||||
if (binding!!.composeParticipantGrid.visibility != View.INVISIBLE) {
|
||||
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
|
||||
}
|
||||
if (binding!!.callStates.callStateProgressBar.visibility != View.VISIBLE) {
|
||||
binding!!.callStates.callStateProgressBar.visibility = View.VISIBLE
|
||||
@ -2818,8 +2781,8 @@ class CallActivity : CallBaseActivity() {
|
||||
if (binding!!.callStates.callStateRelativeLayout.visibility != View.VISIBLE) {
|
||||
binding!!.callStates.callStateRelativeLayout.visibility = View.VISIBLE
|
||||
}
|
||||
if (binding!!.gridview.visibility != View.INVISIBLE) {
|
||||
binding!!.gridview.visibility = View.INVISIBLE
|
||||
if (binding!!.composeParticipantGrid.visibility != View.INVISIBLE) {
|
||||
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
|
||||
}
|
||||
if (binding!!.callStates.callStateProgressBar.visibility != View.VISIBLE) {
|
||||
binding!!.callStates.callStateProgressBar.visibility = View.VISIBLE
|
||||
@ -2839,8 +2802,8 @@ class CallActivity : CallBaseActivity() {
|
||||
if (binding!!.callStates.callStateProgressBar.visibility != View.GONE) {
|
||||
binding!!.callStates.callStateProgressBar.visibility = View.GONE
|
||||
}
|
||||
if (binding!!.gridview.visibility != View.INVISIBLE) {
|
||||
binding!!.gridview.visibility = View.INVISIBLE
|
||||
if (binding!!.composeParticipantGrid.visibility != View.INVISIBLE) {
|
||||
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
|
||||
}
|
||||
binding!!.callStates.errorImageView.setImageResource(R.drawable.ic_av_timer_timer_24dp)
|
||||
if (binding!!.callStates.errorImageView.visibility != View.VISIBLE) {
|
||||
@ -2860,8 +2823,8 @@ class CallActivity : CallBaseActivity() {
|
||||
if (binding!!.callStates.callStateRelativeLayout.visibility != View.VISIBLE) {
|
||||
binding!!.callStates.callStateRelativeLayout.visibility = View.VISIBLE
|
||||
}
|
||||
if (binding!!.gridview.visibility != View.INVISIBLE) {
|
||||
binding!!.gridview.visibility = View.INVISIBLE
|
||||
if (binding!!.composeParticipantGrid.visibility != View.INVISIBLE) {
|
||||
binding!!.composeParticipantGrid.visibility = View.INVISIBLE
|
||||
}
|
||||
if (binding!!.callStates.callStateProgressBar.visibility != View.VISIBLE) {
|
||||
binding!!.callStates.callStateProgressBar.visibility = View.VISIBLE
|
||||
@ -3021,8 +2984,8 @@ class CallActivity : CallBaseActivity() {
|
||||
removeParticipantDisplayItem(sessionId, "screen")
|
||||
return
|
||||
}
|
||||
val hasScreenParticipantDisplayItem = participantDisplayItems!!["$sessionId-screen"] != null
|
||||
if (!hasScreenParticipantDisplayItem) {
|
||||
val screenParticipantDisplayItem = participantItems.find { it.sessionKey == "$sessionId-screen" }
|
||||
if (screenParticipantDisplayItem == null) {
|
||||
addParticipantDisplayItem(callParticipantModel, "screen")
|
||||
}
|
||||
}
|
||||
@ -3225,30 +3188,46 @@ class CallActivity : CallBaseActivity() {
|
||||
|
||||
override fun updateUiForPipMode() {
|
||||
Log.d(TAG, "updateUiForPipMode")
|
||||
val params = RelativeLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
params.setMargins(0, 0, 0, 0)
|
||||
binding!!.gridview.layoutParams = params
|
||||
binding!!.callControls.visibility = View.GONE
|
||||
binding!!.callInfosLinearLayout.visibility = View.GONE
|
||||
binding!!.selfVideoViewWrapper.visibility = View.GONE
|
||||
binding!!.callStates.callStateRelativeLayout.visibility = View.GONE
|
||||
binding!!.pipCallConversationNameTextView.text = conversationName
|
||||
|
||||
binding!!.selfVideoRenderer.clearImage()
|
||||
binding!!.selfVideoRenderer.release()
|
||||
if (participantDisplayItems!!.size > 1) {
|
||||
binding!!.pipCallConversationNameTextView.text = conversationName
|
||||
binding!!.pipSelfVideoOverlay.visibility = View.VISIBLE
|
||||
initSelfVideoViewForPipMode()
|
||||
|
||||
if (participantItems.size == 1) {
|
||||
binding!!.pipOverlay.visibility = View.GONE
|
||||
} else {
|
||||
binding!!.pipSelfVideoOverlay.visibility = View.GONE
|
||||
binding!!.composeParticipantGrid.visibility = View.GONE
|
||||
|
||||
if (localVideoTrack?.enabled() == true) {
|
||||
binding!!.pipOverlay.visibility = View.VISIBLE
|
||||
binding!!.pipSelfVideoRenderer.visibility = View.VISIBLE
|
||||
|
||||
try {
|
||||
binding!!.pipSelfVideoRenderer.init(rootEglBase!!.eglBaseContext, null)
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.d(TAG, "pipGroupVideoRenderer already initialized", e)
|
||||
}
|
||||
binding!!.pipSelfVideoRenderer.setZOrderMediaOverlay(true)
|
||||
// disabled because it causes some devices to crash
|
||||
binding!!.pipSelfVideoRenderer.setEnableHardwareScaler(false)
|
||||
binding!!.pipSelfVideoRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
|
||||
|
||||
localVideoTrack?.addSink(binding?.pipSelfVideoRenderer)
|
||||
} else {
|
||||
binding!!.pipOverlay.visibility = View.VISIBLE
|
||||
binding!!.pipSelfVideoRenderer.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateUiForNormalMode() {
|
||||
Log.d(TAG, "updateUiForNormalMode")
|
||||
binding!!.pipSelfVideoOverlay.visibility = View.GONE
|
||||
binding!!.pipOverlay.visibility = View.GONE
|
||||
binding!!.composeParticipantGrid.visibility = View.VISIBLE
|
||||
|
||||
if (isVoiceOnlyCall) {
|
||||
binding!!.callControls.visibility = View.VISIBLE
|
||||
@ -3353,10 +3332,10 @@ class CallActivity : CallBaseActivity() {
|
||||
private const val SELFVIDEO_WIDTH_16_TO_9_RATIO = 136
|
||||
private const val SELFVIDEO_HEIGHT_16_TO_9_RATIO = 80
|
||||
|
||||
private const val SELFVIDEO_POSITION_X_LANDSCAPE = 100F
|
||||
private const val SELFVIDEO_POSITION_Y_LANDSCAPE = 100F
|
||||
private const val SELFVIDEO_POSITION_X_LANDSCAPE = 50F
|
||||
private const val SELFVIDEO_POSITION_Y_LANDSCAPE = 50F
|
||||
private const val SELFVIDEO_POSITION_X_PORTRAIT = 300F
|
||||
private const val SELFVIDEO_POSITION_Y_PORTRAIT = 100F
|
||||
private const val SELFVIDEO_POSITION_Y_PORTRAIT = 50F
|
||||
|
||||
private const val FIVE_SECONDS: Long = 5000
|
||||
private const val CALLING_TIMEOUT: Long = 45000
|
||||
@ -3368,20 +3347,8 @@ class CallActivity : CallBaseActivity() {
|
||||
private const val SPOTLIGHT_HEADING_SIZE: Int = 20
|
||||
private const val SPOTLIGHT_SUBHEADING_SIZE: Int = 16
|
||||
|
||||
private const val GRID_MAX_COLUMN_COUNT_PORTRAIT: Int = 2
|
||||
private const val GRID_MIN_COLUMN_COUNT_PORTRAIT: Int = 1
|
||||
private const val GRID_MAX_COLUMN_COUNT_LANDSCAPE: Int = 3
|
||||
private const val GRID_MIN_GROUP_COLUMN_COUNT_LANDSCAPE: Int = 2
|
||||
private const val GRID_MIN_COLUMN_COUNT_LANDSCAPE: Int = 1
|
||||
|
||||
private const val DELAY_ON_ERROR_STOP_THRESHOLD: Int = 16
|
||||
|
||||
private const val BY_50_PERCENT = 0.5
|
||||
private const val BY_80_PERCENT = 0.8
|
||||
|
||||
private const val Y_POS_CALL_INFO: Float = 250f
|
||||
private const val Y_POS_NO_CALL_INFO: Float = 20f
|
||||
|
||||
private const val SESSION_ID_PREFFIX_END: Int = 4
|
||||
}
|
||||
}
|
||||
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
107
app/src/main/java/com/nextcloud/talk/utils/ColorGenerator.kt
Normal file
107
app/src/main/java/com/nextcloud/talk/utils/ColorGenerator.kt
Normal 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]
|
||||
}
|
||||
}
|
@ -9,13 +9,13 @@ package com.nextcloud.talk.utils.message
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.StyleSpan
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.core.net.toUri
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
@ -145,7 +145,7 @@ class MessageUtils(val context: Context) {
|
||||
|
||||
"file" -> {
|
||||
itemView?.setOnClickListener { v ->
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(individualHashMap["link"]))
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, individualHashMap["link"]?.toUri())
|
||||
context.startActivity(browserIntent)
|
||||
}
|
||||
}
|
||||
|
@ -33,15 +33,10 @@
|
||||
android:visibility="visible"
|
||||
tools:visibility="visible">
|
||||
|
||||
<GridView
|
||||
android:id="@+id/gridview"
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/composeParticipantGrid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:numColumns="2"
|
||||
android:scrollbars="vertical"
|
||||
android:stretchMode="columnWidth"
|
||||
tools:listitem="@layout/call_item" />
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/selfVideoViewWrapper"
|
||||
@ -327,7 +322,7 @@
|
||||
</LinearLayout>
|
||||
|
||||
<RelativeLayout
|
||||
android:id="@+id/pipSelfVideoOverlay"
|
||||
android:id="@+id/pipOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/black"
|
||||
@ -341,9 +336,8 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_centerHorizontal="true"
|
||||
android:layout_marginStart="5dp"
|
||||
android:layout_marginTop="40dp"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_marginEnd="5dp"
|
||||
android:layout_marginBottom="-30dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="3"
|
||||
android:textAlignment="center"
|
||||
|
@ -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>
|
@ -54,7 +54,6 @@
|
||||
<dimen name="dialog_padding">24dp</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_participant_progress_bar_size">48dp</dimen>
|
||||
<dimen name="call_self_participant_progress_bar_size">48dp</dimen>
|
||||
|
@ -175,7 +175,6 @@ How to translate with transifex:
|
||||
|
||||
<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_hint">Notifications are declined. Please allow notifications in Android settings</string>
|
||||
|
||||
<!-- Diagnose -->
|
||||
<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="away">Away</string>
|
||||
<string name="invisible">Invisible</string>
|
||||
<string name="divider" translatable="false">—</string>
|
||||
<string name="default_emoji" translatable="false">😃</string>
|
||||
<string name="emoji_thumbsUp" 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="close">Close</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 -->
|
||||
<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_sending">Sending</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="emoji_category_recent">Recent</string>
|
||||
<plurals name="see_similar_system_messages">
|
||||
<item quantity="one">See %d similar message</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_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_weak_alert_title">Weak password</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_successful">Invitations were sent out again.</string>
|
||||
@ -477,11 +469,8 @@ How to translate with transifex:
|
||||
|
||||
<!-- Other -->
|
||||
<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_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_add">Add participants</string>
|
||||
<string name="nc_start_group_chat">Start group chat</string>
|
||||
|
Loading…
Reference in New Issue
Block a user