velody/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackEngine.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)
}
}
}
}