fixes after rebasing

This commit is contained in:
Satindar Dhillon
2022-10-12 12:58:32 -07:00
parent d84f41767f
commit 8fcbfbbd2e
4 changed files with 524 additions and 991 deletions

View File

@ -150,349 +150,238 @@
}
}
init(id: Int) {
self.id = id
}
}
var audioCards: some View {
ZStack {
let textItems = self.audioController.textItems ?? []
TabView(selection: $tabIndex) {
ForEach(0 ..< textItems.count, id: \.self) { id in
SpeechCard(id: id)
.tag(id)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.onChange(of: tabIndex, perform: { index in
if index != audioController.currentAudioIndex, index < (audioController.textItems?.count ?? 0) {
audioController.seek(toUtterance: index)
}
})
.onChange(of: audioController.currentAudioIndex, perform: { index in
if index >= textItems.count {
return
}
if self.audioController.state != .reachedEnd {
tabIndex = index
}
})
if audioController.state == .reachedEnd {
// If we have reached the end display a replay button with an overlay behind
Color.systemBackground.opacity(0.85)
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: .topLeading
)
Button(
action: {
tabIndex = 0
audioController.unpause()
audioController.seek(to: 0.0)
},
label: {
HStack {
Image(systemName: "gobackward")
.font(.appCallout)
.tint(.appGrayTextContrast)
Text("Replay")
}
var audioCards: some View {
ZStack {
let textItems = self.audioController.textItems ?? []
TabView(selection: $tabIndex) {
ForEach(0 ..< textItems.count, id: \.self) { id in
SpeechCard(id: id)
.tag(id)
}
).buttonStyle(RoundedRectButtonStyle())
}
}
}
// swiftlint:disable:next function_body_length
func playerContent(_ itemAudioProperties: LinkedItemAudioProperties) -> some View {
VStack(spacing: 0) {
if expanded {
ZStack {
closeButton
.padding(.top, 24)
.padding(.leading, 16)
.frame(maxWidth: .infinity, alignment: .leading)
Capsule()
.fill(.gray)
.frame(width: 60, height: 4)
.padding(.top, 8)
.transition(.opacity)
}
} else {
HStack(alignment: .center, spacing: 8) {
let dim = 64.0
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.onChange(of: tabIndex, perform: { index in
if index != audioController.currentAudioIndex, index < (audioController.textItems?.count ?? 0) {
audioController.seek(toUtterance: index)
}
})
.onChange(of: audioController.currentAudioIndex, perform: { index in
if index >= textItems.count {
return
}
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)
if self.audioController.state != .reachedEnd {
tabIndex = index
}
})
if audioController.state == .reachedEnd {
// If we have reached the end display a replay button with an overlay behind
Color.systemBackground.opacity(0.85)
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: .topLeading
)
Button(
action: {
tabIndex = 0
audioController.unpause()
audioController.seek(to: 0.0)
},
label: {
HStack {
Image(systemName: "gobackward")
.font(.appCallout)
.tint(.appGrayTextContrast)
Text("Replay")
}
}
} else {
defaultArtwork(forDimensions: dim)
}
).buttonStyle(RoundedRectButtonStyle())
}
}
}
VStack {
Text(itemAudioProperties.title)
.font(.appCallout)
.foregroundColor(.appGrayTextContrast)
.fixedSize(horizontal: false, vertical: false)
// swiftlint:disable:next function_body_length
func playerContent(_ itemAudioProperties: LinkedItemAudioProperties) -> some View {
VStack(spacing: 0) {
if expanded {
ZStack {
closeButton
.padding(.top, 24)
.padding(.leading, 16)
.frame(maxWidth: .infinity, alignment: .leading)
if let byline = itemAudioProperties.byline {
Text(byline)
.font(.appCaption)
.lineSpacing(1.25)
.foregroundColor(.appGrayText)
Capsule()
.fill(.gray)
.frame(width: 60, height: 4)
.padding(.top, 8)
.transition(.opacity)
}
} else {
HStack(alignment: .center, spacing: 8) {
let dim = 64.0
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)
}
VStack {
Text(itemAudioProperties.title)
.font(.appCallout)
.foregroundColor(.appGrayTextContrast)
.fixedSize(horizontal: false, vertical: false)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
playPauseButtonItem
.frame(width: 28, height: 28)
stopButton
.frame(width: 28, height: 28)
}
.padding(16)
.frame(maxHeight: .infinity)
}
if expanded {
audioCards
Spacer()
Group {
ScrubberView(value: $audioController.timeElapsed,
minValue: 0, maxValue: self.audioController.duration,
onEditingChanged: { scrubStarted in
if scrubStarted {
self.audioController.scrubState = .scrubStarted
} else {
self.audioController.scrubState = .scrubEnded(self.audioController.timeElapsed)
}
})
HStack {
Text(audioController.timeElapsedString ?? "0:00")
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
Spacer()
Text(audioController.durationString ?? "0:00")
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
}
}
.padding(.leading, 16)
.padding(.trailing, 16)
HStack(alignment: .center, spacing: 36) {
Menu {
playbackRateButton(rate: 1.0, title: "1.0×", selected: audioController.playbackRate == 1.0)
playbackRateButton(rate: 1.1, title: "1.1×", selected: audioController.playbackRate == 1.1)
playbackRateButton(rate: 1.2, title: "1.2×", selected: audioController.playbackRate == 1.2)
playbackRateButton(rate: 1.5, title: "1.5×", selected: audioController.playbackRate == 1.5)
playbackRateButton(rate: 1.7, title: "1.7×", selected: audioController.playbackRate == 1.7)
playbackRateButton(rate: 2.0, title: "2.0×", selected: audioController.playbackRate == 2.0)
} label: {
VStack {
Text(String(format: "%.1f×", audioController.playbackRate))
.font(.appCallout)
.lineLimit(0)
}
.contentShape(Rectangle())
}
.padding(8)
Button(
action: { self.audioController.skipBackwards(seconds: 30) },
label: {
Image(systemName: "gobackward.30")
.font(.appTitleTwo)
}
)
playPauseButtonItem
.frame(width: 56, height: 56)
Button(
action: { self.audioController.skipForward(seconds: 30) },
label: {
Image(systemName: "goforward.30")
.font(.appTitleTwo)
}
)
Menu {
Button("View Article", action: { viewArticle() })
Button("Change Voice", action: { showVoiceSheet = true })
} label: {
VStack {
Image(systemName: "ellipsis")
.font(.appCallout)
.frame(width: 20, height: 20)
}
.contentShape(Rectangle())
}
.padding(8)
}.padding(.bottom, 16)
}
}
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.background(
Color.systemBackground
.shadow(color: expanded ? .clear : .gray.opacity(0.33), radius: 8, x: 0, y: 4)
.mask(Rectangle().padding(.top, -20))
)
.onTapGesture {
withAnimation(.easeIn(duration: 0.08)) { expanded = true }
}.sheet(isPresented: $showVoiceSheet) {
NavigationView {
TextToSpeechVoiceSelectionView(forLanguage: audioController.currentVoiceLanguage, showLanguageChanger: true)
.navigationBarTitle("Voice")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }, label: {
Image(systemName: "chevron.backward")
.font(.appNavbarIcon)
.tint(.appGrayTextContrast)
}))
}
}.sheet(isPresented: $showLanguageSheet) {
NavigationView {
TextToSpeechLanguageView()
.navigationBarTitle("Language")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: { self.showLanguageSheet = false }) {
Image(systemName: "chevron.backward")
.font(.appNavbarIcon)
.tint(.appGrayTextContrast)
})
}
}
}
func playbackRateButton(rate: Double, title: String, selected: Bool) -> some View {
Button(action: {
audioController.playbackRate = rate
}) {
HStack {
Text(title)
Spacer()
if selected {
Image(systemName: "checkmark")
}
}
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
}
public var body: some View {
ZStack(alignment: .center) {
presentingView
if let itemAudioProperties = self.audioController.itemAudioProperties, isPresented {
ZStack(alignment: .bottom) {
Color.systemBackground.edgesIgnoringSafeArea(.bottom)
.frame(height: 88, alignment: .bottom)
VStack {
Spacer(minLength: 0)
playerContent(itemAudioProperties)
.offset(y: offset)
.frame(maxHeight: expanded ? .infinity : 88)
.tint(.appGrayTextContrast)
.gesture(DragGesture().onEnded(onDragEnded(value:)).onChanged(onDragChanged(value:)))
.background(expanded ? .clear : .systemBackground)
}
}
}
}
}
var changeVoiceView: some View {
NavigationView {
VStack {
List {
ForEach(audioController.voiceList ?? [], 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")
}
)
Menu {
Button("View Article", action: { viewArticle() })
Button("Change Voice", action: { showVoiceSheet = true })
} label: {
VStack {
Image(systemName: "ellipsis")
.font(.appCallout)
.frame(width: 20, height: 20)
}
.contentShape(Rectangle())
if let byline = itemAudioProperties.byline {
Text(byline)
.font(.appCaption)
.lineSpacing(1.25)
.foregroundColor(.appGrayText)
.fixedSize(horizontal: false, vertical: false)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(8)
}.padding(.bottom, 16)
}
playPauseButtonItem
.frame(width: 28, height: 28)
stopButton
.frame(width: 28, height: 28)
}
.padding(16)
.frame(maxHeight: .infinity)
}
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.background(
Color.systemBackground
.shadow(color: expanded ? .clear : .gray.opacity(0.33), radius: 8, x: 0, y: 4)
.mask(Rectangle().padding(.top, -20))
)
.onTapGesture {
withAnimation(.easeIn(duration: 0.08)) { expanded = true }
}.sheet(isPresented: $showVoiceSheet) {
NavigationView {
TextToSpeechVoiceSelectionView(forLanguage: audioController.currentVoiceLanguage, showLanguageChanger: true)
.navigationBarTitle("Voice")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }) {
Image(systemName: "chevron.backward")
.font(.appNavbarIcon)
.tint(.appGrayTextContrast)
})
}
}.sheet(isPresented: $showLanguageSheet) {
NavigationView {
TextToSpeechLanguageView()
.navigationBarTitle("Language")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: { self.showLanguageSheet = false }) {
Image(systemName: "chevron.backward")
.font(.appNavbarIcon)
.tint(.appGrayTextContrast)
})
if expanded {
audioCards
Spacer()
Group {
ScrubberView(value: $audioController.timeElapsed,
minValue: 0, maxValue: self.audioController.duration,
onEditingChanged: { scrubStarted in
if scrubStarted {
self.audioController.scrubState = .scrubStarted
} else {
self.audioController.scrubState = .scrubEnded(self.audioController.timeElapsed)
}
})
HStack {
Text(audioController.timeElapsedString ?? "0:00")
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
Spacer()
Text(audioController.durationString ?? "0:00")
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
}
}
.padding(.leading, 16)
.padding(.trailing, 16)
HStack(alignment: .center, spacing: 36) {
Menu {
playbackRateButton(rate: 1.0, title: "1.0×", selected: audioController.playbackRate == 1.0)
playbackRateButton(rate: 1.1, title: "1.1×", selected: audioController.playbackRate == 1.1)
playbackRateButton(rate: 1.2, title: "1.2×", selected: audioController.playbackRate == 1.2)
playbackRateButton(rate: 1.5, title: "1.5×", selected: audioController.playbackRate == 1.5)
playbackRateButton(rate: 1.7, title: "1.7×", selected: audioController.playbackRate == 1.7)
playbackRateButton(rate: 2.0, title: "2.0×", selected: audioController.playbackRate == 2.0)
} label: {
VStack {
Text(String(format: "%.1f×", audioController.playbackRate))
.font(.appCallout)
.lineLimit(0)
}
.contentShape(Rectangle())
}
.padding(8)
Button(
action: { self.audioController.skipBackwards(seconds: 30) },
label: {
Image(systemName: "gobackward.30")
.font(.appTitleTwo)
}
)
playPauseButtonItem
.frame(width: 56, height: 56)
Button(
action: { self.audioController.skipForward(seconds: 30) },
label: {
Image(systemName: "goforward.30")
.font(.appTitleTwo)
}
)
Menu {
Button("View Article", action: { viewArticle() })
Button("Change Voice", action: { showVoiceSheet = true })
} label: {
VStack {
Image(systemName: "ellipsis")
.font(.appCallout)
.frame(width: 20, height: 20)
}
.contentShape(Rectangle())
}
.padding(8)
}.padding(.bottom, 16)
}
}
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.background(
Color.systemBackground
.shadow(color: expanded ? .clear : .gray.opacity(0.33), radius: 8, x: 0, y: 4)
.mask(Rectangle().padding(.top, -20))
)
.onTapGesture {
withAnimation(.easeIn(duration: 0.08)) { expanded = true }
}.sheet(isPresented: $showVoiceSheet) {
NavigationView {
TextToSpeechVoiceSelectionView(forLanguage: audioController.currentVoiceLanguage, showLanguageChanger: true)
.navigationBarTitle("Voice")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }, label: {
Image(systemName: "chevron.backward")
.font(.appNavbarIcon)
.tint(.appGrayTextContrast)
}))
}
}.sheet(isPresented: $showLanguageSheet) {
NavigationView {
TextToSpeechLanguageView()
.navigationBarTitle("Language")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: { self.showLanguageSheet = false }) {
Image(systemName: "chevron.backward")
.font(.appNavbarIcon)
.tint(.appGrayTextContrast)
})
}
}
}
@ -602,5 +491,4 @@
MiniPlayer(presentingView: self)
}
}
#endif

