Improve the UI for voice selection

This commit is contained in:
Jackson Harper 2022-09-22 22:57:54 +08:00
parent f6510103e5
commit e249a97bfd
12 changed files with 270 additions and 29 deletions

View file

@ -171,7 +171,7 @@ public struct ShareExtensionView: View {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
},
label: {
Text("Dismiss")
Text("Read Later")
.frame(maxWidth: .infinity)
}
)

View file

@ -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()
}
}
}

View file

@ -99,6 +99,12 @@ struct ProfileView: View {
}
}
Section {
NavigationLink(destination: TextToSpeechView()) {
Text("Text to Speech")
}
}
Section {
NavigationLink(
destination: BasicWebAppView.privacyPolicyWebView(baseURL: dataService.appEnvironment.webAppBaseURL)

View file

@ -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())
}
}
}

View file

@ -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())
// }
// }
// }
}
}

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21A559" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21279" systemVersion="21G115" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Highlight" representedClassName="Highlight" syncable="YES" codeGenerationType="class">
<attribute name="annotation" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
@ -30,6 +30,7 @@
<attribute name="id" attributeType="String"/>
<attribute name="imageURLString" optional="YES" attributeType="String"/>
<attribute name="isArchived" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="localPDF" optional="YES" attributeType="String"/>
<attribute name="onDeviceImageURLString" optional="YES" attributeType="String"/>
<attribute name="originalHtml" optional="YES" attributeType="String"/>
@ -91,11 +92,4 @@
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<elements>
<element name="Highlight" positionX="27" positionY="225" width="128" height="224"/>
<element name="LinkedItem" positionX="-18" positionY="63" width="128" height="464"/>
<element name="LinkedItemLabel" positionX="-36" positionY="18" width="128" height="134"/>
<element name="NewsletterEmail" positionX="0" positionY="180" width="128" height="74"/>
<element name="Viewer" positionX="45" positionY="234" width="128" height="89"/>
</elements>
</model>

View file

@ -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
)
}

View file

@ -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)

View file

@ -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(),

View file

@ -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) ?? []
)
}

View file

@ -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: []
)

View file

@ -14,5 +14,6 @@ public enum UserDefaultKey: String {
case lastUsedAppBuildNumber
case lastItemSyncTime
case textToSpeechPlaybackRate
case textToSpeechCurrentVoice
case textToSpeechPreferredVoice
case textToSpeechDefaultLanguage
}