add animated emoji reactions to calls (no signaling yet)

Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
This commit is contained in:
Marcel Hibbe 2023-04-28 16:04:30 +02:00
parent 2ce57f4956
commit c379630610
No known key found for this signature in database
GPG key ID: C793F8B59F43CE7B
6 changed files with 300 additions and 17 deletions

View file

@ -66,6 +66,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication;
import com.nextcloud.talk.call.CallParticipant;
import com.nextcloud.talk.call.CallParticipantList;
import com.nextcloud.talk.call.CallParticipantModel;
import com.nextcloud.talk.call.ReactionAnimator;
import com.nextcloud.talk.chat.ChatActivity;
import com.nextcloud.talk.data.user.model.User;
import com.nextcloud.talk.databinding.CallActivityBinding;
@ -251,7 +252,7 @@ public class CallActivity extends CallBaseActivity {
private List<PeerConnection.IceServer> iceServers;
private CameraEnumerator cameraEnumerator;
private String roomToken;
private User conversationUser;
public User conversationUser;
private String conversationName;
private String callSession;
private MediaStream localStream;
@ -370,6 +371,8 @@ public class CallActivity extends CallBaseActivity {
private boolean isModerator;
private ReactionAnimator reactionAnimator;
@SuppressLint("ClickableViewAccessibility")
@Override
public void onCreate(Bundle savedInstanceState) {
@ -506,6 +509,8 @@ public class CallActivity extends CallBaseActivity {
initiateCall();
}
updateSelfVideoViewPosition();
reactionAnimator = new ReactionAnimator(context, binding.reactionAnimationWrapper, viewThemeUtils);
}
@Override
@ -2725,6 +2730,10 @@ public class CallActivity extends CallBaseActivity {
}
}
public void addCallReaction(String emoji, String displayName) {
reactionAnimator.addReaction(emoji, displayName);
}
/**
* Temporary implementation of SignalingMessageReceiver until signaling related code is extracted from
* CallActivity.

View file

@ -0,0 +1,184 @@
/*
* Nextcloud Talk application
*
* @author Marcel Hibbe
* Copyright (C) 2023 Marcel Hibbe <dev@mhibbe.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.call
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.view.ViewGroup
import android.view.animation.LinearInterpolator
import android.widget.LinearLayout
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import com.nextcloud.talk.R
import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.vanniktech.emoji.EmojiTextView
class ReactionAnimator(
val context: Context,
private val startPointView: RelativeLayout,
val viewThemeUtils: ViewThemeUtils?
) {
private val reactionsList: MutableList<CallReaction> = ArrayList()
fun addReaction(
emoji: String,
displayName: String
) {
val callReaction = CallReaction(emoji, displayName)
reactionsList.add(callReaction)
if (reactionsList.size == 1) {
animateReaction(reactionsList[0])
}
}
private fun animateReaction(
callReaction: CallReaction
) {
val reactionWrapper = getReactionWrapperView(callReaction)
val params = RelativeLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = 0
bottomMargin = 0
}
params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM, 1)
startPointView.addView(reactionWrapper, params)
val moveWithFullAlpha = ObjectAnimator.ofFloat(
reactionWrapper,
TRANSLATION_Y_PROPERTY,
POSITION_Y_WITH_FULL_ALPHA
)
moveWithFullAlpha.duration = DURATION_FULL_ALPHA
moveWithFullAlpha.interpolator = LinearInterpolator()
val moveWithDecreasingAlpha = ObjectAnimator.ofFloat(
reactionWrapper,
TRANSLATION_Y_PROPERTY,
POSITION_Y_WITH_DECREASING_ALPHA
)
moveWithDecreasingAlpha.duration = DURATION_DECREASING_ALPHA
moveWithDecreasingAlpha.interpolator = LinearInterpolator()
val decreasingAlpha: ObjectAnimator = ObjectAnimator.ofFloat(
reactionWrapper,
ALPHA_PROPERTY,
ZERO_ALPHA
)
decreasingAlpha.duration = DURATION_DECREASING_ALPHA
val animatorWithFullAlpha = AnimatorSet()
animatorWithFullAlpha.play(moveWithFullAlpha)
animatorWithFullAlpha.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
reactionsList.remove(callReaction)
if (reactionsList.isNotEmpty()) {
animateReaction(reactionsList[0])
}
}
})
val animatorWithDecreasingAlpha = AnimatorSet()
animatorWithDecreasingAlpha.playTogether(moveWithDecreasingAlpha, decreasingAlpha)
val finalAnimator = AnimatorSet()
finalAnimator.play(animatorWithFullAlpha).before(animatorWithDecreasingAlpha)
finalAnimator.start()
}
private fun getReactionWrapperView(callReaction: CallReaction): LinearLayout {
val reactionWrapper = LinearLayout(context)
reactionWrapper.orientation = LinearLayout.HORIZONTAL
val emojiView = EmojiTextView(context)
emojiView.text = callReaction.emoji
emojiView.textSize = 20f
val nameView = getNameView(callReaction)
reactionWrapper.addView(emojiView)
reactionWrapper.addView(nameView)
return reactionWrapper
}
@SuppressLint("SetTextI18n")
private fun getNameView(callReaction: CallReaction): TextView {
val nameView = TextView(context)
val nameViewParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
nameViewParams.setMargins(20, 0, 20, 5)
nameView.layoutParams = nameViewParams
nameView.text = " " + callReaction.userName + " "
nameView.setTextColor(context.resources.getColor(R.color.white))
val backgroundColor = ContextCompat.getColor(
context,
R.color.colorPrimary
)
val drawable = AppCompatResources
.getDrawable(context, R.drawable.reaction_self_background)!!
.mutate()
DrawableCompat.setTintList(
drawable,
ColorStateList.valueOf(backgroundColor)
)
nameView.background = drawable
return nameView
}
companion object {
private const val TRANSLATION_Y_PROPERTY = "translationY"
// 1333ms to move emoji up 400px with full alpha
private const val DURATION_FULL_ALPHA = 1333L
private const val POSITION_Y_WITH_FULL_ALPHA = -400f
// 666ms to move emoji up 200px while decreasing alpha
private const val DURATION_DECREASING_ALPHA = 666L
private const val POSITION_Y_WITH_DECREASING_ALPHA = -600f
private const val ZERO_ALPHA = 0f
private const val ALPHA_PROPERTY = "alpha"
}
}
data class CallReaction(
var emoji: String,
var userName: String
)

View file

@ -24,6 +24,7 @@ import android.os.Bundle
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.core.content.ContextCompat
import autodagger.AutoInjector
import com.google.android.material.bottomsheet.BottomSheetBehavior
@ -34,7 +35,9 @@ import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.databinding.DialogMoreCallActionsBinding
import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel
import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew
import com.nextcloud.talk.viewmodels.CallRecordingViewModel
import com.vanniktech.emoji.EmojiTextView
import javax.inject.Inject
@AutoInjector(NextcloudTalkApplication::class)
@ -56,6 +59,7 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee
viewThemeUtils.platform.themeDialogDark(binding.root)
initItemsVisibility()
initEmojiBar()
initClickListeners()
initObservers()
}
@ -68,6 +72,12 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee
}
private fun initItemsVisibility() {
if (CapabilitiesUtilNew.isCallReactionsSupported(callActivity.conversationUser)) {
binding.callEmojiBar.visibility = View.VISIBLE
} else {
binding.callEmojiBar.visibility = View.GONE
}
if (callActivity.isAllowedToStartOrStopRecording) {
binding.recordCall.visibility = View.VISIBLE
} else {
@ -91,6 +101,40 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee
}
}
private fun initEmojiBar() {
if (CapabilitiesUtilNew.isCallReactionsSupported(callActivity.conversationUser)) {
binding.advancedCallOptionsTitle.visibility = View.GONE
val capabilities = callActivity.conversationUser.capabilities
val availableReactions: ArrayList<*> =
capabilities?.spreedCapability?.config!!["call"]!!["supported-reactions"] as ArrayList<*>
val param = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT,
1.0f
)
availableReactions.forEach {
val emojiView = EmojiTextView(context)
emojiView.text = it.toString()
emojiView.textSize = 20f
emojiView.layoutParams = param
emojiView.setOnClickListener { view ->
callActivity.addCallReaction(
(view as EmojiTextView).text.toString(),
callActivity.conversationUser.displayName
)
dismiss()
}
binding.callEmojiBar.addView(emojiView)
}
} else {
binding.callEmojiBar.visibility = View.GONE
}
}
private fun initObservers() {
callActivity.callRecordingViewModel.viewState.observe(this) { state ->
when (state) {
@ -102,12 +146,14 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee
)
dismiss()
}
is CallRecordingViewModel.RecordingStartingState -> {
binding.recordCallText.text = context.getText(R.string.record_cancel_start)
binding.recordCallIcon.setImageDrawable(
ContextCompat.getDrawable(context, R.drawable.record_stop)
)
}
is CallRecordingViewModel.RecordingStartedState -> {
binding.recordCallText.text = context.getText(R.string.record_stop_description)
binding.recordCallIcon.setImageDrawable(
@ -115,12 +161,15 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee
)
dismiss()
}
is CallRecordingViewModel.RecordingStoppingState -> {
binding.recordCallText.text = context.getText(R.string.record_stopping)
}
is CallRecordingViewModel.RecordingConfirmStopState -> {
binding.recordCallText.text = context.getText(R.string.record_stop_description)
}
else -> {
Log.e(TAG, "unknown viewState for callRecordingViewModel")
}
@ -136,6 +185,7 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee
)
dismiss()
}
is RaiseHandViewModel.LoweredHandState -> {
binding.raiseHandText.text = context.getText(R.string.raise_hand)
binding.raiseHandIcon.setImageDrawable(
@ -143,6 +193,7 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee
)
dismiss()
}
else -> {}
}
}

View file

@ -174,6 +174,16 @@ object CapabilitiesUtilNew {
return false
}
fun isCallReactionsSupported(user: User?): Boolean {
if (user?.capabilities != null) {
val capabilities = user.capabilities
return capabilities?.spreedCapability?.config?.containsKey("call") == true &&
capabilities.spreedCapability!!.config!!["call"] != null &&
capabilities.spreedCapability!!.config!!["call"]!!.containsKey("supported-reactions")
}
return false
}
@JvmStatic
fun isUnifiedSearchAvailable(user: User): Boolean {
return hasSpreedFeatureCapability(user, "unified-search")

View file

@ -175,22 +175,37 @@
android:gravity="center_vertical"
android:orientation="vertical">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/lower_hand_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginEnd="@dimen/standard_margin"
android:layout_marginBottom="@dimen/standard_half_margin"
android:contentDescription="@string/lower_hand"
android:visibility="gone"
app:backgroundTint="@color/call_buttons_background"
app:borderWidth="0dp"
app:fabCustomSize="40dp"
app:shapeAppearance="@style/fab_3_rounded"
app:srcCompat="@drawable/ic_baseline_do_not_touch_24"
app:tint="@color/white"
tools:visibility="visible" />
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="300dp">
<RelativeLayout
android:id="@+id/reaction_animation_wrapper"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="50dp"
android:layout_marginBottom="50dp">
</RelativeLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/lower_hand_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_marginEnd="@dimen/standard_margin"
android:layout_marginBottom="@dimen/standard_half_margin"
android:contentDescription="@string/lower_hand"
android:visibility="gone"
app:backgroundTint="@color/call_buttons_background"
app:borderWidth="0dp"
app:fabCustomSize="40dp"
app:shapeAppearance="@style/fab_3_rounded"
app:srcCompat="@drawable/ic_baseline_do_not_touch_24"
app:tint="@color/white"
tools:visibility="visible" />
</RelativeLayout>
<com.google.android.flexbox.FlexboxLayout
android:id="@+id/callControls"

View file

@ -27,7 +27,21 @@
android:orientation="vertical"
android:paddingBottom="@dimen/standard_half_padding">
<LinearLayout
android:id="@+id/call_emoji_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/standard_margin"
android:layout_marginTop="@dimen/standard_half_margin"
android:layout_marginEnd="@dimen/standard_margin"
android:layout_marginBottom="@dimen/standard_half_margin"
android:gravity="center_vertical"
android:orientation="horizontal"
android:weightSum="10">
</LinearLayout>
<TextView
android:id="@+id/advanced_call_options_title"
android:layout_width="wrap_content"
android:layout_height="@dimen/bottom_sheet_item_height"
android:gravity="start|center_vertical"