라이브룸 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

@@ -73,6 +73,9 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
buildConfigField 'String', 'BASE_URL', '"https://api.sodalive.net"' 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_ID', '"64c35be1d25985001dc50c87"'
buildConfigField 'String', 'BOOTPAY_APP_HECTO_ID', '"664c1707b18b225deca4b429"' buildConfigField 'String', 'BOOTPAY_APP_HECTO_ID', '"664c1707b18b225deca4b429"'
buildConfigField 'String', 'AGORA_APP_ID', '"e34e40046e9847baba3adfe2b8ffb4f6"' buildConfigField 'String', 'AGORA_APP_ID', '"e34e40046e9847baba3adfe2b8ffb4f6"'
@@ -100,6 +103,9 @@ android {
applicationIdSuffix '.debug' applicationIdSuffix '.debug'
buildConfigField 'String', 'BASE_URL', '"https://test-api.sodalive.net"' 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_ID', '"6242a7772701800023f68b2e"'
buildConfigField 'String', 'BOOTPAY_APP_HECTO_ID', '"667fca5d3bab7404f831c3e4"' buildConfigField 'String', 'BOOTPAY_APP_HECTO_ID', '"667fca5d3bab7404f831c3e4"'
buildConfigField 'String', 'AGORA_APP_ID', '"b96574e191a9430fa54c605528aa3ef7"' buildConfigField 'String', 'AGORA_APP_ID', '"b96574e191a9430fa54c605528aa3ef7"'

View File

@@ -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<V2vJoinResponse>
@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<V2vLeaveResponse>
}

View File

@@ -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<String>,
@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
)

View File

@@ -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<V2vJoinResponse> {
return api.join(
appId = BuildConfig.AGORA_APP_ID,
authorization = buildAuthorizationHeader(),
requestId = generateRequestId(),
request = request
)
}
fun leave(agentId: String): Single<V2vLeaveResponse> {
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("-", "")
}
}

View File

