fixes after rebasing
This commit is contained in:
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user