라이브룸 V2V 번역 자막 기능을 추가한다
라이브룸에서 진행자 언어와 기기 언어가 다를 때 자막 토글을 제공한다. 룸 정보 응답에 V2V 워커 토큰과 진행자 언어 코드를 포함한다. Agora V2V 에이전트 참여와 종료 API 연동을 추가한다
This commit is contained in:
@@ -29,3 +29,6 @@ let GID_SERVER_CLIENT_ID = "758414412471-mosodbj2chno7l1j0iihldh6edmk0gk9.apps.g
|
|||||||
let KAKAO_APP_KEY = "20cf19413d63bfdfd30e8e6dff933d33"
|
let KAKAO_APP_KEY = "20cf19413d63bfdfd30e8e6dff933d33"
|
||||||
|
|
||||||
let LINE_CHANNEL_ID = "2008995582"
|
let LINE_CHANNEL_ID = "2008995582"
|
||||||
|
|
||||||
|
let AGORA_CUSTOMER_ID = "de5dd9ea151f4a43ba1ad8411817b169"
|
||||||
|
let AGORA_CUSTOMER_SECRET = "3855da8bc5ae4743af8bf4f87408b515"
|
||||||
|
|||||||
@@ -729,6 +729,8 @@ enum I18n {
|
|||||||
|
|
||||||
static var signatureOn: String { pick(ko: "시그 ON", en: "Sign ON", ja: "シグ ON") }
|
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 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 backgroundOn: String { pick(ko: "배경 ON", en: "Back ON", ja: "背景 ON") }
|
||||||
static var backgroundOff: String { pick(ko: "배경 OFF", en: "Back OFF", ja: "背景 OFF") }
|
static var backgroundOff: String { pick(ko: "배경 OFF", en: "Back OFF", ja: "背景 OFF") }
|
||||||
static var notice: String { pick(ko: "공지", en: "Notice", ja: "お知らせ") }
|
static var notice: String { pick(ko: "공지", en: "Notice", ja: "お知らせ") }
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct GetRoomInfoResponse: Decodable {
|
|||||||
let channelName: String
|
let channelName: String
|
||||||
let rtcToken: String
|
let rtcToken: String
|
||||||
let rtmToken: String
|
let rtmToken: String
|
||||||
|
let v2vWorkerToken: String
|
||||||
let creatorId: Int
|
let creatorId: Int
|
||||||
let creatorNickname: String
|
let creatorNickname: String
|
||||||
let creatorProfileUrl: String
|
let creatorProfileUrl: String
|
||||||
@@ -25,6 +26,7 @@ struct GetRoomInfoResponse: Decodable {
|
|||||||
let managerList: [LiveRoomMember]
|
let managerList: [LiveRoomMember]
|
||||||
let donationRankingTop3UserIds: [Int]
|
let donationRankingTop3UserIds: [Int]
|
||||||
let menuPan: String
|
let menuPan: String
|
||||||
|
let creatorLanguageCode: String?
|
||||||
let isActiveRoulette: Bool
|
let isActiveRoulette: Bool
|
||||||
let isPrivateRoom: Bool
|
let isPrivateRoom: Bool
|
||||||
let password: String?
|
let password: String?
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
private var agora: Agora = Agora.shared
|
private var agora: Agora = Agora.shared
|
||||||
|
|
||||||
private let repository = LiveRepository()
|
private let repository = LiveRepository()
|
||||||
|
private let v2vRepository: V2VRepository
|
||||||
private let userRepository = UserRepository()
|
private let userRepository = UserRepository()
|
||||||
private let reportRepository = ReportRepository()
|
private let reportRepository = ReportRepository()
|
||||||
private let rouletteRepository = RouletteRepository()
|
private let rouletteRepository = RouletteRepository()
|
||||||
@@ -55,6 +56,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
|
|
||||||
@Published var isLoadingLikeHeart = false
|
@Published var isLoadingLikeHeart = false
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
|
@Published var isV2VLoading = false
|
||||||
@Published var errorMessage = ""
|
@Published var errorMessage = ""
|
||||||
@Published var reportMessage = ""
|
@Published var reportMessage = ""
|
||||||
@Published var isShowReportPopup = false
|
@Published var isShowReportPopup = false
|
||||||
@@ -283,9 +285,33 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
private var blockedMemberIdList = Set<Int>()
|
private var blockedMemberIdList = Set<Int>()
|
||||||
|
|
||||||
private var hasInvokedJoinChannel = false
|
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 발신자: 원격 물 채움 연출 억제 플래그
|
// 로컬 BIG_HEART 발신자: 원격 물 채움 연출 억제 플래그
|
||||||
private var suppressNextRemoteWaterFill = false
|
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() {
|
func getBlockedMemberIdList() {
|
||||||
userRepository.getBlockedMemberIdList()
|
userRepository.getBlockedMemberIdList()
|
||||||
@@ -332,8 +358,149 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func deInitAgoraEngine() {
|
private func deInitAgoraEngine() {
|
||||||
|
stopV2VTranslationIfJoined()
|
||||||
agora.deInit()
|
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) {
|
func agoraConnectSuccess(isManager: Bool) {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
@@ -360,6 +527,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func quitRoom() {
|
func quitRoom() {
|
||||||
|
stopV2VTranslationIfJoined()
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
|
||||||
if let index = muteSpeakers.firstIndex(of: UInt(UserDefaults.int(forKey: .userId))) {
|
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 {
|
if let data = decoded.data, decoded.success {
|
||||||
self.liveRoomInfo = data
|
self.liveRoomInfo = data
|
||||||
|
self.updateV2VAvailability(roomInfo: data)
|
||||||
|
|
||||||
if self.coverImageUrl != data.coverImageUrl {
|
if self.coverImageUrl != data.coverImageUrl {
|
||||||
self.coverImageUrl = data.coverImageUrl
|
self.coverImageUrl = data.coverImageUrl
|
||||||
@@ -2497,6 +2666,7 @@ extension LiveRoomViewModel: AgoraRtcEngineDelegate {
|
|||||||
DispatchQueue.main.async {[unowned self] in
|
DispatchQueue.main.async {[unowned self] in
|
||||||
if uid == UInt(self.liveRoomInfo!.creatorId) {
|
if uid == UInt(self.liveRoomInfo!.creatorId) {
|
||||||
// 라이브 종료
|
// 라이브 종료
|
||||||
|
self.stopV2VTranslationIfJoined()
|
||||||
self.liveRoomInfo = nil
|
self.liveRoomInfo = nil
|
||||||
self.errorMessage = "라이브가 종료되었습니다."
|
self.errorMessage = "라이브가 종료되었습니다."
|
||||||
self.isShowErrorPopup = true
|
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 {
|
extension LiveRoomViewModel: AgoraRtmClientDelegate {
|
||||||
@@ -2521,6 +2701,11 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
|
|||||||
let rawMessageString = String(data: message, encoding: .utf8)
|
let rawMessageString = String(data: message, encoding: .utf8)
|
||||||
|
|
||||||
DispatchQueue.main.async { [unowned self] in
|
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 {
|
if rawMessageString == LiveRoomRequestType.CHANGE_LISTENER.rawValue {
|
||||||
self.setListener()
|
self.setListener()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ struct LiveRoomInfoGuestView: View {
|
|||||||
let isOnNotice: Bool
|
let isOnNotice: Bool
|
||||||
let isOnMenuPan: Bool
|
let isOnMenuPan: Bool
|
||||||
let isOnSignature: Bool
|
let isOnSignature: Bool
|
||||||
|
let isOnV2VCaption: Bool
|
||||||
let isShowMenuPanButton: Bool
|
let isShowMenuPanButton: Bool
|
||||||
|
let isShowV2VCaptionButton: Bool
|
||||||
|
|
||||||
let creatorId: Int
|
let creatorId: Int
|
||||||
let creatorNickname: String
|
let creatorNickname: String
|
||||||
@@ -37,6 +39,7 @@ struct LiveRoomInfoGuestView: View {
|
|||||||
let onClickTotalHeart: () -> Void
|
let onClickTotalHeart: () -> Void
|
||||||
let onClickTotalDonation: () -> Void
|
let onClickTotalDonation: () -> Void
|
||||||
let onClickChangeListener: () -> Void
|
let onClickChangeListener: () -> Void
|
||||||
|
let onClickToggleV2VCaption: () -> Void
|
||||||
let onClickToggleSignature: () -> Void
|
let onClickToggleSignature: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -62,6 +65,20 @@ struct LiveRoomInfoGuestView: View {
|
|||||||
strokeCornerRadius: 5.3
|
strokeCornerRadius: 5.3
|
||||||
) { onClickChangeListener() }
|
) { 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(
|
LiveRoomOverlayStrokeTextToggleButton(
|
||||||
isOn: isOnSignature,
|
isOn: isOnSignature,
|
||||||
@@ -222,7 +239,9 @@ struct LiveRoomInfoGuestView_Previews: PreviewProvider {
|
|||||||
isOnNotice: false,
|
isOnNotice: false,
|
||||||
isOnMenuPan: false,
|
isOnMenuPan: false,
|
||||||
isOnSignature: false,
|
isOnSignature: false,
|
||||||
|
isOnV2VCaption: false,
|
||||||
isShowMenuPanButton: false,
|
isShowMenuPanButton: false,
|
||||||
|
isShowV2VCaptionButton: true,
|
||||||
creatorId: 1,
|
creatorId: 1,
|
||||||
creatorNickname: "도화",
|
creatorNickname: "도화",
|
||||||
creatorProfileUrl: "https://cf.sodalive.net/profile/26/26-profile-ddf78b4d-0300-4c50-9c84-5d8a95fd5fe2-4892-1705256364320",
|
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: {},
|
onClickTotalHeart: {},
|
||||||
onClickTotalDonation: {},
|
onClickTotalDonation: {},
|
||||||
onClickChangeListener: {},
|
onClickChangeListener: {},
|
||||||
|
onClickToggleV2VCaption: {},
|
||||||
onClickToggleSignature: {}
|
onClickToggleSignature: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ struct LiveRoomViewV2: View {
|
|||||||
isOnNotice: viewModel.isShowNotice,
|
isOnNotice: viewModel.isShowNotice,
|
||||||
isOnMenuPan: viewModel.isShowMenuPan,
|
isOnMenuPan: viewModel.isShowMenuPan,
|
||||||
isOnSignature: viewModel.isSignatureOn,
|
isOnSignature: viewModel.isSignatureOn,
|
||||||
|
isOnV2VCaption: viewModel.isV2VCaptionOn,
|
||||||
isShowMenuPanButton: !liveRoomInfo.menuPan.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
isShowMenuPanButton: !liveRoomInfo.menuPan.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
|
isShowV2VCaptionButton: viewModel.isV2VAvailable,
|
||||||
creatorId: liveRoomInfo.creatorId,
|
creatorId: liveRoomInfo.creatorId,
|
||||||
creatorNickname: liveRoomInfo.creatorNickname,
|
creatorNickname: liveRoomInfo.creatorNickname,
|
||||||
creatorProfileUrl: liveRoomInfo.creatorProfileUrl,
|
creatorProfileUrl: liveRoomInfo.creatorProfileUrl,
|
||||||
@@ -132,6 +134,9 @@ struct LiveRoomViewV2: View {
|
|||||||
onClickChangeListener: {
|
onClickChangeListener: {
|
||||||
viewModel.setListener()
|
viewModel.setListener()
|
||||||
},
|
},
|
||||||
|
onClickToggleV2VCaption: {
|
||||||
|
viewModel.toggleV2VCaption()
|
||||||
|
},
|
||||||
onClickToggleSignature: {
|
onClickToggleSignature: {
|
||||||
viewModel.isSignatureOn.toggle()
|
viewModel.isSignatureOn.toggle()
|
||||||
}
|
}
|
||||||
@@ -182,7 +187,7 @@ struct LiveRoomViewV2: View {
|
|||||||
.onPreferenceChange(ScrollOffsetKey.self) {
|
.onPreferenceChange(ScrollOffsetKey.self) {
|
||||||
viewModel.setOffset($0)
|
viewModel.setOffset($0)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 70)
|
.padding(.bottom, v2vCaptionBottomInset)
|
||||||
}
|
}
|
||||||
.padding(.top, 16)
|
.padding(.top, 16)
|
||||||
|
|
||||||
@@ -308,6 +313,19 @@ struct LiveRoomViewV2: View {
|
|||||||
}
|
}
|
||||||
.padding(.trailing, 13.3)
|
.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 {
|
LiveRoomInputChatView {
|
||||||
viewModel.sendMessage(chatMessage: $0) {
|
viewModel.sendMessage(chatMessage: $0) {
|
||||||
viewModel.isShowingNewChat = false
|
viewModel.isShowingNewChat = false
|
||||||
@@ -316,6 +334,7 @@ struct LiveRoomViewV2: View {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
.padding(.top, isV2VCaptionVisible ? -13.3 : 0)
|
||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +342,7 @@ struct LiveRoomViewV2: View {
|
|||||||
LiveRoomNewChatView{
|
LiveRoomNewChatView{
|
||||||
viewModel.isShowingNewChat = false
|
viewModel.isShowingNewChat = false
|
||||||
proxy.scrollTo(viewModel.messages.count - 1, anchor: .center)
|
proxy.scrollTo(viewModel.messages.count - 1, anchor: .center)
|
||||||
}.padding(.bottom, 70)
|
}.padding(.bottom, v2vCaptionBottomInset)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.isSignatureOn && viewModel.signatureImageUrl.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 {
|
if viewModel.isSignatureOn && viewModel.signatureImageUrl.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 {
|
||||||
@@ -474,6 +493,7 @@ struct LiveRoomViewV2: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
UIApplication.shared.isIdleTimerDisabled = false
|
UIApplication.shared.isIdleTimerDisabled = false
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
viewModel.stopV2VTranslationIfJoined()
|
||||||
viewModel.stopPeriodicPlaybackValidation()
|
viewModel.stopPeriodicPlaybackValidation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,6 +764,10 @@ struct LiveRoomViewV2: View {
|
|||||||
if viewModel.isLoading && viewModel.liveRoomInfo == nil {
|
if viewModel.isLoading && viewModel.liveRoomInfo == nil {
|
||||||
LoadingView()
|
LoadingView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if viewModel.isV2VLoading {
|
||||||
|
LoadingView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.overlay(alignment: .center) {
|
.overlay(alignment: .center) {
|
||||||
ZStack {
|
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 {
|
struct LiveRoomViewV2_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
LiveRoomViewV2()
|
LiveRoomViewV2()
|
||||||
|
|||||||
53
SodaLive/Sources/Live/Room/V2V/V2vApi.swift
Normal file
53
SodaLive/Sources/Live/Room/V2V/V2vApi.swift
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
109
SodaLive/Sources/Live/Room/V2V/V2vModels.swift
Normal file
109
SodaLive/Sources/Live/Room/V2V/V2vModels.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
128
SodaLive/Sources/Live/Room/V2V/V2vRepository.swift
Normal file
128
SodaLive/Sources/Live/Room/V2V/V2vRepository.swift
Normal file
@@ -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<String, V2VRepositoryError>
|
||||||
|
func leave(agentId: String) -> AnyPublisher<Void, V2VRepositoryError>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<V2vApi>
|
||||||
|
|
||||||
|
init(api: MoyaProvider<V2vApi> = MoyaProvider<V2vApi>()) {
|
||||||
|
self.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
func join(request: V2VJoinRequest) -> AnyPublisher<String, V2VRepositoryError> {
|
||||||
|
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<Void, V2VRepositoryError> {
|
||||||
|
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?
|
||||||
|
}
|
||||||
274
SodaLive/Sources/Live/Room/V2V/V2vState.swift
Normal file
274
SodaLive/Sources/Live/Room/V2V/V2vState.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,3 +29,6 @@ let GID_SERVER_CLIENT_ID = "983594297130-5hrmkh6vpskeq6v34350kmilf74574h2.apps.g
|
|||||||
let KAKAO_APP_KEY = "231cf78acfa8252fca38b9eedf87c5cb"
|
let KAKAO_APP_KEY = "231cf78acfa8252fca38b9eedf87c5cb"
|
||||||
|
|
||||||
let LINE_CHANNEL_ID = "2008995539"
|
let LINE_CHANNEL_ID = "2008995539"
|
||||||
|
|
||||||
|
let AGORA_CUSTOMER_ID = "de5dd9ea151f4a43ba1ad8411817b169"
|
||||||
|
let AGORA_CUSTOMER_SECRET = "3855da8bc5ae4743af8bf4f87408b515"
|
||||||
|
|||||||
Reference in New Issue
Block a user