From e249a97bfd090a0f1eae6e8b6a8f58132566c6ba Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Thu, 22 Sep 2022 22:57:54 +0800 Subject: [PATCH 01/11] Improve the UI for voice selection --- .../Share/Views/ShareExtensionView.swift | 2 +- .../App/Views/AudioPlayer/MiniPlayer.swift | 12 +- .../App/Views/Profile/ProfileView.swift | 6 + .../Profile/TextToSpeechLanguageView.swift | 44 ++++++ .../App/Views/Profile/TextToSpeechView.swift | 82 +++++++++++ .../CoreDataModel.xcdatamodel/contents | 10 +- .../Sources/Models/DataModels/FeedItem.swift | 5 +- .../AudioSession/AudioController.swift | 129 +++++++++++++++--- .../Queries/ArticleContentQuery.swift | 1 + .../Queries/LinkedItemNetworkQuery.swift | 2 + .../InternalModels/InternalLinkedItem.swift | 3 + .../Sources/Utils/UserDefaultKeys.swift | 3 +- 12 files changed, 270 insertions(+), 29 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift create mode 100644 apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift 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 } From 461100d35904f073dae05815a199c42005a7071c Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 23 Sep 2022 14:06:13 +0800 Subject: [PATCH 02/11] Better voice selection, handle failures fetching audio, handle seeking when paused better --- .../App/Views/AudioPlayer/MiniPlayer.swift | 10 ++- .../App/Views/Profile/TextToSpeechView.swift | 7 +- .../AudioSession/AudioController.swift | 72 +++++++++++++------ 3 files changed, 64 insertions(+), 25 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift index 6f8d91ec9..39552b85f 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift @@ -304,6 +304,7 @@ public struct MiniPlayer: View { Menu { Button("View Article", action: { viewArticle() }) Button("Change Voice", action: { showVoiceSheet = true }) + Button("Change Language", action: { showLanguageSheet = true }) } label: { VStack { Image(systemName: "ellipsis") @@ -334,7 +335,14 @@ public struct MiniPlayer: View { }) } }.sheet(isPresented: $showLanguageSheet) { - TextToSpeechLanguageView() + NavigationView { + TextToSpeechLanguageView() + .navigationBarTitle("Language") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(leading: Button(action: { self.showLanguageSheet = false }) { + Image(systemName: "chevron.backward") + }) + } } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift index bd2a08cc1..ae18c137c 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift @@ -33,10 +33,9 @@ struct TextToSpeechView: View { 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") -// } + NavigationLink(destination: TextToSpeechLanguageView()) { + Text("Default Language") + } innerBody } #elseif os(macOS) diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index f03eec18f..dd714c046 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -58,10 +58,14 @@ public struct VoicePair { let firstName: String let secondName: String + let language: String let category: VoiceCategory } -private let ENGLISH = VoiceLanguage(key: "en", name: "English", defaultVoice: "en-US-ChristopherNeural", categories: [.enUS, .enAU, .enCA, .enIE, .enIN, .enSG, .enUK]) +private let ENGLISH = VoiceLanguage(key: "en", + name: "English", + defaultVoice: "en-US-ChristopherNeural", + categories: [.enUS, .enAU, .enCA, .enIE, .enIN, .enSG, .enUK]) public let VOICELANGUAGES = [ ENGLISH, @@ -72,22 +76,22 @@ public let VOICELANGUAGES = [ // 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), + VoicePair(firstKey: "en-US-JennyNeural", secondKey: "en-US-BrandonNeural", firstName: "Jenny", secondName: "Brandon", language: "en-US", category: .enUS), + VoicePair(firstKey: "en-US-CoraNeural", secondKey: "en-US-ChristopherNeural", firstName: "Cora", secondName: "Christopher", language: "en-US", category: .enUS), + VoicePair(firstKey: "en-US-ElizabethNeural", secondKey: "en-US-EricNeural", firstName: "Elizabeth", secondName: "Eric", language: "en-US", category: .enUS), + VoicePair(firstKey: "en-CA-ClaraNeural", secondKey: "en-CA-LiamNeural", firstName: "Clara", secondName: "Liam", language: "en-CA", category: .enCA), + VoicePair(firstKey: "en-GB-LibbyNeural", secondKey: "en-GB-EthanNeural", firstName: "Libby", secondName: "Ethan", language: "en-GB", category: .enUK), + VoicePair(firstKey: "en-AU-NatashaNeural", secondKey: "en-AU-WilliamNeural", firstName: "Natasha", secondName: "William", language: "en-AU", category: .enAU), + VoicePair(firstKey: "en-IE-ConnorNeural", secondKey: "en-IE-EmilyNeural", firstName: "Connor", secondName: "Emily", language: "en-IE", category: .enIE), + VoicePair(firstKey: "en-IN-NeerjaNeural", secondKey: "en-IN-PrabhatNeural", firstName: "Neerja", secondName: "Prabhat", language: "en-IN", category: .enIN), + VoicePair(firstKey: "en-SG-LunaNeural", secondKey: "en-SG-WayneNeural", firstName: "Luna", secondName: "Wayne", language: "en-SG", category: .enSG), // ja - VoicePair(firstKey: "ja-JP-NanamiNeural", secondKey: "ja-JP-KeitaNeural", firstName: "Nanami", secondName: "Keita", category: .jaJP), + VoicePair(firstKey: "ja-JP-NanamiNeural", secondKey: "ja-JP-KeitaNeural", firstName: "Nanami", secondName: "Keita", language: "ja-JP", 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) + VoicePair(firstKey: "zh-CN-XiaochenNeural", secondKey: "zh-CN-XiaohanNeural", firstName: "Xiaochen", secondName: "Xiaohan", language: "zh-CN", category: .zhCN), + VoicePair(firstKey: "zh-CN-XiaoxiaoNeural", secondKey: "zh-CN-YunyangNeural", firstName: "Xiaoxiao", secondName: "Yunyang", language: "zh-CN", category: .zhCN) ] let VOICE_REGIONS = ["English "] @@ -185,6 +189,9 @@ class SpeechPlayerItem: AVPlayerItem { // TODO: how do we want to propogate this and handle it in the player let audioData = try? await SpeechSynthesizer.download(speechItem: speechItem, session: self.session) DispatchQueue.main.async { + if audioData == nil { + self.session = nil + } self.mediaData = audioData self.processPendingRequests() } @@ -371,8 +378,15 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } public func seek(to: TimeInterval) { + var hasOffset = false let position = max(0, to) + // If we are in reachedEnd state, and seek back, we need to move to + // paused state + if to < duration, state == .reachedEnd { + state = .paused + } + // First find the item that this interval is within // Not the most effecient, but these lists should be less than 500 items var sum = 0.0 @@ -390,6 +404,10 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate let before = durationBefore(playerIndex: foundIdx) let remainder = position - before + if remainder > 0 { + hasOffset = true + } + // if the foundIdx happens to be the current item, we just set the position if let playerItem = player?.currentItem as? SpeechPlayerItem { if playerItem.speechItem.audioIdx == foundIdx { @@ -484,6 +502,11 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } } + public var currentVoicePair: VoicePair? { + let voice = currentVoice + return VOICES.first(where: { $0.firstKey == voice || $0.secondKey == voice }) + } + public func getPreferredVoice(forLanguage language: String) -> String { UserDefaults.standard.string(forKey: "\(language)-\(UserDefaultKey.textToSpeechPreferredVoice.rawValue)") ?? currentVoiceLanguage.defaultVoice } @@ -622,13 +645,13 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } } player?.insert(playerItem, after: nil) - if playWhenReady, player?.items().count == 1 { - if atOffset > 0.0 { - playerItem.seek(to: CMTimeMakeWithSeconds(atOffset, preferredTimescale: 600)) { success in - print("success seeking to time: ", success) - self.fireTimer() - } + if player?.items().count == 1, atOffset > 0.0 { + playerItem.seek(to: CMTimeMakeWithSeconds(atOffset, preferredTimescale: 600)) { success in + print("success seeking to time: ", success) + self.fireTimer() } + } + if playWhenReady, player?.items().count == 1 { startTimer() unpause() setupRemoteControl() @@ -812,6 +835,14 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } } + func isoLangForCurrentVoice() -> String { + // currentVoicePair should not ever be nil but if it is we return an empty string + if let isoLang = currentVoicePair?.language { + return "&language=\(isoLang)" + } + return "" + } + func downloadSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { let decoder = JSONDecoder() let speechFileUrl = pathForSpeechFile(itemID: itemID) @@ -826,7 +857,8 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } } - let path = "/api/article/\(itemID)/speech?voice=\(currentVoice)&secondaryVoice=\(secondaryVoice)&priority=\(priority)" + let path = "/api/article/\(itemID)/speech?voice=\(currentVoice)&secondaryVoice=\(secondaryVoice)&priority=\(priority)\(isoLangForCurrentVoice())" + print("fetching audio for path", path) guard let url = URL(string: path, relativeTo: appEnvironment.serverBaseURL) else { throw BasicError.message(messageText: "Invalid audio URL") } From d11091a4585ac2d4225c2a1698b476049d43d815 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 23 Sep 2022 16:38:21 +0800 Subject: [PATCH 03/11] Make tts preload optional, use headphones for tts icon, language ux --- .../Profile/TextToSpeechLanguageView.swift | 1 - .../App/Views/Profile/TextToSpeechView.swift | 46 +----- .../Views/WebReader/WebReaderContainer.swift | 9 +- .../AudioSession/AudioController.swift | 25 +++- .../Sources/Utils/UserDefaultKeys.swift | 1 + .../AppIcon.appiconset/Contents.json | 134 ++++++++++-------- 6 files changed, 106 insertions(+), 110 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift index ac27a98c5..abfc581d1 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift @@ -19,7 +19,6 @@ struct TextToSpeechLanguageView: View { .listStyle(InsetListStyle()) #endif } - .navigationTitle("Default Language") } private var innerBody: some View { diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift index ae18c137c..744915998 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift @@ -3,37 +3,17 @@ 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) + Toggle("Enable audio prefetch", isOn: $audioController.preloadEnabled) } - NavigationLink(destination: TextToSpeechLanguageView()) { + NavigationLink(destination: TextToSpeechLanguageView().navigationTitle("Default Language")) { Text("Default Language") } innerBody @@ -55,27 +35,5 @@ struct TextToSpeechView: View { } } } -// 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/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index d4449c735..c80e32ef5 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -82,7 +82,7 @@ struct WebReaderContainerView: View { } }, label: { - Image(systemName: audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle") + Image(systemName: textToSpeechButtonImage) .font(.appTitleTwo) } ) @@ -91,6 +91,13 @@ struct WebReaderContainerView: View { } } + var textToSpeechButtonImage: String { + if audioController.state == .stopped { + return "headphones" + } + return audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle" + } + var navBar: some View { HStack(alignment: .center) { #if os(iOS) diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index dd714c046..df8e3ba6f 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -312,7 +312,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate if let stoppedId = stoppedId { EventTracker.track( - .audioSessionEnd(linkID: stoppedId, timeElapsed: stoppedTimeElapsed ?? 0.0) + .audioSessionEnd(linkID: stoppedId, timeElapsed: stoppedTimeElapsed) ) } } @@ -327,8 +327,11 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } public func preload(itemIDs: [String], retryCount _: Int = 0) async -> Bool { + if !preloadEnabled { + return true + } + for itemID in itemIDs { - print("preloading speech file: ", itemID) if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) { let synthesizer = SpeechSynthesizer(appEnvironment: appEnvironment, networker: networker, document: document) do { @@ -450,6 +453,8 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } } + @AppStorage(UserDefaultKey.textToSpeechPreloadEnabled.rawValue) public var preloadEnabled = true + public var currentVoiceLanguage: VoiceLanguage { VOICELANGUAGES.first(where: { $0.key == currentLanguage }) ?? ENGLISH } @@ -556,6 +561,20 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate return "en-US-CoraNeural" } + public func playVoiceSample(voice: String) { + do { + if let url = Bundle.main.url(forResource: "tts-voice-sample-\(voice)", withExtension: "mp3") { + let player = try AVAudioPlayer(contentsOf: url, fileTypeHint: AVFileType.mp3.rawValue) + player.play() + } else { + NSNotification.operationFailed(message: "Error playing voice sample.") + } + } catch { + print("ERROR", error) + NSNotification.operationFailed(message: "Error playing voice sample.") + } + } + private func updateDurations(oldPlayback: Double, newPlayback: Double) { if let oldDurations = durations { durations = oldDurations.map { $0 * oldPlayback / newPlayback } @@ -846,7 +865,6 @@ 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) @@ -858,7 +876,6 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } let path = "/api/article/\(itemID)/speech?voice=\(currentVoice)&secondaryVoice=\(secondaryVoice)&priority=\(priority)\(isoLangForCurrentVoice())" - print("fetching audio for path", path) guard let url = URL(string: path, relativeTo: appEnvironment.serverBaseURL) else { throw BasicError.message(messageText: "Invalid audio URL") } diff --git a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift index 77b415035..caef28d45 100644 --- a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift +++ b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift @@ -16,4 +16,5 @@ public enum UserDefaultKey: String { case textToSpeechPlaybackRate case textToSpeechPreferredVoice case textToSpeechDefaultLanguage + case textToSpeechPreloadEnabled } diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apple/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 71ce7a1d6..913e238c9 100644 --- a/apple/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/apple/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -150,6 +150,66 @@ "scale" : "1x", "size" : "1024x1024" }, + { + "filename" : "image 1-1.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "image 1@2x-1.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "image 1.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "image 1@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "512-1.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + }, { "filename" : "48.png", "idiom" : "watch", @@ -225,6 +285,13 @@ "size" : "51x51", "subtype" : "45mm" }, + { + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "54x54", + "subtype" : "49mm" + }, { "filename" : "172.png", "idiom" : "watch", @@ -256,71 +323,18 @@ "size" : "117x117", "subtype" : "45mm" }, + { + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "129x129", + "subtype" : "49mm" + }, { "filename" : "1024.png", "idiom" : "watch-marketing", "scale" : "1x", "size" : "1024x1024" - }, - { - "filename" : "image 1-1.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "image 1@2x-1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "image 1.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "image 1@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "128@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "256@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "512-1.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "512@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" } ], "info" : { From b610a7f7346aed844772864f39ce956ad991a33a Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 23 Sep 2022 17:01:09 +0800 Subject: [PATCH 04/11] Better sizing of the headphones image --- .../App/Views/WebReader/WebReaderContainer.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index c80e32ef5..80e03ee26 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -82,8 +82,7 @@ struct WebReaderContainerView: View { } }, label: { - Image(systemName: textToSpeechButtonImage) - .font(.appTitleTwo) + textToSpeechButtonImage } ) .padding(.horizontal) @@ -91,11 +90,12 @@ struct WebReaderContainerView: View { } } - var textToSpeechButtonImage: String { + var textToSpeechButtonImage: some View { if audioController.state == .stopped { - return "headphones" + return Image(systemName: "headphones").font(Font.system(size: 19)) } - return audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle" + let name = audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle" + return Image(systemName: name).font(.appTitleTwo) } var navBar: some View { From fbd6e97be6e976c8943832f21f32aa0e16e6fc4e Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 23 Sep 2022 17:49:45 +0800 Subject: [PATCH 05/11] Handle async threading error with read now accessing the currentViewer property --- .../WebReader/WebReaderLoadingContainer.swift | 16 +++++++++++++--- .../DataService/Public/LinkedItemLoading.swift | 6 +----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift index 49f3665f3..86c101160 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift @@ -9,8 +9,12 @@ import Views @Published var item: LinkedItem? @Published var errorMessage: String? - func loadItem(dataService: DataService, requestID: String) async { - guard let objectID = try? await dataService.loadItemContentUsingRequestID(requestID: requestID) else { return } + func loadItem(dataService: DataService, username: String, requestID: String) async { + guard let objectID = try? await dataService.loadItemContentUsingRequestID(username: username, + requestID: requestID) + else { + return + } item = dataService.viewContext.object(with: objectID) as? LinkedItem } @@ -60,7 +64,13 @@ public struct WebReaderLoadingContainer: View { Text(errorMessage) } else { ProgressView() - .task { await viewModel.loadItem(dataService: dataService, requestID: requestID) } + .task { + if let username = dataService.currentViewer?.username { + await viewModel.loadItem(dataService: dataService, username: username, requestID: requestID) + } else { + viewModel.errorMessage = "You are not logged in." + } + } } } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift b/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift index 66dc75968..f1e7b517d 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift @@ -84,11 +84,7 @@ public extension DataService { return persistedItemID } - func loadItemContentUsingRequestID(requestID: String) async throws -> NSManagedObjectID? { - let username: String? = await username() - guard let username = username else { throw BasicError.message(messageText: "unauthorized user") } - - // If the page was locally created, make sure they are synced before we pull content + func loadItemContentUsingRequestID(username: String, requestID: String) async throws -> NSManagedObjectID? { await syncUnsyncedArticleContent(itemID: requestID) let articleContent = try await loadArticleContentWithRetries(itemID: requestID, username: username, requestCount: 0) From b4ae822199e12eca3fd7dbc347acb20216fbd357 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 23 Sep 2022 17:50:07 +0800 Subject: [PATCH 06/11] More contrast on the share extension buttons and text --- .../App/AppExtensions/Share/Views/ShareExtensionView.swift | 4 ++-- apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift index 93093f2ff..a4d58cc84 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift @@ -110,7 +110,7 @@ public struct ShareExtensionView: View { VStack(alignment: .leading) { Text(viewModel.title ?? "") .lineLimit(1) - .foregroundColor(.appGrayText) + .foregroundColor(.appGrayTextContrast) .font(Font.system(size: 15, weight: .semibold)) Text(viewModel.url ?? "") .lineLimit(1) @@ -137,7 +137,7 @@ public struct ShareExtensionView: View { public var body: some View { VStack(alignment: .leading) { Text(titleText) - .foregroundColor(.appGrayText) + .foregroundColor(.appGrayTextContrast) .font(Font.system(size: 17, weight: .semibold)) .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 23) diff --git a/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift b/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift index 336dd22d0..7fa9be7f5 100644 --- a/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift +++ b/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift @@ -31,7 +31,7 @@ public struct RoundedRectButtonStyle: ButtonStyle { let backgroundColor: Color let textColor: Color - public init(color: Color = .appButtonBackground, textColor: Color = .appGrayText) { + public init(color: Color = .appButtonBackground, textColor: Color = .appGrayTextContrast) { self.backgroundColor = color self.textColor = textColor } From cad8df31a3c42a9d5d3e3af59590b5732e4ffdf1 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 23 Sep 2022 18:27:34 +0800 Subject: [PATCH 07/11] Better placeholder image when no URL is set --- .../App/Views/AudioPlayer/MiniPlayer.swift | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift index 39552b85f..db8231b93 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift @@ -157,16 +157,35 @@ public struct MiniPlayer: View { let maxSize = 2 * (min(geom.size.width, geom.size.height) / 3) let dim = expanded ? maxSize : 64 - AsyncImage(url: itemAudioProperties.imageURL) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: dim, height: dim) - .cornerRadius(6) - } placeholder: { - Color.appButtonBackground - .frame(width: dim, height: dim) - .cornerRadius(6) + if let imageURL = itemAudioProperties.imageURL { + AsyncImage(url: imageURL) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: dim, height: dim) + .cornerRadius(6) + } else if phase.error != nil { + Color.appButtonBackground + .frame(width: dim, height: dim) + .cornerRadius(6) + } else { + Color.appButtonBackground + .frame(width: dim, height: dim) + .cornerRadius(6) + } + } + } else { + ZStack(alignment: .center) { + Color.appButtonBackground + .frame(width: dim, height: dim) + .cornerRadius(6) + + Image(systemName: "headphones") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: dim / 2, height: dim / 2) + } } if !expanded { From 9598a3c686d97cf70952712ad1e5e0e591ef2d41 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Sat, 24 Sep 2022 09:45:37 +0800 Subject: [PATCH 08/11] Display default artwork if there is no image or image download fails --- .../App/Views/AudioPlayer/MiniPlayer.swift | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift index db8231b93..4c7824701 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift @@ -129,6 +129,19 @@ public struct MiniPlayer: View { } } + func defaultArtwork(forDimensions dim: Double) -> some View { + ZStack(alignment: .center) { + Color.appButtonBackground + .frame(width: dim, height: dim) + .cornerRadius(6) + + Image(systemName: "headphones") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: dim / 2, height: dim / 2) + } + } + // swiftlint:disable:next function_body_length func playerContent(_ itemAudioProperties: LinkedItemAudioProperties) -> some View { GeometryReader { geom in @@ -166,9 +179,7 @@ public struct MiniPlayer: View { .frame(width: dim, height: dim) .cornerRadius(6) } else if phase.error != nil { - Color.appButtonBackground - .frame(width: dim, height: dim) - .cornerRadius(6) + defaultArtwork(forDimensions: dim) } else { Color.appButtonBackground .frame(width: dim, height: dim) @@ -176,16 +187,7 @@ public struct MiniPlayer: View { } } } else { - ZStack(alignment: .center) { - Color.appButtonBackground - .frame(width: dim, height: dim) - .cornerRadius(6) - - Image(systemName: "headphones") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: dim / 2, height: dim / 2) - } + defaultArtwork(forDimensions: dim) } if !expanded { From 09bea6d8c14a57411e434cb2a279aeafc383650e Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Sat, 24 Sep 2022 10:05:04 +0800 Subject: [PATCH 09/11] Improve byline display, centralize the code a bit --- .../App/Views/AudioPlayer/MiniPlayer.swift | 18 ++-------------- .../Sources/Models/DataModels/FeedItem.swift | 21 +++++++++++++++---- .../AudioSession/AudioController.swift | 2 +- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift index 4c7824701..2a4ceb01a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift @@ -223,28 +223,14 @@ public struct MiniPlayer: View { HStack { Spacer() - if let author = itemAudioProperties.author { - Text(author) + if let byline = itemAudioProperties.byline { + Text(byline) .lineLimit(1) .font(.appCallout) .lineSpacing(1.25) .foregroundColor(.appGrayText) .frame(alignment: .trailing) } - if itemAudioProperties.author != nil, itemAudioProperties.siteName != nil { - Text(" • ") - .font(.appCallout) - .lineSpacing(1.25) - .foregroundColor(.appGrayText) - } - if let siteName = itemAudioProperties.siteName { - Text(siteName) - .lineLimit(1) - .font(.appCallout) - .lineSpacing(1.25) - .foregroundColor(.appGrayText) - .frame(alignment: .leading) - } Spacer() } diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift index 150cd493d..6984c7dad 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift @@ -16,8 +16,7 @@ public struct LinkedItemAudioProperties { public let itemID: String public let objectID: NSManagedObjectID public let title: String - public let author: String? - public let siteName: String? + public let byline: String? public let imageURL: URL? public let language: String? } @@ -95,13 +94,27 @@ public extension LinkedItem { return String(data: JSON, encoding: .utf8) ?? "[]" } + var formattedByline: String { + var byline = "" + + if let author = author { + byline += author + } + if author != nil, publisherDisplayName != nil { + byline += " • " + } + if let publisherDisplayName = publisherDisplayName { + byline += publisherDisplayName + } + return byline + } + var audioProperties: LinkedItemAudioProperties { LinkedItemAudioProperties( itemID: unwrappedID, objectID: objectID, title: unwrappedTitle, - author: author, - siteName: siteName, + byline: formattedByline, imageURL: imageURL, language: language ) diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index df8e3ba6f..f56afb9b3 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -800,7 +800,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate if let itemAudioProperties = itemAudioProperties { MPNowPlayingInfoCenter.default().nowPlayingInfo = [ MPMediaItemPropertyTitle: NSString(string: itemAudioProperties.title), - MPMediaItemPropertyArtist: NSString(string: itemAudioProperties.author ?? "Omnivore"), + MPMediaItemPropertyArtist: NSString(string: itemAudioProperties.byline ?? "Omnivore"), MPMediaItemPropertyPlaybackDuration: NSNumber(value: duration), MPNowPlayingInfoPropertyElapsedPlaybackTime: NSNumber(value: timeElapsed) ] From be6e52fbab4f7bd61d41e2bb1ff311d2675583e2 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Sat, 24 Sep 2022 11:19:14 +0800 Subject: [PATCH 10/11] Display headphones if playing another audio item --- .../Sources/App/Views/WebReader/WebReaderContainer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 80e03ee26..3624aec4b 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -91,7 +91,7 @@ struct WebReaderContainerView: View { } var textToSpeechButtonImage: some View { - if audioController.state == .stopped { + if audioController.state == .stopped || audioController.itemAudioProperties?.itemID != self.item.id { return Image(systemName: "headphones").font(Font.system(size: 19)) } let name = audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle" From ecdf45e44711b02ee1035a3e4df71a22c3e72137 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 26 Sep 2022 11:34:20 +0800 Subject: [PATCH 11/11] Remove Change Language button, add German and Spanish options --- .../Sources/App/Views/AudioPlayer/MiniPlayer.swift | 1 - .../Services/AudioSession/AudioController.swift | 12 +++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift index 2a4ceb01a..d17521415 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift @@ -311,7 +311,6 @@ public struct MiniPlayer: View { Menu { Button("View Article", action: { viewArticle() }) Button("Change Voice", action: { showVoiceSheet = true }) - Button("Change Language", action: { showLanguageSheet = true }) } label: { VStack { Image(systemName: "ellipsis") diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index f56afb9b3..158727d4c 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -47,6 +47,8 @@ public enum VoiceCategory: String, CaseIterable { case enIN = "English (India)" case enSG = "English (Singapore)" case enUK = "English (UK)" + case deDE = "German (Germany)" + case esES = "Spanish (Spain)" case jaJP = "Japanese (Japan)" case zhCN = "Chinese (China Mainland)" } @@ -69,8 +71,11 @@ private let ENGLISH = VoiceLanguage(key: "en", public let VOICELANGUAGES = [ ENGLISH, + VoiceLanguage(key: "zh", name: "Chinese", defaultVoice: "zh-CN-XiaochenNeural", categories: [.zhCN]), VoiceLanguage(key: "ja", name: "Japanese", defaultVoice: "ja-JP-NanamiNeural", categories: [.jaJP]), - VoiceLanguage(key: "zh", name: "Chinese", defaultVoice: "zh-CN-XiaochenNeural", categories: [.zhCN]) + VoiceLanguage(key: "ja", name: "Japanese", defaultVoice: "ja-JP-NanamiNeural", categories: [.jaJP]), + VoiceLanguage(key: "de", name: "German", defaultVoice: "de-CH-JanNeural", categories: [.deDE]), + VoiceLanguage(key: "es", name: "Spanish", defaultVoice: "es-ES-AlvaroNeural", categories: [.esES]) ] // swiftlint:disable all @@ -86,6 +91,11 @@ public let VOICES = [ VoicePair(firstKey: "en-IN-NeerjaNeural", secondKey: "en-IN-PrabhatNeural", firstName: "Neerja", secondName: "Prabhat", language: "en-IN", category: .enIN), VoicePair(firstKey: "en-SG-LunaNeural", secondKey: "en-SG-WayneNeural", firstName: "Luna", secondName: "Wayne", language: "en-SG", category: .enSG), + VoicePair(firstKey: "es-ES-AlvaroNeural", secondKey: "es-ES-ElviraNeural", firstName: "Alvaro", secondName: "Elvira", language: "es-ES", category: .esES), + VoicePair(firstKey: "de-CH-LeniNeural", secondKey: "de-DE-KatjaNeural", firstName: "Leni", secondName: "Katja", language: "de-DE", category: .deDE), + VoicePair(firstKey: "de-DE-AmalaNeural", secondKey: "de-DE-BerndNeural", firstName: "Amala", secondName: "Bernd", language: "de-DE", category: .deDE), + VoicePair(firstKey: "de-DE-ChristophNeural", secondKey: "de-DE-LouisaNeural", firstName: "Christoph", secondName: "Louisa", language: "de-DE", category: .deDE), + // ja VoicePair(firstKey: "ja-JP-NanamiNeural", secondKey: "ja-JP-KeitaNeural", firstName: "Nanami", secondName: "Keita", language: "ja-JP", category: .jaJP),