From 8c7602bb1a6253ae4df242a16ebafcf2bf40327c Mon Sep 17 00:00:00 2001 From: klaus Date: Mon, 9 Feb 2026 00:13:21 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=A3=B8=20V2V=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EC=9E=90=EB=A7=89=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라이브룸에서 진행자 언어와 기기 언어가 다를 때 자막 토글을 제공한다. 룸 정보 응답에 V2V 워커 토큰과 진행자 언어 코드를 포함한다. Agora V2V 에이전트 참여와 종료 API 연동을 추가한다. --- app/build.gradle | 6 + .../co/vividnext/sodalive/agora/v2v/V2vApi.kt | 25 ++ .../vividnext/sodalive/agora/v2v/V2vModels.kt | 63 ++++ .../sodalive/agora/v2v/V2vRepository.kt | 36 +++ .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 43 ++- .../sodalive/live/room/LiveRoomActivity.kt | 285 ++++++++++++++++++ .../sodalive/live/room/LiveRoomViewModel.kt | 94 +++++- .../live/room/info/GetRoomInfoResponse.kt | 2 + .../main/res/layout/activity_live_room.xml | 35 +++ app/src/main/res/values-en/strings.xml | 2 + app/src/main/res/values-ja/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 12 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/agora/v2v/V2vApi.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/agora/v2v/V2vModels.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/agora/v2v/V2vRepository.kt diff --git a/app/build.gradle b/app/build.gradle index c334ce3f..6405c52e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -73,6 +73,9 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' buildConfigField 'String', 'BASE_URL', '"https://api.sodalive.net"' + buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"' + buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"' + buildConfigField 'String', 'AGORA_CUSTOMER_SECRET', '"3855da8bc5ae4743af8bf4f87408b515"' buildConfigField 'String', 'BOOTPAY_APP_ID', '"64c35be1d25985001dc50c87"' buildConfigField 'String', 'BOOTPAY_APP_HECTO_ID', '"664c1707b18b225deca4b429"' buildConfigField 'String', 'AGORA_APP_ID', '"e34e40046e9847baba3adfe2b8ffb4f6"' @@ -100,6 +103,9 @@ android { applicationIdSuffix '.debug' buildConfigField 'String', 'BASE_URL', '"https://test-api.sodalive.net"' + buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"' + buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"' + buildConfigField 'String', 'AGORA_CUSTOMER_SECRET', '"3855da8bc5ae4743af8bf4f87408b515"' buildConfigField 'String', 'BOOTPAY_APP_ID', '"6242a7772701800023f68b2e"' buildConfigField 'String', 'BOOTPAY_APP_HECTO_ID', '"667fca5d3bab7404f831c3e4"' buildConfigField 'String', 'AGORA_APP_ID', '"b96574e191a9430fa54c605528aa3ef7"' diff --git a/app/src/main/java/kr/co/vividnext/sodalive/agora/v2v/V2vApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/agora/v2v/V2vApi.kt new file mode 100644 index 00000000..d092d01b --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/agora/v2v/V2vApi.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.agora.v2v + +import io.reactivex.rxjava3.core.Single +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path + +interface V2vApi { + @POST("projects/{appId}/join") + fun join( + @Path("appId") appId: String, + @Header("Authorization") authorization: String, + @Header("X-Request-Id") requestId: String, + @Body request: V2vJoinRequest + ): Single + + @POST("projects/{appId}/agents/{agentId}/leave") + fun leave( + @Path("appId") appId: String, + @Path("agentId") agentId: String, + @Header("Authorization") authorization: String, + @Header("X-Request-Id") requestId: String + ): Single +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/agora/v2v/V2vModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/agora/v2v/V2vModels.kt new file mode 100644 index 00000000..3e3580d0 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/agora/v2v/V2vModels.kt @@ -0,0 +1,63 @@ +package kr.co.vividnext.sodalive.agora.v2v + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +@Keep +data class V2vJoinRequest( + @SerializedName("name") val name: String, + @SerializedName("preset") val preset: String, + @SerializedName("properties") val properties: V2vJoinProperties +) + +@Keep +data class V2vJoinProperties( + @SerializedName("channel") val channel: String, + @SerializedName("token") val token: String, + @SerializedName("agent_rtc_uid") val agentRtcUid: String, + @SerializedName("remote_rtc_uids") val remoteRtcUids: List, + @SerializedName("idle_timeout") val idleTimeout: Int, + @SerializedName("advanced_features") val advancedFeatures: V2vAdvancedFeatures, + @SerializedName("parameters") val parameters: V2vParameters, + @SerializedName("asr") val asr: V2vAsr, + @SerializedName("translation") val translation: V2vTranslation, + @SerializedName("tts") val tts: V2vTts +) + +@Keep +data class V2vAdvancedFeatures( + @SerializedName("enable_rtm") val enableRtm: Boolean +) + +@Keep +data class V2vParameters( + @SerializedName("data_channel") val dataChannel: String +) + +@Keep +data class V2vAsr( + @SerializedName("language") val language: String +) + +@Keep +data class V2vTranslation( + @SerializedName("language") val language: String +) + +@Keep +data class V2vTts( + @SerializedName("enable") val enable: Boolean +) + +@Keep +data class V2vJoinResponse( + @SerializedName("agent_id") val agentId: String, + @SerializedName("create_ts") val createTs: Long, + @SerializedName("status") val status: String +) + +@Keep +data class V2vLeaveResponse( + @SerializedName("agent_id") val agentId: String, + @SerializedName("status") val status: String +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/agora/v2v/V2vRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/agora/v2v/V2vRepository.kt new file mode 100644 index 00000000..2b582dab --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/agora/v2v/V2vRepository.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.agora.v2v + +import android.util.Base64 +import io.reactivex.rxjava3.core.Single +import kr.co.vividnext.sodalive.BuildConfig +import java.util.UUID + +class V2vRepository(private val api: V2vApi) { + fun join(request: V2vJoinRequest): Single { + return api.join( + appId = BuildConfig.AGORA_APP_ID, + authorization = buildAuthorizationHeader(), + requestId = generateRequestId(), + request = request + ) + } + + fun leave(agentId: String): Single { + return api.leave( + appId = BuildConfig.AGORA_APP_ID, + agentId = agentId, + authorization = buildAuthorizationHeader(), + requestId = generateRequestId() + ) + } + + private fun buildAuthorizationHeader(): String { + val credentials = "${BuildConfig.AGORA_CUSTOMER_ID}:${BuildConfig.AGORA_CUSTOMER_SECRET}" + val encoded = Base64.encodeToString(credentials.toByteArray(), Base64.NO_WRAP) + return "Basic $encoded" + } + + private fun generateRequestId(): String { + return UUID.randomUUID().toString().replace("-", "") + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt index 60144121..bc32506f 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt @@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.di import android.content.Context import com.google.gson.GsonBuilder import kr.co.vividnext.sodalive.BuildConfig +import kr.co.vividnext.sodalive.agora.v2v.V2vApi +import kr.co.vividnext.sodalive.agora.v2v.V2vRepository import kr.co.vividnext.sodalive.audio_content.AudioContentApi import kr.co.vividnext.sodalive.audio_content.AudioContentRepository import kr.co.vividnext.sodalive.audio_content.AudioContentViewModel @@ -176,6 +178,7 @@ import okhttp3.logging.HttpLoggingInterceptor import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.context.startKoin +import org.koin.core.qualifier.named import org.koin.dsl.module import retrofit2.Retrofit import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory @@ -184,6 +187,7 @@ import java.util.concurrent.TimeUnit class AppDI(private val context: Context, isDebugMode: Boolean) { private val baseUrl = BuildConfig.BASE_URL + private val agoraBaseUrl = "https://api.agora.io/api/speech-to-speech-translation/v2/" private val otherModule = module { single { GsonBuilder().create() } @@ -211,6 +215,23 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { .build() } + single(named("agoraHttpClient")) { + val logging = HttpLoggingInterceptor() + + if (isDebugMode) { + logging.setLevel(HttpLoggingInterceptor.Level.BODY) + } else { + logging.setLevel(HttpLoggingInterceptor.Level.NONE) + } + + OkHttpClient().newBuilder() + .addInterceptor(logging) + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + } + single { AcceptLanguageInterceptor(get()) } single { @@ -222,6 +243,15 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { .build() } + single(named("agoraRetrofit")) { + Retrofit.Builder() + .baseUrl(agoraBaseUrl) + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) + .client(get(named("agoraHttpClient"))) + .build() + } + single { ApiBuilder().build(get(), AlarmListApi::class.java) } single { ApiBuilder().build(get(), CanApi::class.java) } single { ApiBuilder().build(get(), CanTempApi::class.java) } @@ -255,6 +285,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { single { ApiBuilder().build(get(), TalkApi::class.java) } single { ApiBuilder().build(get(), CharacterCommentApi::class.java) } single { ApiBuilder().build(get(), OriginalWorkApi::class.java) } + single { ApiBuilder().build(get(named("agoraRetrofit")), V2vApi::class.java) } } private val viewModelModule = module { @@ -273,7 +304,16 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { LiveRoomCreateViewModel(get()) } viewModel { LiveTagViewModel(get()) } viewModel { LiveRoomEditViewModel(get()) } - viewModel { LiveRoomViewModel(get(), get(), get(), get(), get()) } + viewModel { + LiveRoomViewModel( + repository = get(), + userRepository = get(), + reportRepository = get(), + rouletteRepository = get(), + userEventRepository = get(), + v2vRepository = get() + ) + } viewModel { LiveRoomDonationMessageViewModel(get()) } viewModel { ExplorerViewModel(get()) } viewModel { UserProfileViewModel(get(), get(), get()) } @@ -353,6 +393,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { factory { TermsRepository(get()) } factory { SeriesRepository(get()) } factory { LiveRepository(get(), get(), get()) } + factory { V2vRepository(get()) } factory { EventRepository(get()) } factory { LiveRecommendRepository(get()) } factory { AuthRepository(get()) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt index a4092294..0518ca2d 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt @@ -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(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(ActivityLiveRoomB // joinChannel 중복 호출 방지 플래그 private var hasInvokedJoinChannel = false + private var v2vSourceLanguage: String? = null + private var v2vTargetLanguage: String? = null + private var isV2vAvailable = false + private val v2vMessageCache = mutableMapOf>() + private val v2vMessageCacheTimestamps = mutableMapOf() + + 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(ActivityLiveRoomB cropper.cleanup() hideKeyboard { viewModel.quitRoom(roomId) { + viewModel.stopV2vTranslation() SodaLiveService.stopService(this) agora.deInitAgoraEngine(rtmEventListener) RtmClient.release() @@ -515,6 +540,7 @@ class LiveRoomActivity : BaseActivity(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(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(ActivityLiveRoomB } viewModel.roomInfoLiveData.observe(this) { response -> + updateV2vAvailability(response) binding.ivShield.visibility = if (response.isAdult) { View.VISIBLE } else { @@ -1101,6 +1159,7 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB } }) rvChat.adapter = chatAdapter + setupV2vCaptionOffset() rvChat.setOnScrollChangeListener { _, _, _, _, _ -> if (!rvChat.canScrollVertically(1)) { binding.tvNewChat.visibility = View.GONE @@ -1138,6 +1197,33 @@ class LiveRoomActivity : BaseActivity(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(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(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, diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt index 7ffcbd6d..cb3ad73a 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt @@ -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() val roomInfoLiveData: LiveData get() = _roomInfoLiveData @@ -99,6 +110,12 @@ class LiveRoomViewModel( val isSignatureOn: LiveData get() = _isSignatureOn + private var _isV2vCaptionOn = MutableLiveData(false) + val isV2vCaptionOn: LiveData + get() = _isV2vCaptionOn + + private var v2vAgentId: String? = null + private val blockedMemberIdList: MutableList = 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) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt index 4f6d5a45..326ba28c 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt @@ -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, @SerializedName("donationRankingTop3UserIds") val donationRankingTop3UserIds: List, @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 diff --git a/app/src/main/res/layout/activity_live_room.xml b/app/src/main/res/layout/activity_live_room.xml index d8b06b75..06f83812 100644 --- a/app/src/main/res/layout/activity_live_room.xml +++ b/app/src/main/res/layout/activity_live_room.xml @@ -47,6 +47,25 @@ app:layout_constraintTop_toBottomOf="@+id/fl_margin" app:layout_goneMarginEnd="0dp" /> + + + + Leave Change to listener Sign OFF + Caption OFF + Caption ON Back OFF Notice Menu diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 155009ce..eb02e006 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -450,6 +450,8 @@ 退出 リスナー変更 シグ OFF + 字幕 OFF + 字幕 ON 背景 OFF 告知 メニュー表 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 07a8b4e4..583db0a9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -450,6 +450,8 @@ 나가기 리스너 변경 시그 OFF + 자막 OFF + 자막 ON 배경 OFF 공지 메뉴판