diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift index b7b7d39f0..93093f2ff 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift @@ -171,7 +171,7 @@ public struct ShareExtensionView: View { extensionContext?.completeRequest(returningItems: [], completionHandler: nil) }, label: { - Text("Dismiss") + Text("Read Later") .frame(maxWidth: .infinity) } ) diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift index 37bb01a2a..6f8d91ec9 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift @@ -19,6 +19,7 @@ public struct MiniPlayer: View { @State var expanded = false @State var offset: CGFloat = 0 @State var showVoiceSheet = false + @State var showLanguageSheet = false @Namespace private var animation let minExpandedHeight = UIScreen.main.bounds.height / 3 @@ -324,7 +325,16 @@ public struct MiniPlayer: View { .onTapGesture { withAnimation(.easeIn(duration: 0.08)) { expanded = true } }.sheet(isPresented: $showVoiceSheet) { - changeVoiceView + NavigationView { + TextToSpeechVoiceSelectionView(forLanguage: audioController.currentVoiceLanguage) + .navigationBarTitle("Voice") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }) { + Image(systemName: "chevron.backward") + }) + } + }.sheet(isPresented: $showLanguageSheet) { + TextToSpeechLanguageView() } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index 1f327fd83..6a665c1ee 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -99,6 +99,12 @@ struct ProfileView: View { } } + Section { + NavigationLink(destination: TextToSpeechView()) { + Text("Text to Speech") + } + } + Section { NavigationLink( destination: BasicWebAppView.privacyPolicyWebView(baseURL: dataService.appEnvironment.webAppBaseURL) diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift new file mode 100644 index 000000000..ac27a98c5 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift @@ -0,0 +1,44 @@ +import Models +import Services +import SwiftUI +import Views + +struct TextToSpeechLanguageView: View { + @EnvironmentObject var audioController: AudioController + + var body: some View { + Group { + #if os(iOS) + Form { + innerBody + } + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } + .navigationTitle("Default Language") + } + + private var innerBody: some View { + ForEach(VOICELANGUAGES, id: \.key.self) { language in + Button(action: { + audioController.defaultLanguage = language.key + }) { + HStack { + Text(language.name) + + Spacer() + + if audioController.defaultLanguage == language.key { + Image(systemName: "checkmark") + } + } + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift new file mode 100644 index 000000000..bd2a08cc1 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift @@ -0,0 +1,82 @@ +import Models +import Services +import SwiftUI +import Views + +@MainActor final class TextToSpeechViewModel: ObservableObject { + @Published var enableAudioPrefetch: Bool = true +// func cancelSubscription(dataService: DataService) async -> Bool { +// guard let subscriptionName = subscriptionNameToCancel else { return false } +// +// do { +// try await dataService.deleteSubscription(subscriptionName: subscriptionName) +// let index = subscriptions.firstIndex { $0.name == subscriptionName } +// if let index = index { +// subscriptions.remove(at: index) +// } +// return true +// } catch { +// appLogger.debug("failed to remove subscription") +// return false +// } +// } +} + +struct TextToSpeechView: View { + @EnvironmentObject var audioController: AudioController + @StateObject var viewModel = TextToSpeechViewModel() + + var body: some View { + Group { + #if os(iOS) + Form { + Section("Audio Settings") { + Toggle("Enable audio prefetch", isOn: $viewModel.enableAudioPrefetch) + } +// Currently the backend doesn't allow overriding the language +// NavigationLink(destination: TextToSpeechLanguageView()) { +// Text("Default Language") +// } + innerBody + } + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } + } + + private var innerBody: some View { + Section("Voices") { + ForEach(VOICELANGUAGES, id: \.key) { language in + NavigationLink(destination: TextToSpeechVoiceSelectionView(forLanguage: language)) { + Text(language.name) + } + } + } +// ForEach(VoiceCategory.allCases, id: \.self) { category in +// Section(category.rawValue) { +// ForEach(audioController.voiceList?.filter { $0.category == category } ?? [], id: \.key.self) { voice in +// Button(action: { +// audioController.currentVoice = voice.key +// // self.showVoiceSheet = false +// }) { +// HStack { +// Text(voice.name) +// +// Spacer() +// +// if voice.selected { +// Image(systemName: "checkmark") +// } +// } +// .contentShape(Rectangle()) +// } +// .buttonStyle(PlainButtonStyle()) +// } +// } +// } + } +} diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index 30b35f970..03a45ee30 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -30,6 +30,7 @@ + @@ -91,11 +92,4 @@ - - - - - - - \ No newline at end of file diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift index 965ce82d2..150cd493d 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift @@ -19,6 +19,7 @@ public struct LinkedItemAudioProperties { public let author: String? public let siteName: String? public let imageURL: URL? + public let language: String? } // Internal model used for parsing a push notification object only @@ -36,6 +37,7 @@ public struct JSONArticle: Decodable { public let contentReader: String public let url: String public let isArchived: Bool + public let language: String? } public extension LinkedItem { @@ -100,7 +102,8 @@ public extension LinkedItem { title: unwrappedTitle, author: author, siteName: siteName, - imageURL: imageURL + imageURL: imageURL, + language: language ) } diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index 618a6cbf1..f03eec18f 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -32,26 +32,66 @@ enum DownloadPriority: String { case high } -struct VoicePair { +public struct VoiceLanguage { + public let key: String + public let name: String + public let defaultVoice: String + public let categories: [VoiceCategory] +} + +public enum VoiceCategory: String, CaseIterable { + case enUS = "English (US)" + case enAU = "English (Australia)" + case enCA = "English (Canada)" + case enIE = "English (Ireland)" + case enIN = "English (India)" + case enSG = "English (Singapore)" + case enUK = "English (UK)" + case jaJP = "Japanese (Japan)" + case zhCN = "Chinese (China Mainland)" +} + +public struct VoicePair { let firstKey: String let secondKey: String let firstName: String let secondName: String + + let category: VoiceCategory } -// swiftlint:disable all -let VOICES = [ - VoicePair(firstKey: "en-US-JennyNeural", secondKey: "en-US-BrandonNeural", firstName: "Jenny (USA)", secondName: "Brandon (USA)"), - VoicePair(firstKey: "en-US-CoraNeural", secondKey: "en-US-ChristopherNeural", firstName: "Cora (USA)", secondName: "Christopher (USA)"), - VoicePair(firstKey: "en-US-ElizabethNeural", secondKey: "en-US-EricNeural", firstName: "Elizabeth (USA)", secondName: "Eric (USA)"), - VoicePair(firstKey: "en-CA-ClaraNeural", secondKey: "en-CA-LiamNeural", firstName: "Clara (Canada)", secondName: "Liam (Canada)"), - VoicePair(firstKey: "en-GB-LibbyNeural", secondKey: "en-GB-EthanNeural", firstName: "Libby (UK)", secondName: "Ethan (UK)"), - VoicePair(firstKey: "en-AU-NatashaNeural", secondKey: "en-AU-WilliamNeural", firstName: "Natasha (Australia)", secondName: "William (Australia)"), - VoicePair(firstKey: "en-IN-NeerjaNeural", secondKey: "en-IN-PrabhatNeural", firstName: "Neerja (India)", secondName: "Prabhat (India)"), - VoicePair(firstKey: "en-SG-LunaNeural", secondKey: "en-SG-WayneNeural", firstName: "Luna (Singapore)", secondName: "Wayne (Singapore)") +private let ENGLISH = VoiceLanguage(key: "en", name: "English", defaultVoice: "en-US-ChristopherNeural", categories: [.enUS, .enAU, .enCA, .enIE, .enIN, .enSG, .enUK]) + +public let VOICELANGUAGES = [ + ENGLISH, + VoiceLanguage(key: "ja", name: "Japanese", defaultVoice: "ja-JP-NanamiNeural", categories: [.jaJP]), + VoiceLanguage(key: "zh", name: "Chinese", defaultVoice: "zh-CN-XiaochenNeural", categories: [.zhCN]) ] +// swiftlint:disable all +public let VOICES = [ + // en + VoicePair(firstKey: "en-US-JennyNeural", secondKey: "en-US-BrandonNeural", firstName: "Jenny", secondName: "Brandon", category: .enUS), + VoicePair(firstKey: "en-US-CoraNeural", secondKey: "en-US-ChristopherNeural", firstName: "Cora", secondName: "Christopher", category: .enUS), + VoicePair(firstKey: "en-US-ElizabethNeural", secondKey: "en-US-EricNeural", firstName: "Elizabeth", secondName: "Eric", category: .enUS), + VoicePair(firstKey: "en-CA-ClaraNeural", secondKey: "en-CA-LiamNeural", firstName: "Clara", secondName: "Liam", category: .enCA), + VoicePair(firstKey: "en-GB-LibbyNeural", secondKey: "en-GB-EthanNeural", firstName: "Libby", secondName: "Ethan", category: .enUK), + VoicePair(firstKey: "en-AU-NatashaNeural", secondKey: "en-AU-WilliamNeural", firstName: "Natasha", secondName: "William", category: .enAU), + VoicePair(firstKey: "en-IE-ConnorNeural", secondKey: "en-IE-EmilyNeural", firstName: "Connor", secondName: "Emily", category: .enIE), + VoicePair(firstKey: "en-IN-NeerjaNeural", secondKey: "en-IN-PrabhatNeural", firstName: "Neerja", secondName: "Prabhat", category: .enIN), + VoicePair(firstKey: "en-SG-LunaNeural", secondKey: "en-SG-WayneNeural", firstName: "Luna", secondName: "Wayne", category: .enSG), + + // ja + VoicePair(firstKey: "ja-JP-NanamiNeural", secondKey: "ja-JP-KeitaNeural", firstName: "Nanami", secondName: "Keita", category: .jaJP), + + // zh + VoicePair(firstKey: "zh-CN-XiaochenNeural", secondKey: "zh-CN-XiaohanNeural", firstName: "Xiaochen", secondName: "Xiaohan", category: .zhCN), + VoicePair(firstKey: "zh-CN-XiaoxiaoNeural", secondKey: "zh-CN-YunyangNeural", firstName: "Xiaoxiao", secondName: "Yunyang", category: .zhCN) +] + +let VOICE_REGIONS = ["English "] + // Somewhat based on: https://github.com/neekeetab/CachingPlayerItem/blob/master/CachingPlayerItem.swift class SpeechPlayerItem: AVPlayerItem { let resourceLoaderDelegate = ResourceLoaderDelegate() @@ -112,6 +152,10 @@ class SpeechPlayerItem: AVPlayerItem { weak var owner: SpeechPlayerItem? func resourceLoader(_: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { + if owner == nil { + return true + } + if session == nil { guard let initialUrl = owner?.speechItem.urlRequest else { fatalError("internal inconsistency") @@ -207,7 +251,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate @Published public var duration: TimeInterval = 0 @Published public var timeElapsedString: String? @Published public var durationString: String? - @Published public var voiceList: [(name: String, key: String, selected: Bool)]? + @Published public var voiceList: [(name: String, key: String, category: VoiceCategory, selected: Bool)]? let appEnvironment: AppEnvironment let networker: Networker @@ -266,11 +310,11 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } } - public func generateVoiceList() -> [(name: String, key: String, selected: Bool)] { + public func generateVoiceList() -> [(name: String, key: String, category: VoiceCategory, selected: Bool)] { VOICES.flatMap { voicePair in [ - (name: voicePair.firstName, key: voicePair.firstKey, selected: voicePair.firstKey == currentVoice), - (name: voicePair.secondName, key: voicePair.secondKey, selected: voicePair.secondKey == currentVoice) + (name: voicePair.firstName, key: voicePair.firstKey, category: voicePair.category, selected: voicePair.firstKey == currentVoice), + (name: voicePair.secondName, key: voicePair.secondKey, category: voicePair.category, selected: voicePair.secondKey == currentVoice) ] }.sorted { $0.name.lowercased() < $1.name.lowercased() } } @@ -374,6 +418,12 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate fireTimer() } + @AppStorage(UserDefaultKey.textToSpeechDefaultLanguage.rawValue) public var defaultLanguage = "en" { + didSet { + currentLanguage = defaultLanguage + } + } + @AppStorage(UserDefaultKey.textToSpeechPlaybackRate.rawValue) public var playbackRate = 1.0 { didSet { updateDurations(oldPlayback: oldValue, newPlayback: playbackRate) @@ -382,8 +432,44 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } } - @AppStorage(UserDefaultKey.textToSpeechCurrentVoice.rawValue) public var currentVoice = "en-US-ChristopherNeural" { - didSet { + public var currentVoiceLanguage: VoiceLanguage { + VOICELANGUAGES.first(where: { $0.key == currentLanguage }) ?? ENGLISH + } + + private var _currentLanguage: String? + public var currentLanguage: String { + get { + if let currentLanguage = _currentLanguage { + return currentLanguage + } + if let itemLang = itemAudioProperties?.language, let lang = VOICELANGUAGES.first(where: { $0.name == itemLang || $0.key == itemLang }) { + return lang.key + } + return defaultLanguage + } + set { + _currentLanguage = newValue + + let newVoice = getPreferredVoice(forLanguage: newValue) + currentVoice = newVoice + } + } + + private var _currentVoice: String? + public var currentVoice: String { + get { + if let currentVoice = _currentVoice { + return currentVoice + } + + if let currentVoice = UserDefaults.standard.string(forKey: "\(currentLanguage)-\(UserDefaultKey.textToSpeechPreferredVoice.rawValue)") { + return currentVoice + } + + return currentVoiceLanguage.defaultVoice + } + set { + _currentVoice = newValue voiceList = generateVoiceList() var currentIdx = 0 @@ -398,6 +484,14 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } } + public func getPreferredVoice(forLanguage language: String) -> String { + UserDefaults.standard.string(forKey: "\(language)-\(UserDefaultKey.textToSpeechPreferredVoice.rawValue)") ?? currentVoiceLanguage.defaultVoice + } + + public func setPreferredVoice(_ voice: String, forLanguage language: String) { + UserDefaults.standard.set(voice, forKey: "\(language)-\(UserDefaultKey.textToSpeechPreferredVoice.rawValue)") + } + private func downloadAndPlayFrom(_ currentIdx: Int, _ currentOffset: Double) { let desiredState = state @@ -721,6 +815,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate func downloadSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { let decoder = JSONDecoder() let speechFileUrl = pathForSpeechFile(itemID: itemID) + print("looking up speeh file: ", speechFileUrl) if FileManager.default.fileExists(atPath: speechFileUrl.path) { let data = try Data(contentsOf: speechFileUrl) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift index 4e1a09eae..46c4160db 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift @@ -43,6 +43,7 @@ extension DataService { isArchived: try $0.isArchived(), contentReader: try $0.contentReader().rawValue, originalHtml: nil, + language: try $0.language(), labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [] ), htmlContent: try $0.content(), diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift index 348e26cd9..8df9dc6b3 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift @@ -245,6 +245,7 @@ private let libraryArticleSelection = Selection.Article { isArchived: try $0.isArchived(), contentReader: try $0.contentReader().rawValue, originalHtml: nil, + language: try $0.language(), labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [] ) } @@ -281,6 +282,7 @@ private let searchItemSelection = Selection.SearchItem { isArchived: try $0.isArchived(), contentReader: try $0.contentReader().rawValue, originalHtml: nil, + language: try $0.language(), labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [] ) } diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift index f39daf4b6..1950ef3c6 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift @@ -25,6 +25,7 @@ struct InternalLinkedItem { let isArchived: Bool let contentReader: String? let originalHtml: String? + let language: String? var labels: [InternalLinkedItemLabel] var isPDF: Bool { @@ -60,6 +61,7 @@ struct InternalLinkedItem { linkedItem.isArchived = isArchived linkedItem.contentReader = contentReader linkedItem.originalHtml = originalHtml + linkedItem.language = language // Remove existing labels in case a label had been deleted if let existingLabels = linkedItem.labels { @@ -130,6 +132,7 @@ extension JSONArticle { isArchived: isArchived, contentReader: contentReader, originalHtml: nil, + language: language, labels: [] ) diff --git a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift index 07d3dde4b..77b415035 100644 --- a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift +++ b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift @@ -14,5 +14,6 @@ public enum UserDefaultKey: String { case lastUsedAppBuildNumber case lastItemSyncTime case textToSpeechPlaybackRate - case textToSpeechCurrentVoice + case textToSpeechPreferredVoice + case textToSpeechDefaultLanguage }