diff --git a/apple/OmnivoreKit/Sources/App/Services.swift b/apple/OmnivoreKit/Sources/App/Services.swift index b7b602c17..0ae13f443 100644 --- a/apple/OmnivoreKit/Sources/App/Services.swift +++ b/apple/OmnivoreKit/Sources/App/Services.swift @@ -12,11 +12,13 @@ public final class Services { public let authenticator: Authenticator public let dataService: DataService + public let audioSession: AudioSession public init(appEnvironment: AppEnvironment = PublicValet.storedAppEnvironment ?? .initialAppEnvironment) { let networker = Networker(appEnvironment: appEnvironment) self.authenticator = Authenticator(networker: networker) self.dataService = DataService(appEnvironment: appEnvironment, networker: networker) + self.audioSession = AudioSession() } } diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift new file mode 100644 index 000000000..ef4cc9571 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift @@ -0,0 +1,52 @@ +// +// MiniPlayer.swift +// +// +// Created by Jackson Harper on 8/15/22. +// + +import Foundation +import Services +import SwiftUI + +public struct MiniPlayer: View { + @EnvironmentObject var audioSession: AudioSession + @Environment(\.colorScheme) private var colorScheme: ColorScheme + + private let presentingView: AnyView + + init( + presentingView: PresentingView + ) where PresentingView: View { + self.presentingView = AnyView(presentingView) + } + + public var body: some View { + GeometryReader { geometry in + ZStack(alignment: .center) { + presentingView + VStack { + Spacer() + if self.audioSession.state != .stopped { + HStack { + Text("Title of the playing content") + .font(.appCallout) + Spacer() + } + .padding() + .frame(width: geometry.size.width, height: 108) + .animation(.spring(), value: true) + .background(Color.systemBackground) + .shadow(color: .gray, radius: 2) + } + } + } + } + } +} + +public extension View { + func miniPlayer() -> some View { + MiniPlayer(presentingView: self) + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift index 77f6c2982..c603f4ff2 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift @@ -27,6 +27,7 @@ public struct RootView: View { InnerRootView(viewModel: viewModel) .environmentObject(viewModel.services.authenticator) .environmentObject(viewModel.services.dataService) + .environmentObject(viewModel.services.audioSession) .environment(\.managedObjectContext, viewModel.services.dataService.viewContext) .onChange(of: scenePhase) { phase in if phase == .background { @@ -50,6 +51,7 @@ struct InnerRootView: View { .onAppear { viewModel.triggerPushNotificationRequestIfNeeded() } + .miniPlayer() .snackBar(isShowing: $viewModel.showSnackbar, message: viewModel.snackbarMessage) // Schedule the dismissal every time we present the snackbar. .onChange(of: viewModel.showSnackbar) { newValue in diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift index 033f6f5e4..92eec8ce0 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift @@ -17,6 +17,7 @@ public final class RootViewModel: ObservableObject { @Published public var showPushNotificationPrimer = false @Published var snackbarMessage: String? @Published var showSnackbar = false + @Published var showMiniPlayer = true public init() { registerFonts() diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/TextToSpeechCoordindator.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/TextToSpeechCoordindator.swift new file mode 100644 index 000000000..4edeed0ba --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/TextToSpeechCoordindator.swift @@ -0,0 +1,85 @@ +// +// TextToSpeechViewModel.swift +// +// +// Created by Jackson Harper on 8/15/22. +// + +import AVFoundation + +public enum TextToSpeechState { + case stopped + case paused + case loading + case playing +} + +public class AudioSession: ObservableObject { + @Published public var state: TextToSpeechState = .stopped + @Published public var audioUrl: URL? + @Published public var title: String? + + var timer: Timer? + var player: AVAudioPlayer? + + deinit { + print("CLOSING DOWN THE SPEECH VIEW MODEL") + self.player?.stop() + } + + func startAudio() { + // Just simulating some loading delay here + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { + self.audioUrl = Bundle.main.url(forResource: "speech-sample", withExtension: "mp3")! + + if let url = self.audioUrl { + do { + self.player = try AVAudioPlayer(contentsOf: url) + if self.player?.play() ?? false { + self.state = .playing + self.startTimer() + } + } catch { + print(error.localizedDescription) + } + } + } + } + + func startTimer() { + if timer == nil { + // Update every 100ms + timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(update(_:)), userInfo: nil, repeats: true) + timer?.fire() + } + } + + func stopTimer() { + timer = nil + } + + // Every second, get the current playing time of the player and refresh the status of the player progressslider + @objc func update(_: Timer) { + if let player = player, player.isPlaying { + print("play time in ms: ", Int(player.currentTime * 1000)) + } + } + + func pause() -> Bool { + if let player = player { + player.pause() + state = .paused + return true + } + return false + } + + func playAudio() -> Bool { + if let player = player { + player.play() + state = .playing + return true + } + return false + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 5fcf22bbb..17236e0fb 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -24,6 +24,7 @@ struct WebReaderContainerView: View { @State var annotation = String() @EnvironmentObject var dataService: DataService + @EnvironmentObject var audioSession: AudioSession @Environment(\.presentationMode) var presentationMode: Binding @StateObject var viewModel = WebReaderViewModel() @@ -55,24 +56,14 @@ struct WebReaderContainerView: View { } } - static var player: AVAudioPlayer? - - func playSound() { - guard let url = Bundle.main.url(forResource: "speech-sample", withExtension: "mp3") else { return } - - do { - print("starting to play audio") - try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback) - try AVAudioSession.sharedInstance().setActive(true) - - let player = try AVAudioPlayer(contentsOf: url) -// -// player.enableRate = true -// player.rate = 1.5 - player.play() - - } catch { - print("error playing audio", error.localizedDescription) + var ttsButtonIcon: String { + switch audioSession.state { + case .stopped, .paused: + return "play" + case .playing: + return "pause" + case .loading: + return "hourglass.circle" } } @@ -94,14 +85,20 @@ struct WebReaderContainerView: View { if FeatureFlag.enableTextToSpeechButton { Button( action: { -// guard let htmlContent = item.htmlContent else { return } -// let synthesizer = AVSpeechSynthesizer() -// synthesizer.speak(AVSpeechUtterance(string: htmlContent)) -// synthesizer.sp - playSound() + switch audioSession.state { + case .loading: + // Do nothing here + break + case .stopped: + audioSession.startAudio() + case .paused: + audioSession.playAudio() + case .playing: + audioSession.pause() + } }, label: { - Image(systemName: "play") + Image(systemName: ttsButtonIcon) .font(.appTitleTwo) } ) diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioSession.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioSession.swift new file mode 100644 index 000000000..4f7ddd576 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioSession.swift @@ -0,0 +1,21 @@ +// +// AudioSession.swift +// +// +// Created by Jackson Harper on 8/15/22. +// + +import Foundation + +public enum AudioSessionState { + case stopped + case paused + case loading + case playing +} + +// Our observable object class +public class AudioSession: ObservableObject { + @Published var state: AudioSessionState = .stopped + @Published var title = "This is the title of my session" +}