라이브룸 V2V 번역 자막 기능을 추가한다
라이브룸에서 진행자 언어와 기기 언어가 다를 때 자막 토글을 제공한다. 룸 정보 응답에 V2V 워커 토큰과 진행자 언어 코드를 포함한다. Agora V2V 에이전트 참여와 종료 API 연동을 추가한다.
This commit is contained in:
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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("-", "")
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user