From 8fcbfbbd2e6bc5b899c2072fcdd06b70947ee9c7 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 12 Oct 2022 12:58:32 -0700 Subject: [PATCH] fixes after rebasing --- .../App/Views/AudioPlayer/MiniPlayer.swift | 546 ++++++-------- .../Sources/App/Views/Home/HomeView.swift | 14 +- .../App/Views/Home/LibrarySearchView.swift | 264 +++---- .../AudioSession/AudioController.swift | 691 +++++------------- 4 files changed, 524 insertions(+), 991 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift index 1cbc2e7d8..e8c5aa8a5 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift @@ -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 diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift index d7f6781a9..6c2c49875 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift @@ -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) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift index 6369f9ac4..b6e06cb44 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift @@ -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 diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index b00e6a4bc..e1598bc75 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -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() + 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(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[.. 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