velody/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackController.swift

441 lines
12 KiB
Swift

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 {
let fileURL = try localFileURL(for: currentTrack)
try engine.loadTrack(
at: fileURL,
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 = currentTrack.durationSeconds ?? 0
applyPlaybackError(error)
}
}
private func restoreTrack(_ track: LibraryTrack, position: Double) {
do {
let fileURL = try localFileURL(for: track)
try engine.loadTrack(
at: fileURL,
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 = track.durationSeconds ?? 0
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 localFileURL(for track: LibraryTrack) throws -> URL {
guard !track.localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw PlaybackError.missingLocalFile(path: track.localFilePath)
}
return URL(fileURLWithPath: track.localFilePath)
}
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)
}
}