Add macOS local playback engine

This commit is contained in:
diyaa
2026-05-28 14:05:15 +02:00
parent 183d5db4a9
commit 799c07b068
14 changed files with 1427 additions and 29 deletions
@@ -0,0 +1,45 @@
import Foundation
import VelodyDomain
public struct NowPlayingState: Hashable, Sendable {
public var currentTrack: LibraryTrack?
public var queueTrackIDs: [String]
public var isPlaying: Bool
public var currentTime: Double
public var duration: Double
public var isShuffleEnabled: Bool
public var repeatMode: PlaybackRepeatMode
public var error: PlaybackError?
public init(
currentTrack: LibraryTrack? = nil,
queueTrackIDs: [String] = [],
isPlaying: Bool = false,
currentTime: Double = 0,
duration: Double = 0,
isShuffleEnabled: Bool = false,
repeatMode: PlaybackRepeatMode = .off,
error: PlaybackError? = nil
) {
self.currentTrack = currentTrack
self.queueTrackIDs = queueTrackIDs
self.isPlaying = isPlaying
self.currentTime = currentTime
self.duration = duration
self.isShuffleEnabled = isShuffleEnabled
self.repeatMode = repeatMode
self.error = error
}
public var currentTrackID: String? {
currentTrack?.id
}
public var progress: Double {
guard duration > 0 else {
return 0
}
return min(max(currentTime / duration, 0), 1)
}
}
@@ -0,0 +1,430 @@
import Foundation
import VelodyDomain
@MainActor
public final class PlaybackController {
public var onStateChange: (@MainActor (NowPlayingState) -> Void)?
public private(set) var nowPlayingState = NowPlayingState()
private let engine: any PlaybackEngine
private let sessionStore: any PlaybackSessionStore
private var queue = PlaybackQueue()
private var catalogTracksByID: [String: LibraryTrack] = [:]
private var hasRestoredSession = false
private var loadedTrackID: String?
private var progressTimer: Timer?
public init(
engine: any PlaybackEngine,
sessionStore: any PlaybackSessionStore = UserDefaultsPlaybackSessionStore()
) {
self.engine = engine
self.sessionStore = sessionStore
self.engine.onEvent = { [weak self] event in
guard let self else {
return
}
switch event {
case .finishedPlaying:
self.handlePlaybackFinished()
}
}
}
public convenience init(
sessionStore: any PlaybackSessionStore = UserDefaultsPlaybackSessionStore()
) {
self.init(
engine: AVFoundationPlaybackEngine(),
sessionStore: sessionStore
)
}
deinit {
progressTimer?.invalidate()
}
public func setCatalogTracks(_ tracks: [LibraryTrack]) {
catalogTracksByID = Dictionary(
uniqueKeysWithValues: tracks.map { ($0.id, $0) }
)
let catalogTrackIDs = tracks.map(\.id)
if !hasRestoredSession {
let session = sessionStore.loadSession()
queue = PlaybackQueue(
trackIDs: catalogTrackIDs,
currentTrackID: session?.currentTrackID,
queuedTrackIDs: session?.queueTrackIDs,
isShuffleEnabled: session?.isShuffleEnabled ?? false,
repeatMode: session?.repeatMode ?? .off
)
hasRestoredSession = true
syncStateFromQueue()
if let currentTrack = currentTrack {
restoreTrack(currentTrack, position: session?.currentTime ?? 0)
} else {
persistSession()
publishState()
}
return
}
queue.replaceTrackIDs(
catalogTrackIDs,
currentTrackID: nowPlayingState.currentTrackID,
queuedTrackIDs: queue.queuedTrackIDs
)
syncStateFromQueue()
if let currentTrack = currentTrack {
nowPlayingState.currentTrack = currentTrack
if loadedTrackID == currentTrack.id {
nowPlayingState.duration = effectiveDuration(for: currentTrack)
}
} else {
loadedTrackID = nil
progressTimer?.invalidate()
progressTimer = nil
engine.stop()
nowPlayingState.isPlaying = false
nowPlayingState.currentTime = 0
nowPlayingState.duration = 0
}
persistSession()
publishState()
}
public func play(trackID: String) {
guard catalogTracksByID[trackID] != nil else {
nowPlayingState.error = .noTrackSelected
publishState()
return
}
queue.selectTrack(trackID)
syncStateFromQueue()
if loadedTrackID == trackID {
if nowPlayingState.isPlaying {
return
}
if isCurrentTrackFinished {
playCurrentTrackFromStart()
return
}
do {
try engine.play()
startProgressTimer()
nowPlayingState.isPlaying = true
nowPlayingState.error = nil
syncTimingFromEngine()
persistSession()
publishState()
} catch {
applyPlaybackError(error)
}
return
}
playCurrentTrackFromStart()
}
public func playPause() {
if nowPlayingState.isPlaying {
pause()
return
}
if let currentTrack {
if loadedTrackID != currentTrack.id {
playCurrentTrackFromStart(startTime: nowPlayingState.currentTime)
return
}
if isCurrentTrackFinished {
playCurrentTrackFromStart()
return
}
do {
try engine.play()
nowPlayingState.isPlaying = true
nowPlayingState.error = nil
startProgressTimer()
syncTimingFromEngine()
persistSession()
publishState()
} catch {
applyPlaybackError(error)
}
return
}
if let firstTrackID = queue.queuedTrackIDs.first {
play(trackID: firstTrackID)
} else {
applyPlaybackError(PlaybackError.queueEmpty)
}
}
public func pause() {
engine.pause()
progressTimer?.invalidate()
progressTimer = nil
nowPlayingState.isPlaying = false
syncTimingFromEngine()
persistSession()
publishState()
}
public func stop() {
engine.stop()
progressTimer?.invalidate()
progressTimer = nil
nowPlayingState.isPlaying = false
nowPlayingState.currentTime = 0
if let currentTrack {
nowPlayingState.duration = effectiveDuration(for: currentTrack)
} else {
nowPlayingState.duration = 0
}
persistSession()
publishState()
}
public func seek(to time: Double) {
do {
try engine.seek(to: time)
nowPlayingState.currentTime = min(max(time, 0), effectiveDuration(for: currentTrack))
persistSession()
publishState()
} catch {
applyPlaybackError(error)
}
}
public func next() {
guard queue.advanceToNextTrack() != nil else {
stop()
return
}
syncStateFromQueue()
playCurrentTrackFromStart()
}
public func previous() {
if nowPlayingState.currentTime > 5 {
seek(to: 0)
return
}
guard queue.moveToPreviousTrack() != nil else {
seek(to: 0)
return
}
syncStateFromQueue()
playCurrentTrackFromStart()
}
public func toggleShuffle() {
queue.toggleShuffle()
syncStateFromQueue()
persistSession()
publishState()
}
public func cycleRepeatMode() {
queue.cycleRepeatMode()
syncStateFromQueue()
persistSession()
publishState()
}
private var currentTrack: LibraryTrack? {
guard let currentTrackID = queue.currentTrackID else {
return nil
}
return catalogTracksByID[currentTrackID]
}
private var isCurrentTrackFinished: Bool {
let duration = effectiveDuration(for: currentTrack)
guard duration > 0 else {
return false
}
return nowPlayingState.currentTime >= max(duration - 0.25, 0)
}
private func playCurrentTrackFromStart(startTime: Double = 0) {
guard let currentTrack else {
applyPlaybackError(PlaybackError.noTrackSelected)
return
}
do {
try engine.loadTrack(
at: URL(fileURLWithPath: currentTrack.localFilePath),
startTime: startTime
)
loadedTrackID = currentTrack.id
try engine.play()
nowPlayingState.currentTrack = currentTrack
nowPlayingState.isPlaying = true
nowPlayingState.error = nil
syncTimingFromEngine()
startProgressTimer()
persistSession()
publishState()
} catch {
loadedTrackID = nil
progressTimer?.invalidate()
progressTimer = nil
nowPlayingState.isPlaying = false
nowPlayingState.currentTrack = currentTrack
nowPlayingState.currentTime = startTime
nowPlayingState.duration = effectiveDuration(for: currentTrack)
applyPlaybackError(error)
}
}
private func restoreTrack(_ track: LibraryTrack, position: Double) {
do {
try engine.loadTrack(
at: URL(fileURLWithPath: track.localFilePath),
startTime: position
)
loadedTrackID = track.id
nowPlayingState.currentTrack = track
nowPlayingState.isPlaying = false
nowPlayingState.error = nil
syncTimingFromEngine()
} catch {
loadedTrackID = nil
nowPlayingState.currentTrack = track
nowPlayingState.isPlaying = false
nowPlayingState.currentTime = position
nowPlayingState.duration = effectiveDuration(for: track)
applyPlaybackError(error)
}
persistSession()
publishState()
}
private func handlePlaybackFinished() {
if queue.repeatMode == .one {
playCurrentTrackFromStart()
return
}
guard queue.advanceToNextTrack() != nil else {
engine.stop()
progressTimer?.invalidate()
progressTimer = nil
nowPlayingState.isPlaying = false
nowPlayingState.currentTime = nowPlayingState.duration
persistSession()
publishState()
return
}
syncStateFromQueue()
playCurrentTrackFromStart()
}
private func syncStateFromQueue() {
nowPlayingState.currentTrack = currentTrack
nowPlayingState.queueTrackIDs = queue.queuedTrackIDs
nowPlayingState.isShuffleEnabled = queue.isShuffleEnabled
nowPlayingState.repeatMode = queue.repeatMode
if let currentTrack {
nowPlayingState.duration = effectiveDuration(for: currentTrack)
} else {
nowPlayingState.currentTime = 0
nowPlayingState.duration = 0
}
}
private func syncTimingFromEngine() {
nowPlayingState.currentTime = engine.currentTime
if engine.duration > 0 {
nowPlayingState.duration = engine.duration
}
}
private func effectiveDuration(for track: LibraryTrack?) -> Double {
if engine.duration > 0 {
return engine.duration
}
return track?.durationSeconds ?? 0
}
private func startProgressTimer() {
progressTimer?.invalidate()
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) {
[weak self] _ in
Task { @MainActor [weak self] in
guard let self else {
return
}
self.syncTimingFromEngine()
self.persistSession()
self.publishState()
}
}
if let progressTimer {
RunLoop.main.add(progressTimer, forMode: .common)
}
}
private func persistSession() {
sessionStore.saveSession(
PlaybackSessionSnapshot(
queueTrackIDs: queue.queuedTrackIDs,
currentTrackID: queue.currentTrackID,
currentTime: nowPlayingState.currentTime,
isShuffleEnabled: queue.isShuffleEnabled,
repeatMode: queue.repeatMode
)
)
}
private func applyPlaybackError(_ error: Error) {
if let playbackError = error as? PlaybackError {
nowPlayingState.error = playbackError
} else {
nowPlayingState.error = .failedToStartPlayback
}
nowPlayingState.isPlaying = false
progressTimer?.invalidate()
progressTimer = nil
persistSession()
publishState()
}
private func publishState() {
onStateChange?(nowPlayingState)
}
}
@@ -0,0 +1,101 @@
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 {
guard fileManager.fileExists(atPath: fileURL.path) else {
throw PlaybackError.missingLocalFile(path: fileURL.path)
}
do {
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 {
throw error
} catch {
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)
}
nonisolated public func audioPlayerDidFinishPlaying(
_ player: AVAudioPlayer,
successfully flag: Bool
) {
if flag {
Task { @MainActor [weak self] in
self?.onEvent?(.finishedPlaying)
}
}
}
}
@@ -0,0 +1,30 @@
import Foundation
public enum PlaybackError: Error, LocalizedError, Equatable, Hashable, Sendable {
case queueEmpty
case noTrackSelected
case noTrackLoaded
case missingLocalFile(path: String)
case failedToLoadTrack(path: String)
case failedToStartPlayback
case seekUnavailable
public var errorDescription: String? {
switch self {
case .queueEmpty:
return "No tracks are available in the playback queue."
case .noTrackSelected:
return "Choose a track before starting playback."
case .noTrackLoaded:
return "No audio track is currently loaded."
case .missingLocalFile(let path):
return "The local file could not be found: \(path)"
case .failedToLoadTrack(let path):
return "The audio file could not be opened: \(path)"
case .failedToStartPlayback:
return "Playback could not be started."
case .seekUnavailable:
return "Seeking is unavailable until a track is loaded."
}
}
}
@@ -0,0 +1,229 @@
import Foundation
public struct PlaybackQueue: Hashable, Sendable {
public private(set) var catalogTrackIDs: [String]
public private(set) var queuedTrackIDs: [String]
public private(set) var currentTrackID: String?
public private(set) var isShuffleEnabled: Bool
public private(set) var repeatMode: PlaybackRepeatMode
public init(
trackIDs: [String] = [],
currentTrackID: String? = nil,
queuedTrackIDs: [String]? = nil,
isShuffleEnabled: Bool = false,
repeatMode: PlaybackRepeatMode = .off
) {
self.catalogTrackIDs = []
self.queuedTrackIDs = []
self.currentTrackID = nil
self.isShuffleEnabled = isShuffleEnabled
self.repeatMode = repeatMode
replaceTrackIDs(
trackIDs,
currentTrackID: currentTrackID,
queuedTrackIDs: queuedTrackIDs
)
}
public var isEmpty: Bool {
queuedTrackIDs.isEmpty
}
public mutating func replaceTrackIDs(
_ trackIDs: [String],
currentTrackID preferredCurrentTrackID: String? = nil,
queuedTrackIDs preferredQueuedTrackIDs: [String]? = nil
) {
let normalizedCatalogTrackIDs = Self.uniqueTrackIDs(trackIDs)
catalogTrackIDs = normalizedCatalogTrackIDs
if normalizedCatalogTrackIDs.isEmpty {
queuedTrackIDs = []
currentTrackID = nil
return
}
if isShuffleEnabled {
if let preferredQueuedTrackIDs {
let normalizedQueuedTrackIDs = Self.normalizedQueuedTrackIDs(
preferredQueuedTrackIDs,
validTrackIDs: normalizedCatalogTrackIDs
)
queuedTrackIDs = normalizedQueuedTrackIDs.isEmpty
? Self.makeShuffledTrackIDs(
from: normalizedCatalogTrackIDs,
currentTrackID: preferredCurrentTrackID ?? currentTrackID
)
: normalizedQueuedTrackIDs
} else {
let normalizedCurrentQueue = Self.normalizedQueuedTrackIDs(
queuedTrackIDs,
validTrackIDs: normalizedCatalogTrackIDs
)
queuedTrackIDs = normalizedCurrentQueue.isEmpty
? Self.makeShuffledTrackIDs(
from: normalizedCatalogTrackIDs,
currentTrackID: preferredCurrentTrackID ?? currentTrackID
)
: normalizedCurrentQueue
}
} else {
queuedTrackIDs = normalizedCatalogTrackIDs
}
if let preferredCurrentTrackID,
queuedTrackIDs.contains(preferredCurrentTrackID)
{
currentTrackID = preferredCurrentTrackID
} else if let currentTrackID,
queuedTrackIDs.contains(currentTrackID)
{
self.currentTrackID = currentTrackID
} else {
currentTrackID = nil
}
}
public mutating func selectTrack(_ trackID: String) {
guard queuedTrackIDs.contains(trackID) else {
return
}
currentTrackID = trackID
}
public mutating func toggleShuffle() {
setShuffleEnabled(!isShuffleEnabled)
}
public mutating func setShuffleEnabled(
_ isEnabled: Bool,
queuedTrackIDs preferredQueuedTrackIDs: [String]? = nil
) {
isShuffleEnabled = isEnabled
replaceTrackIDs(
catalogTrackIDs,
currentTrackID: currentTrackID,
queuedTrackIDs: preferredQueuedTrackIDs
)
}
public mutating func cycleRepeatMode() {
repeatMode = repeatMode.nextMode
}
public mutating func setRepeatMode(_ repeatMode: PlaybackRepeatMode) {
self.repeatMode = repeatMode
}
public func nextTrackID() -> String? {
guard let currentTrackID else {
return queuedTrackIDs.first
}
guard let currentIndex = queuedTrackIDs.firstIndex(of: currentTrackID) else {
return queuedTrackIDs.first
}
if repeatMode == .one {
return currentTrackID
}
let nextIndex = currentIndex + 1
if queuedTrackIDs.indices.contains(nextIndex) {
return queuedTrackIDs[nextIndex]
}
if repeatMode == .all {
return queuedTrackIDs.first
}
return nil
}
public func previousTrackID() -> String? {
guard let currentTrackID else {
return queuedTrackIDs.first
}
guard let currentIndex = queuedTrackIDs.firstIndex(of: currentTrackID) else {
return queuedTrackIDs.first
}
if repeatMode == .one {
return currentTrackID
}
let previousIndex = currentIndex - 1
if queuedTrackIDs.indices.contains(previousIndex) {
return queuedTrackIDs[previousIndex]
}
if repeatMode == .all {
return queuedTrackIDs.last
}
return nil
}
public mutating func advanceToNextTrack() -> String? {
let nextTrackID = nextTrackID()
if let nextTrackID {
currentTrackID = nextTrackID
}
return nextTrackID
}
public mutating func moveToPreviousTrack() -> String? {
let previousTrackID = previousTrackID()
if let previousTrackID {
currentTrackID = previousTrackID
}
return previousTrackID
}
private static func uniqueTrackIDs(_ trackIDs: [String]) -> [String] {
var seenTrackIDs = Set<String>()
return trackIDs.filter { trackID in
seenTrackIDs.insert(trackID).inserted
}
}
private static func normalizedQueuedTrackIDs(
_ queuedTrackIDs: [String],
validTrackIDs: [String]
) -> [String] {
let validTrackIDSet = Set(validTrackIDs)
var seenTrackIDs = Set<String>()
let normalizedQueuedTrackIDs = queuedTrackIDs.filter { trackID in
validTrackIDSet.contains(trackID) && seenTrackIDs.insert(trackID).inserted
}
let missingTrackIDs = validTrackIDs.filter { !seenTrackIDs.contains($0) }
return normalizedQueuedTrackIDs + missingTrackIDs
}
private static func makeShuffledTrackIDs(
from trackIDs: [String],
currentTrackID: String?
) -> [String] {
guard !trackIDs.isEmpty else {
return []
}
let remainingTrackIDs = trackIDs.filter { $0 != currentTrackID }.shuffled()
if let currentTrackID,
trackIDs.contains(currentTrackID)
{
return [currentTrackID] + remainingTrackIDs
}
return trackIDs.shuffled()
}
}
@@ -0,0 +1,18 @@
import Foundation
public enum PlaybackRepeatMode: String, Codable, CaseIterable, Hashable, Sendable {
case off
case all
case one
public var nextMode: PlaybackRepeatMode {
switch self {
case .off:
.all
case .all:
.one
case .one:
.off
}
}
}
@@ -0,0 +1,84 @@
import Foundation
public struct PlaybackSessionSnapshot: Codable, Hashable, Sendable {
public var queueTrackIDs: [String]
public var currentTrackID: String?
public var currentTime: Double
public var isShuffleEnabled: Bool
public var repeatMode: PlaybackRepeatMode
public init(
queueTrackIDs: [String] = [],
currentTrackID: String? = nil,
currentTime: Double = 0,
isShuffleEnabled: Bool = false,
repeatMode: PlaybackRepeatMode = .off
) {
self.queueTrackIDs = queueTrackIDs
self.currentTrackID = currentTrackID
self.currentTime = currentTime
self.isShuffleEnabled = isShuffleEnabled
self.repeatMode = repeatMode
}
}
public protocol PlaybackSessionStore: Sendable {
func loadSession() -> PlaybackSessionSnapshot?
func saveSession(_ session: PlaybackSessionSnapshot)
func clearSession()
}
public struct UserDefaultsPlaybackSessionStore: PlaybackSessionStore, @unchecked Sendable {
private let userDefaults: UserDefaults
private let storageKey: String
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
public init(
userDefaults: UserDefaults = .standard,
storageKey: String = "velody.playback.session"
) {
self.userDefaults = userDefaults
self.storageKey = storageKey
}
public func loadSession() -> PlaybackSessionSnapshot? {
guard let data = userDefaults.data(forKey: storageKey) else {
return nil
}
return try? decoder.decode(PlaybackSessionSnapshot.self, from: data)
}
public func saveSession(_ session: PlaybackSessionSnapshot) {
guard let data = try? encoder.encode(session) else {
return
}
userDefaults.set(data, forKey: storageKey)
}
public func clearSession() {
userDefaults.removeObject(forKey: storageKey)
}
}
public final class InMemoryPlaybackSessionStore: PlaybackSessionStore, @unchecked Sendable {
private var session: PlaybackSessionSnapshot?
public init(session: PlaybackSessionSnapshot? = nil) {
self.session = session
}
public func loadSession() -> PlaybackSessionSnapshot? {
session
}
public func saveSession(_ session: PlaybackSessionSnapshot) {
self.session = session
}
public func clearSession() {
session = nil
}
}