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

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 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<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 {
@@ -222,6 +243,15 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
.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(), 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<Retrofit>(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()) }

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

View File

@@ -47,6 +47,25 @@
app:layout_constraintTop_toBottomOf="@+id/fl_margin"
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
android:id="@+id/fl_margin"
android:layout_width="0dp"
@@ -233,6 +252,22 @@
android:visibility="gone"
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
android:id="@+id/tv_signature_switch"
android:layout_width="wrap_content"

View File

@@ -451,6 +451,8 @@
<string name="screen_live_room_leave">Leave</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_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_notice">Notice</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_change_listener">リスナー変更</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_notice">告知</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_change_listener">리스너 변경</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_notice">공지</string>
<string name="screen_live_room_menu">메뉴판</string>