Add macOS local playback engine
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user