diff --git a/MyLibrary/Package.resolved b/MyLibrary/Package.resolved index e98cef9..72d5243 100644 --- a/MyLibrary/Package.resolved +++ b/MyLibrary/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/flitto/rtt_sdk", "state" : { - "revision" : "e0870639abf3a1858d507d6b19e62ef5746c17fa", - "version" : "0.1.2" + "branch" : "0.1.5", + "revision" : "f1da670032cb52081285752b7a8c479118038393" } }, { diff --git a/MyLibrary/Package.swift b/MyLibrary/Package.swift index 1b1e690..2d7891c 100644 --- a/MyLibrary/Package.swift +++ b/MyLibrary/Package.swift @@ -42,6 +42,12 @@ let package = Package( "trySwiftFeature", ] ), + .target( + name: "BuildConfig", + dependencies: [ + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), .target( name: "DataClient", dependencies: [ @@ -69,6 +75,7 @@ let package = Package( .target( name: "LiveTranslationFeature", dependencies: [ + "BuildConfig", .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "rtt-sdk", package: "rtt_sdk"), ] diff --git a/MyLibrary/Sources/AppFeature/AppView.swift b/MyLibrary/Sources/AppFeature/AppView.swift index 591ff2e..b2e8c6c 100644 --- a/MyLibrary/Sources/AppFeature/AppView.swift +++ b/MyLibrary/Sources/AppFeature/AppView.swift @@ -13,6 +13,7 @@ public struct AppReducer { @ObservableState public struct State: Equatable { var schedule = Schedule.State() + var liveTranslation = LiveTranslation.State() var guidance = Guidance.State() var sponsors = SponsorsList.State() var trySwift = TrySwift.State() @@ -24,6 +25,7 @@ public struct AppReducer { public enum Action { case schedule(Schedule.Action) + case liveTranslation(LiveTranslation.Action) case guidance(Guidance.Action) case sponsors(SponsorsList.Action) case trySwift(TrySwift.Action) @@ -35,6 +37,9 @@ public struct AppReducer { Scope(state: \.schedule, action: \.schedule) { Schedule() } + Scope(state: \.liveTranslation, action: \.liveTranslation) { + LiveTranslation() + } Scope(state: \.guidance, action: \.guidance) { Guidance() } @@ -60,7 +65,7 @@ public struct AppView: View { .tabItem { Label(String(localized: "Schedule", bundle: .module), systemImage: "calendar") } - LiveTranslationView() + LiveTranslationView(store: store.scope(state: \.liveTranslation, action: \.liveTranslation)) .tabItem { Label(String(localized: "Translation", bundle: .module), systemImage: "text.bubble") } diff --git a/MyLibrary/Sources/BuildConfig/BuildConfig.swift b/MyLibrary/Sources/BuildConfig/BuildConfig.swift new file mode 100644 index 0000000..670fb61 --- /dev/null +++ b/MyLibrary/Sources/BuildConfig/BuildConfig.swift @@ -0,0 +1,24 @@ +import Dependencies +import Foundation +import DependenciesMacros + +@DependencyClient +public struct BuildConfig { + public var liveTranslationRoomNumber: () -> String = { "" } +} + +extension DependencyValues { + public var buildConfig: BuildConfig { + get { self[BuildConfig.self] } + set { self[BuildConfig.self] = newValue } + } +} + +extension BuildConfig: DependencyKey { + public static let liveValue: Self = Self( + liveTranslationRoomNumber: { + ProcessInfo.processInfo.environment["LIVE_TRANSLATION_KEY"] + ?? (Bundle.main.infoDictionary?["Live translation room number"] as? String) ?? "" + } + ) +} diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift new file mode 100644 index 0000000..6333ae9 --- /dev/null +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift @@ -0,0 +1,595 @@ +import ComposableArchitecture +import Foundation +import LiveTranslationSDK_iOS +import SwiftUI +import BuildConfig + +@Reducer +public struct LiveTranslation { + @ObservableState + public struct State: Equatable { + /// Live Translation Room Number + var roomNumber: String = "" + /// Current visible translation items + var chatList: [TranslationEntity.CompositeChatItem] = [] + /// Current language set + var langSet: LanguageEntity.Response.LangSet? = .none + /// Available language list + var langList: [LanguageEntity.Response.LanguageItem] = [] + /// Live Translation Room Info + var roomInfo: ChatRoomEntity.Make.Response? = .none + /// Current language code which user selected + var selectedLangCode: String = + Locale.autoupdatingCurrent.language.languageCode?.identifier ?? "en" + + /// While updating chat + var isUpdatingChat: Bool = false + /// While updating translation response + var isUpdatingTR: Bool = false + /// Chat updating request queue + var updateChatWaitingQueue: [RealTimeEntity.Chat.Response] = [] + /// Translation response request queue + var updateTrWaitingQueue: [RealTimeEntity.Translation.Response] = [] + /// Latest item's list type + var latestListType: RealTimeEntity.ListType? = .none + + /// Streaming is connected + var isConnected: Bool = false + /// The task of connecting stream + var chatStreamTask: Task? = nil + + /// selected language sheet + var isSelectedLanguageSheet: Bool = false + /// showing last chat + var isShowingLastChat: Bool = false + + public init() {} + } + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case connectChatStream + case disconnectChatStream + case changeLangCode(String) + case view(View) + + case handleResponseChat(RealTimeEntity.Chat.Response) + case checkUpdateChatWaitingQueue + case handleResponseTranslation(RealTimeEntity.Translation.Response) + case checkUpdateTRWaitingQueue + + public enum View { + case onAppear + case connectStream + case disconnectStream + case selectLangCode(String) + case setSelectedLanguageSheet(Bool) + case setShowingLastChat(Bool) + } + } + + @Dependency(\.liveTranslationServiceClient) var liveTranslationServiceClient + @Dependency(\.buildConfig) var buildConfig + + private let connectChatRoomTaskId: String = "connectChatRoomTask" + + public init() {} + + public var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .view(.onAppear): + state.roomNumber = buildConfig.liveTranslationRoomNumber() + return .run { [state] send in + await withTaskGroup(of: Void.self) { group in + group.addTask { + await loadLangSet(send: send) + } + group.addTask { + await loadChatRoomInfo(state: state, send: send) + } + group.addTask { + await loadLangList(send: send) + } + } + } + case .view(.connectStream): + return .run { [state] send in + await connectChatRoom(state: state, send: send) + }.cancellable(id: connectChatRoomTaskId) + case .view(.disconnectStream): + return .cancel(id: connectChatRoomTaskId) + case .view(.selectLangCode(let langCode)): + return .run { send in + await send(.changeLangCode(langCode)) + } + case .view(.setSelectedLanguageSheet(let flag)): + state.isSelectedLanguageSheet = flag + return .none + case .view(.setShowingLastChat(let flag)): + state.isShowingLastChat = flag + return .none + case .connectChatStream: + return .run { [state] send in + await connectChatRoom(state: state, send: send) + }.cancellable(id: connectChatRoomTaskId) + case .disconnectChatStream: + return .cancel(id: connectChatRoomTaskId) + case .changeLangCode(let newLangCode): + state.selectedLangCode = newLangCode + return .run { [state] send in + await loadLangSet(langCode: newLangCode, send: send) + await loadTranslation(chatList: state.chatList, newLangCode) + } + case .handleResponseChat(let chatItem): + return .run { [state] send in + await handleResponseChat(chatItem, state: state, send: send) + } + case .checkUpdateChatWaitingQueue: + return .run { [state] send in + await checkUpdateChatWaitingQueue(state: state, send: send) + } + case .handleResponseTranslation(let trItem): + return .run { [state] send in + await handleResponseTranslation(trItem, state: state, send: send) + } + case .checkUpdateTRWaitingQueue: + return .run { [state] send in + await checkUpdateTRWaitingQueue(state: state, send: send) + } + case .binding: + return .none + } + } + } +} + +extension LiveTranslation { + private func connectChatRoom(state: State, send: Send) async { + if state.isConnected { return } + + do { + let stream = liveTranslationServiceClient.chatConnection(state.roomNumber) + for try await action in stream { + switch action { + case .connect: + await send(.set(\.isConnected, true)) + break + case .disconnect: + await send(.set(\.isConnected, false)) + break + case .peerClosed: + await send(.connectChatStream) + case .responseChat(let chatItem): + await send(.handleResponseChat(chatItem)) + case .responseBatchTranslation(let trItem): + await send(.handleResponseTranslation(trItem)) + default: break + } + } + } catch { + print("\(#function): \(error.serialized().displayMessage)") + } + } + + private func loadLangSet(langCode: String? = nil, send: Send) async { + do { + let langSet = try await liveTranslationServiceClient.langSet(langCode) + await send( + .set(\.langSet, langSet) + ) + } catch { + print("\(#function): \(error.serialized().displayMessage)") + } + } + + private func loadLangList(send: Send) async { + do { + let langList = try await liveTranslationServiceClient.langList() + await send( + .set(\.langList, langList) + ) + } catch { + print("\(#function): \(error.serialized().displayMessage)") + } + } + + private func loadChatRoomInfo(state: State, send: Send) async { + do { + let roomInfo = try await liveTranslationServiceClient.chatRoomInfo(state.roomNumber) + await send( + .set(\.roomInfo, roomInfo) + ) + } catch { + print("\(#function): \(error.serialized().displayMessage)") + } + } + + private func loadTranslation( + chatList: [TranslationEntity.CompositeChatItem], _ dstLangCode: String + ) async { + + await withTaskGroup(of: Void.self) { group in + let chunkedArray = chatList.chunked(into: 20) + for array in chunkedArray { + group.addTask { + let mutatedArray = array.map { + RealTimeEntity.Translation.Request.ContentData( + chatRoomID: $0.item.chatRoomID, + chatID: $0.id, + srcLangCode: $0.item.srcLangCode, + dstLangCode: dstLangCode, + timestamp: $0.item.timestamp, + text: $0.item.textForTR + ) + } + await liveTranslationServiceClient.requestBatchTranslation(mutatedArray) + } + } + } + } + + /// Handle chat item response + private func handleResponseChat( + _ chatItem: RealTimeEntity.Chat.Response, state: State, send: Send + ) async { + guard !state.isUpdatingChat else { + await send( + .set(\.updateChatWaitingQueue, state.updateChatWaitingQueue + [chatItem]) + ) + return + } + // NOTE: Updating chat list + await send(.set(\.isUpdatingChat, true)) + await send(.set(\.latestListType, chatItem.contentData.listType)) + let newChatList = await state.chatList.merge( + item: chatItem, dstLangCode: state.selectedLangCode) + await send(.set(\.chatList, newChatList)) + await send(.set(\.isUpdatingChat, false)) + + switch chatItem.contentData.listType { + case .update: + let updateTargetList = chatItem.contentData.chatList.reduce( + [TranslationEntity.CompositeChatItem]() + ) { current, next in + guard let firstIndex = newChatList.firstIndex(where: { $0.id == next.id }) else { + return current + } + return current + [newChatList[firstIndex]] + } + await loadTranslation(chatList: updateTargetList, state.selectedLangCode) + + case .append: + guard let lastItem = newChatList.last else { return } + await loadTranslation(chatList: [lastItem], state.selectedLangCode) + + case .realtime: break + + default: + await loadTranslation(chatList: newChatList, state.selectedLangCode) + } + await send(.checkUpdateChatWaitingQueue) + } + + /// Check chat item wating queue + private func checkUpdateChatWaitingQueue(state: State, send: Send) async { + guard let task = state.updateChatWaitingQueue.first else { return } + await send(.set(\.updateChatWaitingQueue, state.updateChatWaitingQueue.dropFirst().map { $0 })) + await send(.handleResponseChat(task)) + } + + /// Handle translation item response + private func handleResponseTranslation( + _ trItem: RealTimeEntity.Translation.Response, state: State, send: Send + ) async { + guard !state.isUpdatingTR else { + await send(.set(\.updateTrWaitingQueue, state.updateTrWaitingQueue + [trItem])) + return + } + + await send(.set(\.isUpdatingTR, true)) + await send(.set(\.latestListType, trItem.contentData.listType)) + let newChatList = await state.chatList.updateTranslation(item: trItem) + await send(.set(\.chatList, newChatList)) + await send(.set(\.isUpdatingTR, false)) + + await send(.checkUpdateTRWaitingQueue) + } + + /// Check translation item waiting queue + private func checkUpdateTRWaitingQueue(state: State, send: Send) async { + guard let task = state.updateTrWaitingQueue.first else { return } + await send(.set(\.updateTrWaitingQueue, state.updateTrWaitingQueue.dropFirst().map { $0 })) + await send(.handleResponseTranslation(task)) + } +} + +@ViewAction(for: LiveTranslation.self) +public struct LiveTranslationView: View { + + @Bindable public var store: StoreOf + @Environment(\.scenePhase) var scenePhase + + private let scrollContentBottomID: String = "atBottom" + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + NavigationStack { + VStack { + ScrollViewReader { proxy in + ScrollView { + if store.roomNumber.isEmpty { + ContentUnavailableView("Room is unavailable", systemImage: "text.page.slash.fill") + Spacer() + } else if store.chatList.isEmpty { + ContentUnavailableView("Not started yet", systemImage: "text.page.slash.fill") + Spacer() + } else { + translationContents + } + + flittoLogo + .id(scrollContentBottomID) + .padding(.bottom, 16) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: "Powered by Flitto")) + } + .onChange(of: store.chatList.last) { old, new in + guard old != .none else { + proxy.scrollTo(scrollContentBottomID, anchor: .bottom) + return + } + + guard store.isShowingLastChat else { return } + + withAnimation(.interactiveSpring) { + proxy.scrollTo(scrollContentBottomID, anchor: .center) + } + } + .onChange(of: scenePhase) { + switch scenePhase { + case .inactive: break + case .active: + send(.connectStream) + case .background: + send(.disconnectStream) + @unknown default: break + } + } + } + } + .task { + send(.onAppear) + send(.connectStream) + } + .navigationTitle(Text("Live translation", bundle: .module)) + .toolbar { + if !store.isConnected { + ToolbarItem(placement: .topBarLeading) { + Button { + send(.connectStream) + } label: { + Image(systemName: "arrow.trianglehead.2.clockwise") + } + } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + send(.setSelectedLanguageSheet(!store.isSelectedLanguageSheet)) + } label: { + let selectedLanguage = + store.langSet?.langCodingKey(store.selectedLangCode) ?? "" + Text(selectedLanguage) + Image(systemName: "globe") + } + .sheet(isPresented: $store.isSelectedLanguageSheet) { + SelectLanguageSheet( + languageList: store.langList, + langSet: store.langSet, + selectedLanguageAction: { langCode in + send(.selectLangCode(langCode)) + send(.setSelectedLanguageSheet(false)) + } + ) + .presentationDetents([.medium, .large]) + } + } + } + } + } + + @ViewBuilder + var translationContents: some View { + LazyVStack { + ForEach(store.chatList) { item in + Text(item.trItem?.content ?? item.item.text) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .padding() + .onAppear { + guard item == store.chatList.last else { return } + send(.setShowingLastChat(true)) + } + .onDisappear { + guard item == store.chatList.last else { return } + send(.setShowingLastChat(false)) + } + } + } + } + + @ViewBuilder + var flittoLogo: some View { + HStack { + Spacer() + Text("Powered by", bundle: .module) + .font(.caption) + .foregroundStyle(Color(.secondaryLabel)) + Image(.flitto) + .resizable() + .offset(x: -10) + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 30) + .accessibilityIgnoresInvertColors() + Spacer() + } + } +} + +extension [TranslationEntity.CompositeChatItem] { + fileprivate func merge(item: RealTimeEntity.Chat.Response, dstLangCode: String) async + -> [TranslationEntity.CompositeChatItem] + { + await withCheckedContinuation { continuation in + switch item.contentData.listType { + case .append: + var mutableSelf = self + for newItem in item.contentData.chatList { + if let lastIdx = mutableSelf.lastIndex(where: { $0.id == newItem.id }) { + mutableSelf.remove(at: lastIdx) + } + + guard !(newItem.textForTR.isEmpty || newItem.text.isEmpty) else { continue } + mutableSelf.append( + .init(item: newItem, trItem: .none, ttsData: .none, dstLangCode: dstLangCode)) + } + + return continuation.resume(returning: mutableSelf.suffix(100)) + + case .realtime: + var mutableSelf = self + for newItem in item.contentData.chatList { + if let lastIdx = mutableSelf.lastIndex(where: { $0.id == newItem.id }) { + mutableSelf.remove(at: lastIdx) + } + + mutableSelf.append( + .init(item: newItem, trItem: .none, ttsData: .none, dstLangCode: dstLangCode)) + } + return continuation.resume(returning: mutableSelf) + + case .renew: + let newArr: [TranslationEntity.CompositeChatItem] = item.contentData.chatList.reduce([]) { + current, next in + guard !next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return current + } + let first = self.first(where: { $0.item.id == next.id }) + let new: TranslationEntity.CompositeChatItem = .init( + item: next, trItem: first?.trItem, ttsData: first?.ttsData, dstLangCode: dstLangCode) + + return current + [new] + } + + return continuation.resume(returning: newArr.suffix(100)) + + case .update: + let newArr = item.contentData.chatList.reduce(self) { current, next in + // If the update target is included in the current chat list (when modifying a chat with non-empty value) + if let idx = current.firstIndex(where: { $0.item.chatID == next.chatID }) { + var variableCurrent = current + + // If modified to empty value, delete the chat from the chat list + if next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + variableCurrent.remove(at: idx) + return variableCurrent + } else { + variableCurrent[idx] = .init( + item: next, + trItem: variableCurrent[idx].trItem, + ttsData: .none, + dstLangCode: dstLangCode) + return variableCurrent + } + // If the update target is not included in the current chat list (when modifying an empty chat) + } else if let willAppendIndex = current.firstIndex(where: { + $0.item.timestamp > next.timestamp + }) { + guard !next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return current + } + var variableCurrent = current + variableCurrent.insert( + .init(item: next, trItem: .none, ttsData: .none, dstLangCode: dstLangCode), + at: willAppendIndex) + return variableCurrent + } else { + return current + } + } + + return continuation.resume(returning: newArr.suffix(100)) + + default: + return continuation.resume(returning: self) + } + } + } + + fileprivate func updateTranslation(item: RealTimeEntity.Translation.Response) async + -> [TranslationEntity.CompositeChatItem] + { + await withCheckedContinuation { continuation in + guard + let firstIndex = self.firstIndex(where: { + $0.id == item.contentData.chatList.first?.chatID + } + ) + else { + return continuation.resume(returning: self) + } + + let range = firstIndex..<(firstIndex + item.contentData.chatList.count) + var mutatedArray: [TranslationEntity.CompositeChatItem] = [] + + for index in range { + guard let trItem = item.contentData.chatList[safe: mutatedArray.count] else { break } + guard let newItem = self[safe: index]?.setTranslation(trItem: trItem) else { break } + + mutatedArray.append(newItem) + } + + var mutateSelf = self + mutateSelf.replaceSubrange(range, with: mutatedArray) + + return continuation.resume(returning: mutateSelf) + } + } +} + +extension TranslationEntity.CompositeChatItem { + fileprivate func setTranslation(trItem: TranslationEntity.TR.Response) -> Self { + .init( + item: item, + trItem: trItem, + ttsData: .none, + dstLangCode: trItem.dstLangCode) + } +} + +extension Collection { + fileprivate subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} + +extension Array { + fileprivate func chunked(into size: Int) -> [[Element]] { + guard size > .zero else { return [self] } + return stride(from: 0, to: count, by: size).map { startIndex in + let endIndex = index(startIndex, offsetBy: size, limitedBy: count) ?? endIndex + return Array(self[startIndex.. LanguageEntity.Response.LangSet + public var langList: @Sendable () async throws -> [LanguageEntity.Response.LanguageItem] + public var chatRoomInfo: @Sendable (String) async throws -> ChatRoomEntity.Make.Response + public var chatConnection: + @Sendable (String) -> AsyncThrowingStream = { _ in .never + } + public var requestBatchTranslation: + @Sendable ([RealTimeEntity.Translation.Request.ContentData]) async -> Void +} + +extension LiveTranslationServiceClient: DependencyKey { + public static var liveValue: Self = { + let service = LiveTranslationService() + return Self( + langSet: { langCode in + try await service.getLangSet(.init(langCode: langCode ?? LanguageCodeFunctor.deviceCode)) + }, + langList: { + try await service.getLangList() + }, + chatRoomInfo: { roomNumber in + try await service.getChatRoomInfo(.init(interactionKey: roomNumber)) + }, + chatConnection: { roomNumber in + service.chatConnection(.init(interactionKey: roomNumber)) + }, + requestBatchTranslation: { array in + await service.requestBatchTranslation(.init(data: array)) + } + ) + }() +} diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationView.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationView.swift deleted file mode 100644 index 40adc73..0000000 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationView.swift +++ /dev/null @@ -1,116 +0,0 @@ -import LiveTranslationSDK_iOS -import SwiftUI - -public struct LiveTranslationView: View { - let viewModel: ViewModel - @State var isSelectedLanguageSheet: Bool = false - @State var isShowingLastChat: Bool = false - - private let scrollContentBottomID: String = "atBottom" - - public init( - roomNumber: String = ProcessInfo.processInfo.environment["LIVE_TRANSLATION_KEY"] - ?? (Bundle.main.infoDictionary?["Live translation room number"] as? String) ?? "" - ) { - print(roomNumber) - self.viewModel = ViewModel(roomNumber: roomNumber) - } - - public var body: some View { - NavigationStack { - VStack { - ScrollViewReader { reader in - ScrollView { - if self.viewModel.roomNumber.isEmpty { - ContentUnavailableView("Room is unavailable", systemImage: "text.page.slash.fill") - Spacer() - } else if viewModel.chatList.isEmpty { - ContentUnavailableView("Not started yet", systemImage: "text.page.slash.fill") - Spacer() - } else { - LazyVStack { - ForEach(viewModel.chatList) { item in - Text(item.trItem?.content ?? item.item.text) - .frame(maxWidth: .infinity, alignment: .leading) - .multilineTextAlignment(.leading) - .padding() - .onAppear { - guard item == viewModel.chatList.last else { return } - isShowingLastChat = true - } - .onDisappear { - guard item == viewModel.chatList.last else { return } - isShowingLastChat = false - } - } - } - } - - HStack { - Spacer() - Text("Powered by", bundle: .module) - .font(.caption) - .foregroundStyle(Color(.secondaryLabel)) - Image(.flitto) - .resizable() - .offset(x: -10) - .aspectRatio(contentMode: .fit) - .frame(maxHeight: 30) - .accessibilityIgnoresInvertColors() - Spacer() - } - .id(scrollContentBottomID) - .padding(.bottom, 16) - .accessibilityElement(children: .ignore) - .accessibilityLabel(Text(verbatim: "Powered by Flitto")) - } - .onChange(of: viewModel.chatList.last) { old, new in - guard old != .none else { - reader.scrollTo(scrollContentBottomID, anchor: .bottom) - return - } - - guard isShowingLastChat else { return } - - guard new != .none else { return } - withAnimation(.interactiveSpring) { - reader.scrollTo(scrollContentBottomID, anchor: .center) - } - } - } - } - .task { - viewModel.send(.onAppearedPage) - viewModel.send(.connectChatStream) - } - .navigationTitle(Text("Live translation", bundle: .module)) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - isSelectedLanguageSheet.toggle() - } label: { - let selectedLanguage = - viewModel.langSet?.langCodingKey(viewModel.selectedLangCode) ?? "" - Text(selectedLanguage) - Image(systemName: "globe") - } - .sheet(isPresented: $isSelectedLanguageSheet) { - SelectLanguageSheet( - languageList: viewModel.langList, - langSet: viewModel.langSet, - selectedLanguageAction: { langCode in - viewModel.send(.changeLangCode(langCode)) - isSelectedLanguageSheet = false - } - ) - .presentationDetents([.medium, .large]) - } - } - } - } - } -} - -#Preview { - LiveTranslationView(roomNumber: "490294") -} diff --git a/MyLibrary/Sources/LiveTranslationFeature/ViewModel.swift b/MyLibrary/Sources/LiveTranslationFeature/ViewModel.swift deleted file mode 100644 index bc26f22..0000000 --- a/MyLibrary/Sources/LiveTranslationFeature/ViewModel.swift +++ /dev/null @@ -1,346 +0,0 @@ -import Foundation -import LiveTranslationSDK_iOS - -@Observable -@MainActor -public final class ViewModel { - public init(roomNumber: String) { - self.roomNumber = roomNumber - } - - var roomNumber: String - var chatList: [TranslationEntity.CompositeChatItem] = [] - var langSet: LanguageEntity.Response.LangSet? = .none - var langList: [LanguageEntity.Response.LanguageItem] = [] - var roomInfo: ChatRoomEntity.Make.Response? = .none - var selectedLangCode: String = - Locale.autoupdatingCurrent.language.languageCode?.identifier ?? "en" - - let service: LiveTranslationService = .init() - - var isUpdatingChat: Bool = false - var isUpdatingTR: Bool = false - var updateChatWaitingQueue: [RealTimeEntity.Chat.Response] = [] - var updateTrWaitingQueue: [RealTimeEntity.Translation.Response] = [] - var latestListType: RealTimeEntity.ListType? = .none -} - -extension ViewModel { - public func send(_ inputAction: InputAction) { - switch inputAction { - case .onAppearedPage: - Task { - await withTaskGroup(of: Void.self) { [weak self] group in - group.addTask { await self?.loadLangSet() } - group.addTask { await self?.loadChatRoomInfo(self?.roomNumber) } - group.addTask { await self?.loadLangList() } - } - } - case .connectChatStream: - Task { - await connectChatStream(roomNumber) - } - case .changeLangCode(let newLangCode): - selectedLangCode = newLangCode - Task { - await loadLangSet(langCode: newLangCode) - await loadTranslation(chatList: chatList, newLangCode) - } - } - } -} - -extension ViewModel { - private func loadLangSet(langCode: String = LanguageCodeFunctor.deviceCode) async { - do { - let langSet = try await service.getLangSet(.init(langCode: langCode)) - self.langSet = langSet - } catch { - print(error.displayMessage) - } - } - - private func loadLangList() async { - do { - let langList = try await service.getLangList() - self.langList = langList - } catch { - print(error.displayMessage) - } - } - - private func loadChatRoomInfo(_ roomNumber: String?) async { - do { - guard let roomNumber else { return assert(true, "roomNumber is required") } - let roomInfo = try await service.getChatRoomInfo(.init(interactionKey: roomNumber)) - self.roomInfo = roomInfo - } catch { - print(error.displayMessage) - } - } - - private func connectChatStream(_ roomNumber: String) async { - do { - let stream = service.chatConnection(.init(interactionKey: roomNumber)) - for try await action in stream { - switch action { - case .connect: break - case .disconnect: break - case .peerClosed: - send(.connectChatStream) - case .responseChat(let chatItem): - await handleResponseChat(chatItem) - case .responseBatchTranslation(let trItem): - await handleResponseTranslation(trItem) - default: break - } - } - } catch { - print(error.serialized().displayMessage) - } - } - - package func loadTranslation( - chatList: [TranslationEntity.CompositeChatItem], _ dstLangCode: String - ) async { - await withTaskGroup(of: Void.self) { [weak self] group in - let chunkedArray = chatList.chunked(into: 20) - for array in chunkedArray { - group.addTask { - let mutatedArray = array.map { - RealTimeEntity.Translation.Request.ContentData( - chatRoomID: $0.item.chatRoomID, - chatID: $0.id, - srcLangCode: $0.item.srcLangCode, - dstLangCode: dstLangCode, - timestamp: $0.item.timestamp, - text: $0.item.textForTR) - } - await self?.service.requestBatchTranslation(.init(data: mutatedArray)) - } - } - } - } -} - -extension ViewModel { - fileprivate func handleResponseChat(_ chatItem: RealTimeEntity.Chat.Response) async { - guard !isUpdatingChat else { - updateChatWaitingQueue.append(chatItem) - return - } - - self.isUpdatingChat = true - self.latestListType = chatItem.contentData.listType - let newChatList = await self.chatList.merge(item: chatItem, dstLangCode: selectedLangCode) - - self.chatList = newChatList - self.isUpdatingChat = false - - switch chatItem.contentData.listType { - case .update: - let updateTargetList = chatItem.contentData.chatList.reduce( - [TranslationEntity.CompositeChatItem]() - ) { current, next in - guard let firstIndex = newChatList.firstIndex(where: { $0.id == next.id }) else { - return current - } - return current + [newChatList[firstIndex]] - } - await loadTranslation(chatList: updateTargetList, selectedLangCode) - - case .append: - guard let lastItem = newChatList.last else { return } - await loadTranslation(chatList: [lastItem], selectedLangCode) - - case .realtime: break - default: await loadTranslation(chatList: chatList, selectedLangCode) - } - - await checkUpdateChatWaitingQueue() - } - - private func checkUpdateChatWaitingQueue() async { - guard let task = updateChatWaitingQueue.first else { return } - updateChatWaitingQueue.removeFirst() - await handleResponseChat(task) - } - - private func handleResponseTranslation(_ trItem: RealTimeEntity.Translation.Response) async { - guard !isUpdatingTR else { - updateTrWaitingQueue.append(trItem) - return - } - - self.isUpdatingTR = true - self.latestListType = trItem.contentData.listType - - let newChatList = await self.chatList.updateTranslation(item: trItem) - - self.chatList = newChatList - self.isUpdatingTR = false - - await checkUpdateTRWaitingQueue() - } - - private func checkUpdateTRWaitingQueue() async { - guard let task = updateTrWaitingQueue.first else { return } - updateTrWaitingQueue.removeFirst() - await handleResponseTranslation(task) - } -} - -extension [TranslationEntity.CompositeChatItem] { - fileprivate func merge(item: RealTimeEntity.Chat.Response, dstLangCode: String) async - -> [TranslationEntity.CompositeChatItem] - { - await withCheckedContinuation { continuation in - switch item.contentData.listType { - case .append: - var mutableSelf = self - for newItem in item.contentData.chatList { - if let lastIdx = mutableSelf.lastIndex(where: { $0.id == newItem.id }) { - mutableSelf.remove(at: lastIdx) - } - - guard !(newItem.textForTR.isEmpty || newItem.text.isEmpty) else { continue } - mutableSelf.append( - .init(item: newItem, trItem: .none, ttsData: .none, dstLangCode: dstLangCode)) - } - - return continuation.resume(returning: mutableSelf.suffix(100)) - - case .realtime: - var mutableSelf = self - for newItem in item.contentData.chatList { - if let lastIdx = mutableSelf.lastIndex(where: { $0.id == newItem.id }) { - mutableSelf.remove(at: lastIdx) - } - - mutableSelf.append( - .init(item: newItem, trItem: .none, ttsData: .none, dstLangCode: dstLangCode)) - } - return continuation.resume(returning: mutableSelf) - - case .renew: - let newArr: [TranslationEntity.CompositeChatItem] = item.contentData.chatList.reduce([]) { - current, next in - guard !next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return current - } - let first = self.first(where: { $0.item.id == next.id }) - let new: TranslationEntity.CompositeChatItem = .init( - item: next, trItem: first?.trItem, ttsData: first?.ttsData, dstLangCode: dstLangCode) - - return current + [new] - } - - return continuation.resume(returning: newArr.suffix(100)) - - case .update: - let newArr = item.contentData.chatList.reduce(self) { current, next in - // If the update target is included in the current chat list (when modifying a chat with non-empty value) - if let idx = current.firstIndex(where: { $0.item.chatID == next.chatID }) { - var variableCurrent = current - - // If modified to empty value, delete the chat from the chat list - if next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - variableCurrent.remove(at: idx) - return variableCurrent - } else { - variableCurrent[idx] = .init( - item: next, - trItem: variableCurrent[idx].trItem, - ttsData: .none, - dstLangCode: dstLangCode) - return variableCurrent - } - // If the update target is not included in the current chat list (when modifying an empty chat) - } else if let willAppendIndex = current.firstIndex(where: { - $0.item.timestamp > next.timestamp - }) { - guard !next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return current - } - var variableCurrent = current - variableCurrent.insert( - .init(item: next, trItem: .none, ttsData: .none, dstLangCode: dstLangCode), - at: willAppendIndex) - return variableCurrent - } else { - return current - } - } - - return continuation.resume(returning: newArr.suffix(100)) - - default: - return continuation.resume(returning: self) - } - } - } - - fileprivate func updateTranslation(item: RealTimeEntity.Translation.Response) async - -> [TranslationEntity.CompositeChatItem] - { - await withCheckedContinuation { continuation in - guard - let firstIndex = self.firstIndex(where: { - $0.id == item.contentData.chatList.first?.chatID - } - ) - else { - return continuation.resume(returning: self) - } - - let range = firstIndex..<(firstIndex + item.contentData.chatList.count) - var mutatedArray: [TranslationEntity.CompositeChatItem] = [] - - for index in range { - guard let trItem = item.contentData.chatList[safe: mutatedArray.count] else { break } - guard let newItem = self[safe: index]?.setTranslation(trItem: trItem) else { break } - - mutatedArray.append(newItem) - } - - var mutateSelf = self - mutateSelf.replaceSubrange(range, with: mutatedArray) - - return continuation.resume(returning: mutateSelf) - } - } -} - -extension ViewModel { - public enum InputAction { - case onAppearedPage - case connectChatStream - case changeLangCode(String) - } -} - -extension TranslationEntity.CompositeChatItem { - fileprivate func setTranslation(trItem: TranslationEntity.TR.Response) -> Self { - .init( - item: item, - trItem: trItem, - ttsData: .none, - dstLangCode: trItem.dstLangCode) - } -} - -extension Collection { - fileprivate subscript(safe index: Index) -> Element? { - indices.contains(index) ? self[index] : nil - } -} - -extension Array { - func chunked(into size: Int) -> [[Element]] { - guard size > .zero else { return [self] } - return stride(from: 0, to: count, by: size).map { startIndex in - let endIndex = index(startIndex, offsetBy: size, limitedBy: count) ?? endIndex - return Array(self[startIndex..