441 lines
12 KiB
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)
|
|
}
|
|
}
|