mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Improve the UI for voice selection
This commit is contained in:
parent
f6510103e5
commit
e249a97bfd
12 changed files with 270 additions and 29 deletions
|
|
@ -171,7 +171,7 @@ public struct ShareExtensionView: View {
|
|||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
},
|
||||
label: {
|
||||
Text("Dismiss")
|
||||
Text("Read Later")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,12 @@ struct ProfileView: View {
|
|||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink(destination: TextToSpeechView()) {
|
||||
Text("Text to Speech")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink(
|
||||
destination: BasicWebAppView.privacyPolicyWebView(baseURL: dataService.appEnvironment.webAppBaseURL)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) ?? []
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,5 +14,6 @@ public enum UserDefaultKey: String {
|
|||
case lastUsedAppBuildNumber
|
||||
case lastItemSyncTime
|
||||
case textToSpeechPlaybackRate
|
||||
case textToSpeechCurrentVoice
|
||||
case textToSpeechPreferredVoice
|
||||
case textToSpeechDefaultLanguage
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue