mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1228 from omnivore-app/feat/voice-selectors
Improve the UI for voice selection
This commit is contained in:
commit
f74fc226c2
17 changed files with 453 additions and 142 deletions
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,7 @@ public enum UserDefaultKey: String {
|
|||
case lastUsedAppBuildNumber
|
||||
case lastItemSyncTime
|
||||
case textToSpeechPlaybackRate
|
||||
case textToSpeechCurrentVoice
|
||||
case textToSpeechPreferredVoice
|
||||
case textToSpeechDefaultLanguage
|
||||
case textToSpeechPreloadEnabled
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" : {
|
||||
|
|
|
|||
Loading…
Reference in a new issue