diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionView.swift index 115cfe29e..e9979e969 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionView.swift @@ -2,15 +2,18 @@ import Models import Services import SwiftUI + import Utils import Views struct TextToSpeechVoiceSelectionView: View { @EnvironmentObject var audioController: AudioController + @EnvironmentObject var dataService: DataService + + @StateObject var viewModel = TextToSpeechVoiceSelectionViewModel() + let language: VoiceLanguage let showLanguageChanger: Bool - @State var playbackSample: String? = nil - init(forLanguage: VoiceLanguage, showLanguageChanger: Bool) { self.language = forLanguage self.showLanguageChanger = showLanguageChanger @@ -20,15 +23,30 @@ Group { Form { if language.key == "en" { - Toggle("Use Ultra Realistic Voices", isOn: $audioController.useUltraRealisticVoices) - .accentColor(Color.green) - } + if viewModel.waitingForRealisticVoices { + HStack { + Text("Signing up for beta") + Spacer() + ProgressView() + } + } else { + Toggle("Use Ultra Realistic Voices", isOn: $viewModel.realisticVoicesToggle) + .accentColor(Color.green) + } - if language.key == "en", audioController.useUltraRealisticVoices { - Section { - Text("Ultra realistic voices take longer to generate and do not offer a follow along user interface.") + if !viewModel.waitingForRealisticVoices, !audioController.ultraRealisticFeatureKey.isEmpty { + Text("You are in the ultra realistic voices beta. During the beta you can listen to 10,000 words of audio per day.") + .multilineTextAlignment(.leading) + } else if audioController.ultraRealisticFeatureRequested { + Text("Your request to join the ultra realistic voices demo has been received. You will be informed by email when a spot is available.") + .multilineTextAlignment(.leading) + } else { + Text("Ultra realistic voices are currently in limited beta. Enabling the feature will add you to the beta queue.") .multilineTextAlignment(.leading) } + } + + if audioController.useUltraRealisticVoices { ultraRealisticVoices } else { if showLanguageChanger { @@ -43,6 +61,24 @@ } } .navigationTitle("Choose a Voice") + .onAppear { + viewModel.realisticVoicesToggle = (audioController.useUltraRealisticVoices && !audioController.ultraRealisticFeatureKey.isEmpty) + }.onChange(of: viewModel.realisticVoicesToggle) { value in + if value, audioController.ultraRealisticFeatureKey.isEmpty { + // User wants to sign up + viewModel.waitingForRealisticVoices = true + Task { + await viewModel.requestUltraRealisticFeatureAccess( + dataService: self.dataService, + audioController: audioController + ) + } + } else if value, !audioController.ultraRealisticFeatureKey.isEmpty { + audioController.useUltraRealisticVoices = true + } else if !value { + audioController.useUltraRealisticVoices = false + } + } } private var standardVoices: some View { @@ -69,27 +105,27 @@ HStack { Button(action: { if audioController.isPlayingSample(voice: voice.key) { - playbackSample = nil + viewModel.playbackSample = nil audioController.stopVoiceSample() } else { - playbackSample = voice.key + viewModel.playbackSample = voice.key audioController.playVoiceSample(voice: voice.key) Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { timer in let playing = audioController.isPlayingSample(voice: voice.key) if playing { - playbackSample = voice.key + viewModel.playbackSample = voice.key } else if !playing { // If the playback sample is something else, its taken ownership // of the value so we just ignore it and shut down our timer. - if playbackSample == voice.key { - playbackSample = nil + if viewModel.playbackSample == voice.key { + viewModel.playbackSample = nil } timer.invalidate() } } } }, label: { - if playbackSample == voice.key { + if viewModel.playbackSample == voice.key { Image(systemName: "stop.circle") .font(.appTitleTwo) .padding(.trailing, 16) diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionViewModel.swift new file mode 100644 index 000000000..0d17aa374 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionViewModel.swift @@ -0,0 +1,49 @@ +// +// TextToSpeechVoiceSelectionViewModel.swift +// +// +// Created by Jackson Harper on 11/10/22. +// + +import CoreData +import Foundation +import Models +import Services +import SwiftUI +import Views + +@MainActor final class TextToSpeechVoiceSelectionViewModel: ObservableObject { + @Published var playbackSample: String? + @Published var realisticVoicesToggle: Bool = false + @Published var waitingForRealisticVoices: Bool = false + + func requestUltraRealisticFeatureAccess( + dataService: DataService, + audioController: AudioController + ) async { + do { + let feature = try await dataService.optInFeature(name: "ultra-realistic-voice") + DispatchQueue.main.async { + if let feature = feature { + audioController.useUltraRealisticVoices = true + audioController.ultraRealisticFeatureRequested = true + audioController.ultraRealisticFeatureKey = feature.granted ? feature.token : "" + self.realisticVoicesToggle = true + } else { + audioController.useUltraRealisticVoices = false + audioController.ultraRealisticFeatureKey = "" + audioController.ultraRealisticFeatureRequested = false + self.realisticVoicesToggle = false + } + self.waitingForRealisticVoices = false + } + } catch { + print("ERROR OPTING INTO FEATURE", error) + audioController.useUltraRealisticVoices = false + realisticVoicesToggle = false + waitingForRealisticVoices = false + audioController.ultraRealisticFeatureRequested = false + Snackbar.show(message: "Error signing up for beta. Please try again.") + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index 58fff4248..8840e82d0 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -275,6 +275,9 @@ @AppStorage(UserDefaultKey.textToSpeechUseUltraRealisticVoices.rawValue) public var useUltraRealisticVoices = false + @AppStorage(UserDefaultKey.textToSpeechUltraRealisticFeatureKey.rawValue) public var ultraRealisticFeatureKey: String = "" + @AppStorage(UserDefaultKey.textToSpeechUltraRealisticFeatureRequested.rawValue) public var ultraRealisticFeatureRequested: Bool = false + public var currentVoiceLanguage: VoiceLanguage { Voices.Languages.first(where: { $0.key == currentLanguage }) ?? Voices.English } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/OptIntoFeature.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/OptIntoFeature.swift new file mode 100644 index 000000000..2202b95db --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/OptIntoFeature.swift @@ -0,0 +1,57 @@ +// +// File.swift +// +// +// Created by Jackson Harper on 11/10/22. +// + +import Foundation +import Models +import SwiftGraphQL + +public struct Feature { + public let name: String + public let token: String + public let granted: Bool +} + +public extension DataService { + func optInFeature(name: String) async throws -> Feature? { + enum MutationResult { + case success(feature: Feature) + case error(errorCode: Enums.OptInFeatureErrorCode) + } + + let featureSelection = Selection.Feature { Feature(name: try $0.name(), token: try $0.token(), granted: try $0.grantedAt() != nil) } + let selection = Selection { + try $0.on( + optInFeatureError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }, + optInFeatureSuccess: .init { .success(feature: try $0.feature(selection: featureSelection)) } + ) + } + + let mutation = Selection.Mutation { + try $0.optInFeature(input: InputObjects.OptInFeatureInput(name: name), + selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network error")) + return + } + + switch payload.data { + case let .success(feature: feature): + continuation.resume(returning: feature) + case let .error(errorCode: errorCode): + continuation.resume(throwing: BasicError.message(messageText: errorCode.rawValue)) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift index 7ab00c494..ed93b0045 100644 --- a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift +++ b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift @@ -18,6 +18,8 @@ public enum UserDefaultKey: String { case textToSpeechDefaultLanguage case textToSpeechPreloadEnabled case textToSpeechUseUltraRealisticVoices + case textToSpeechUltraRealisticFeatureKey + case textToSpeechUltraRealisticFeatureRequested case recentSearchTerms case audioPlayerExpanded case themeName