라이브룸 V2V 번역 자막 기능을 추가한다

라이브룸에서 진행자 언어와 기기 언어가 다를 때 자막 토글을 제공한다.
룸 정보 응답에 V2V 워커 토큰과 진행자 언어 코드를 포함한다.
Agora V2V 에이전트 참여와 종료 API 연동을 추가한다.
This commit is contained in:
2026-02-09 00:13:21 +09:00
parent 1dcf16ba2a
commit 8c7602bb1a
12 changed files with 593 additions and 2 deletions

View File

@@ -30,9 +30,11 @@ import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.Base64
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
@@ -109,8 +111,10 @@ import kr.co.vividnext.sodalive.live.roulette.config.RouletteConfigActivity
import kr.co.vividnext.sodalive.report.ProfileReportDialog
import kr.co.vividnext.sodalive.report.ReportType
import kr.co.vividnext.sodalive.report.UserReportDialog
import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.notification.MemberRole
import org.koin.android.ext.android.inject
import org.json.JSONObject
import java.util.concurrent.TimeUnit
import java.util.regex.Pattern
import kotlin.random.Random
@@ -135,6 +139,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
showLiveRoomUserProfileDialog(userId = userId)
}
private lateinit var layoutManager: LinearLayoutManager
private var rvChatBaseBottomMargin: Int? = null
private lateinit var agora: Agora
private lateinit var roomDialog: LiveRoomDialog
@@ -155,6 +160,25 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
// joinChannel 중복 호출 방지 플래그
private var hasInvokedJoinChannel = false
private var v2vSourceLanguage: String? = null
private var v2vTargetLanguage: String? = null
private var isV2vAvailable = false
private val v2vMessageCache = mutableMapOf<String, MutableList<V2vMessageChunk>>()
private val v2vMessageCacheTimestamps = mutableMapOf<String, Long>()
private data class V2vMessageChunk(
val partIdx: Int,
val partSum: Int,
val content: String
)
private data class V2vChunkEnvelope(
val messageId: String,
val partIdx: Int,
val partSum: Int,
val content: String
)
// region 채팅 금지
private var isNoChatting = false
private var remainingNoChattingTime = NO_CHATTING_TIME
@@ -262,6 +286,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
cropper.cleanup()
hideKeyboard {
viewModel.quitRoom(roomId) {
viewModel.stopV2vTranslation()
SodaLiveService.stopService(this)
agora.deInitAgoraEngine(rtmEventListener)
RtmClient.release()
@@ -515,6 +540,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
binding.tvBgSwitch.setOnClickListener { viewModel.toggleBackgroundImage() }
binding.tvSignatureSwitch.setOnClickListener { viewModel.toggleSignatureImage() }
binding.tvV2vSignatureSwitch.setOnClickListener { toggleV2vCaption() }
binding.llDonation.setOnClickListener {
LiveRoomDonationRankingDialog(
activity = this,
@@ -723,6 +749,37 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
}
viewModel.isV2vCaptionOn.observe(this) { isOn ->
if (isOn) {
binding.tvV2vSignatureSwitch.text =
getString(R.string.screen_live_room_v2v_signature_on_label)
binding.tvV2vSignatureSwitch.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_3bb9f1
)
)
binding.tvV2vSignatureSwitch
.setBackgroundResource(R.drawable.bg_round_corner_5_3_transparent_3bb9f1)
binding.tvV2vCaption.visibility = View.VISIBLE
updateChatBottomMarginByV2vCaption()
} else {
binding.tvV2vSignatureSwitch.text =
getString(R.string.screen_live_room_v2v_signature_off_label)
binding.tvV2vSignatureSwitch.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
binding.tvV2vSignatureSwitch
.setBackgroundResource(R.drawable.bg_round_corner_5_3_transparent_bbbbbb)
binding.tvV2vCaption.text = ""
binding.tvV2vCaption.visibility = View.GONE
updateChatBottomMarginByV2vCaption()
}
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth)
@@ -763,6 +820,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
viewModel.roomInfoLiveData.observe(this) { response ->
updateV2vAvailability(response)
binding.ivShield.visibility = if (response.isAdult) {
View.VISIBLE
} else {
@@ -1101,6 +1159,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
})
rvChat.adapter = chatAdapter
setupV2vCaptionOffset()
rvChat.setOnScrollChangeListener { _, _, _, _, _ ->
if (!rvChat.canScrollVertically(1)) {
binding.tvNewChat.visibility = View.GONE
@@ -1138,6 +1197,33 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
rvSpeakers.adapter = speakerListAdapter
}
private fun setupV2vCaptionOffset() {
binding.tvV2vCaption.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateChatBottomMarginByV2vCaption()
}
updateChatBottomMarginByV2vCaption()
}
private fun updateChatBottomMarginByV2vCaption() {
val rvChat = binding.rvChat
val layoutParams = rvChat.layoutParams as? ViewGroup.MarginLayoutParams ?: return
val baseMargin = rvChatBaseBottomMargin ?: layoutParams.bottomMargin.also {
rvChatBaseBottomMargin = it
}
val captionHeight = if (binding.tvV2vCaption.visibility == View.VISIBLE) {
binding.tvV2vCaption.height
} else {
0
}
val targetBottomMargin = baseMargin + captionHeight
if (layoutParams.bottomMargin != targetBottomMargin) {
layoutParams.bottomMargin = targetBottomMargin
rvChat.layoutParams = layoutParams
}
}
private fun inviteSpeaker(peerId: Long) {
agora.sendRawMessageToPeer(
receiverUid = peerId.toString(),
@@ -1487,6 +1573,17 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
Logger.e("onJoinChannelSuccess - uid: $uid, channel: $channel")
}
override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) {
super.onStreamMessage(uid, streamId, data)
if (!isV2vAvailable || viewModel.isV2vCaptionOn.value != true) {
return
}
val rawMessage = data?.toString(Charsets.UTF_8)?.takeIf { it.isNotBlank() } ?: return
Logger.d("v2v onStreamMessage: uid=$uid, streamId=$streamId, length=${rawMessage.length}")
handleV2vStreamChunk(rawMessage)
}
override fun onActiveSpeaker(uid: Int) {
Logger.e("onActiveSpeaker - uid: $uid")
super.onActiveSpeaker(uid)
@@ -1814,6 +1911,194 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
}
private fun toggleV2vCaption() {
if (!isV2vAvailable || !viewModel.isRoomInfoInitialized()) {
return
}
val isOn = viewModel.isV2vCaptionOn.value == true
if (isOn) {
viewModel.setV2vCaptionEnabled(false)
viewModel.stopV2vTranslation()
} else {
val source = v2vSourceLanguage ?: return
val target = v2vTargetLanguage ?: return
viewModel.startV2vTranslation(
roomInfo = viewModel.roomInfoResponse,
sourceLanguage = source,
targetLanguage = target,
userId = SharedPreferenceManager.userId
)
}
}
private fun updateV2vAvailability(response: GetRoomInfoResponse) {
val source = mapRoomLanguage(response.creatorLanguageCode)
val target = mapDeviceLanguage(LanguageManager.getEffectiveLanguage(this))
val available = !source.isNullOrBlank() && !target.isNullOrBlank() && source != target
Logger.d(
"v2v language mapping - creatorLanguageCode=${response.creatorLanguageCode}, " +
"effectiveDeviceLanguage=${LanguageManager.getEffectiveLanguage(this)}, " +
"source=$source, target=$target, available=$available"
)
isV2vAvailable = available
v2vSourceLanguage = if (available) source else null
v2vTargetLanguage = if (available) target else null
binding.tvV2vSignatureSwitch.visibility = if (available) {
View.VISIBLE
} else {
View.GONE
}
if (!available) {
viewModel.setV2vCaptionEnabled(false)
binding.tvV2vCaption.text = ""
binding.tvV2vCaption.visibility = View.GONE
updateChatBottomMarginByV2vCaption()
viewModel.stopV2vTranslation()
}
}
private fun mapRoomLanguage(languageCode: String?): String? {
return when (languageCode?.trim()?.lowercase()) {
"ko" -> "ko-KR"
"en" -> "en-US"
"ja" -> "ja-JP"
else -> "ko-KR"
}
}
private fun mapDeviceLanguage(languageCode: String): String? {
return when (languageCode.lowercase()) {
"ko" -> "ko-KR"
"en" -> "en-US"
"ja" -> "ja-JP"
else -> "ja-JP"
}
}
private fun handleV2vRawMessage(rawMessage: String) {
Logger.d("v2v raw message before parse: $rawMessage")
try {
val json = JSONObject(rawMessage)
if (json.has("part_idx") && json.has("part_sum") && json.has("content")) {
handleV2vChunk(json)
return
}
handleV2vTranslation(json)
} catch (e: Exception) {
Logger.e("Failed to parse v2v message: ${e.message}")
}
}
private fun handleV2vStreamChunk(rawMessage: String) {
val chunk = parseV2vChunk(rawMessage)
if (chunk == null) {
handleV2vRawMessage(rawMessage)
return
}
clearExpiredV2vCache()
val chunks = v2vMessageCache.getOrPut(chunk.messageId) { mutableListOf() }
if (chunks.none { it.partIdx == chunk.partIdx }) {
chunks.add(V2vMessageChunk(chunk.partIdx, chunk.partSum, chunk.content))
v2vMessageCacheTimestamps[chunk.messageId] = System.currentTimeMillis()
}
if (chunks.size == chunk.partSum) {
val fullContent = chunks.sortedBy { it.partIdx }
.joinToString(separator = "") { it.content }
v2vMessageCache.remove(chunk.messageId)
v2vMessageCacheTimestamps.remove(chunk.messageId)
val decoded = String(Base64.decode(fullContent, Base64.DEFAULT))
handleV2vRawMessage(decoded)
}
}
private fun parseV2vChunk(rawMessage: String): V2vChunkEnvelope? {
val parts = rawMessage.split("|", limit = 4)
if (parts.size != 4) {
return null
}
val messageId = parts[0]
val partIdx = parts[1].toIntOrNull()
val partSum = parts[2].toIntOrNull()
val content = parts[3]
if (messageId.isBlank() || partIdx == null || partSum == null || partIdx <= 0 || partSum <= 0 || content.isBlank()) {
return null
}
return V2vChunkEnvelope(
messageId = messageId,
partIdx = partIdx,
partSum = partSum,
content = content
)
}
private fun handleV2vChunk(json: JSONObject) {
val messageId = json.optString("message_id")
val partIdx = json.optInt("part_idx", -1)
val partSum = json.optInt("part_sum", -1)
val content = json.optString("content")
if (messageId.isBlank() || partIdx < 0 || partSum <= 0 || content.isBlank()) {
return
}
clearExpiredV2vCache()
val chunks = v2vMessageCache.getOrPut(messageId) { mutableListOf() }
if (chunks.none { it.partIdx == partIdx }) {
chunks.add(V2vMessageChunk(partIdx, partSum, content))
v2vMessageCacheTimestamps[messageId] = System.currentTimeMillis()
}
if (chunks.size == partSum) {
val fullContent = chunks.sortedBy { it.partIdx }
.joinToString(separator = "") { it.content }
v2vMessageCache.remove(messageId)
v2vMessageCacheTimestamps.remove(messageId)
val decoded = String(Base64.decode(fullContent, Base64.DEFAULT))
handleV2vRawMessage(decoded)
}
}
private fun clearExpiredV2vCache() {
val now = System.currentTimeMillis()
val expiredKeys = v2vMessageCacheTimestamps
.filter { now - it.value > 10_000 }
.keys
expiredKeys.forEach {
v2vMessageCache.remove(it)
v2vMessageCacheTimestamps.remove(it)
}
}
private fun handleV2vTranslation(json: JSONObject) {
val type = json.optString("object")
if (type != "user.translation" && type != "agent.translation") {
return
}
val text = json.optString("text")
if (text.isBlank() || viewModel.isV2vCaptionOn.value != true) {
return
}
handler.post {
binding.tvV2vCaption.text = text
binding.tvV2vCaption.visibility = View.VISIBLE
updateChatBottomMarginByV2vCaption()
}
}
private fun initAgora() {
agora = Agora(
uid = SharedPreferenceManager.userId,

View File

@@ -9,6 +9,14 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.agora.v2v.V2vAdvancedFeatures
import kr.co.vividnext.sodalive.agora.v2v.V2vAsr
import kr.co.vividnext.sodalive.agora.v2v.V2vJoinProperties
import kr.co.vividnext.sodalive.agora.v2v.V2vJoinRequest
import kr.co.vividnext.sodalive.agora.v2v.V2vParameters
import kr.co.vividnext.sodalive.agora.v2v.V2vRepository
import kr.co.vividnext.sodalive.agora.v2v.V2vTranslation
import kr.co.vividnext.sodalive.agora.v2v.V2vTts
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
@@ -45,8 +53,11 @@ class LiveRoomViewModel(
private val userRepository: UserRepository,
private val reportRepository: ReportRepository,
private val rouletteRepository: RouletteRepository,
private val userEventRepository: UserEventRepository
private val userEventRepository: UserEventRepository,
private val v2vRepository: V2vRepository
) : BaseViewModel() {
private val v2vPreset = "v2vt_base"
private val _roomInfoLiveData = MutableLiveData<GetRoomInfoResponse>()
val roomInfoLiveData: LiveData<GetRoomInfoResponse>
get() = _roomInfoLiveData
@@ -99,6 +110,12 @@ class LiveRoomViewModel(
val isSignatureOn: LiveData<Boolean>
get() = _isSignatureOn
private var _isV2vCaptionOn = MutableLiveData(false)
val isV2vCaptionOn: LiveData<Boolean>
get() = _isV2vCaptionOn
private var v2vAgentId: String? = null
private val blockedMemberIdList: MutableList<Long> = mutableListOf()
// 메인 스레드 보장을 위한 Handler (postValue의 병합(coalescing) 이슈 방지 목적)
@@ -275,6 +292,81 @@ class LiveRoomViewModel(
blockedMemberIdList.add(memberId)
}
fun setV2vCaptionEnabled(isEnabled: Boolean) {
_isV2vCaptionOn.value = isEnabled
}
fun startV2vTranslation(
roomInfo: GetRoomInfoResponse,
sourceLanguage: String,
targetLanguage: String,
userId: Long
) {
val agentRtcUid = "${userId}333"
val request = V2vJoinRequest(
name = "v2v-translation-agent-${System.currentTimeMillis()}",
preset = v2vPreset,
properties = V2vJoinProperties(
channel = roomInfo.channelName,
token = roomInfo.v2vWorkerToken,
agentRtcUid = agentRtcUid,
remoteRtcUids = listOf(roomInfo.creatorId.toString()),
idleTimeout = 300,
advancedFeatures = V2vAdvancedFeatures(enableRtm = false),
parameters = V2vParameters(dataChannel = "datastream"),
asr = V2vAsr(language = sourceLanguage),
translation = V2vTranslation(language = targetLanguage),
tts = V2vTts(enable = false)
)
)
Logger.d(
"v2v join request - roomId=${roomInfo.roomId}, creatorId=${roomInfo.creatorId}, " +
"sourceLanguage=$sourceLanguage, targetLanguage=$targetLanguage"
)
compositeDisposable.add(
v2vRepository.join(request)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
v2vAgentId = it.agentId
_isV2vCaptionOn.value = true
},
{
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.screen_live_room_unknown_error)
)
_isV2vCaptionOn.value = false
}
)
)
}
fun stopV2vTranslation() {
val agentId = v2vAgentId ?: return
compositeDisposable.add(
v2vRepository.leave(agentId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
v2vAgentId = null
_isV2vCaptionOn.value = false
},
{
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.screen_live_room_unknown_error)
)
}
)
)
}
fun removeBlockedMember(memberId: Long) {
blockedMemberIdList.remove(memberId)
}

View File

@@ -12,6 +12,7 @@ data class GetRoomInfoResponse(
@SerializedName("channelName") val channelName: String,
@SerializedName("rtcToken") val rtcToken: String,
@SerializedName("rtmToken") val rtmToken: String,
@SerializedName("v2vWorkerToken") val v2vWorkerToken: String,
@SerializedName("creatorId") val creatorId: Long,
@SerializedName("creatorNickname") val creatorNickname: String,
@SerializedName("creatorProfileUrl") val creatorProfileUrl: String,
@@ -24,6 +25,7 @@ data class GetRoomInfoResponse(
@SerializedName("managerList") val managerList: List<LiveRoomMember>,
@SerializedName("donationRankingTop3UserIds") val donationRankingTop3UserIds: List<Long>,
@SerializedName("menuPan") val menuPan: String,
@SerializedName("creatorLanguageCode") val creatorLanguageCode: String?,
@SerializedName("isActiveRoulette") val isActiveRoulette: Boolean,
@SerializedName("isPrivateRoom") val isPrivateRoom: Boolean,
@SerializedName("password") val password: String? = null