feat(live-room): 하트를 길게(2초)간 누르면 표시 되는 왕하트(100캔) 추가, 애니메이션 제외

This commit is contained in:
2025-11-03 16:23:44 +09:00
parent d6e9a63b1f
commit 6653ca2c11
5 changed files with 253 additions and 51 deletions

View File

@@ -245,10 +245,11 @@ class LiveRepository(
authHeader = token authHeader = token
) )
fun likeHeart(roomId: Long, token: String) = api.likeHeart( fun likeHeart(roomId: Long, heartCount: Int = 1, token: String) = api.likeHeart(
request = LiveRoomLikeHeartRequest( request = LiveRoomLikeHeartRequest(
roomId = roomId, roomId = roomId,
container = "aos" container = "aos",
heartCount = heartCount
), ),
authHeader = token authHeader = token
) )

View File

@@ -23,7 +23,9 @@ import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
@@ -1687,6 +1689,16 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
} }
LiveRoomChatRawMessageType.BIG_HEART_DONATION -> {
handler.post {
addHeartMessage(nickname)
addHeartAnimation()
lifecycleScope.launch {
viewModel.addHeartDonation(heartCount = message.can)
}
}
}
LiveRoomChatRawMessageType.SECRET_DONATION -> { LiveRoomChatRawMessageType.SECRET_DONATION -> {
val nickname = viewModel.getUserNickname(memberId.toInt()) val nickname = viewModel.getUserNickname(memberId.toInt())
val profileUrl = viewModel.getUserProfileUrl(memberId.toInt()) val profileUrl = viewModel.getUserProfileUrl(memberId.toInt())
@@ -1839,63 +1851,248 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
} }
@SuppressLint("ClickableViewAccessibility")
private fun initLikeHeartButton() { private fun initLikeHeartButton() {
if (!isHost) { if (!isHost) {
binding.flLikeHeart.visibility = View.VISIBLE binding.flLikeHeart.visibility = View.VISIBLE
binding.flLikeHeart.setOnClickListener { binding.flLikeHeart.isClickable = true
if (isAvailableLikeHeart) {
binding.flLikeHeart.isEnabled = false
viewModel.likeHeart(
roomId = roomId,
onSuccess = {
val donationRawMessage = Gson().toJson(
LiveRoomChatRawMessage(
type = LiveRoomChatRawMessageType.HEART_DONATION,
message = "",
can = 1,
signature = null,
signatureImageUrl = null,
donationMessage = null
)
)
agora.sendRawMessageToGroup( // 클릭: 기존 동작 유지 (1캔 소모)
rawMessage = donationRawMessage.toByteArray(), binding.flLikeHeart.setOnClickListener { handleHeartClick() }
onSuccess = {
val nickname = viewModel.getUserNickname( // 롱클릭 터치 핸들러로 위임
SharedPreferenceManager.userId.toInt() binding.flLikeHeart.setOnTouchListener { _, event ->
) handleHeartLongPressTouch(event)
handler.post { }
addHeartMessage(nickname) } else {
addHeartAnimation() binding.flLikeHeart.visibility = View.GONE
lifecycleScope.launch { viewModel.addHeartDonation() } }
} }
},
onFailure = { private var longPressCenterHeart: ImageView? = null
viewModel.refundDonation(roomId)
} // 롱프레스 상태/파라미터
) private var isLongPressTriggered = false
private var isTrackingLongPress = false
private var pressStartTime = 0L
private var isLongPressBlockedByAvailability = false
// 롱프레스 트리거까지의 총 시간(2초 유지 시 BIG HEART)
private val longPressDurationMs = 2000L
private val longPressScaleDurationMs get() = longPressDurationMs
private val longPressScaleStart = 1.0f
private val longPressScaleMax = 4.0f
// 롱프레스 진행 중 스케일 업데이트 러너블
private val longPressScaleUpdateRunnable = object : Runnable {
override fun run() {
if (!isTrackingLongPress) return
if (isLongPressBlockedByAvailability) return
val elapsed = System.currentTimeMillis() - pressStartTime
// 스케일은 더 빠르게 커지도록 별도의 duration 기준으로 계산(최대치 도달 후 유지)
val scaleFraction = (elapsed.coerceAtMost(longPressScaleDurationMs)
.toFloat() / longPressScaleDurationMs.toFloat())
val scale =
longPressScaleStart + (longPressScaleMax - longPressScaleStart) * scaleFraction
updateCenterHeartScale(scale)
// 2초 유지 시 트리거 실행
if (elapsed >= longPressDurationMs && !isLongPressTriggered) {
isLongPressTriggered = true
isTrackingLongPress = false
removeCenterHeartForLongPress(withFade = true)
triggerBigHeartDonation()
return
}
if (isTrackingLongPress && !isLongPressTriggered) {
// 다음 프레임 업데이트(약 60fps)
handler.postDelayed(this, 16L)
}
}
}
// 롱프레스 터치 처리 로직 (initLikeHeartButton에서 위임)
private fun handleHeartLongPressTouch(event: MotionEvent): Boolean {
return when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 이용 가능하지 않으면 롱프레스를 즉시 취소하고 안내 팝업 노출
if (!isAvailableLikeHeart) {
isLongPressTriggered = false
isTrackingLongPress = false
isLongPressBlockedByAvailability = true
removeCenterHeartForLongPress(withFade = false)
binding.flLikeHeart.isEnabled = true
},
onFailure = {
binding.flLikeHeart.isEnabled = true
}
)
} else {
SodaDialog( SodaDialog(
activity = this@LiveRoomActivity, activity = this@LiveRoomActivity,
layoutInflater = layoutInflater, layoutInflater = layoutInflater,
title = "안내", title = "안내",
desc = "'좋아해요'는 유료 후원입니다.\n" + desc = "'좋아해요'는 유료 후원입니다.\n클릭시 1캔이 소진됩니다.",
"클릭시 1캔이 소진됩니다.",
confirmButtonTitle = "확인", confirmButtonTitle = "확인",
confirmButtonClick = { isAvailableLikeHeart = true } confirmButtonClick = { isAvailableLikeHeart = true }
).show(screenWidth) ).show(screenWidth)
return true
} }
isLongPressTriggered = false
isTrackingLongPress = true
pressStartTime = System.currentTimeMillis()
// 중앙 하트 생성 및 초기 스케일 설정
showCenterHeartForLongPress()
updateCenterHeartScale(longPressScaleStart)
handler.post(longPressScaleUpdateRunnable)
true
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
// 이용 불가로 인해 즉시 안내를 띄운 케이스: 클릭 위임 없이 종료
if (isLongPressBlockedByAvailability) {
isLongPressBlockedByAvailability = false
return true
}
val wasTriggered = isLongPressTriggered
isTrackingLongPress = false
// 중앙 하트 제거
removeCenterHeartForLongPress(withFade = !wasTriggered)
true
}
else -> false
}
}
private fun showCenterHeartForLongPress() {
if (longPressCenterHeart != null) return
val size = 33.3f.dpToPx().toInt()
val lp = FrameLayout.LayoutParams(size, size).apply { gravity = Gravity.CENTER }
val heart = ImageView(this).apply {
setImageResource(R.drawable.ic_heart_pink)
layoutParams = lp
scaleX = 1f
scaleY = 1f
alpha = 1f
}
binding.flRoot.addView(heart)
longPressCenterHeart = heart
}
private fun updateCenterHeartScale(scale: Float) {
longPressCenterHeart?.let {
it.scaleX = scale
it.scaleY = scale
}
}
private fun removeCenterHeartForLongPress(withFade: Boolean = false) {
val heart = longPressCenterHeart ?: return
longPressCenterHeart = null
if (withFade) {
heart.animate()
.alpha(0f)
.setDuration(150L)
.withEndAction { binding.flRoot.removeView(heart) }
.start()
} else { } else {
binding.flLikeHeart.visibility = View.GONE binding.flRoot.removeView(heart)
}
}
private fun handleHeartClick() {
if (isAvailableLikeHeart) {
binding.flLikeHeart.isEnabled = false
viewModel.likeHeart(
roomId = roomId,
onSuccess = {
val donationRawMessage = Gson().toJson(
LiveRoomChatRawMessage(
type = LiveRoomChatRawMessageType.HEART_DONATION,
message = "",
can = 1,
signature = null,
signatureImageUrl = null,
donationMessage = null
)
)
agora.sendRawMessageToGroup(
rawMessage = donationRawMessage.toByteArray(),
onSuccess = {
val nickname = viewModel.getUserNickname(
SharedPreferenceManager.userId.toInt()
)
handler.post {
addHeartMessage(nickname)
addHeartAnimation()
lifecycleScope.launch { viewModel.addHeartDonation() }
}
},
onFailure = {
viewModel.refundDonation(roomId)
}
)
binding.flLikeHeart.isEnabled = true
},
onFailure = {
binding.flLikeHeart.isEnabled = true
}
)
} else {
SodaDialog(
activity = this@LiveRoomActivity,
layoutInflater = layoutInflater,
title = "안내",
desc = "'좋아해요'는 유료 후원입니다.\n클릭시 1캔이 소진됩니다.",
confirmButtonTitle = "확인",
confirmButtonClick = { isAvailableLikeHeart = true }
).show(screenWidth)
}
}
private fun triggerBigHeartDonation() {
if (isAvailableLikeHeart) {
binding.flLikeHeart.isEnabled = false
viewModel.likeHeart(
roomId = roomId,
heartCount = 100,
onSuccess = {
val donationRawMessage = Gson().toJson(
LiveRoomChatRawMessage(
type = LiveRoomChatRawMessageType.BIG_HEART_DONATION,
message = "",
can = 100,
signature = null,
signatureImageUrl = null,
donationMessage = null
)
)
agora.sendRawMessageToGroup(
rawMessage = donationRawMessage.toByteArray(),
onSuccess = {
val nickname = viewModel.getUserNickname(
SharedPreferenceManager.userId.toInt()
)
handler.post {
addHeartMessage(nickname)
addHeartAnimation()
lifecycleScope.launch { viewModel.addHeartDonation(100) }
}
},
onFailure = {
viewModel.refundDonation(roomId)
}
)
binding.flLikeHeart.isEnabled = true
},
onFailure = {
binding.flLikeHeart.isEnabled = true
}
)
} }
} }