View File

@ -3,13 +3,15 @@ import SwiftUI
struct HomeView: View {
@StateObject private var viewModel = HomeFeedViewModel()
var navView: some View {
NavigationView {
HomeFeedContainerView(viewModel: viewModel)
#if os(iOS)
var navView: some View {
NavigationView {
HomeFeedContainerView(viewModel: viewModel)
}
.navigationViewStyle(.stack)
.accentColor(.appGrayTextContrast)
}
.navigationViewStyle(.stack)
.accentColor(.appGrayTextContrast)
}
#endif
var body: some View {
#if os(iOS)

View File

@ -1,153 +1,155 @@
import Introspect
import Models
import Services
import SwiftUI
import UIKit
import Views
#if os(iOS)
import Introspect
import Models
import Services
import SwiftUI
import UIKit
import Views
struct LibrarySearchView: View {
@State private var searchBar: UISearchBar?
@State private var recents: [String] = []
@StateObject var viewModel = LibrarySearchViewModel()
struct LibrarySearchView: View {
@State private var searchBar: UISearchBar?
@State private var recents: [String] = []
@StateObject var viewModel = LibrarySearchViewModel()
@EnvironmentObject var dataService: DataService
@Environment(\.isSearching) var isSearching
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var dataService: DataService
@Environment(\.isSearching) var isSearching
@Environment(\.dismiss) private var dismiss
let homeFeedViewModel: HomeFeedViewModel
let homeFeedViewModel: HomeFeedViewModel
init(homeFeedViewModel: HomeFeedViewModel) {
self.homeFeedViewModel = homeFeedViewModel
}
func performTypeahead(_ searchTerm: String) {
Task {
await viewModel.search(dataService: self.dataService, searchTerm: searchTerm)
init(homeFeedViewModel: HomeFeedViewModel) {
self.homeFeedViewModel = homeFeedViewModel
}
}
func setSearchTerm(_ searchTerm: String) {
viewModel.searchTerm = searchTerm
searchBar?.becomeFirstResponder()
performTypeahead(searchTerm)
}
func performTypeahead(_ searchTerm: String) {
Task {
await viewModel.search(dataService: self.dataService, searchTerm: searchTerm)
}
}
func performSearch(_ searchTerm: String) {
let term = searchTerm.trimmingCharacters(in: Foundation.CharacterSet.whitespacesAndNewlines)
viewModel.saveRecentSearch(dataService: dataService, searchTerm: term)
recents = viewModel.recentSearches(dataService: dataService)
homeFeedViewModel.searchTerm = term
func setSearchTerm(_ searchTerm: String) {
viewModel.searchTerm = searchTerm
searchBar?.becomeFirstResponder()
performTypeahead(searchTerm)
}
dismiss()
}
func performSearch(_ searchTerm: String) {
let term = searchTerm.trimmingCharacters(in: Foundation.CharacterSet.whitespacesAndNewlines)
viewModel.saveRecentSearch(dataService: dataService, searchTerm: term)
recents = viewModel.recentSearches(dataService: dataService)
homeFeedViewModel.searchTerm = term
func recentSearchRow(_ term: String) -> some View {
HStack {
dismiss()
}
func recentSearchRow(_ term: String) -> some View {
HStack {
Image(systemName: "clock.arrow.circlepath")
Text(term).foregroundColor(.appGrayText)
}.onTapGesture {
performSearch(term)
HStack {
Image(systemName: "clock.arrow.circlepath")
Text(term).foregroundColor(.appGrayText)
}.onTapGesture {
performSearch(term)
}
Spacer()
Image(systemName: "arrow.up.backward")
.onTapGesture {
setSearchTerm(viewModel.searchTerm + (viewModel.searchTerm.count > 0 ? " " : "") + term)
}
.searchCompletion(term)
}.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
withAnimation(.linear(duration: 0.4)) {
viewModel.removeRecentSearch(dataService: dataService, searchTerm: term)
self.recents = viewModel.recentSearches(dataService: dataService)
}
} label: {
Label("Remove", systemImage: "trash")
}.tint(.red)
}
Spacer()
Image(systemName: "arrow.up.backward")
.onTapGesture {
setSearchTerm(viewModel.searchTerm + (viewModel.searchTerm.count > 0 ? " " : "") + term)
}
.searchCompletion(term)
}.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
withAnimation(.linear(duration: 0.4)) {
viewModel.removeRecentSearch(dataService: dataService, searchTerm: term)
self.recents = viewModel.recentSearches(dataService: dataService)
}
} label: {
Label("Remove", systemImage: "trash")
}.tint(.red)
}
}
var body: some View {
NavigationView {
innerBody
}.introspectViewController { controller in
searchBar = Introspect.findChild(ofType: UISearchBar.self, in: controller.view)
}
}
var innerBody: some View {
ZStack {
if let linkRequest = viewModel.linkRequest {
NavigationLink(
destination: WebReaderLoadingContainer(requestID: linkRequest.serverID),
tag: linkRequest,
selection: $viewModel.linkRequest
) {
EmptyView()
}
var body: some View {
NavigationView {
innerBody
}.introspectViewController { controller in
searchBar = Introspect.findChild(ofType: UISearchBar.self, in: controller.view)
}
listBody
.navigationTitle("Search")
.navigationBarItems(trailing: Button(action: { dismiss() }, label: { Text("Close") }))
.navigationBarTitleDisplayMode(NavigationBarItem.TitleDisplayMode.inline)
.searchable(text: $viewModel.searchTerm, placement: .navigationBarDrawer(displayMode: .always)) {
ForEach(viewModel.items) { item in
HStack {
Text(item.title)
Spacer()
Image(systemName: "chevron.right")
}.onTapGesture {
viewModel.linkRequest = LinkRequest(id: UUID(), serverID: item.id)
}
}
var innerBody: some View {
ZStack {
if let linkRequest = viewModel.linkRequest {
NavigationLink(
destination: WebReaderLoadingContainer(requestID: linkRequest.serverID),
tag: linkRequest,
selection: $viewModel.linkRequest
) {
EmptyView()
}
}
.onAppear {
self.recents = viewModel.recentSearches(dataService: dataService)
}
.onSubmit(of: .search) {
performSearch(viewModel.searchTerm)
}
.onChange(of: viewModel.searchTerm) { term in
performTypeahead(term)
}
}
}
var listBody: some View {
VStack {
List {
if viewModel.searchTerm.count == 0 {
if recents.count > 0 {
Section("Recent Searches") {
ForEach(recents, id: \.self) { term in
recentSearchRow(term)
listBody
.navigationTitle("Search")
.navigationBarItems(trailing: Button(action: { dismiss() }, label: { Text("Close") }))
.navigationBarTitleDisplayMode(NavigationBarItem.TitleDisplayMode.inline)
.searchable(text: $viewModel.searchTerm, placement: .navigationBarDrawer(displayMode: .always)) {
ForEach(viewModel.items) { item in
HStack {
Text(item.title)
Spacer()
Image(systemName: "chevron.right")
}.onTapGesture {
viewModel.linkRequest = LinkRequest(id: UUID(), serverID: item.id)
}
}
}
Section("Narrow with advanced search") {
(Text("**in:** ") + Text("filter to inbox, archive, or all"))
.foregroundColor(.appGrayText)
.onTapGesture { setSearchTerm("is:") }
(Text("**title:** ") + Text("search for a specific title"))
.foregroundColor(.appGrayText)
.onTapGesture { setSearchTerm("site:") }
(Text("**has:highlights** ") + Text("any saved read with highlights"))
.foregroundColor(.appGrayText)
.onTapGesture { setSearchTerm("has:highlights") }
Button(action: {}, label: {
Text("[More on Advanced Search](https://omnivore.app/help/search)")
.underline()
.padding(.top, 25)
})
.onAppear {
self.recents = viewModel.recentSearches(dataService: dataService)
}
}
}.listStyle(PlainListStyle())
.onSubmit(of: .search) {
performSearch(viewModel.searchTerm)
}
.onChange(of: viewModel.searchTerm) { term in
performTypeahead(term)
}
}
}
var listBody: some View {
VStack {
List {
if viewModel.searchTerm.count == 0 {
if recents.count > 0 {
Section("Recent Searches") {
ForEach(recents, id: \.self) { term in
recentSearchRow(term)
}
}
}
Section("Narrow with advanced search") {
(Text("**in:** ") + Text("filter to inbox, archive, or all"))
.foregroundColor(.appGrayText)
.onTapGesture { setSearchTerm("is:") }
(Text("**title:** ") + Text("search for a specific title"))
.foregroundColor(.appGrayText)
.onTapGesture { setSearchTerm("site:") }
(Text("**has:highlights** ") + Text("any saved read with highlights"))
.foregroundColor(.appGrayText)
.onTapGesture { setSearchTerm("has:highlights") }
Button(action: {}, label: {
Text("[More on Advanced Search](https://omnivore.app/help/search)")
.underline()
.padding(.top, 25)
})
}
}
}.listStyle(PlainListStyle())
}
}
}
}
#endif

View File

@ -1,73 +1,19 @@
#if os(iOS)
import AVFoundation
import CryptoKit
import Foundation
import MediaPlayer
import Models
import SwiftUI
import Utils
import AVFoundation
import CryptoKit
import Foundation
import MediaPlayer
import Models
import SwiftUI
import Utils
public enum AudioControllerState {
case stopped
case paused
case loading
case playing
case reachedEnd
}
public enum PlayerScrubState {
case reset
case scrubStarted
case scrubEnded(TimeInterval)
}
enum DownloadPriority: String {
case low
case high
}
// Somewhat based on: https://github.com/neekeetab/CachingPlayerItem/blob/master/CachingPlayerItem.swift
class SpeechPlayerItem: AVPlayerItem {
let resourceLoaderDelegate = ResourceLoaderDelegate()
let session: AudioController
let speechItem: SpeechItem
var speechMarks: [SpeechMark]?
let completed: () -> Void
var observer: Any?
init(session: AudioController, speechItem: SpeechItem, completed: @escaping () -> Void) {
self.speechItem = speechItem
self.session = session
self.completed = completed
guard let fakeUrl = URL(string: "app.omnivore.speech://\(speechItem.localAudioURL.path).mp3") else {
fatalError("internal inconsistency")
}
let asset = AVURLAsset(url: fakeUrl)
asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main)
super.init(asset: asset, automaticallyLoadedAssetKeys: nil)
resourceLoaderDelegate.owner = self
self.observer = observe(\.status, options: [.new]) { item, _ in
if item.status == .readyToPlay {
let duration = CMTimeGetSeconds(item.duration)
item.session.updateDuration(forItem: item.speechItem, newDuration: duration)
}
}
NotificationCenter.default.addObserver(
forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
object: self, queue: OperationQueue.main
) { [weak self] _ in
guard let self = self else { return }
self.completed()
}
public enum AudioControllerState {
case stopped
case paused
case loading
case playing
case reachedEnd
}
public enum PlayerScrubState {
@ -115,133 +61,121 @@ class SpeechPlayerItem: AVPlayerItem {
}
}
NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: self, queue: OperationQueue.main) { [weak self] _ in
NotificationCenter.default.addObserver(
forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
object: self, queue: OperationQueue.main
) { [weak self] _ in
guard let self = self else { return }
self.completed()
}
}
deinit {
session?.invalidateAndCancel()
}
}
}
// swiftlint:disable all
public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate {
@Published public var state: AudioControllerState = .stopped
@Published public var currentAudioIndex: Int = 0
@Published public var readText: String = ""
@Published public var unreadText: String = ""
@Published public var itemAudioProperties: LinkedItemAudioProperties?
@Published public var timeElapsed: TimeInterval = 0
@Published public var duration: TimeInterval = 0
@Published public var timeElapsedString: String?
@Published public var durationString: String?
@Published public var voiceList: [(name: String, key: String, category: VoiceCategory, selected: Bool)]?
let dataService: DataService
var timer: Timer?
var player: AVQueuePlayer?
var observer: Any?
var document: SpeechDocument?
var synthesizer: SpeechSynthesizer?
var durations: [Double]?
var lastReadUpdate = 0.0
public init(dataService: DataService) {
self.dataService = dataService
super.init()
self.voiceList = generateVoiceList()
}
deinit {
player = nil
observer = nil
}
public func play(itemAudioProperties: LinkedItemAudioProperties) {
stop()
self.itemAudioProperties = itemAudioProperties
startAudio()
EventTracker.track(
.audioSessionStart(linkID: itemAudioProperties.itemID)
)
}
public func stop() {
let stoppedId = itemAudioProperties?.itemID
let stoppedTimeElapsed = timeElapsed
player?.pause()
timer?.invalidate()
clearNowPlayingInfo()
player?.replaceCurrentItem(with: nil)
player?.removeAllItems()
document = nil
textItems = nil
timer = nil
player = nil
observer = nil
synthesizer = nil
lastReadUpdate = 0
itemAudioProperties = nil
state = .stopped
timeElapsed = 0
duration = 1
durations = nil
if let stoppedId = stoppedId {
EventTracker.track(
.audioSessionEnd(linkID: stoppedId, timeElapsed: stoppedTimeElapsed)
)
}
}
public func generateVoiceList() -> [(name: String, key: String, category: VoiceCategory, selected: Bool)] {
Voices.Pairs.flatMap { voicePair in
[
(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
observer = nil
resourceLoaderDelegate.session?.invalidateAndCancel()
}
for itemID in itemIDs {
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document)
do {
try await synthesizer.preload()
return true
} catch {
print("error preloading audio file", error)
}
open func download() {
if resourceLoaderDelegate.session == nil {
resourceLoaderDelegate.startDataRequest(with: speechItem.urlRequest)
}
}
public func downloadForOffline(itemID: String) async -> Bool {
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document)
for item in synthesizer.createPlayerItems(from: 0) {
do {
_ = try await SpeechSynthesizer.download(speechItem: item, redownloadCached: true)
} catch {
print("error downloading audio segment: ", error)
@objc func playbackStalledHandler() {
print("playback stalled...")
}
class ResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {
var session: URLSession?
var mediaData: Data?
var pendingRequests = Set<AVAssetResourceLoadingRequest>()
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")
}
startDataRequest(with: initialUrl)
}
pendingRequests.insert(loadingRequest)
processPendingRequests()
return true
}
func startDataRequest(with _: URLRequest) {
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
session = URLSession(configuration: configuration)
Task {
guard let speechItem = self.owner?.speechItem else {
// This probably can't happen, but if it does, just returning should
// let AVPlayer try again.
print("No speech item found: ", self.owner?.speechItem)
return
}
// TODO: how do we want to propogate this and handle it in the player
let speechData = try? await SpeechSynthesizer.download(speechItem: speechItem, session: self.session)
DispatchQueue.main.async {
if speechData == nil {
self.session = nil
}
if let owner = self.owner, let speechData = speechData {
owner.speechMarks = speechData.speechMarks
}
self.mediaData = speechData?.audioData
self.processPendingRequests()
}
}
}
func resourceLoader(_: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
pendingRequests.remove(loadingRequest)
}
func processPendingRequests() {
let requestsFulfilled = Set<AVAssetResourceLoadingRequest>(pendingRequests.compactMap {
self.fillInContentInformationRequest($0.contentInformationRequest)
if self.haveEnoughDataToFulfillRequest($0.dataRequest!) {
$0.finishLoading()
return $0
}
return nil
})
// remove fulfilled requests from pending requests
_ = requestsFulfilled.map { self.pendingRequests.remove($0) }
}
func fillInContentInformationRequest(_ contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) {
contentInformationRequest?.contentType = UTType.mp3.identifier
if let mediaData = mediaData {
contentInformationRequest?.isByteRangeAccessSupported = true
contentInformationRequest?.contentLength = Int64(mediaData.count)
}
}
func haveEnoughDataToFulfillRequest(_ dataRequest: AVAssetResourceLoadingDataRequest) -> Bool {
let requestedOffset = Int(dataRequest.requestedOffset)
let requestedLength = dataRequest.requestedLength
let currentOffset = Int(dataRequest.currentOffset)
guard let songDataUnwrapped = mediaData,
songDataUnwrapped.count > currentOffset
else {
// Don't have any data at all for this request.
return false
}
@ -253,288 +187,8 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
return songDataUnwrapped.count >= requestedLength + requestedOffset
}
// Move the playback to the found index, we also seek by the remainder amount
// before moving we pause the player so playback doesnt jump to a previous spot
player?.pause()
player?.removeAllItems()
synthesizeFrom(start: foundIdx, playWhenReady: state == .playing, atOffset: remainder)
} else {
// There was no foundIdx, so we are probably trying to seek past the end, so
// just seek to the last possible duration.
if let durations = self.durations, let last = durations.last {
player?.removeAllItems()
synthesizeFrom(start: durations.count - 1, playWhenReady: state == .playing, atOffset: last)
}
}
scrubState = .reset
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)
unpause()
fireTimer()
}
}
@AppStorage(UserDefaultKey.textToSpeechPreloadEnabled.rawValue) public var preloadEnabled = false
public var currentVoiceLanguage: VoiceLanguage {
Voices.Languages.first(where: { $0.key == currentLanguage }) ?? Voices.English
}
private var _currentLanguage: String?
public var currentLanguage: String {
get {
if let currentLanguage = _currentLanguage {
return currentLanguage
}
if let itemLang = itemAudioProperties?.language, let lang = Voices.Languages.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
var currentOffset = 0.0
if let player = self.player, let item = self.player?.currentItem as? SpeechPlayerItem {
currentIdx = item.speechItem.audioIdx
currentOffset = CMTimeGetSeconds(player.currentTime())
}
player?.removeAllItems()
downloadAndPlayFrom(currentIdx, currentOffset)
}
}
public var currentVoicePair: VoicePair? {
let voice = currentVoice
return Voices.Pairs.first(where: { $0.firstKey == voice || $0.secondKey == voice })
}
struct TextNode: Codable {
let to: String
let from: String
let heading: String
let body: String
}
public var textItems: [String]?
func setTextItems() {
if let document = self.document {
textItems = document.utterances.map { utterance in
if let regex = try? NSRegularExpression(pattern: "<[^>]*>", options: .caseInsensitive) {
let modString = regex.stringByReplacingMatches(in: utterance.text, options: [], range: NSRange(location: 0, length: utterance.text.count), withTemplate: "")
return modString
}
return ""
}
} else {
textItems = nil
}
}
func updateReadText() {
if let item = player?.currentItem as? SpeechPlayerItem, let speechMarks = item.speechMarks {
var currentItemOffset = 0
for i in 0 ..< speechMarks.count {
if speechMarks[i].time ?? 0 < 0 {
continue
}
if (speechMarks[i].time ?? 0.0) > CMTimeGetSeconds(item.currentTime()) * 1000 {
currentItemOffset = speechMarks[i].start ?? 0
break
}
}
// check to see if we are greater than all
if let last = speechMarks.last, let lastTime = last.time {
if CMTimeGetSeconds(item.currentTime()) * 1000 > lastTime {
currentItemOffset = (last.start ?? 0) + (last.length ?? 0)
}
}
// Sometimes we get negatives
currentItemOffset = max(currentItemOffset, 0)
let idx = item.speechItem.audioIdx
let currentItem = document?.utterances[idx].text ?? ""
let currentReadIndex = currentItem.index(currentItem.startIndex, offsetBy: min(currentItemOffset, currentItem.count))
let lastItem = String(currentItem[..<currentReadIndex])
let lastItemAfter = String(currentItem[currentReadIndex...])
readText = lastItem
unreadText = lastItemAfter
} else {
readText = ""
}
}
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
pause()
document = nil
synthesizer = nil
if let itemID = itemAudioProperties?.itemID {
Task {
let document = try? await self.downloadSpeechFile(itemID: itemID, priority: .high)
DispatchQueue.main.async {
if let document = document {
let synthesizer = SpeechSynthesizer(appEnvironment: self.dataService.appEnvironment, networker: self.dataService.networker, document: document)
self.setTextItems()
self.durations = synthesizer.estimatedDurations(forSpeed: self.playbackRate)
self.synthesizer = synthesizer
self.state = desiredState
self.synthesizeFrom(start: currentIdx, playWhenReady: self.state == .playing, atOffset: currentOffset)
} else {
print("error loading audio")
// TODO: post error to SnackBar?
}
}
}
}
}
public var secondaryVoice: String {
let pair = Voices.Pairs.first { $0.firstKey == currentVoice || $0.secondKey == currentVoice }
if let pair = pair {
if pair.firstKey == currentVoice {
return pair.secondKey
}
if pair.secondKey == currentVoice {
return pair.firstKey
}
}
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 }
}
}
public var isLoading: Bool {
if state == .reachedEnd {
return false
}
return (state == .loading || player?.currentItem == nil || player?.currentItem?.status == .unknown)
}
public var isPlaying: Bool {
state == .playing
}
public func isLoadingItem(itemID: String) -> Bool {
if state == .reachedEnd {
return false
}
return itemAudioProperties?.itemID == itemID && isLoading
}
public func isPlayingItem(itemID: String) -> Bool {
itemAudioProperties?.itemID == itemID && isPlaying
}
public func skipForward(seconds: Double) {
seek(to: timeElapsed + seconds)
}
public func skipBackwards(seconds: Double) {
seek(to: timeElapsed - seconds)
}
public func fileNameForAudioFile(_ itemID: String) -> String {
itemID + "-" + currentVoice + ".mp3"
}
public func pathForAudioDirectory(itemID: String) -> URL {
FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("audio-\(itemID)/")
}
public func pathForSpeechFile(itemID: String) -> URL {
pathForAudioDirectory(itemID: itemID)
.appendingPathComponent("speech-\(currentVoice).json")
}
public func startAudio() {
state = .loading
setupNotifications()
if let itemID = itemAudioProperties?.itemID {
Task {
let document = try? await downloadSpeechFile(itemID: itemID, priority: .high)
DispatchQueue.main.async {
self.setTextItems()
if let document = document {
self.startStreamingAudio(itemID: itemID, document: document)
} else {
print("unable to load speech document")
// TODO: Post error to SnackBar
}
}
deinit {
session?.invalidateAndCancel()
}
}
}
@ -553,8 +207,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
@Published public var durationString: String?
@Published public var voiceList: [(name: String, key: String, category: VoiceCategory, selected: Bool)]?
let appEnvironment: AppEnvironment
let networker: Networker
let dataService: DataService
var timer: Timer?
var player: AVQueuePlayer?
@ -562,10 +215,10 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
var document: SpeechDocument?
var synthesizer: SpeechSynthesizer?
var durations: [Double]?
var lastReadUpdate = 0.0
public init(appEnvironment: AppEnvironment, networker: Networker) {
self.appEnvironment = appEnvironment
self.networker = networker
public init(dataService: DataService) {
self.dataService = dataService
super.init()
self.voiceList = generateVoiceList()
@ -606,6 +259,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
player = nil
observer = nil
synthesizer = nil
lastReadUpdate = 0
itemAudioProperties = nil
state = .stopped
@ -620,9 +274,14 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
}
}
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document)
durations = synthesizer.estimatedDurations(forSpeed: playbackRate)
self.synthesizer = synthesizer
public func generateVoiceList() -> [(name: String, key: String, category: VoiceCategory, selected: Bool)] {
Voices.Pairs.flatMap { voicePair in
[
(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 {
@ -631,7 +290,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
for itemID in itemIDs {
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
let synthesizer = SpeechSynthesizer(appEnvironment: appEnvironment, networker: networker, document: document)
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document)
do {
try await synthesizer.preload()
return true
@ -645,7 +304,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
public func downloadForOffline(itemID: String) async -> Bool {
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
let synthesizer = SpeechSynthesizer(appEnvironment: appEnvironment, networker: networker, document: document)
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document)
for item in synthesizer.createPlayerItems(from: 0) {
do {
_ = try await SpeechSynthesizer.download(speechItem: item, redownloadCached: true)
@ -690,34 +349,10 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
public func seek(to: TimeInterval) {
let position = max(0, to)
func formatTimeInterval(_ time: TimeInterval) -> String? {
let componentFormatter = DateComponentsFormatter()
componentFormatter.unitsStyle = .positional
componentFormatter.allowedUnits = time >= 3600 ? [.second, .minute, .hour] : [.second, .minute]
componentFormatter.zeroFormattingBehavior = .pad
return componentFormatter.string(from: time)
}
// What we need is an array of all items in a document, either Utterances if unloaded or AVPlayerItems
// if they have been loaded, then for each one we can calculate a duration
func durationBefore(playerIndex: Int) -> TimeInterval {
let result = durations?.prefix(playerIndex).reduce(0, +) ?? 0
return result
}
func startTimer() {
if timer == nil {
lastReadUpdate = 0
timer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(fireTimer), userInfo: nil, repeats: true)
timer?.fire()
}
}
// Every second, get the current playing time of the player and refresh the status of the player progressslider
@objc func fireTimer() {
if let player = player {
if player.error != nil || player.currentItem?.error != nil {
stop()
// 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
@ -913,11 +548,11 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
if let itemID = itemAudioProperties?.itemID {
Task {
let document = try? await downloadSpeechFile(itemID: itemID, priority: .high)
let document = try? await self.downloadSpeechFile(itemID: itemID, priority: .high)
DispatchQueue.main.async {
if let document = document {
let synthesizer = SpeechSynthesizer(appEnvironment: self.appEnvironment, networker: self.networker, document: document)
let synthesizer = SpeechSynthesizer(appEnvironment: self.dataService.appEnvironment, networker: self.dataService.networker, document: document)
self.setTextItems()
self.durations = synthesizer.estimatedDurations(forSpeed: self.playbackRate)
@ -1050,7 +685,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
}
}
let synthesizer = SpeechSynthesizer(appEnvironment: appEnvironment, networker: networker, document: document)
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document)
durations = synthesizer.estimatedDurations(forSpeed: playbackRate)
self.synthesizer = synthesizer
@ -1117,6 +752,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
func startTimer() {
if timer == nil {
lastReadUpdate = 0
timer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(fireTimer), userInfo: nil, repeats: true)
timer?.fire()
}
@ -1163,19 +799,18 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
}
}
}
}
if timeElapsed - 10 > lastReadUpdate {
let percentProgress = timeElapsed / duration
let anchorIndex = Int((player?.currentItem as? SpeechPlayerItem)?.speechItem.htmlIdx ?? "") ?? 0
if timeElapsed - 10 > lastReadUpdate {
let percentProgress = timeElapsed / duration
let anchorIndex = Int((player?.currentItem as? SpeechPlayerItem)?.speechItem.htmlIdx ?? "") ?? 0
if let itemID = itemAudioProperties?.itemID {
dataService.updateLinkReadingProgress(itemID: itemID, readingProgress: percentProgress, anchorIndex: anchorIndex)
if let itemID = itemAudioProperties?.itemID {
dataService.updateLinkReadingProgress(itemID: itemID, readingProgress: percentProgress, anchorIndex: anchorIndex)
}
lastReadUpdate = timeElapsed
}
lastReadUpdate = timeElapsed
}
}
func clearNowPlayingInfo() {
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
@ -1283,13 +918,13 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
}
let path = "/api/article/\(itemID)/speech?voice=\(currentVoice)&secondaryVoice=\(secondaryVoice)&priority=\(priority)\(isoLangForCurrentVoice())"
guard let url = URL(string: path, relativeTo: appEnvironment.serverBaseURL) else {
guard let url = URL(string: path, relativeTo: dataService.appEnvironment.serverBaseURL) else {
throw BasicError.message(messageText: "Invalid audio URL")
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
for (header, value) in networker.defaultHeaders {
for (header, value) in dataService.networker.defaultHeaders {
request.setValue(value, forHTTPHeaderField: header)
}
@ -1327,16 +962,21 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
}
}
let path = "/api/article/\(itemID)/speech?voice=\(currentVoice)&secondaryVoice=\(secondaryVoice)&priority=\(priority)\(isoLangForCurrentVoice())"
guard let url = URL(string: path, relativeTo: dataService.appEnvironment.serverBaseURL) else {
throw BasicError.message(messageText: "Invalid audio URL")
func setupNotifications() {
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
NotificationCenter.default.addObserver(self,
selector: #selector(handleInterruption),
name: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance())
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
for (header, value) in dataService.networker.defaultHeaders {
request.setValue(value, forHTTPHeaderField: header)
}
@objc func handleInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else {
return
}
// Switch over the interruption type.
switch type {
@ -1355,4 +995,5 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
}
}
}
#endif