From c287eb4a5905933bfc9f43443a83a8ef37c6ed03 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 12 Oct 2022 08:32:02 -0700 Subject: [PATCH] add os conditionals so the mac app compiles --- .../xcshareddata/swiftpm/Package.resolved | 9 - apple/OmnivoreKit/Package.swift | 4 +- apple/OmnivoreKit/Sources/App/Services.swift | 6 +- .../App/Views/AudioPlayer/MiniPlayer.swift | 430 ++++--- .../App/Views/AudioPlayer/ScrubberView.swift | 123 +- .../App/Views/Home/HomeFeedViewMac.swift | 9 +- .../App/Views/Home/HomeFeedViewModel.swift | 8 +- .../App/Views/Labels/MarqueTextView.swift | 264 ++-- .../App/Views/Profile/ProfileView.swift | 10 +- .../Profile/TextToSpeechLanguageView.swift | 72 +- .../App/Views/Profile/TextToSpeechView.swift | 39 +- .../TextToSpeechVoiceSelectionView.swift | 83 +- .../EmailAuth/EmailAuthView.swift | 16 +- .../EmailAuth/EmailLoginFormView.swift | 8 +- .../EmailAuth/EmailSignupFormView.swift | 16 +- .../Sources/App/Views/RootView/RootView.swift | 4 +- .../Views/WebReader/WebReaderContainer.swift | 76 +- .../AudioSession/AudioController.swift | 1114 +++++++++++------ .../AudioSession/MacAudioController.swift | 11 + .../Views/Article/OmnivoreWebView.swift | 8 +- 20 files changed, 1346 insertions(+), 964 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/Services/AudioSession/MacAudioController.swift diff --git a/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0667008b4..5090a25ab 100644 --- a/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -153,15 +153,6 @@ "version" : "2.1.0" } }, - { - "identity" : "pspdfkit-sp", - "kind" : "remoteSourceControl", - "location" : "https://github.com/PSPDFKit/PSPDFKit-SP", - "state" : { - "branch" : "master", - "revision" : "e9757beadad1b30de84073d3c33c1cd0f7a94b80" - } - }, { "identity" : "sovran-swift", "kind" : "remoteSourceControl", diff --git a/apple/OmnivoreKit/Package.swift b/apple/OmnivoreKit/Package.swift index f5ef78249..58f0d3de0 100644 --- a/apple/OmnivoreKit/Package.swift +++ b/apple/OmnivoreKit/Package.swift @@ -56,7 +56,7 @@ let package = Package( var appPackageDependencies: [Target.Dependency] { var deps: [Target.Dependency] = ["Views", "Services", "Models", "Utils"] // #if canImport(UIKit) - deps.append(.product(name: "PSPDFKit", package: "PSPDFKit-SP")) +// deps.append(.product(name: "PSPDFKit", package: "PSPDFKit-SP")) // #endif return deps } @@ -70,7 +70,7 @@ var dependencies: [Package.Dependency] { .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.2.2") ] // #if canImport(UIKit) - deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", branch: "master")) +// deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", branch: "master")) // #endif return deps } diff --git a/apple/OmnivoreKit/Sources/App/Services.swift b/apple/OmnivoreKit/Sources/App/Services.swift index 10f0a0467..f438131c4 100644 --- a/apple/OmnivoreKit/Sources/App/Services.swift +++ b/apple/OmnivoreKit/Sources/App/Services.swift @@ -18,7 +18,11 @@ public final class Services { let networker = Networker(appEnvironment: appEnvironment) self.authenticator = Authenticator(networker: networker) self.dataService = DataService(appEnvironment: appEnvironment, networker: networker) - self.audioController = AudioController(dataService: dataService) + #if os(iOS) + self.audioController = AudioController(dataService: dataService) + #else + self.audioController = AudioController() + #endif } } diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift index 69f1b45de..1cbc2e7d8 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift @@ -1,153 +1,153 @@ -// -// MiniPlayer.swift -// -// -// Created by Jackson Harper on 8/15/22. -// +#if os(iOS) -import Foundation -import Models -import Services -import SwiftUI -import Views + import Foundation + import Models + import Services + import SwiftUI + import Views -public struct MiniPlayer: View { - @EnvironmentObject var audioController: AudioController - @Environment(\.colorScheme) private var colorScheme: ColorScheme - private let presentingView: AnyView + public struct MiniPlayer: View { + @EnvironmentObject var audioController: AudioController + @Environment(\.colorScheme) private var colorScheme: ColorScheme + private let presentingView: AnyView - @State var expanded = false - @State var offset: CGFloat = 0 - @State var showVoiceSheet = false - @State var showLanguageSheet = false + @State var expanded = false + @State var offset: CGFloat = 0 + @State var showVoiceSheet = false + @State var showLanguageSheet = false - @State var tabIndex: Int = 0 - @Namespace private var animation + @State var tabIndex: Int = 0 + @Namespace private var animation - let minExpandedHeight = UIScreen.main.bounds.height / 3 + let minExpandedHeight = UIScreen.main.bounds.height / 3 - init( - presentingView: PresentingView - ) where PresentingView: View { - self.presentingView = AnyView(presentingView) - } - - var isPresented: Bool { - audioController.itemAudioProperties != nil && audioController.state != .stopped - } - - var playPauseButtonImage: String { - switch audioController.state { - case .playing: - return "pause.circle" - case .paused: - return "play.circle" - case .reachedEnd: - return "gobackward" - default: - return "" + init( + presentingView: PresentingView + ) where PresentingView: View { + self.presentingView = AnyView(presentingView) } - } - var playPauseButtonItem: some View { - if let itemID = audioController.itemAudioProperties?.itemID, audioController.isLoadingItem(itemID: itemID) { - return AnyView(ProgressView()) - } else { - return AnyView(Button( + var isPresented: Bool { + audioController.itemAudioProperties != nil && audioController.state != .stopped + } + + var playPauseButtonImage: String { + switch audioController.state { + case .playing: + return "pause.circle" + case .paused: + return "play.circle" + case .reachedEnd: + return "gobackward" + default: + return "" + } + } + + var playPauseButtonItem: some View { + if let itemID = audioController.itemAudioProperties?.itemID, audioController.isLoadingItem(itemID: itemID) { + return AnyView(ProgressView()) + } else { + return AnyView(Button( + action: { + switch audioController.state { + case .playing: + audioController.pause() + case .paused: + audioController.unpause() + case .reachedEnd: + audioController.seek(to: 0.0) + audioController.unpause() + default: + break + } + }, + label: { + Image(systemName: playPauseButtonImage) + .font(expanded ? .system(size: 56.0, weight: .thin) : .appTitleTwo) + } + )) + } + } + + var stopButton: some View { + Button( action: { - switch audioController.state { - case .playing: - audioController.pause() - case .paused: - audioController.unpause() - case .reachedEnd: - audioController.seek(to: 0.0) - audioController.unpause() - default: - break + audioController.stop() + }, + label: { + Image(systemName: "xmark") + .font(.appTitleTwo) + } + ) + } + + var closeButton: some View { + Button( + action: { + withAnimation(.interactiveSpring()) { + self.expanded = false } }, label: { - Image(systemName: playPauseButtonImage) - .font(expanded ? .system(size: 56.0, weight: .thin) : .appTitleTwo) + Image(systemName: "chevron.down") + .font(.appNavbarIcon) + .tint(.appGrayTextContrast) } - )) + ) + // .contentShape(Rectangle()) } - } - var stopButton: some View { - Button( - action: { - audioController.stop() - }, - label: { - Image(systemName: "xmark") - .font(.appTitleTwo) - } - ) - } - - var closeButton: some View { - Button( - action: { - withAnimation(.interactiveSpring()) { - self.expanded = false + func viewArticle() { + if let objectID = audioController.itemAudioProperties?.objectID { + NSNotification.pushReaderItem(objectID: objectID) + withAnimation(.easeIn(duration: 0.1)) { + expanded = false } - }, - label: { - Image(systemName: "chevron.down") - .font(.appNavbarIcon) - .tint(.appGrayTextContrast) - } - ) - // .contentShape(Rectangle()) - } - - func viewArticle() { - if let objectID = audioController.itemAudioProperties?.objectID { - NSNotification.pushReaderItem(objectID: objectID) - withAnimation(.easeIn(duration: 0.1)) { - expanded = false } } - } - func defaultArtwork(forDimensions dim: Double) -> some View { - ZStack(alignment: .center) { - Color.appButtonBackground - .frame(width: dim, height: dim) - .cornerRadius(6) + func defaultArtwork(forDimensions dim: Double) -> some View { + ZStack(alignment: .center) { + Color.appButtonBackground + .frame(width: dim, height: dim) + .cornerRadius(6) - Image(systemName: "headphones") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: dim / 2, height: dim / 2) + Image(systemName: "headphones") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: dim / 2, height: dim / 2) + } } - } - struct SpeechCard: View { - let id: Int - @EnvironmentObject var audioController: AudioController + struct SpeechCard: View { + let id: Int + @EnvironmentObject var audioController: AudioController - var body: some View { - Group { - if id != self.audioController.currentAudioIndex || self.audioController.isLoading { - Text(self.audioController.textItems?[id] ?? "\(id)") - .font(.textToSpeechRead.leading(.loose)) - .foregroundColor(Color.appGrayTextContrast) - } else { - Group { - Text(audioController.readText) + var body: some View { + Group { + if id != self.audioController.currentAudioIndex || self.audioController.isLoading { + Text(self.audioController.textItems?[id] ?? "\(id)") .font(.textToSpeechRead.leading(.loose)) .foregroundColor(Color.appGrayTextContrast) - + - Text(audioController.unreadText) - .font(.textToSpeechRead.leading(.loose)) - .foregroundColor(Color.appGrayText) + } else { + Group { + Text(audioController.readText) + .font(.textToSpeechRead.leading(.loose)) + .foregroundColor(Color.appGrayTextContrast) + + + Text(audioController.unreadText) + .font(.textToSpeechRead.leading(.loose)) + .foregroundColor(Color.appGrayText) + } } } + .padding(16) + } + + init(id: Int) { + self.id = id } - .padding(16) } init(id: Int) { @@ -446,53 +446,161 @@ public struct MiniPlayer: View { 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()) } - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) + .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 }) { + 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) + }) } } - .padding(.top, 32) - .listStyle(.plain) - Spacer() } - .navigationBarTitle("Voice") - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }) { - Image(systemName: "chevron.backward") - .font(.appNavbarIcon) - .tint(.appGrayTextContrast) - }) } - } - var scrubbing: Bool { - switch audioController.scrubState { - case .scrubStarted: - return true - default: - return false - } - } - - func onDragChanged(value: DragGesture.Value) { - if value.translation.height > 0, expanded, !scrubbing { - offset = value.translation.height - } - } - - func onDragEnded(value: DragGesture.Value) { - withAnimation(.interactiveSpring()) { - if value.translation.height > minExpandedHeight, !scrubbing { - expanded = false + 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") + } + } + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.top, 32) + .listStyle(.plain) + Spacer() + } + .navigationBarTitle("Voice") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }) { + Image(systemName: "chevron.backward") + .font(.appNavbarIcon) + .tint(.appGrayTextContrast) + }) + } + } + + var scrubbing: Bool { + switch audioController.scrubState { + case .scrubStarted: + return true + default: + return false + } + } + + func onDragChanged(value: DragGesture.Value) { + if value.translation.height > 0, expanded, !scrubbing { + offset = value.translation.height + } + } + + func onDragEnded(value: DragGesture.Value) { + withAnimation(.interactiveSpring()) { + if value.translation.height > minExpandedHeight, !scrubbing { + expanded = false + } + offset = 0 } - offset = 0 } } -} -public extension View { - func miniPlayer() -> some View { - MiniPlayer(presentingView: self) + public extension View { + func miniPlayer() -> some View { + MiniPlayer(presentingView: self) + } } -} + +#endif diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ScrubberView.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ScrubberView.swift index edff16b09..a8ddde418 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ScrubberView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ScrubberView.swift @@ -1,73 +1,70 @@ -// -// ScrubberView.swift -// -// -// Created by Jackson Harper on 9/27/22. -// +#if os(iOS) -import Foundation -import SwiftUI + import Foundation + import SwiftUI -struct ScrubberView: UIViewRepresentable { - typealias UIViewType = UISlider + struct ScrubberView: UIViewRepresentable { + typealias UIViewType = UISlider - @Binding var value: Double - var minValue: Double - var maxValue: Double - var onEditingChanged: (Bool) -> Void - - init(value: Binding, minValue: Double, maxValue: Double, onEditingChanged: @escaping (Bool) -> Void) { - self._value = value - self.minValue = minValue - self.maxValue = maxValue - self.onEditingChanged = onEditingChanged - } - - func makeUIView(context: Context) -> UISlider { - let slider = UISlider(frame: .zero) - slider.maximumValue = Float(minValue) - slider.maximumValue = Float(maxValue) - - let tintColor = UIColor(Color.appCtaYellow) - - let image = UIImage(systemName: "circle.fill", - withConfiguration: UIImage.SymbolConfiguration(scale: .small))? - .withTintColor(tintColor) - .withRenderingMode(.alwaysOriginal) - - slider.setThumbImage(image, for: .selected) - slider.setThumbImage(image, for: .normal) - - slider.minimumTrackTintColor = tintColor - slider.addTarget(context.coordinator, - action: #selector(Coordinator.valueChanged(_:)), - for: .valueChanged) - - return slider - } - - func updateUIView(_ uiView: UISlider, context _: Context) { - uiView.value = Float(value) - } - - func makeCoordinator() -> Coordinator { - let coordinator = Coordinator(value: $value, onEditingChanged: onEditingChanged) - return coordinator - } - - class Coordinator: NSObject { - var value: Binding + @Binding var value: Double + var minValue: Double + var maxValue: Double var onEditingChanged: (Bool) -> Void - init(value: Binding, onEditingChanged: @escaping (Bool) -> Void) { - self.value = value + init(value: Binding, minValue: Double, maxValue: Double, onEditingChanged: @escaping (Bool) -> Void) { + self._value = value + self.minValue = minValue + self.maxValue = maxValue self.onEditingChanged = onEditingChanged - super.init() } - @objc func valueChanged(_ sender: UISlider) { - value.wrappedValue = Double(sender.value) - onEditingChanged(sender.isTracking) + func makeUIView(context: Context) -> UISlider { + let slider = UISlider(frame: .zero) + slider.maximumValue = Float(minValue) + slider.maximumValue = Float(maxValue) + + let tintColor = UIColor(Color.appCtaYellow) + + let image = UIImage(systemName: "circle.fill", + withConfiguration: UIImage.SymbolConfiguration(scale: .small))? + .withTintColor(tintColor) + .withRenderingMode(.alwaysOriginal) + + slider.setThumbImage(image, for: .selected) + slider.setThumbImage(image, for: .normal) + + slider.minimumTrackTintColor = tintColor + slider.addTarget(context.coordinator, + action: #selector(Coordinator.valueChanged(_:)), + for: .valueChanged) + + return slider + } + + func updateUIView(_ uiView: UISlider, context _: Context) { + uiView.value = Float(value) + } + + func makeCoordinator() -> Coordinator { + let coordinator = Coordinator(value: $value, onEditingChanged: onEditingChanged) + return coordinator + } + + class Coordinator: NSObject { + var value: Binding + var onEditingChanged: (Bool) -> Void + + init(value: Binding, onEditingChanged: @escaping (Bool) -> Void) { + self.value = value + self.onEditingChanged = onEditingChanged + super.init() + } + + @objc func valueChanged(_ sender: UISlider) { + value.wrappedValue = Double(sender.value) + onEditingChanged(sender.isTracking) + } } } -} + +#endif diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift index 49a12d1c9..17b800db9 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift @@ -8,13 +8,20 @@ import Views #if os(macOS) struct HomeFeedView: View { @EnvironmentObject var dataService: DataService + @EnvironmentObject var audioController: AudioController @State private var itemToRemove: LinkedItem? @State private var confirmationShown = false @ObservedObject var viewModel: HomeFeedViewModel func loadItems(isRefresh: Bool) { - Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) } + Task { + await viewModel.loadItems( + dataService: dataService, + audioController: audioController, + isRefresh: isRefresh + ) + } } var body: some View { diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 7e5d23189..c301d7aff 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -43,7 +43,9 @@ import Views // Pop the current selected item if needed if selectedItem != nil, selectedItem?.objectID != objectID { // Temporarily disable animation to avoid excessive animations - UIView.setAnimationsEnabled(false) + #if os(iOS) + UIView.setAnimationsEnabled(false) + #endif linkIsActive = false selectedItem = nil @@ -54,7 +56,9 @@ import Views } DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { - UIView.setAnimationsEnabled(true) + #if os(iOS) + UIView.setAnimationsEnabled(true) + #endif } } else { selectedItem = dataService.viewContext.object(with: objectID) as? LinkedItem diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/MarqueTextView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/MarqueTextView.swift index 333f79e60..7fe66c592 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/MarqueTextView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/MarqueTextView.swift @@ -1,204 +1,96 @@ -import SwiftUI +#if os(iOS) + import SwiftUI -// Mostly from: https://kavsoft.dev/swiftui_3.0_marquee_text_animation with some customizations + // Mostly from: https://kavsoft.dev/swiftui_3.0_marquee_text_animation with some customizations -struct Marquee: View { - var text: String - var font: UIFont + struct Marquee: View { + var text: String + var font: UIFont - // Storing Text Size - @State var storedSize: CGSize = .zero - @State var offset: CGFloat = 0 - @State var animatedText: String = "" + // Storing Text Size + @State var storedSize: CGSize = .zero + @State var offset: CGFloat = 0 + @State var animatedText: String = "" - var animationSpeed: Double = 0.03 - var delayTime: Double = 3.0 + var animationSpeed: Double = 0.03 + var delayTime: Double = 3.0 - var body: some View { - // Since it scrolls horizontal using ScrollView - GeometryReader { proxy in + var body: some View { + // Since it scrolls horizontal using ScrollView + GeometryReader { proxy in - let size = proxy.size + let size = proxy.size - let condition = textSize(text: text).width < (size.width - 50) + let condition = textSize(text: text).width < (size.width - 50) - ScrollView(condition ? .init() : .horizontal, showsIndicators: false) { - HStack(alignment: .center) { - Spacer(minLength: 0) - Text(condition ? text : animatedText) - .font(Font(font)) - .offset(x: condition ? 0 : offset) - .padding(.horizontal, 15) - Spacer(minLength: 0) + ScrollView(condition ? .init() : .horizontal, showsIndicators: false) { + HStack(alignment: .center) { + Spacer(minLength: 0) + Text(condition ? text : animatedText) + .font(Font(font)) + .offset(x: condition ? 0 : offset) + .padding(.horizontal, 15) + Spacer(minLength: 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + .frame(height: storedSize.height) + .overlay(content: { + HStack { + let color: Color = .systemBackground + + LinearGradient(colors: [color, color.opacity(0.7), color.opacity(0.5), color.opacity(0.3)], startPoint: .leading, endPoint: .trailing) + .frame(width: 8) + + Spacer() + + LinearGradient(colors: [color, color.opacity(0.7), color.opacity(0.5), color.opacity(0.3)].reversed(), startPoint: .leading, endPoint: .trailing) + .frame(width: 8) + } + }) + .disabled(true) + .onAppear { + startAnimation(text: text) + } + .onReceive(Timer.publish(every: (animationSpeed * storedSize.width) + delayTime, + on: .main, + in: .default).autoconnect() + ) { _ in + offset = 0 + withAnimation(.linear(duration: animationSpeed * storedSize.width).delay(delayTime)) { + offset = -storedSize.width } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - } - .frame(height: storedSize.height) - .overlay(content: { - HStack { - let color: Color = .systemBackground - - LinearGradient(colors: [color, color.opacity(0.7), color.opacity(0.5), color.opacity(0.3)], startPoint: .leading, endPoint: .trailing) - .frame(width: 8) - - Spacer() - - LinearGradient(colors: [color, color.opacity(0.7), color.opacity(0.5), color.opacity(0.3)].reversed(), startPoint: .leading, endPoint: .trailing) - .frame(width: 8) + .onChange(of: text) { newValue in + animatedText = "" + offset = 0 + startAnimation(text: newValue) } - }) - .disabled(true) - .onAppear { - startAnimation(text: text) } - .onReceive(Timer.publish(every: (animationSpeed * storedSize.width) + delayTime, - on: .main, - in: .default).autoconnect() - ) { _ in - offset = 0 - withAnimation(.linear(duration: animationSpeed * storedSize.width).delay(delayTime)) { + + func startAnimation(text: String) { + // Double the text with some spacing so that we can create a continuous loop + animatedText.append(text) + (1 ... 15).forEach { _ in + animatedText.append(" ") + } + storedSize = textSize(text: animatedText) + animatedText.append(text) + + let timing: Double = (animationSpeed * storedSize.width) + withAnimation(.linear(duration: timing).delay(delayTime)) { offset = -storedSize.width } } - .onChange(of: text) { newValue in - animatedText = "" - offset = 0 - startAnimation(text: newValue) + + func textSize(text: String) -> CGSize { + let attributes = [NSAttributedString.Key.font: font] + + let size = (text as NSString).size(withAttributes: attributes) + + return size } } - func startAnimation(text: String) { - // Double the text with some spacing so that we can create a continuous loop - animatedText.append(text) - (1 ... 15).forEach { _ in - animatedText.append(" ") - } - storedSize = textSize(text: animatedText) - animatedText.append(text) - - let timing: Double = (animationSpeed * storedSize.width) - withAnimation(.linear(duration: timing).delay(delayTime)) { - offset = -storedSize.width - } - } - - func textSize(text: String) -> CGSize { - let attributes = [NSAttributedString.Key.font: font] - - let size = (text as NSString).size(withAttributes: attributes) - - return size - } -} - -// Old version: -// -// struct MarqueTextView: View { -// let font: Font -// -// @State var text: String -// @State private var intrinsicSize: CGSize = .zero -// @State private var truncatedSize: CGSize = .zero -// -// @State private var shouldAnimate: Bool = false -// @State private var animationOffset: Double = 0.0 -// -// var body: some View { -// GeometryReader { geo in -// ScrollView(.horizontal, showsIndicators: false) { -// HStack(alignment: .center) { -// Spacer(minLength: 0) -// Text(text) -// .font(font) -// .lineLimit(1) -// .lineSpacing(1.25) -// .offset(x: animationOffset) -// .readSize { size in -// truncatedSize = size -// intrinsicSize = geo.size -// -// shouldAnimate = textSize().width > intrinsicSize.width -// } -// Spacer(minLength: 0) -// } -// .frame(width: max(geo.size.width, textSize().width + 10)) -// } -// .frame(maxWidth: .infinity, alignment: .center) -// .disabled(true) -// .onChange(of: shouldAnimate) { _ in -// -// let baseText = text -// text.append(" ") -// let initialSize = textSize() -// -// print("starting animation, truncatedSize: ", truncatedSize, "geo width: ", geo.size) -// if shouldAnimate { -// DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { -// withAnimation(.linear(duration: 0.05 * truncatedSize.width)) { -// animationOffset = -truncatedSize.width -// } -// } -// } -// } -// .onReceive(Timer.publish(every: 0.05 * truncatedSize.width + 0.5, on: .main, in: .default).autoconnect()) { _ in -// if shouldAnimate { -// animationOffset = 0 -// withAnimation(.linear(duration: 0.05 * truncatedSize.width)) { -// animationOffset = -truncatedSize.width -// } -// } -// } -// } -// } -// -// func textSize() -> CGSize { -// let attributes = [NSAttributedString.Key.font: UIFont(name: "Inter-Regular", size: 16)!] -// return (text as NSString).size(withAttributes: attributes) -// } -// } -// -//// text() -//// .lineLimit(lineLimit) -//// .offset(x: animationOffset) -//// .readSize { size in -//// truncatedSize = size -//// shouldAnimate = truncatedSize != intrinsicSize -//// print("trunvatedSize: ", truncatedSize, "intrinsicSize: ", intrinsicSize) -//// } -//// .background( -//// text() -//// .fixedSize(horizontal: false, vertical: true) -//// .hidden() -//// .readSize { size in -//// intrinsicSize = size -//// shouldAnimate = truncatedSize != intrinsicSize -//// } -//// ) -//// .onChange(of: shouldAnimate, perform: { _ in -//// print("starting animation") -//// DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { -//// withAnimation(.linear(duration: 0.2 * intrinsicSize.width)) { -//// animationOffset = intrinsicSize.width -//// } -//// } -//// }) -//// } -//// } -// -// extension View { -// func readSize(onChange: @escaping (CGSize) -> Void) -> some View { -// background( -// GeometryReader { geometryProxy in -// Color.clear -// .preference(key: SizePreferenceKey.self, value: geometryProxy.size) -// } -// ) -// .onPreferenceChange(SizePreferenceKey.self, perform: onChange) -// } -// } -// -// struct SizePreferenceKey: PreferenceKey { -// static var defaultValue: CGSize = .zero -// static func reduce(value _: inout CGSize, nextValue _: () -> CGSize) {} -// } +#endif diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index 6a665c1ee..e73aa03f8 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -99,11 +99,13 @@ struct ProfileView: View { } } - Section { - NavigationLink(destination: TextToSpeechView()) { - Text("Text to Speech") + #if os(iOS) + Section { + NavigationLink(destination: TextToSpeechView()) { + Text("Text to Speech") + } } - } + #endif Section { NavigationLink( diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift index 2b35325b7..dae09f095 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift @@ -1,47 +1,49 @@ -import Models -import Services -import SwiftUI -import Views +#if os(iOS) + import Models + import Services + import SwiftUI + import Views -struct TextToSpeechLanguageView: View { - @EnvironmentObject var audioController: AudioController + struct TextToSpeechLanguageView: View { + @EnvironmentObject var audioController: AudioController - var body: some View { - Group { - #if os(iOS) - Form { - innerBody - } - #elseif os(macOS) - List { - innerBody - } - .listStyle(InsetListStyle()) - #endif + var body: some View { + Group { + #if os(iOS) + Form { + innerBody + } + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } } - } - private var innerBody: some View { - ForEach(Voices.Languages, id: \.key.self) { language in - Button(action: { - audioController.defaultLanguage = language.key - }) { - HStack { - Text(language.name) + private var innerBody: some View { + ForEach(Voices.Languages, id: \.key.self) { language in + Button(action: { + audioController.defaultLanguage = language.key + }) { + HStack { + Text(language.name) - Spacer() + Spacer() - if audioController.defaultLanguage == language.key { - if audioController.isPlaying, audioController.isLoading { - ProgressView() - } else { - Image(systemName: "checkmark") + if audioController.defaultLanguage == language.key { + if audioController.isPlaying, audioController.isLoading { + ProgressView() + } else { + Image(systemName: "checkmark") + } } } + .contentShape(Rectangle()) } - .contentShape(Rectangle()) + .buttonStyle(PlainButtonStyle()) } - .buttonStyle(PlainButtonStyle()) } } -} +#endif diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift index ea5fadfc0..99ed59dfb 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift @@ -1,14 +1,14 @@ -import Models -import Services -import SwiftUI -import Views +#if os(iOS) + import Models + import Services + import SwiftUI + import Views -struct TextToSpeechView: View { - @EnvironmentObject var audioController: AudioController + struct TextToSpeechView: View { + @EnvironmentObject var audioController: AudioController - var body: some View { - Group { - #if os(iOS) + var body: some View { + Group { Form { Section("Audio Settings") { Toggle("Enable audio prefetch", isOn: $audioController.preloadEnabled) @@ -18,22 +18,17 @@ struct TextToSpeechView: View { } innerBody } - #elseif os(macOS) - List { - innerBody - } - .listStyle(InsetListStyle()) - #endif + } } - } - private var innerBody: some View { - Section("Voices") { - ForEach(Voices.Languages, id: \.key) { language in - NavigationLink(destination: TextToSpeechVoiceSelectionView(forLanguage: language, showLanguageChanger: false)) { - Text(language.name) + private var innerBody: some View { + Section("Voices") { + ForEach(Voices.Languages, id: \.key) { language in + NavigationLink(destination: TextToSpeechVoiceSelectionView(forLanguage: language, showLanguageChanger: false)) { + Text(language.name) + } } } } } -} +#endif diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionView.swift index ad6ce2ba3..34549ef02 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionView.swift @@ -1,21 +1,21 @@ -import Models -import Services -import SwiftUI -import Views +#if os(iOS) + import Models + import Services + import SwiftUI + import Views -struct TextToSpeechVoiceSelectionView: View { - @EnvironmentObject var audioController: AudioController - let language: VoiceLanguage - let showLanguageChanger: Bool + struct TextToSpeechVoiceSelectionView: View { + @EnvironmentObject var audioController: AudioController + let language: VoiceLanguage + let showLanguageChanger: Bool - init(forLanguage: VoiceLanguage, showLanguageChanger: Bool) { - self.language = forLanguage - self.showLanguageChanger = showLanguageChanger - } + init(forLanguage: VoiceLanguage, showLanguageChanger: Bool) { + self.language = forLanguage + self.showLanguageChanger = showLanguageChanger + } - var body: some View { - Group { - #if os(iOS) + var body: some View { + Group { Form { if showLanguageChanger { Section("Language") { @@ -26,22 +26,16 @@ struct TextToSpeechVoiceSelectionView: View { } innerBody } - #elseif os(macOS) - List { - innerBody - } - .listStyle(InsetListStyle()) - #endif + } + .navigationTitle("Choose a Voice") } - .navigationTitle("Choose a Voice") - } - private var innerBody: some View { - ForEach(language.categories, id: \.self) { category in - Section(category.rawValue) { - ForEach(audioController.voiceList?.filter { $0.category == category } ?? [], id: \.key.self) { voice in - HStack { - // Voice samples are not working yet + private var innerBody: some View { + ForEach(language.categories, id: \.self) { category in + Section(category.rawValue) { + ForEach(audioController.voiceList?.filter { $0.category == category } ?? [], id: \.key.self) { voice in + HStack { + // Voice samples are not working yet // Button(action: { // audioController.playVoiceSample(voice: voice.key) // }) { @@ -49,28 +43,29 @@ struct TextToSpeechVoiceSelectionView: View { // } // .buttonStyle(PlainButtonStyle()) - Button(action: { - audioController.setPreferredVoice(voice.key, forLanguage: language.key) - audioController.currentVoice = voice.key - }) { - HStack { - Text(voice.name) - Spacer() + Button(action: { + audioController.setPreferredVoice(voice.key, forLanguage: language.key) + audioController.currentVoice = voice.key + }) { + HStack { + Text(voice.name) + Spacer() - if voice.selected { - if audioController.isPlaying, audioController.isLoading { - ProgressView() - } else { - Image(systemName: "checkmark") + if voice.selected { + if audioController.isPlaying, audioController.isLoading { + ProgressView() + } else { + Image(systemName: "checkmark") + } } } + .contentShape(Rectangle()) } - .contentShape(Rectangle()) + .buttonStyle(PlainButtonStyle()) } - .buttonStyle(PlainButtonStyle()) } } } } } -} +#endif diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailAuthView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailAuthView.swift index 0a5905809..c30693016 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailAuthView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailAuthView.swift @@ -48,15 +48,17 @@ struct EmailAuthView: View { Color.appBackground.edgesIgnoringSafeArea(.all) primaryContent .frame(maxWidth: 300) + #if os(iOS) .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .barTrailing) { - Button( - action: { presentationMode.wrappedValue.dismiss() }, - label: { Image(systemName: "xmark").foregroundColor(.appGrayTextContrast) } - ) - } + #endif + .toolbar { + ToolbarItem(placement: .barTrailing) { + Button( + action: { presentationMode.wrappedValue.dismiss() }, + label: { Image(systemName: "xmark").foregroundColor(.appGrayTextContrast) } + ) } + } } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailLoginFormView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailLoginFormView.swift index 0e859efa0..b8d20ccd4 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailLoginFormView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailLoginFormView.swift @@ -52,10 +52,12 @@ struct EmailLoginFormView: View { .font(.appFootnote) .foregroundColor(.appGrayText) TextField("", text: $email) + #if os(iOS) .keyboardType(.emailAddress) .textContentType(.emailAddress) .textInputAutocapitalization(.never) - .disableAutocorrection(true) + #endif + .disableAutocorrection(true) .focused($focusedField, equals: .email) .submitLabel(.next) } @@ -67,8 +69,10 @@ struct EmailLoginFormView: View { .foregroundColor(.appGrayText) SecureField("", text: $password) .textContentType(.password) + #if os(iOS) .textInputAutocapitalization(.never) - .disableAutocorrection(true) + #endif + .disableAutocorrection(true) .focused($focusedField, equals: .password) .submitLabel(.done) } diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailSignupFormView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailSignupFormView.swift index 086c6a90c..12d6ae09a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailSignupFormView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailSignupFormView.swift @@ -96,10 +96,12 @@ struct EmailSignupFormView: View { .foregroundColor(.appGrayText) TextField("", text: $email) .focused($focusedField, equals: .email) + #if os(iOS) .textContentType(.emailAddress) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) - .disableAutocorrection(true) + #endif + .disableAutocorrection(true) .submitLabel(.next) } .padding(.bottom, 8) @@ -111,9 +113,11 @@ struct EmailSignupFormView: View { .foregroundColor(.appGrayText) SecureField("", text: $password) .focused($focusedField, equals: .password) + #if os(iOS) .textContentType(.newPassword) .textInputAutocapitalization(.never) - .disableAutocorrection(true) + #endif + .disableAutocorrection(true) .submitLabel(.next) } .padding(.bottom, 8) @@ -125,9 +129,11 @@ struct EmailSignupFormView: View { .foregroundColor(.appGrayText) TextField("", text: $name) .focused($focusedField, equals: .fullName) + #if os(iOS) .textContentType(.name) .keyboardType(.alphabet) - .disableAutocorrection(true) + #endif + .disableAutocorrection(true) .submitLabel(.next) } .padding(.bottom, 8) @@ -141,10 +147,12 @@ struct EmailSignupFormView: View { .foregroundColor(.appGrayText) TextField("", text: $viewModel.potentialUsername) .focused($focusedField, equals: .username) + #if os(iOS) .textInputAutocapitalization(.never) .textContentType(.username) - .disableAutocorrection(true) .keyboardType(.alphabet) + #endif + .disableAutocorrection(true) .submitLabel(.done) } diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift index 4c60d75fb..26532feda 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift @@ -51,8 +51,10 @@ struct InnerRootView: View { .onAppear { viewModel.triggerPushNotificationRequestIfNeeded() } + #if os(iOS) .miniPlayer() - .snackBar(isShowing: $viewModel.showSnackbar, message: viewModel.snackbarMessage) + #endif + .snackBar(isShowing: $viewModel.showSnackbar, message: viewModel.snackbarMessage) // Schedule the dismissal every time we present the snackbar. .onChange(of: viewModel.showSnackbar) { newValue in if newValue { diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index f40ca22d5..00891c3d0 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -75,47 +75,49 @@ struct WebReaderContainerView: View { } } - var audioNavbarItem: some View { - if audioController.isLoadingItem(itemID: item.unwrappedID) { - return AnyView(ProgressView() + #if os(iOS) + var audioNavbarItem: some View { + if audioController.isLoadingItem(itemID: item.unwrappedID) { + return AnyView(ProgressView() + .padding(.horizontal) + .scaleEffect(navBarVisibilityRatio)) + } else { + return AnyView(Button( + action: { + switch audioController.state { + case .playing: + if audioController.itemAudioProperties?.itemID == self.item.unwrappedID { + audioController.pause() + return + } + fallthrough + case .paused: + if audioController.itemAudioProperties?.itemID == self.item.unwrappedID { + audioController.unpause() + return + } + fallthrough + default: + audioController.play(itemAudioProperties: item.audioProperties) + } + }, + label: { + textToSpeechButtonImage + } + ) .padding(.horizontal) .scaleEffect(navBarVisibilityRatio)) - } else { - return AnyView(Button( - action: { - switch audioController.state { - case .playing: - if audioController.itemAudioProperties?.itemID == self.item.unwrappedID { - audioController.pause() - return - } - fallthrough - case .paused: - if audioController.itemAudioProperties?.itemID == self.item.unwrappedID { - audioController.unpause() - return - } - fallthrough - default: - audioController.play(itemAudioProperties: item.audioProperties) - } - }, - label: { - textToSpeechButtonImage - } - ) - .padding(.horizontal) - .scaleEffect(navBarVisibilityRatio)) + } } - } - var textToSpeechButtonImage: some View { - if audioController.state == .stopped || audioController.itemAudioProperties?.itemID != self.item.id { - return Image(systemName: "headphones").font(.appTitleThree) + var textToSpeechButtonImage: some View { + if audioController.state == .stopped || audioController.itemAudioProperties?.itemID != self.item.id { + return Image(systemName: "headphones").font(.appTitleThree) + } + let name = audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle" + return Image(systemName: name).font(.appNavbarIcon) } - let name = audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle" - return Image(systemName: name).font(.appNavbarIcon) - } + #endif var navBar: some View { HStack(alignment: .center) { @@ -131,8 +133,8 @@ struct WebReaderContainerView: View { ) .scaleEffect(navBarVisibilityRatio) Spacer() + audioNavbarItem #endif - audioNavbarItem Button( action: { showPreferencesPopover.toggle() }, label: { diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index aea393b20..b00e6a4bc 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -1,9 +1,4 @@ -// -// AudioController.swift -// -// -// Created by Jackson Harper on 8/15/22. -// +#if os(iOS) import AVFoundation import CryptoKit @@ -75,123 +70,57 @@ class SpeechPlayerItem: AVPlayerItem { } } - deinit { - observer = nil - resourceLoaderDelegate.session?.invalidateAndCancel() + public enum PlayerScrubState { + case reset + case scrubStarted + case scrubEnded(TimeInterval) } - open func download() { - if resourceLoaderDelegate.session == nil { - resourceLoaderDelegate.startDataRequest(with: speechItem.urlRequest) - } + enum DownloadPriority: String { + case low + case high } - @objc func playbackStalledHandler() { - print("playback stalled...") - } + // 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]? - class ResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate { - var session: URLSession? - var mediaData: Data? - var pendingRequests = Set() - weak var owner: SpeechPlayerItem? + let completed: () -> Void - func resourceLoader(_: AVAssetResourceLoader, - shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool - { - if owner == nil { - return true + 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") } - if session == nil { - guard let initialUrl = owner?.speechItem.urlRequest else { - fatalError("internal inconsistency") - } + let asset = AVURLAsset(url: fakeUrl) + asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main) - startDataRequest(with: initialUrl) - } + super.init(asset: asset, automaticallyLoadedAssetKeys: nil) - pendingRequests.insert(loadingRequest) - processPendingRequests() - return true - } + resourceLoaderDelegate.owner = self - 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() + 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) } } - } - 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) + NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: self, queue: OperationQueue.main) { [weak self] _ in + guard let self = self else { return } + self.completed() } } - 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 - } - - let bytesToRespond = min(songDataUnwrapped.count - currentOffset, requestedLength) - let range = Range(uncheckedBounds: (currentOffset, currentOffset + bytesToRespond)) - let dataToRespond = songDataUnwrapped.subdata(in: range) - dataRequest.respond(with: dataToRespond) - - return songDataUnwrapped.count >= requestedLength + requestedOffset - } - deinit { session?.invalidateAndCancel() } @@ -304,8 +233,6 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } } } - return false - } public func downloadForOffline(itemID: String) async -> Bool { if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) { @@ -317,74 +244,13 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate print("error downloading audio segment: ", error) return false } - } - return true - } - return false - } - public var scrubState: PlayerScrubState = .reset { - didSet { - switch scrubState { - case .reset: - return - case .scrubStarted: - return - case let .scrubEnded(seekTime): - seek(to: seekTime) - } - } - } + let bytesToRespond = min(songDataUnwrapped.count - currentOffset, requestedLength) + let range = Range(uncheckedBounds: (currentOffset, currentOffset + bytesToRespond)) + let dataToRespond = songDataUnwrapped.subdata(in: range) + dataRequest.respond(with: dataToRespond) - func updateDuration(forItem item: SpeechItem, newDuration: TimeInterval) { - if let durations = self.durations, item.audioIdx < durations.count { - self.durations?[item.audioIdx] = (newDuration / playbackRate) - } - } - - public func seek(toUtterance: Int) { - player?.pause() - - player?.removeAllItems() - synthesizeFrom(start: toUtterance, playWhenReady: state == .playing, atOffset: 0.0) - scrubState = .reset - fireTimer() - } - - public func seek(to: TimeInterval) { - let position = max(0, to) - - // If we are in reachedEnd state, and seek back, we need to move to - // paused state - if to < duration, state == .reachedEnd { - state = .paused - } - - // First find the item that this interval is within - // Not the most effecient, but these lists should be less than 500 items - var sum = 0.0 - var foundIdx: Int? - for (idx, duration) in (durations ?? []).enumerated() { - if sum + duration > position { - foundIdx = idx - break - } - sum += duration - } - - if let foundIdx = foundIdx { - // Now figure out how far into this segment we need to seek to - let before = durationBefore(playerIndex: foundIdx) - let remainder = position - before - - // if the foundIdx happens to be the current item, we just set the position - if let playerItem = player?.currentItem as? SpeechPlayerItem { - if playerItem.speechItem.audioIdx == foundIdx { - playerItem.seek(to: CMTimeMakeWithSeconds(remainder, preferredTimescale: 600), completionHandler: nil) - scrubState = .reset - fireTimer() - return - } + return songDataUnwrapped.count >= requestedLength + requestedOffset } // Move the playback to the found index, we also seek by the remainder amount @@ -674,19 +540,83 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } // swiftlint:disable all - private func startStreamingAudio(itemID _: String, document: SpeechDocument) { - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) - } catch { - print("error playing MP3 file", error) - // try? FileManager.default.removeItem(atPath: audioUrl.path) - state = .stopped + 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 appEnvironment: AppEnvironment + let networker: Networker + + var timer: Timer? + var player: AVQueuePlayer? + var observer: Any? + var document: SpeechDocument? + var synthesizer: SpeechSynthesizer? + var durations: [Double]? + + public init(appEnvironment: AppEnvironment, networker: Networker) { + self.appEnvironment = appEnvironment + self.networker = networker + + super.init() + self.voiceList = generateVoiceList() } - player = AVQueuePlayer(items: []) - if let player = player { - observer = player.observe(\.currentItem, options: [.new]) { _, _ in - self.currentAudioIndex = (player.currentItem as? SpeechPlayerItem)?.speechItem.audioIdx ?? 0 + 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 + + itemAudioProperties = nil + state = .stopped + timeElapsed = 0 + duration = 1 + durations = nil + + if let stoppedId = stoppedId { + EventTracker.track( + .audioSessionEnd(linkID: stoppedId, timeElapsed: stoppedTimeElapsed) + ) } } @@ -694,51 +624,71 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate durations = synthesizer.estimatedDurations(forSpeed: playbackRate) self.synthesizer = synthesizer - synthesizeFrom(start: 0, playWhenReady: true) - } + public func preload(itemIDs: [String], retryCount _: Int = 0) async -> Bool { + if !preloadEnabled { + return true + } - func synthesizeFrom(start: Int, playWhenReady: Bool, atOffset: Double = 0.0) { - if let synthesizer = self.synthesizer, let items = self.synthesizer?.createPlayerItems(from: start) { - for speechItem in items { - let isLast = speechItem.audioIdx == synthesizer.document.utterances.count - 1 - let playerItem = SpeechPlayerItem(session: self, speechItem: speechItem) { - if isLast { - self.player?.pause() - self.state = .reachedEnd + for itemID in itemIDs { + if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) { + let synthesizer = SpeechSynthesizer(appEnvironment: appEnvironment, networker: networker, document: document) + do { + try await synthesizer.preload() + return true + } catch { + print("error preloading audio file", error) } } - player?.insert(playerItem, after: nil) - if player?.items().count == 1, atOffset > 0.0 { - playerItem.seek(to: CMTimeMakeWithSeconds(atOffset, preferredTimescale: 600)) { success in - print("success seeking to time: ", success) - self.fireTimer() + } + return false + } + + 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) + for item in synthesizer.createPlayerItems(from: 0) { + do { + _ = try await SpeechSynthesizer.download(speechItem: item, redownloadCached: true) + } catch { + print("error downloading audio segment: ", error) + return false } } - if playWhenReady, player?.items().count == 1 { - startTimer() - unpause() - setupRemoteControl() + return true + } + return false + } + + public var scrubState: PlayerScrubState = .reset { + didSet { + switch scrubState { + case .reset: + return + case .scrubStarted: + return + case let .scrubEnded(seekTime): + seek(to: seekTime) } } - if items.count < 1 { - state = .reachedEnd + } + + func updateDuration(forItem item: SpeechItem, newDuration: TimeInterval) { + if let durations = self.durations, item.audioIdx < durations.count { + self.durations?[item.audioIdx] = (newDuration / playbackRate) } } - } - public func pause() { - if let player = player { - player.pause() - state = .paused - } - } + public func seek(toUtterance: Int) { + player?.pause() - public func unpause() { - if let player = player { - player.rate = Float(playbackRate) - state = .playing + player?.removeAllItems() + synthesizeFrom(start: toUtterance, playWhenReady: state == .playing, atOffset: 0.0) + scrubState = .reset + fireTimer() } - } + + public func seek(to: TimeInterval) { + let position = max(0, to) func formatTimeInterval(_ time: TimeInterval) -> String? { let componentFormatter = DateComponentsFormatter() @@ -770,38 +720,448 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate stop() } - if let durations = durations { - duration = durations.reduce(0, +) - durationString = formatTimeInterval(duration) + // First find the item that this interval is within + // Not the most effecient, but these lists should be less than 500 items + var sum = 0.0 + var foundIdx: Int? + for (idx, duration) in (durations ?? []).enumerated() { + if sum + duration > position { + foundIdx = idx + break + } + sum += duration } - updateReadText() + if let foundIdx = foundIdx { + // Now figure out how far into this segment we need to seek to + let before = durationBefore(playerIndex: foundIdx) + let remainder = position - before + + // if the foundIdx happens to be the current item, we just set the position + if let playerItem = player?.currentItem as? SpeechPlayerItem { + if playerItem.speechItem.audioIdx == foundIdx { + playerItem.seek(to: CMTimeMakeWithSeconds(remainder, preferredTimescale: 600), completionHandler: nil) + scrubState = .reset + fireTimer() + return + } + } + + // 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() } - if let player = player { - switch scrubState { - case .reset: - if let playerItem = player.currentItem as? SpeechPlayerItem { - let itemElapsed = playerItem.status == .readyToPlay ? CMTimeGetSeconds(playerItem.currentTime()) : 0 - timeElapsed = durationBefore(playerIndex: playerItem.speechItem.audioIdx) + itemElapsed - timeElapsedString = formatTimeInterval(timeElapsed) + @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 downloadSpeechFile(itemID: itemID, priority: .high) + + DispatchQueue.main.async { + if let document = document { + let synthesizer = SpeechSynthesizer(appEnvironment: self.appEnvironment, networker: self.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 + } + } + } + } + } + + // swiftlint:disable all + private func startStreamingAudio(itemID _: String, document: SpeechDocument) { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) + } catch { + print("error playing MP3 file", error) + // try? FileManager.default.removeItem(atPath: audioUrl.path) + state = .stopped + } + + player = AVQueuePlayer(items: []) + if let player = player { + observer = player.observe(\.currentItem, options: [.new]) { _, _ in + self.currentAudioIndex = (player.currentItem as? SpeechPlayerItem)?.speechItem.audioIdx ?? 0 + } + } + + let synthesizer = SpeechSynthesizer(appEnvironment: appEnvironment, networker: networker, document: document) + durations = synthesizer.estimatedDurations(forSpeed: playbackRate) + self.synthesizer = synthesizer + + synthesizeFrom(start: 0, playWhenReady: true) + } + + func synthesizeFrom(start: Int, playWhenReady: Bool, atOffset: Double = 0.0) { + if let synthesizer = self.synthesizer, let items = self.synthesizer?.createPlayerItems(from: start) { + for speechItem in items { + let isLast = speechItem.audioIdx == synthesizer.document.utterances.count - 1 + let playerItem = SpeechPlayerItem(session: self, speechItem: speechItem) { + if isLast { + self.player?.pause() + self.state = .reachedEnd + } + } + player?.insert(playerItem, after: nil) + if player?.items().count == 1, atOffset > 0.0 { + playerItem.seek(to: CMTimeMakeWithSeconds(atOffset, preferredTimescale: 600)) { success in + print("success seeking to time: ", success) + self.fireTimer() + } + } + if playWhenReady, player?.items().count == 1 { + startTimer() + unpause() + setupRemoteControl() + } + } + if items.count < 1 { + state = .reachedEnd + } + } + } + + public func pause() { + if let player = player { + player.pause() + state = .paused + } + } + + public func unpause() { + if let player = player { + player.rate = Float(playbackRate) + state = .playing + } + } + + 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 { + 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 let durations = durations { + duration = durations.reduce(0, +) + durationString = formatTimeInterval(duration) + } + + updateReadText() + } + + if let player = player { + switch scrubState { + case .reset: + if let playerItem = player.currentItem as? SpeechPlayerItem { + let itemElapsed = playerItem.status == .readyToPlay ? CMTimeGetSeconds(playerItem.currentTime()) : 0 + timeElapsed = durationBefore(playerIndex: playerItem.speechItem.audioIdx) + itemElapsed + timeElapsedString = formatTimeInterval(timeElapsed) + + if var nowPlaying = MPNowPlayingInfoCenter.default().nowPlayingInfo { + nowPlaying[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration) + nowPlaying[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: timeElapsed) + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlaying + } + } + case .scrubStarted: + break + case let .scrubEnded(seekTime): + timeElapsed = seekTime + timeElapsedString = formatTimeInterval(timeElapsed) if var nowPlaying = MPNowPlayingInfoCenter.default().nowPlayingInfo { nowPlaying[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration) nowPlaying[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: timeElapsed) MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlaying } } - case .scrubStarted: - break - case let .scrubEnded(seekTime): - timeElapsed = seekTime - timeElapsedString = formatTimeInterval(timeElapsed) - if var nowPlaying = MPNowPlayingInfoCenter.default().nowPlayingInfo { - nowPlaying[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration) - nowPlaying[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: timeElapsed) - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlaying - } } } @@ -817,108 +1177,153 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } } - func clearNowPlayingInfo() { - MPNowPlayingInfoCenter.default().nowPlayingInfo = [:] - } + func clearNowPlayingInfo() { + MPNowPlayingInfoCenter.default().nowPlayingInfo = [:] + } - func downloadAndSetArtwork() async { - if let pageId = itemAudioProperties?.itemID, let imageURL = itemAudioProperties?.imageURL { - if let result = try? await URLSession.shared.data(from: imageURL) { - if let downloadedImage = UIImage(data: result.0) { - let artwork = MPMediaItemArtwork(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in - downloadedImage - }) - DispatchQueue.main.async { - if pageId == self.itemAudioProperties?.itemID { - if var nowPlaying = MPNowPlayingInfoCenter.default().nowPlayingInfo { - nowPlaying[MPMediaItemPropertyArtwork] = artwork - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlaying + func downloadAndSetArtwork() async { + if let pageId = itemAudioProperties?.itemID, let imageURL = itemAudioProperties?.imageURL { + if let result = try? await URLSession.shared.data(from: imageURL) { + if let downloadedImage = UIImage(data: result.0) { + let artwork = MPMediaItemArtwork(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in + downloadedImage + }) + DispatchQueue.main.async { + if pageId == self.itemAudioProperties?.itemID { + if var nowPlaying = MPNowPlayingInfoCenter.default().nowPlayingInfo { + nowPlaying[MPMediaItemPropertyArtwork] = artwork + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlaying + } } } } } } } - } - func setupRemoteControl() { - UIApplication.shared.beginReceivingRemoteControlEvents() + func setupRemoteControl() { + UIApplication.shared.beginReceivingRemoteControlEvents() - if let itemAudioProperties = itemAudioProperties { - MPNowPlayingInfoCenter.default().nowPlayingInfo = [ - MPMediaItemPropertyTitle: NSString(string: itemAudioProperties.title), - MPMediaItemPropertyArtist: NSString(string: itemAudioProperties.byline ?? "Omnivore"), - MPMediaItemPropertyPlaybackDuration: NSNumber(value: duration), - MPNowPlayingInfoPropertyElapsedPlaybackTime: NSNumber(value: timeElapsed) - ] - } + if let itemAudioProperties = itemAudioProperties { + MPNowPlayingInfoCenter.default().nowPlayingInfo = [ + MPMediaItemPropertyTitle: NSString(string: itemAudioProperties.title), + MPMediaItemPropertyArtist: NSString(string: itemAudioProperties.byline ?? "Omnivore"), + MPMediaItemPropertyPlaybackDuration: NSNumber(value: duration), + MPNowPlayingInfoPropertyElapsedPlaybackTime: NSNumber(value: timeElapsed) + ] + } - let commandCenter = MPRemoteCommandCenter.shared() + let commandCenter = MPRemoteCommandCenter.shared() - commandCenter.playCommand.isEnabled = true - commandCenter.playCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in - self.unpause() - return .success - } - - commandCenter.pauseCommand.isEnabled = true - commandCenter.pauseCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in - self.pause() - return .success - } - - commandCenter.skipForwardCommand.isEnabled = true - commandCenter.skipForwardCommand.preferredIntervals = [30, 60] - commandCenter.skipForwardCommand.addTarget { event -> MPRemoteCommandHandlerStatus in - if let event = event as? MPSkipIntervalCommandEvent { - self.skipForward(seconds: event.interval) + commandCenter.playCommand.isEnabled = true + commandCenter.playCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in + self.unpause() return .success } - return .commandFailed - } - commandCenter.skipBackwardCommand.isEnabled = true - commandCenter.skipBackwardCommand.preferredIntervals = [30, 60] - commandCenter.skipBackwardCommand.addTarget { event -> MPRemoteCommandHandlerStatus in - if let event = event as? MPSkipIntervalCommandEvent { - self.skipBackwards(seconds: event.interval) + commandCenter.pauseCommand.isEnabled = true + commandCenter.pauseCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in + self.pause() return .success } - return .commandFailed - } - commandCenter.changePlaybackPositionCommand.isEnabled = true - commandCenter.changePlaybackPositionCommand.addTarget { event -> MPRemoteCommandHandlerStatus in - if let event = event as? MPChangePlaybackPositionCommandEvent { - self.seek(to: event.positionTime) - return .success + commandCenter.skipForwardCommand.isEnabled = true + commandCenter.skipForwardCommand.preferredIntervals = [30, 60] + commandCenter.skipForwardCommand.addTarget { event -> MPRemoteCommandHandlerStatus in + if let event = event as? MPSkipIntervalCommandEvent { + self.skipForward(seconds: event.interval) + return .success + } + return .commandFailed + } + + commandCenter.skipBackwardCommand.isEnabled = true + commandCenter.skipBackwardCommand.preferredIntervals = [30, 60] + commandCenter.skipBackwardCommand.addTarget { event -> MPRemoteCommandHandlerStatus in + if let event = event as? MPSkipIntervalCommandEvent { + self.skipBackwards(seconds: event.interval) + return .success + } + return .commandFailed + } + + commandCenter.changePlaybackPositionCommand.isEnabled = true + commandCenter.changePlaybackPositionCommand.addTarget { event -> MPRemoteCommandHandlerStatus in + if let event = event as? MPChangePlaybackPositionCommandEvent { + self.seek(to: event.positionTime) + return .success + } + return .commandFailed + } + + Task { + await downloadAndSetArtwork() } - return .commandFailed } - Task { - await downloadAndSetArtwork() + func isoLangForCurrentVoice() -> String { + // currentVoicePair should not ever be nil but if it is we return an empty string + if let isoLang = currentVoicePair?.language { + return "&language=\(isoLang)" + } + return "" } - } - func isoLangForCurrentVoice() -> String { - // currentVoicePair should not ever be nil but if it is we return an empty string - if let isoLang = currentVoicePair?.language { - return "&language=\(isoLang)" - } - return "" - } + func downloadSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { + let decoder = JSONDecoder() + let speechFileUrl = pathForSpeechFile(itemID: itemID) - func downloadSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { - let decoder = JSONDecoder() - let speechFileUrl = pathForSpeechFile(itemID: itemID) + if FileManager.default.fileExists(atPath: speechFileUrl.path) { + let data = try Data(contentsOf: speechFileUrl) + document = try decoder.decode(SpeechDocument.self, from: data) + // If we can't load it from disk we make the API call + if let document = document { + return document + } + } - if FileManager.default.fileExists(atPath: speechFileUrl.path) { - let data = try Data(contentsOf: speechFileUrl) - document = try decoder.decode(SpeechDocument.self, from: data) - // If we can't load it from disk we make the API call + let path = "/api/article/\(itemID)/speech?voice=\(currentVoice)&secondaryVoice=\(secondaryVoice)&priority=\(priority)\(isoLangForCurrentVoice())" + guard let url = URL(string: path, relativeTo: appEnvironment.serverBaseURL) else { + throw BasicError.message(messageText: "Invalid audio URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + for (header, value) in networker.defaultHeaders { + request.setValue(value, forHTTPHeaderField: header) + } + + let result: (Data, URLResponse)? = try? await URLSession.shared.data(for: request) + guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else { + throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.") + } + + guard let data = result?.0 else { + throw BasicError.message(messageText: "audioFetch failed. no data received.") + } + + let str = String(decoding: data, as: UTF8.self) + print("result speech file: ", str) + + let document = try? JSONDecoder().decode(SpeechDocument.self, from: data) + + // Cache the file - if it exists if let document = document { - return document + do { + try? FileManager.default.createDirectory(at: document.audioDirectory, withIntermediateDirectories: true) + try data.write(to: speechFileUrl) + } catch { + print("error writing file", error) + } + } + + return document + } + + public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully _: Bool) { + if player == self.player { + pause() + player.currentTime = 0 } } @@ -933,70 +1338,21 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate request.setValue(value, forHTTPHeaderField: header) } - let result: (Data, URLResponse)? = try? await URLSession.shared.data(for: request) - guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else { - throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.") - } + // Switch over the interruption type. + switch type { + case .began: + // An interruption began. Update the UI as necessary. + pause() + case .ended: + // An interruption ended. Resume playback, if appropriate. - guard let data = result?.0 else { - throw BasicError.message(messageText: "audioFetch failed. no data received.") - } - - let str = String(decoding: data, as: UTF8.self) - print("result speech file: ", str) - - let document = try? JSONDecoder().decode(SpeechDocument.self, from: data) - - // Cache the file - if it exists - if let document = document { - do { - try? FileManager.default.createDirectory(at: document.audioDirectory, withIntermediateDirectories: true) - try data.write(to: speechFileUrl) - } catch { - print("error writing file", error) + guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return } + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + if options.contains(.shouldResume) { + unpause() + } else {} + default: () } } - - return document } - - public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully _: Bool) { - if player == self.player { - pause() - player.currentTime = 0 - } - } - - 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()) - } - - @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 { - case .began: - // An interruption began. Update the UI as necessary. - pause() - case .ended: - // An interruption ended. Resume playback, if appropriate. - - guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return } - let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) - if options.contains(.shouldResume) { - unpause() - } else {} - default: () - } - } -} +#endif diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/MacAudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/MacAudioController.swift new file mode 100644 index 000000000..2b2848dcb --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/MacAudioController.swift @@ -0,0 +1,11 @@ +#if os(macOS) + import Foundation + + public final class AudioController: ObservableObject { + public init() {} + + public func preload(itemIDs _: [String]) {} + + public func downloadForOffline(itemID _: String) -> Bool { true } + } +#endif diff --git a/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift b/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift index cbce256c2..006e57924 100644 --- a/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift +++ b/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift @@ -26,11 +26,11 @@ public final class OmnivoreWebView: WKWebView { #if os(iOS) initNativeIOSMenus() - #endif - if #available(iOS 16.0, *) { - self.isFindInteractionEnabled = true - } + if #available(iOS 16.0, *) { + self.isFindInteractionEnabled = true + } + #endif NotificationCenter.default.addObserver(forName: NSNotification.Name("SpeakingReaderItem"), object: nil, queue: OperationQueue.main, using: { notification in if let pageID = notification.userInfo?["pageID"] as? String, let anchorIdx = notification.userInfo?["anchorIdx"] as? String {