View File

@@ -567,15 +567,15 @@ class LiveRoomViewModel(
) )
} }
fun likeHeart(roomId: Long, onSuccess: () -> Unit, onFailure: () -> Unit) { fun likeHeart(roomId: Long, heartCount: Int = 1, onSuccess: () -> Unit, onFailure: () -> Unit) {
compositeDisposable.add( compositeDisposable.add(
repository.likeHeart(roomId, token = "Bearer ${SharedPreferenceManager.token}") repository.likeHeart(roomId, heartCount = heartCount, token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ {
if (it.success) { if (it.success) {
SharedPreferenceManager.can -= 1 SharedPreferenceManager.can -= heartCount
onSuccess() onSuccess()
} else { } else {
if (it.message != null) { if (it.message != null) {
@@ -682,9 +682,9 @@ class LiveRoomViewModel(
} }
} }
suspend fun addHeartDonation() { suspend fun addHeartDonation(heartCount: Int = 1) {
mutex.withLock { mutex.withLock {
_totalHeartCount.postValue(totalHeartCount.value!! + 1) _totalHeartCount.postValue(totalHeartCount.value!! + heartCount)
} }
} }

View File

@@ -35,5 +35,8 @@ enum class LiveRoomChatRawMessageType {
ROULETTE_DONATION, ROULETTE_DONATION,
@SerializedName("HEART_DONATION") @SerializedName("HEART_DONATION")
HEART_DONATION HEART_DONATION,
@SerializedName("BIG_HEART_DONATION")
BIG_HEART_DONATION
} }

View File

@@ -6,5 +6,6 @@ import com.google.gson.annotations.SerializedName
@Keep @Keep
data class LiveRoomLikeHeartRequest( data class LiveRoomLikeHeartRequest(
@SerializedName("roomId") val roomId: Long, @SerializedName("roomId") val roomId: Long,
@SerializedName("container") val container: String @SerializedName("container") val container: String,
@SerializedName("heartCount") val heartCount: Int = 1,
) )