@@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.di
import android.content.Context import android.content.Context
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import kr.co.vividnext.sodalive.BuildConfig 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.AudioContentApi
import kr.co.vividnext.sodalive.audio_content.AudioContentRepository import kr.co.vividnext.sodalive.audio_content.AudioContentRepository
import kr.co.vividnext.sodalive.audio_content.AudioContentViewModel 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.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory
@@ -184,6 +187,7 @@ import java.util.concurrent.TimeUnit
class AppDI(private val context: Context, isDebugMode: Boolean) { class AppDI(private val context: Context, isDebugMode: Boolean) {
private val baseUrl = BuildConfig.BASE_URL private val baseUrl = BuildConfig.BASE_URL
private val agoraBaseUrl = "https://api.agora.io/api/speech-to-speech-translation/v2/"
private val otherModule = module { private val otherModule = module {
single { GsonBuilder().create() } single { GsonBuilder().create() }
@@ -211,6 +215,23 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
.build() .build()
} }
single<OkHttpClient>(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 { AcceptLanguageInterceptor(get()) }
single { single {
@@ -222,6 +243,15 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
.build() .build()
} }
single<Retrofit>(named("agoraRetrofit")) {
Retrofit.Builder()
.baseUrl(agoraBaseUrl)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.client(get<OkHttpClient>(named("agoraHttpClient")))
.build()
}
single { ApiBuilder().build(get(), AlarmListApi::class.java) } single { ApiBuilder().build(get(), AlarmListApi::class.java) }
single { ApiBuilder().build(get(), CanApi::class.java) } single { ApiBuilder().build(get(), CanApi::class.java) }
single { ApiBuilder().build(get(), CanTempApi::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(), TalkApi::class.java) }
single { ApiBuilder().build(get(), CharacterCommentApi::class.java) } single { ApiBuilder().build(get(), CharacterCommentApi::class.java) }
single { ApiBuilder().build(get(), OriginalWorkApi::class.java) } single { ApiBuilder().build(get(), OriginalWorkApi::class.java) }
single { ApiBuilder().build(get<Retrofit>(named("agoraRetrofit")), V2vApi::class.java) }
} }
private val viewModelModule = module { private val viewModelModule = module {
@@ -273,7 +304,16 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { LiveRoomCreateViewModel(get()) } viewModel { LiveRoomCreateViewModel(get()) }
viewModel { LiveTagViewModel(get()) } viewModel { LiveTagViewModel(get()) }
viewModel { LiveRoomEditViewModel(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 { LiveRoomDonationMessageViewModel(get()) }
viewModel { ExplorerViewModel(get()) } viewModel { ExplorerViewModel(get()) }
viewModel { UserProfileViewModel(get(), get(), get()) } viewModel { UserProfileViewModel(get(), get(), get()) }
@@ -353,6 +393,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { TermsRepository(get()) } factory { TermsRepository(get()) }
factory { SeriesRepository(get()) } factory { SeriesRepository(get()) }
factory { LiveRepository(get(), get(), get()) } factory { LiveRepository(get(), get(), get()) }
factory { V2vRepository(get()) }
factory { EventRepository(get()) } factory { EventRepository(get()) }
factory { LiveRecommendRepository(get()) } factory { LiveRecommendRepository(get()) }
factory { AuthRepository(get()) } factory { AuthRepository(get()) }

View File

@@ -30,9 +30,11 @@ 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.util.Base64
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager 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.ProfileReportDialog
import kr.co.vividnext.sodalive.report.ReportType import kr.co.vividnext.sodalive.report.ReportType
import kr.co.vividnext.sodalive.report.UserReportDialog import kr.co.vividnext.sodalive.report.UserReportDialog
import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.notification.MemberRole import kr.co.vividnext.sodalive.settings.notification.MemberRole
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.json.JSONObject
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlin.random.Random import kotlin.random.Random
@@ -135,6 +139,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
showLiveRoomUserProfileDialog(userId = userId) showLiveRoomUserProfileDialog(userId = userId)
} }
private lateinit var layoutManager: LinearLayoutManager private lateinit var layoutManager: LinearLayoutManager
private var rvChatBaseBottomMargin: Int? = null
private lateinit var agora: Agora private lateinit var agora: Agora
private lateinit var roomDialog: LiveRoomDialog private lateinit var roomDialog: LiveRoomDialog
@@ -155,6 +160,25 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
// joinChannel 중복 호출 방지 플래그 // joinChannel 중복 호출 방지 플래그
private var hasInvokedJoinChannel = false 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 채팅 금지 // region 채팅 금지
private var isNoChatting = false private var isNoChatting = false
private var remainingNoChattingTime = NO_CHATTING_TIME private var remainingNoChattingTime = NO_CHATTING_TIME
@@ -262,6 +286,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
cropper.cleanup() cropper.cleanup()
hideKeyboard { hideKeyboard {
viewModel.quitRoom(roomId) { viewModel.quitRoom(roomId) {
viewModel.stopV2vTranslation()
SodaLiveService.stopService(this) SodaLiveService.stopService(this)
agora.deInitAgoraEngine(rtmEventListener) agora.deInitAgoraEngine(rtmEventListener)
RtmClient.release() RtmClient.release()
@@ -515,6 +540,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
binding.tvBgSwitch.setOnClickListener { viewModel.toggleBackgroundImage() } binding.tvBgSwitch.setOnClickListener { viewModel.toggleBackgroundImage() }
binding.tvSignatureSwitch.setOnClickListener { viewModel.toggleSignatureImage() } binding.tvSignatureSwitch.setOnClickListener { viewModel.toggleSignatureImage() }
binding.tvV2vSignatureSwitch.setOnClickListener { toggleV2vCaption() }
binding.llDonation.setOnClickListener { binding.llDonation.setOnClickListener {
LiveRoomDonationRankingDialog( LiveRoomDonationRankingDialog(
activity = this, 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) { viewModel.isLoading.observe(this) {
if (it) { if (it) {
loadingDialog.show(screenWidth) loadingDialog.show(screenWidth)
@@ -763,6 +820,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
viewModel.roomInfoLiveData.observe(this) { response -> viewModel.roomInfoLiveData.observe(this) { response ->
updateV2vAvailability(response)
binding.ivShield.visibility = if (response.isAdult) { binding.ivShield.visibility = if (response.isAdult) {
View.VISIBLE View.VISIBLE
} else { } else {
@@ -1101,6 +1159,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
}) })
rvChat.adapter = chatAdapter rvChat.adapter = chatAdapter
setupV2vCaptionOffset()
rvChat.setOnScrollChangeListener { _, _, _, _, _ -> rvChat.setOnScrollChangeListener { _, _, _, _, _ ->
if (!rvChat.canScrollVertically(1)) { if (!rvChat.canScrollVertically(1)) {
binding.tvNewChat.visibility = View.GONE binding.tvNewChat.visibility = View.GONE
@@ -1138,6 +1197,33 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
rvSpeakers.adapter = speakerListAdapter 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) { private fun inviteSpeaker(peerId: Long) {
agora.sendRawMessageToPeer( agora.sendRawMessageToPeer(
receiverUid = peerId.toString(), receiverUid = peerId.toString(),
@@ -1487,6 +1573,17 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
Logger.e("onJoinChannelSuccess - uid: $uid, channel: $channel") 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) { override fun onActiveSpeaker(uid: Int) {
Logger.e("onActiveSpeaker - uid: $uid") Logger.e("onActiveSpeaker - uid: $uid")
super.onActiveSpeaker(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() { private fun initAgora() {
agora = Agora( agora = Agora(
uid = SharedPreferenceManager.userId, 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.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R 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.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
@@ -45,8 +53,11 @@ class LiveRoomViewModel(
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val reportRepository: ReportRepository, private val reportRepository: ReportRepository,
private val rouletteRepository: RouletteRepository, private val rouletteRepository: RouletteRepository,
private val userEventRepository: UserEventRepository private val userEventRepository: UserEventRepository,
private val v2vRepository: V2vRepository
) : BaseViewModel() { ) : BaseViewModel() {
private val v2vPreset = "v2vt_base"
private val _roomInfoLiveData = MutableLiveData<GetRoomInfoResponse>() private val _roomInfoLiveData = MutableLiveData<GetRoomInfoResponse>()
val roomInfoLiveData: LiveData<GetRoomInfoResponse> val roomInfoLiveData: LiveData<GetRoomInfoResponse>
get() = _roomInfoLiveData get() = _roomInfoLiveData
@@ -99,6 +110,12 @@ class LiveRoomViewModel(
val isSignatureOn: LiveData<Boolean> val isSignatureOn: LiveData<Boolean>
get() = _isSignatureOn get() = _isSignatureOn
private var _isV2vCaptionOn = MutableLiveData(false)
val isV2vCaptionOn: LiveData<Boolean>
get() = _isV2vCaptionOn
private var v2vAgentId: String? = null
private val blockedMemberIdList: MutableList<Long> = mutableListOf() private val blockedMemberIdList: MutableList<Long> = mutableListOf()
// 메인 스레드 보장을 위한 Handler (postValue의 병합(coalescing) 이슈 방지 목적) // 메인 스레드 보장을 위한 Handler (postValue의 병합(coalescing) 이슈 방지 목적)
@@ -275,6 +292,81 @@ class LiveRoomViewModel(
blockedMemberIdList.add(memberId) 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) { fun removeBlockedMember(memberId: Long) {
blockedMemberIdList.remove(memberId) blockedMemberIdList.remove(memberId)
} }

View File

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

View File

@@ -47,6 +47,25 @@
app:layout_constraintTop_toBottomOf="@+id/fl_margin" app:layout_constraintTop_toBottomOf="@+id/fl_margin"
app:layout_goneMarginEnd="0dp" /> app:layout_goneMarginEnd="0dp" />
<TextView
android:id="@+id/tv_v2v_caption"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="12dp"
android:background="@color/color_44000000"
android:fontFamily="@font/medium"
android:gravity="center"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:textColor="@android:color/white"
android:textSize="14sp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/rl_input_chat"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Translated caption" />
<FrameLayout <FrameLayout
android:id="@+id/fl_margin" android:id="@+id/fl_margin"
android:layout_width="0dp" android:layout_width="0dp"
@@ -233,6 +252,22 @@
android:visibility="gone" android:visibility="gone"
tools:ignore="SmallSp" /> tools:ignore="SmallSp" />
<TextView
android:id="@+id/tv_v2v_signature_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_round_corner_5_3_transparent_bbbbbb"
android:fontFamily="@font/medium"
android:gravity="center"
android:paddingHorizontal="8dp"
android:paddingVertical="4.7dp"
android:text="@string/screen_live_room_v2v_signature_off_label"
android:textColor="@color/color_eeeeee"
android:textSize="12sp"
android:visibility="gone"
tools:ignore="SmallSp" />
<TextView <TextView
android:id="@+id/tv_signature_switch" android:id="@+id/tv_signature_switch"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@@ -451,6 +451,8 @@
<string name="screen_live_room_leave">Leave</string> <string name="screen_live_room_leave">Leave</string>
<string name="screen_live_room_change_listener">Change to listener</string> <string name="screen_live_room_change_listener">Change to listener</string>
<string name="screen_live_room_signature_off_label">Sign OFF</string> <string name="screen_live_room_signature_off_label">Sign OFF</string>
<string name="screen_live_room_v2v_signature_off_label">Caption OFF</string>
<string name="screen_live_room_v2v_signature_on_label">Caption ON</string>
<string name="screen_live_room_bg_off_label">Back OFF</string> <string name="screen_live_room_bg_off_label">Back OFF</string>
<string name="screen_live_room_notice">Notice</string> <string name="screen_live_room_notice">Notice</string>
<string name="screen_live_room_menu">Menu</string> <string name="screen_live_room_menu">Menu</string>

View File

@@ -450,6 +450,8 @@
<string name="screen_live_room_leave">退出</string> <string name="screen_live_room_leave">退出</string>
<string name="screen_live_room_change_listener">リスナー変更</string> <string name="screen_live_room_change_listener">リスナー変更</string>
<string name="screen_live_room_signature_off_label">シグ OFF</string> <string name="screen_live_room_signature_off_label">シグ OFF</string>
<string name="screen_live_room_v2v_signature_off_label">字幕 OFF</string>
<string name="screen_live_room_v2v_signature_on_label">字幕 ON</string>
<string name="screen_live_room_bg_off_label">背景 OFF</string> <string name="screen_live_room_bg_off_label">背景 OFF</string>
<string name="screen_live_room_notice">告知</string> <string name="screen_live_room_notice">告知</string>
<string name="screen_live_room_menu">メニュー表</string> <string name="screen_live_room_menu">メニュー表</string>

View File

@@ -450,6 +450,8 @@
<string name="screen_live_room_leave">나가기</string> <string name="screen_live_room_leave">나가기</string>
<string name="screen_live_room_change_listener">리스너 변경</string> <string name="screen_live_room_change_listener">리스너 변경</string>
<string name="screen_live_room_signature_off_label">시그 OFF</string> <string name="screen_live_room_signature_off_label">시그 OFF</string>
<string name="screen_live_room_v2v_signature_off_label">자막 OFF</string>
<string name="screen_live_room_v2v_signature_on_label">자막 ON</string>
<string name="screen_live_room_bg_off_label">배경 OFF</string> <string name="screen_live_room_bg_off_label">배경 OFF</string>
<string name="screen_live_room_notice">공지</string> <string name="screen_live_room_notice">공지</string>
<string name="screen_live_room_menu">메뉴판</string> <string name="screen_live_room_menu">메뉴판</string>