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

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

View File

@@ -34,6 +34,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
private var agora: Agora = Agora.shared
private let repository = LiveRepository()
private let v2vRepository: V2VRepository
private let userRepository = UserRepository()
private let reportRepository = ReportRepository()
private let rouletteRepository = RouletteRepository()
@@ -55,6 +56,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
@Published var isLoadingLikeHeart = false
@Published var isLoading = false
@Published var isV2VLoading = false
@Published var errorMessage = ""
@Published var reportMessage = ""
@Published var isShowReportPopup = false
@@ -283,9 +285,33 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
private var blockedMemberIdList = Set<Int>()
private var hasInvokedJoinChannel = false
private var v2vMessageAssembler = V2vMessageAssembler()
private var v2vAgentId: String?
private var v2vSourceLanguage: String?
private var v2vTargetLanguage: String?
private var isV2VJoinInProgress = false
private var isV2VLeaveInProgress = false
@Published var isV2VAvailable = false
@Published var isV2VCaptionOn = false
@Published var v2vCaptionText = ""
// BIG_HEART :
private var suppressNextRemoteWaterFill = false
init(v2vRepository: V2VRepository = V2VRepositoryImpl()) {
self.v2vRepository = v2vRepository
super.init()
}
private var isV2VJoined: Bool {
v2vAgentId != nil
}
func stopV2VTranslationIfJoined(clearCaptionText: Bool = true) {
guard isV2VJoined else { return }
stopV2VTranslation(clearCaptionText: clearCaptionText)
}
func getBlockedMemberIdList() {
userRepository.getBlockedMemberIdList()
@@ -332,8 +358,149 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
}
private func deInitAgoraEngine() {
stopV2VTranslationIfJoined()
agora.deInit()
}
func updateV2VAvailability(roomInfo: GetRoomInfoResponse?, deviceLanguage: String? = nil) {
guard let roomInfo = roomInfo else {
disableV2VIfNeeded()
return
}
let source = V2vLanguageMapper.mapToAgoraLanguage(roomInfo.creatorLanguageCode)
let target = V2vLanguageMapper.mapToAgoraLanguage(deviceLanguage ?? V2vAppLanguageResolver.currentLanguageCode())
let available = source != nil && target != nil && source != target
v2vSourceLanguage = source
v2vTargetLanguage = target
isV2VAvailable = available
if !available {
disableV2VIfNeeded()
}
}
func toggleV2VCaption() {
guard isV2VAvailable else { return }
if isV2VCaptionOn {
stopV2VTranslation()
} else {
isV2VCaptionOn = true
startV2VTranslation()
}
}
func startV2VTranslation() {
guard !isV2VJoinInProgress,
!isV2VLeaveInProgress,
isV2VAvailable,
v2vAgentId == nil,
let roomInfo = liveRoomInfo,
let sourceLanguage = v2vSourceLanguage,
let targetLanguage = v2vTargetLanguage else {
isV2VCaptionOn = false
return
}
isV2VJoinInProgress = true
isV2VLoading = true
let request = V2VJoinRequest(
roomInfo: roomInfo,
sourceLanguage: sourceLanguage,
targetLanguage: targetLanguage
)
v2vRepository.join(request: request)
.sink { [weak self] completion in
guard let self else { return }
self.isV2VJoinInProgress = false
self.isV2VLoading = self.isV2VLeaveInProgress
if case .failure(let error) = completion {
ERROR_LOG("[V2V] join failed: \(error)")
self.isV2VCaptionOn = false
self.errorMessage = error.userMessage
self.isShowErrorPopup = true
}
} receiveValue: { [weak self] agentId in
guard let self else { return }
self.v2vAgentId = agentId
DEBUG_LOG("[V2V] join success. agentId=\(agentId)")
if self.isV2VAvailable {
self.isV2VCaptionOn = true
} else {
self.stopV2VTranslation()
}
}
.store(in: &subscription)
}
func stopV2VTranslation(clearCaptionText: Bool = true) {
if clearCaptionText {
v2vCaptionText = ""
}
isV2VCaptionOn = false
v2vMessageAssembler.reset()
guard !isV2VLeaveInProgress,
!isV2VJoinInProgress else {
return
}
guard let agentId = v2vAgentId else {
return
}
DEBUG_LOG("[V2V] leave start. agentId=\(agentId)")
isV2VLeaveInProgress = true
isV2VLoading = true
v2vRepository.leave(agentId: agentId)
.sink { [weak self] completion in
guard let self else { return }
self.isV2VLeaveInProgress = false
self.isV2VLoading = self.isV2VJoinInProgress
switch completion {
case .finished:
self.v2vAgentId = nil
self.isV2VCaptionOn = false
DEBUG_LOG("[V2V] leave success")
case .failure(let error):
ERROR_LOG("[V2V] leave failed: \(error)")
self.errorMessage = error.userMessage
self.isShowErrorPopup = true
}
} receiveValue: { _ in }
.store(in: &subscription)
}
func handleV2VIncomingData(_ data: Data) {
guard isV2VAvailable, isV2VCaptionOn, isV2VJoined else { return }
guard let parsedText = v2vMessageAssembler.consume(data: data) else {
return
}
let normalized = parsedText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else {
return
}
DEBUG_LOG("[V2V] subtitle received. text=\(normalized)")
v2vCaptionText = normalized
}
private func disableV2VIfNeeded() {
isV2VAvailable = false
stopV2VTranslation(clearCaptionText: true)
}
func agoraConnectSuccess(isManager: Bool) {
self.isLoading = false
@@ -360,6 +527,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
}
func quitRoom() {
stopV2VTranslationIfJoined()
isLoading = true
if let index = muteSpeakers.firstIndex(of: UInt(UserDefaults.int(forKey: .userId))) {
@@ -424,6 +592,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
if let data = decoded.data, decoded.success {
self.liveRoomInfo = data
self.updateV2VAvailability(roomInfo: data)
if self.coverImageUrl != data.coverImageUrl {
self.coverImageUrl = data.coverImageUrl
@@ -2497,6 +2666,7 @@ extension LiveRoomViewModel: AgoraRtcEngineDelegate {
DispatchQueue.main.async {[unowned self] in
if uid == UInt(self.liveRoomInfo!.creatorId) {
//
self.stopV2VTranslationIfJoined()
self.liveRoomInfo = nil
self.errorMessage = "라이브가 종료되었습니다."
self.isShowErrorPopup = true
@@ -2506,6 +2676,16 @@ extension LiveRoomViewModel: AgoraRtcEngineDelegate {
}
}
}
func rtcEngine(_ engine: AgoraRtcEngineKit, receiveStreamMessageFromUid uid: UInt, streamId: Int, data: Data) {
DispatchQueue.main.async { [weak self] in
guard let self,
self.isV2VJoined,
self.isV2VCaptionOn else { return }
DEBUG_LOG("[V2V] rtc stream message received uid=\(uid), streamId=\(streamId), size=\(data.count)")
self.handleV2VIncomingData(data)
}
}
}
extension LiveRoomViewModel: AgoraRtmClientDelegate {
@@ -2521,6 +2701,11 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
let rawMessageString = String(data: message, encoding: .utf8)
DispatchQueue.main.async { [unowned self] in
if self.isV2VJoined && self.isV2VCaptionOn {
DEBUG_LOG("[V2V] rtm raw message received size=\(message.count)")
self.handleV2VIncomingData(message)
}
if rawMessageString == LiveRoomRequestType.CHANGE_LISTENER.rawValue {
self.setListener()
return