134 lines
3.6 KiB
Swift
134 lines
3.6 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
|
|
public enum PlaybackEngineEvent: Hashable, Sendable {
|
|
case finishedPlaying
|
|
}
|
|
|
|
@MainActor
|
|
public protocol PlaybackEngine: AnyObject {
|
|
var onEvent: (@MainActor @Sendable (PlaybackEngineEvent) -> Void)? { get set }
|
|
var currentTime: Double { get }
|
|
var duration: Double { get }
|
|
var isPlaying: Bool { get }
|
|
|
|
func loadTrack(at fileURL: URL, startTime: Double) throws
|
|
func play() throws
|
|
func pause()
|
|
func stop()
|
|
func seek(to time: Double) throws
|
|
}
|
|
|
|
@MainActor
|
|
public final class AVFoundationPlaybackEngine: NSObject, PlaybackEngine, AVAudioPlayerDelegate {
|
|
public var onEvent: (@MainActor @Sendable (PlaybackEngineEvent) -> Void)?
|
|
|
|
private var audioPlayer: AVAudioPlayer?
|
|
private let fileManager: FileManager
|
|
|
|
public init(fileManager: FileManager = .default) {
|
|
self.fileManager = fileManager
|
|
super.init()
|
|
}
|
|
|
|
public var currentTime: Double {
|
|
audioPlayer?.currentTime ?? 0
|
|
}
|
|
|
|
public var duration: Double {
|
|
audioPlayer?.duration ?? 0
|
|
}
|
|
|
|
public var isPlaying: Bool {
|
|
audioPlayer?.isPlaying ?? false
|
|
}
|
|
|
|
public func loadTrack(at fileURL: URL, startTime: Double) throws {
|
|
do {
|
|
unloadCurrentTrack()
|
|
try validatePlayableFile(at: fileURL)
|
|
let audioPlayer = try AVAudioPlayer(contentsOf: fileURL)
|
|
audioPlayer.delegate = self
|
|
audioPlayer.prepareToPlay()
|
|
audioPlayer.currentTime = min(max(startTime, 0), audioPlayer.duration)
|
|
self.audioPlayer = audioPlayer
|
|
} catch let error as PlaybackError {
|
|
unloadCurrentTrack()
|
|
throw error
|
|
} catch {
|
|
unloadCurrentTrack()
|
|
throw PlaybackError.failedToLoadTrack(path: fileURL.path)
|
|
}
|
|
}
|
|
|
|
public func play() throws {
|
|
guard let audioPlayer else {
|
|
throw PlaybackError.noTrackLoaded
|
|
}
|
|
|
|
guard audioPlayer.play() else {
|
|
throw PlaybackError.failedToStartPlayback
|
|
}
|
|
}
|
|
|
|
public func pause() {
|
|
audioPlayer?.pause()
|
|
}
|
|
|
|
public func stop() {
|
|
audioPlayer?.stop()
|
|
audioPlayer?.currentTime = 0
|
|
}
|
|
|
|
public func seek(to time: Double) throws {
|
|
guard let audioPlayer else {
|
|
throw PlaybackError.seekUnavailable
|
|
}
|
|
|
|
audioPlayer.currentTime = min(max(time, 0), audioPlayer.duration)
|
|
}
|
|
|
|
private func validatePlayableFile(at fileURL: URL) throws {
|
|
let filePath = Self.displayPath(for: fileURL)
|
|
guard fileURL.isFileURL else {
|
|
throw PlaybackError.missingLocalFile(path: filePath)
|
|
}
|
|
|
|
guard !fileURL.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
throw PlaybackError.missingLocalFile(path: filePath)
|
|
}
|
|
|
|
guard fileManager.fileExists(atPath: fileURL.path) else {
|
|
throw PlaybackError.missingLocalFile(path: filePath)
|
|
}
|
|
|
|
guard (try? fileURL.checkResourceIsReachable()) == true else {
|
|
throw PlaybackError.missingLocalFile(path: filePath)
|
|
}
|
|
}
|
|
|
|
private func unloadCurrentTrack() {
|
|
audioPlayer?.stop()
|
|
audioPlayer = nil
|
|
}
|
|
|
|
private static func displayPath(for fileURL: URL) -> String {
|
|
if fileURL.isFileURL {
|
|
return fileURL.path
|
|
}
|
|
|
|
return fileURL.absoluteString
|
|
}
|
|
|
|
nonisolated public func audioPlayerDidFinishPlaying(
|
|
_ player: AVAudioPlayer,
|
|
successfully flag: Bool
|
|
) {
|
|
if flag {
|
|
Task { @MainActor [weak self] in
|
|
self?.onEvent?(.finishedPlaying)
|
|
}
|
|
}
|
|
}
|
|
}
|