Merge pull request #1228 from omnivore-app/feat/voice-selectors

Improve the UI for voice selection
This commit is contained in:
Jackson Harper 2022-09-26 16:37:46 +08:00 committed by GitHub
commit f74fc226c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 453 additions and 142 deletions

View file

@ -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)
@ -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
@ -128,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
@ -156,16 +170,24 @@ 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 {
defaultArtwork(forDimensions: dim)
} else {
Color.appButtonBackground
.frame(width: dim, height: dim)
.cornerRadius(6)
}
}
} else {
defaultArtwork(forDimensions: dim)
}
if !expanded {
@ -201,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()
}
@ -324,7 +332,23 @@ 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) {
NavigationView {
TextToSpeechLanguageView()
.navigationBarTitle("Language")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: { self.showLanguageSheet = false }) {
Image(systemName: "chevron.backward")
})
}
}
}
}

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,43 @@
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
}
}
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,39 @@
import Models
import Services
import SwiftUI
import Views
struct TextToSpeechView: View {
@EnvironmentObject var audioController: AudioController
var body: some View {
Group {
#if os(iOS)
Form {
Section("Audio Settings") {
Toggle("Enable audio prefetch", isOn: $audioController.preloadEnabled)
}
NavigationLink(destination: TextToSpeechLanguageView().navigationTitle("Default Language")) {
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)
}
}
}
}
}

View file

@ -82,8 +82,7 @@ struct WebReaderContainerView: View {
}
},
label: {
Image(systemName: audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle")
.font(.appTitleTwo)
textToSpeechButtonImage
}
)
.padding(.horizontal)
@ -91,6 +90,14 @@ struct WebReaderContainerView: View {
}
}
var textToSpeechButtonImage: some View {
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"
return Image(systemName: name).font(.appTitleTwo)
}
var navBar: some View {
HStack(alignment: .center) {
#if os(iOS)

View file

@ -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."
}
}
}
}
}

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

@ -16,9 +16,9 @@ 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?
}
// Internal model used for parsing a push notification object only
@ -36,6 +36,7 @@ public struct JSONArticle: Decodable {
public let contentReader: String
public let url: String
public let isArchived: Bool
public let language: String?
}
public extension LinkedItem {
@ -93,14 +94,29 @@ 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,
imageURL: imageURL
byline: formattedByline,
imageURL: imageURL,
language: language
)
}

View file

@ -32,26 +32,80 @@ 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 deDE = "German (Germany)"
case esES = "Spanish (Spain)"
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 language: 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: "zh", name: "Chinese", defaultVoice: "zh-CN-XiaochenNeural", categories: [.zhCN]),
VoiceLanguage(key: "ja", name: "Japanese", defaultVoice: "ja-JP-NanamiNeural", categories: [.jaJP]),
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
public let VOICES = [
// en
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),
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),
// zh
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 "]
// Somewhat based on: https://github.com/neekeetab/CachingPlayerItem/blob/master/CachingPlayerItem.swift
class SpeechPlayerItem: AVPlayerItem {
let resourceLoaderDelegate = ResourceLoaderDelegate()
@ -112,6 +166,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")
@ -141,6 +199,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()
}
@ -207,7 +268,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
@ -261,23 +322,26 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
if let stoppedId = stoppedId {
EventTracker.track(
.audioSessionEnd(linkID: stoppedId, timeElapsed: stoppedTimeElapsed ?? 0.0)
.audioSessionEnd(linkID: stoppedId, timeElapsed: stoppedTimeElapsed)
)
}
}
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() }
}
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 {
@ -327,8 +391,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
@ -346,6 +417,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 {
@ -374,6 +449,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 +463,46 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
}
}
@AppStorage(UserDefaultKey.textToSpeechCurrentVoice.rawValue) public var currentVoice = "en-US-ChristopherNeural" {
didSet {
@AppStorage(UserDefaultKey.textToSpeechPreloadEnabled.rawValue) public var preloadEnabled = true
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 +517,19 @@ 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
}
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
@ -439,6 +571,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 }
@ -528,13 +674,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()
@ -664,7 +810,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)
]
@ -718,6 +864,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)
@ -731,7 +885,7 @@ 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())"
guard let url = URL(string: path, relativeTo: appEnvironment.serverBaseURL) else {
throw BasicError.message(messageText: "Invalid audio URL")
}

View file

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

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,7 @@ public enum UserDefaultKey: String {
case lastUsedAppBuildNumber
case lastItemSyncTime
case textToSpeechPlaybackRate
case textToSpeechCurrentVoice
case textToSpeechPreferredVoice
case textToSpeechDefaultLanguage
case textToSpeechPreloadEnabled
}

View file

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

View file

@ -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" : {