mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
255 lines
6.9 KiB
Swift
255 lines
6.9 KiB
Swift
//
|
|
// AudioSession.swift
|
|
//
|
|
//
|
|
// Created by Jackson Harper on 8/15/22.
|
|
//
|
|
|
|
import AVFoundation
|
|
import CryptoKit
|
|
import Foundation
|
|
import MediaPlayer
|
|
import Models
|
|
import Utils
|
|
|
|
public enum AudioSessionState {
|
|
case stopped
|
|
case paused
|
|
case loading
|
|
case playing
|
|
}
|
|
|
|
// Our observable object class
|
|
public class AudioSession: ObservableObject {
|
|
@Published public var state: AudioSessionState = .stopped
|
|
@Published public var item: LinkedItem?
|
|
|
|
let appEnvironment: AppEnvironment
|
|
let networker: Networker
|
|
|
|
var timer: Timer?
|
|
var player: AVAudioPlayer?
|
|
|
|
public init(appEnvironment: AppEnvironment, networker: Networker) {
|
|
self.appEnvironment = appEnvironment
|
|
self.networker = networker
|
|
}
|
|
|
|
public func play(item: LinkedItem) {
|
|
// Stop any existing session
|
|
stop()
|
|
|
|
self.item = item
|
|
startAudio()
|
|
}
|
|
|
|
public func stop() {
|
|
player?.stop()
|
|
clearNowPlayingInfo()
|
|
timer = nil
|
|
player = nil
|
|
item = nil
|
|
state = .stopped
|
|
}
|
|
|
|
public func isLoadingItem(item: LinkedItem) -> Bool {
|
|
state == .loading && self.item == item
|
|
}
|
|
|
|
public func isPlayingItem(item: LinkedItem) -> Bool {
|
|
state == .playing && self.item == item
|
|
}
|
|
|
|
public func startAudio() {
|
|
state = .loading
|
|
|
|
let pageId = item!.unwrappedID
|
|
|
|
Task {
|
|
do {
|
|
_ = try await downloadAudioFile(pageId: pageId)
|
|
DispatchQueue.main.async {
|
|
self.startDownloadedAudioFile(pageId: pageId)
|
|
}
|
|
} catch {
|
|
// TODO: maybe we need a failed state?
|
|
DispatchQueue.main.async {
|
|
self.state = .stopped
|
|
}
|
|
print("FAILED TO DOWNLOAD AUDIO URL")
|
|
print(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startDownloadedAudioFile(pageId: String) {
|
|
// Make sure audio file is still correct for the current page
|
|
guard item?.unwrappedID == pageId else {
|
|
state = .stopped
|
|
return
|
|
}
|
|
|
|
// TODO: Maybe check if app is active so it doesn't end up playing later?
|
|
|
|
let audioUrl = FileManager.default
|
|
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
.appendingPathComponent(pageId + ".mp3")
|
|
|
|
if !FileManager.default.fileExists(atPath: audioUrl.path) {
|
|
stop()
|
|
return
|
|
}
|
|
|
|
do {
|
|
try AVAudioSession.sharedInstance().setCategory(.playback)
|
|
|
|
player = try AVAudioPlayer(contentsOf: audioUrl)
|
|
if player?.play() ?? false {
|
|
state = .playing
|
|
startTimer()
|
|
setupRemoteControl()
|
|
}
|
|
} catch {
|
|
print("error playing MP3 file", error)
|
|
print(error.localizedDescription)
|
|
state = .stopped
|
|
}
|
|
}
|
|
|
|
public func pause() -> Bool {
|
|
if let player = player {
|
|
player.pause()
|
|
state = .paused
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
public func unpause() -> Bool {
|
|
playAudio()
|
|
}
|
|
|
|
public func playAudio() -> Bool {
|
|
if let player = player {
|
|
player.play()
|
|
state = .playing
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
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 clearNowPlayingInfo() {
|
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
|
|
}
|
|
|
|
func setupRemoteControl() {
|
|
UIApplication.shared.beginReceivingRemoteControlEvents()
|
|
|
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
|
|
// MPMediaItemArtwork: ""m
|
|
MPMediaItemPropertyArtist: item?.author ?? "Omnivore",
|
|
MPMediaItemPropertyTitle: item?.title ?? "Your Omnivore Article"
|
|
]
|
|
|
|
if let imageURL = item?.imageURL, let cachedImage = ImageCache.shared[imageURL] {
|
|
// #if os(iOS)
|
|
// status = .loaded(image: Image(uiImage: cachedImage))
|
|
// #else
|
|
// status = .loaded(image: Image(nsImage: cachedImage))
|
|
// #endif
|
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
|
|
// MPMediaItemPropertyArtwork: cachedImage,
|
|
MPMediaItemPropertyArtist: item?.author ?? "Omnivore",
|
|
MPMediaItemPropertyTitle: item?.title ?? "Your Omnivore Article"
|
|
]
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
func downloadAudioFile(pageId: String) async throws -> URL? {
|
|
let audioUrl = FileManager.default
|
|
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
.appendingPathComponent(pageId + ".mp3")
|
|
|
|
// if FileManager.default.fileExists(atPath: audioUrl.path) {
|
|
// // Prevent re-download
|
|
// // TODO: We aren't doing this very safely, we should be verifying a checksum
|
|
// return audioUrl
|
|
// }
|
|
|
|
guard let url = URL(string: "/api/article/\(pageId)/mp3", relativeTo: appEnvironment.serverBaseURL) else {
|
|
throw BasicError.message(messageText: "Invalid audio URL")
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = 600
|
|
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.")
|
|
}
|
|
print("httpResponse: ", httpResponse)
|
|
|
|
guard let data = result?.0 else {
|
|
throw BasicError.message(messageText: "audioFetch failed. no data received.")
|
|
}
|
|
|
|
let tempPath = FileManager.default
|
|
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
.appendingPathComponent(UUID().uuidString + ".mp3")
|
|
|
|
do {
|
|
if let googleHash = httpResponse.value(forHTTPHeaderField: "x-goog-hash") {
|
|
let hash = Data(Insecure.MD5.hash(data: data)).base64EncodedString()
|
|
if !googleHash.contains("md5=\(hash)") {
|
|
print("Downloaded mp3 file hashes do not match: returned: \(googleHash) v computed: \(hash)")
|
|
throw BasicError.message(messageText: "Downloaded mp3 file hashes do not match: returned: \(googleHash) v computed: \(hash)")
|
|
}
|
|
}
|
|
|
|
try data.write(to: tempPath)
|
|
try FileManager.default.moveItem(at: tempPath, to: audioUrl)
|
|
} catch {
|
|
let errorMessage = "audioFetch failed. could not write MP3 data to disk"
|
|
throw BasicError.message(messageText: errorMessage)
|
|
}
|
|
|
|
return audioUrl
|
|
}
|
|
}
|