From b796f6d9c53a3a1a18d5c792931d4a8025eb2c30 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 9 Feb 2026 21:11:17 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=A3=B8=20V2V=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EC=9E=90=EB=A7=89=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라이브룸에서 진행자 언어와 기기 언어가 다를 때 자막 토글을 제공한다. 룸 정보 응답에 V2V 워커 토큰과 진행자 언어 코드를 포함한다. Agora V2V 에이전트 참여와 종료 API 연동을 추가한다 --- SodaLive/Sources/Debug/Utils/Constants.swift | 3 + SodaLive/Sources/I18n/I18n.swift | 2 + .../Live/Room/GetRoomInfoResponse.swift | 2 + .../Sources/Live/Room/LiveRoomViewModel.swift | 185 ++++++++++++ .../View/LiveRoomInfoGuestView.swift | 20 ++ .../Sources/Live/Room/V2/LiveRoomViewV2.swift | 39 ++- SodaLive/Sources/Live/Room/V2V/V2vApi.swift | 53 ++++ .../Sources/Live/Room/V2V/V2vModels.swift | 109 +++++++ .../Sources/Live/Room/V2V/V2vRepository.swift | 128 ++++++++ SodaLive/Sources/Live/Room/V2V/V2vState.swift | 274 ++++++++++++++++++ SodaLive/Sources/Utils/Constants.swift | 3 + 11 files changed, 816 insertions(+), 2 deletions(-) create mode 100644 SodaLive/Sources/Live/Room/V2V/V2vApi.swift create mode 100644 SodaLive/Sources/Live/Room/V2V/V2vModels.swift create mode 100644 SodaLive/Sources/Live/Room/V2V/V2vRepository.swift create mode 100644 SodaLive/Sources/Live/Room/V2V/V2vState.swift diff --git a/SodaLive/Sources/Debug/Utils/Constants.swift b/SodaLive/Sources/Debug/Utils/Constants.swift index 551784e..8d26786 100644 --- a/SodaLive/Sources/Debug/Utils/Constants.swift +++ b/SodaLive/Sources/Debug/Utils/Constants.swift @@ -29,3 +29,6 @@ let GID_SERVER_CLIENT_ID = "758414412471-mosodbj2chno7l1j0iihldh6edmk0gk9.apps.g let KAKAO_APP_KEY = "20cf19413d63bfdfd30e8e6dff933d33" let LINE_CHANNEL_ID = "2008995582" + +let AGORA_CUSTOMER_ID = "de5dd9ea151f4a43ba1ad8411817b169" +let AGORA_CUSTOMER_SECRET = "3855da8bc5ae4743af8bf4f87408b515" diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 1160cb6..bd15dc2 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -729,6 +729,8 @@ enum I18n { static var signatureOn: String { pick(ko: "시그 ON", en: "Sign ON", ja: "シグ ON") } static var signatureOff: String { pick(ko: "시그 OFF", en: "Sign OFF", ja: "シグ OFF") } + static var captionOn: String { pick(ko: "자막 ON", en: "Caption ON", ja: "字幕 ON") } + static var captionOff: String { pick(ko: "자막 OFF", en: "Caption OFF", ja: "字幕 OFF") } static var backgroundOn: String { pick(ko: "배경 ON", en: "Back ON", ja: "背景 ON") } static var backgroundOff: String { pick(ko: "배경 OFF", en: "Back OFF", ja: "背景 OFF") } static var notice: String { pick(ko: "공지", en: "Notice", ja: "お知らせ") } diff --git a/SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift b/SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift index 80113be..6f7d0b5 100644 --- a/SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift +++ b/SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift @@ -13,6 +13,7 @@ struct GetRoomInfoResponse: Decodable { let channelName: String let rtcToken: String let rtmToken: String + let v2vWorkerToken: String let creatorId: Int let creatorNickname: String let creatorProfileUrl: String @@ -25,6 +26,7 @@ struct GetRoomInfoResponse: Decodable { let managerList: [LiveRoomMember] let donationRankingTop3UserIds: [Int] let menuPan: String + let creatorLanguageCode: String? let isActiveRoulette: Bool let isPrivateRoom: Bool let password: String? diff --git a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift index 9237c45..2660665 100644 --- a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift +++ b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift @@ -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() 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 diff --git a/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInfoGuestView.swift b/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInfoGuestView.swift index 410adf0..5294f28 100644 --- a/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInfoGuestView.swift +++ b/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInfoGuestView.swift @@ -17,7 +17,9 @@ struct LiveRoomInfoGuestView: View { let isOnNotice: Bool let isOnMenuPan: Bool let isOnSignature: Bool + let isOnV2VCaption: Bool let isShowMenuPanButton: Bool + let isShowV2VCaptionButton: Bool let creatorId: Int let creatorNickname: String @@ -37,6 +39,7 @@ struct LiveRoomInfoGuestView: View { let onClickTotalHeart: () -> Void let onClickTotalDonation: () -> Void let onClickChangeListener: () -> Void + let onClickToggleV2VCaption: () -> Void let onClickToggleSignature: () -> Void var body: some View { @@ -62,6 +65,20 @@ struct LiveRoomInfoGuestView: View { strokeCornerRadius: 5.3 ) { onClickChangeListener() } } + + if isShowV2VCaptionButton { + LiveRoomOverlayStrokeTextToggleButton( + isOn: isOnV2VCaption, + onText: I18n.LiveRoom.captionOn, + onTextColor: Color.button, + onStrokeColor: Color.button, + offText: I18n.LiveRoom.captionOff, + offTextColor: Color.graybb, + offStrokeColor: Color.graybb, + strokeWidth: 1, + strokeCornerRadius: 5.3 + ) { onClickToggleV2VCaption() } + } LiveRoomOverlayStrokeTextToggleButton( isOn: isOnSignature, @@ -222,7 +239,9 @@ struct LiveRoomInfoGuestView_Previews: PreviewProvider { isOnNotice: false, isOnMenuPan: false, isOnSignature: false, + isOnV2VCaption: false, isShowMenuPanButton: false, + isShowV2VCaptionButton: true, creatorId: 1, creatorNickname: "도화", creatorProfileUrl: "https://cf.sodalive.net/profile/26/26-profile-ddf78b4d-0300-4c50-9c84-5d8a95fd5fe2-4892-1705256364320", @@ -258,6 +277,7 @@ struct LiveRoomInfoGuestView_Previews: PreviewProvider { onClickTotalHeart: {}, onClickTotalDonation: {}, onClickChangeListener: {}, + onClickToggleV2VCaption: {}, onClickToggleSignature: {} ) } diff --git a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift index 5c2cc87..a7bed2e 100644 --- a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift +++ b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift @@ -95,7 +95,9 @@ struct LiveRoomViewV2: View { isOnNotice: viewModel.isShowNotice, isOnMenuPan: viewModel.isShowMenuPan, isOnSignature: viewModel.isSignatureOn, + isOnV2VCaption: viewModel.isV2VCaptionOn, isShowMenuPanButton: !liveRoomInfo.menuPan.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + isShowV2VCaptionButton: viewModel.isV2VAvailable, creatorId: liveRoomInfo.creatorId, creatorNickname: liveRoomInfo.creatorNickname, creatorProfileUrl: liveRoomInfo.creatorProfileUrl, @@ -132,6 +134,9 @@ struct LiveRoomViewV2: View { onClickChangeListener: { viewModel.setListener() }, + onClickToggleV2VCaption: { + viewModel.toggleV2VCaption() + }, onClickToggleSignature: { viewModel.isSignatureOn.toggle() } @@ -182,7 +187,7 @@ struct LiveRoomViewV2: View { .onPreferenceChange(ScrollOffsetKey.self) { viewModel.setOffset($0) } - .padding(.bottom, 70) + .padding(.bottom, v2vCaptionBottomInset) } .padding(.top, 16) @@ -308,6 +313,19 @@ struct LiveRoomViewV2: View { } .padding(.trailing, 13.3) + if isV2VCaptionVisible { + Text(viewModel.v2vCaptionText) + .appFont(size: 12, weight: .medium) + .foregroundColor(.white) + .lineLimit(2) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color.black.opacity(0.75)) + .cornerRadius(10) + .padding(.horizontal, 13.3) + } + LiveRoomInputChatView { viewModel.sendMessage(chatMessage: $0) { viewModel.isShowingNewChat = false @@ -316,6 +334,7 @@ struct LiveRoomViewV2: View { return true } + .padding(.top, isV2VCaptionVisible ? -13.3 : 0) .padding(.bottom, 10) } @@ -323,7 +342,7 @@ struct LiveRoomViewV2: View { LiveRoomNewChatView{ viewModel.isShowingNewChat = false proxy.scrollTo(viewModel.messages.count - 1, anchor: .center) - }.padding(.bottom, 70) + }.padding(.bottom, v2vCaptionBottomInset) } if viewModel.isSignatureOn && viewModel.signatureImageUrl.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 { @@ -474,6 +493,7 @@ struct LiveRoomViewV2: View { .onDisappear { UIApplication.shared.isIdleTimerDisabled = false NotificationCenter.default.removeObserver(self) + viewModel.stopV2VTranslationIfJoined() viewModel.stopPeriodicPlaybackValidation() } @@ -744,6 +764,10 @@ struct LiveRoomViewV2: View { if viewModel.isLoading && viewModel.liveRoomInfo == nil { LoadingView() } + + if viewModel.isV2VLoading { + LoadingView() + } } .overlay(alignment: .center) { ZStack { @@ -916,6 +940,17 @@ struct LiveRoomViewV2: View { } } +private extension LiveRoomViewV2 { + var isV2VCaptionVisible: Bool { + viewModel.isV2VCaptionOn && + !viewModel.v2vCaptionText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var v2vCaptionBottomInset: CGFloat { + isV2VCaptionVisible ? 120 : 70 + } +} + struct LiveRoomViewV2_Previews: PreviewProvider { static var previews: some View { LiveRoomViewV2() diff --git a/SodaLive/Sources/Live/Room/V2V/V2vApi.swift b/SodaLive/Sources/Live/Room/V2V/V2vApi.swift new file mode 100644 index 0000000..eb005d5 --- /dev/null +++ b/SodaLive/Sources/Live/Room/V2V/V2vApi.swift @@ -0,0 +1,53 @@ +// +// V2vApi.swift +// SodaLive +// +// Created by klaus on 2/9/26. +// + +import Foundation +import Moya + +enum V2vApi { + case join(request: V2VJoinRequest) + case leave(agentId: String) +} + +extension V2vApi: TargetType { + var baseURL: URL { + URL(string: "https://api.agora.io/api/speech-to-speech-translation/v2/")! + } + + var path: String { + switch self { + case .join: + return "projects/\(AGORA_APP_ID)/join" + case .leave(let agentId): + return "projects/\(AGORA_APP_ID)/agents/\(agentId)/leave" + } + } + + var method: Moya.Method { + .post + } + + var task: Task { + switch self { + case .join(let request): + return .requestJSONEncodable(request) + case .leave: + return .requestPlain + } + } + + var headers: [String: String]? { + let credential = "\(AGORA_CUSTOMER_ID):\(AGORA_CUSTOMER_SECRET)" + let basicToken = Data(credential.utf8).base64EncodedString() + + return [ + "Authorization": "Basic \(basicToken)", + "X-Request-Id": UUID().uuidString, + "Content-Type": "application/json" + ] + } +} diff --git a/SodaLive/Sources/Live/Room/V2V/V2vModels.swift b/SodaLive/Sources/Live/Room/V2V/V2vModels.swift new file mode 100644 index 0000000..1fef134 --- /dev/null +++ b/SodaLive/Sources/Live/Room/V2V/V2vModels.swift @@ -0,0 +1,109 @@ +// +// V2vModels.swift +// SodaLive +// +// Created by klaus on 2/9/26. +// + +import Foundation + +struct V2VJoinRequest: Encodable { + let name: String + let preset: String + let properties: Properties + + init(roomInfo: GetRoomInfoResponse, sourceLanguage: String, targetLanguage: String) { + let rtcUid = UserDefaults.int(forKey: .userId) + + self.name = "sodalive-v2v-\(roomInfo.roomId)-\(UUID().uuidString)" + self.preset = "v2vt_base" + self.properties = .init( + channel: roomInfo.channelName, + token: roomInfo.v2vWorkerToken, + agentRtcUid: "\(rtcUid)333", + remoteRtcUids: ["\(roomInfo.creatorId)"], + idleTimeout: 300, + advancedFeatures: .init(enableRtm: false), + parameters: .init(dataChannel: "datastream"), + asr: .init(language: sourceLanguage), + translation: .init(language: targetLanguage), + tts: .init(enable: false) + ) + } + + struct Properties: Encodable { + let channel: String + let token: String + let agentRtcUid: String + let remoteRtcUids: [String] + let idleTimeout: Int + let advancedFeatures: AdvancedFeatures + let parameters: Parameters + let asr: Asr + let translation: Translation + let tts: Tts + + enum CodingKeys: String, CodingKey { + case channel + case token + case agentRtcUid = "agent_rtc_uid" + case remoteRtcUids = "remote_rtc_uids" + case idleTimeout = "idle_timeout" + case advancedFeatures = "advanced_features" + case parameters + case asr + case translation + case tts + } + } + + struct AdvancedFeatures: Encodable { + let enableRtm: Bool + + enum CodingKeys: String, CodingKey { + case enableRtm = "enable_rtm" + } + } + + struct Parameters: Encodable { + let dataChannel: String + + enum CodingKeys: String, CodingKey { + case dataChannel = "data_channel" + } + } + + struct Asr: Encodable { + let language: String + } + + struct Translation: Encodable { + let language: String + } + + struct Tts: Encodable { + let enable: Bool + } +} + +struct V2VJoinResponse: Decodable { + let agentId: String + let createTs: Int + let status: String + + enum CodingKeys: String, CodingKey { + case agentId = "agent_id" + case createTs = "create_ts" + case status + } +} + +struct V2VLeaveResponse: Decodable { + let agentId: String + let status: String + + enum CodingKeys: String, CodingKey { + case agentId = "agent_id" + case status + } +} diff --git a/SodaLive/Sources/Live/Room/V2V/V2vRepository.swift b/SodaLive/Sources/Live/Room/V2V/V2vRepository.swift new file mode 100644 index 0000000..e4657b1 --- /dev/null +++ b/SodaLive/Sources/Live/Room/V2V/V2vRepository.swift @@ -0,0 +1,128 @@ +// +// V2vRepository.swift +// SodaLive +// +// Created by klaus on 2/9/26. +// + +import Foundation +import Combine +import Moya +import CombineMoya + +protocol V2VRepository { + func join(request: V2VJoinRequest) -> AnyPublisher + func leave(agentId: String) -> AnyPublisher +} + +enum V2VRepositoryError: Error { + case network(message: String) + case decoding + case business(message: String) + + var userMessage: String { + switch self { + case .network(let message): + return message + case .decoding: + return I18n.Common.commonError + case .business(let message): + return message + } + } +} + +final class V2VRepositoryImpl: V2VRepository { + private let api: MoyaProvider + + init(api: MoyaProvider = MoyaProvider()) { + self.api = api + } + + func join(request: V2VJoinRequest) -> AnyPublisher { + api.requestPublisher(.join(request: request)) + .tryMap { response in + try Self.validateStatusCode(response) + + do { + let decoded = try JSONDecoder().decode(V2VJoinResponse.self, from: response.data) + guard !decoded.agentId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw V2VRepositoryError.decoding + } + return decoded.agentId + } catch let error as V2VRepositoryError { + throw error + } catch { + throw V2VRepositoryError.decoding + } + } + .mapError { Self.mapError($0) } + .eraseToAnyPublisher() + } + + func leave(agentId: String) -> AnyPublisher { + api.requestPublisher(.leave(agentId: agentId)) + .tryMap { response in + try Self.validateStatusCode(response) + if !response.data.isEmpty { + if let text = String(data: response.data, encoding: .utf8) { + DEBUG_LOG("[V2V] leave response: \(text)") + } + _ = try? JSONDecoder().decode(V2VLeaveResponse.self, from: response.data) + } + return () + } + .mapError { Self.mapError($0) } + .eraseToAnyPublisher() + } + + private static func validateStatusCode(_ response: Response) throws { + guard (200..<300).contains(response.statusCode) else { + throw V2VRepositoryError.business(message: parseBusinessMessage(from: response.data) ?? I18n.Common.commonError) + } + } + + private static func mapError(_ error: Error) -> V2VRepositoryError { + if let mapped = error as? V2VRepositoryError { + return mapped + } + + guard let moyaError = error as? MoyaError else { + return .network(message: I18n.Common.commonError) + } + + switch moyaError { + case .statusCode(let response): + if let message = parseBusinessMessage(from: response.data) { + return .business(message: message) + } + return .business(message: I18n.Common.commonError) + + case .objectMapping, .jsonMapping, .encodableMapping, .stringMapping, .imageMapping: + return .decoding + + default: + return .network(message: I18n.Common.commonError) + } + } + + private static func parseBusinessMessage(from data: Data) -> String? { + if let decoded = try? JSONDecoder().decode(V2VErrorResponse.self, from: data), + let message = decoded.message?.trimmingCharacters(in: .whitespacesAndNewlines), + !message.isEmpty { + return message + } + + if let decoded = try? JSONDecoder().decode(ApiResponseWithoutData.self, from: data), + let message = decoded.message?.trimmingCharacters(in: .whitespacesAndNewlines), + !message.isEmpty { + return message + } + + return nil + } +} + +private struct V2VErrorResponse: Decodable { + let message: String? +} diff --git a/SodaLive/Sources/Live/Room/V2V/V2vState.swift b/SodaLive/Sources/Live/Room/V2V/V2vState.swift new file mode 100644 index 0000000..a4a2f99 --- /dev/null +++ b/SodaLive/Sources/Live/Room/V2V/V2vState.swift @@ -0,0 +1,274 @@ +// +// V2vState.swift +// SodaLive +// +// Created by klaus on 2/9/26. +// + +import Foundation + +struct V2vState { + var isAvailable: Bool = false + var isCaptionOn: Bool = false + var captionText: String = "" + var agentId: String? = nil + var sourceLanguage: String? = nil + var targetLanguage: String? = nil +} + +enum V2vLanguageMapper { + static func mapToAgoraLanguage(_ code: String?) -> String? { + guard let normalized = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + return nil + } + + if normalized.hasPrefix("ko") { return "ko-KR" } + if normalized.hasPrefix("ja") { return "ja-JP" } + if normalized.hasPrefix("en") { return "en-US" } + return nil + } +} + +enum V2vAppLanguageResolver { + static func currentLanguageCode() -> String { + let headerCode = LanguageHeaderProvider.current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if headerCode == "ko" || headerCode == "ja" || headerCode == "en" { + return headerCode + } + + let saved = UserDefaults.standard.string(forKey: "app.language") + if let saved, + let option = LanguageOption(rawValue: saved), + option != .system { + return option.rawValue + } + + guard let preferred = Locale.preferredLanguages.first?.lowercased() else { + return "ko" + } + + if preferred.hasPrefix("ko") { return "ko" } + if preferred.hasPrefix("ja") { return "ja" } + return "en" + } +} + +final class V2vMessageAssembler { + private struct Chunk { + let messageId: String + let partIdx: Int + let partSum: Int + let content: String + } + + private struct Buffer { + var partSum: Int + var createdAt: Date + var parts: [Int: String] + } + + private var buffers: [String: Buffer] = [:] + private let timeout: TimeInterval = 10 + + func consume(data: Data) -> String? { + cleanupExpiredBuffers() + + guard let chunk = parseChunk(data: data) else { + DEBUG_LOG("[V2V] chunk parsing failed. raw=\(preview(data))") + return nil + } + + DEBUG_LOG("[V2V] chunk received messageId=\(chunk.messageId), partIdx=\(chunk.partIdx), partSum=\(chunk.partSum), contentLength=\(chunk.content.count)") + + var buffer = buffers[chunk.messageId] ?? Buffer( + partSum: chunk.partSum, + createdAt: Date(), + parts: [:] + ) + + buffer.partSum = max(buffer.partSum, chunk.partSum) + + // 동일 partIdx는 중복 제거 + if buffer.parts[chunk.partIdx] == nil { + buffer.parts[chunk.partIdx] = chunk.content + } + + buffers[chunk.messageId] = buffer + + guard buffer.parts.count >= buffer.partSum else { + DEBUG_LOG("[V2V] chunk buffering \(chunk.messageId): \(buffer.parts.count)/\(buffer.partSum)") + return nil + } + + let sortedParts = buffer.parts + .sorted { $0.key < $1.key } + .prefix(buffer.partSum) + .map { $0.value } + + guard sortedParts.count == buffer.partSum else { + return nil + } + + let combined = sortedParts.joined() + DEBUG_LOG("[V2V] chunk assembled messageId=\(chunk.messageId), assembledLength=\(combined.count)") + + buffers[chunk.messageId] = nil + + return decodeCaptionText(from: combined) + } + + func reset() { + buffers.removeAll() + } + + private func cleanupExpiredBuffers() { + let now = Date() + buffers = buffers.filter { _, value in + now.timeIntervalSince(value.createdAt) <= timeout + } + } + + private func parseChunk(data: Data) -> Chunk? { + if let text = String(data: data, encoding: .utf8) { + if let parsed = parsePipeChunk(text) { + return parsed + } + + if let parsed = parseJSONChunk(text) { + return parsed + } + } + + return nil + } + + private func parsePipeChunk(_ raw: String) -> Chunk? { + let items = raw.split(separator: "|", maxSplits: 3, omittingEmptySubsequences: false) + guard items.count == 4, + let partIdx = Int(items[1]), + let partSum = Int(items[2]), + partIdx >= 0, + partSum > 0 else { + return nil + } + + return Chunk( + messageId: String(items[0]), + partIdx: partIdx, + partSum: partSum, + content: String(items[3]) + ) + } + + private func parseJSONChunk(_ raw: String) -> Chunk? { + guard let data = raw.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + guard let messageId = object["message_id"] as? String, + let partIdx = anyToInt(object["part_idx"]), + let partSum = anyToInt(object["part_sum"]), + let content = object["content"] as? String, + partIdx >= 0, + partSum > 0 else { + return nil + } + + return Chunk( + messageId: messageId, + partIdx: partIdx, + partSum: partSum, + content: content + ) + } + + private func anyToInt(_ value: Any?) -> Int? { + if let intValue = value as? Int { + return intValue + } + + if let stringValue = value as? String { + return Int(stringValue) + } + + if let number = value as? NSNumber { + return number.intValue + } + + return nil + } + + private func decodeCaptionText(from base64String: String) -> String? { + if let plainData = base64String.data(using: .utf8), + let plainJson = try? JSONSerialization.jsonObject(with: plainData), + let text = extractTranslationText(from: plainJson) { + DEBUG_LOG("[V2V] plain json subtitle parsed") + return text + } + + guard let decodedData = decodeBase64Payload(base64String), + let jsonObject = try? JSONSerialization.jsonObject(with: decodedData) else { + DEBUG_LOG("[V2V] base64 or final json parsing failed. payloadPrefix=\(String(base64String.prefix(80)))") + return nil + } + + let text = extractTranslationText(from: jsonObject) + if text == nil { + DEBUG_LOG("[V2V] final json parsed but translation text not found") + } + return text + } + + private func extractTranslationText(from object: Any) -> String? { + if let dict = object as? [String: Any] { + let eventType = (dict["object"] as? String) ?? (dict["type"] as? String) ?? (dict["event"] as? String) + + if let eventType, + (eventType == "user.translation" || eventType == "agent.translation"), + let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return text + } + + for value in dict.values { + if let text = extractTranslationText(from: value) { + return text + } + } + } + + if let array = object as? [Any] { + for value in array { + if let text = extractTranslationText(from: value) { + return text + } + } + } + + return nil + } + + private func decodeBase64Payload(_ raw: String) -> Data? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + if let data = Data(base64Encoded: trimmed) { + return data + } + + let normalized = trimmed + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let remainder = normalized.count % 4 + let padded = remainder == 0 ? normalized : normalized + String(repeating: "=", count: 4 - remainder) + + return Data(base64Encoded: padded) + } + + private func preview(_ data: Data) -> String { + if let text = String(data: data, encoding: .utf8) { + return String(text.prefix(80)) + } + return "\(data.count)bytes" + } +} diff --git a/SodaLive/Sources/Utils/Constants.swift b/SodaLive/Sources/Utils/Constants.swift index 1987b03..94f112b 100644 --- a/SodaLive/Sources/Utils/Constants.swift +++ b/SodaLive/Sources/Utils/Constants.swift @@ -29,3 +29,6 @@ let GID_SERVER_CLIENT_ID = "983594297130-5hrmkh6vpskeq6v34350kmilf74574h2.apps.g let KAKAO_APP_KEY = "231cf78acfa8252fca38b9eedf87c5cb" let LINE_CHANNEL_ID = "2008995539" + +let AGORA_CUSTOMER_ID = "de5dd9ea151f4a43ba1ad8411817b169" +let AGORA_CUSTOMER_SECRET = "3855da8bc5ae4743af8bf4f87408b515"