import Foundation import os import SwiftData import SwiftUI private let logger = Logger(subsystem: "com.mixboard.MixBoardiOS", category: "PlayerVM") /// ViewModel wrapping the AudioEngine with additional UI state. @MainActor @Observable final class PlayerViewModel { let audioEngine = AudioEngine() let streamingPlayer = StreamingPlayer() // MARK: - UI State var showingWaveform = true var waveformSamples: [WaveformGenerator.WaveformSample] = [] var isLoadingWaveform = false var showNowPlaying = false var showQueue = false var showEQ = false // MARK: - EQ State (persisted to UserDefaults) var eqLow: Float = 0 var eqMid: Float = 0 var eqHigh: Float = 0 /// Current EQ preset — auto-detected from slider values. var eqPreset: EQPreset { EQPreset.detect(low: eqLow, mid: eqMid, high: eqHigh) } /// True when any EQ band is non-zero. var isEQActive: Bool { eqLow != 0 || eqMid != 0 || eqHigh != 0 } /// ID of the currently playing playlist entry. var currentPlayingEntryID: UUID? /// The playlist currently being played through. var currentPlaylist: Playlist? /// Shuffle mode. var shuffleEnabled: Bool = false { didSet { if shuffleEnabled { upNext.shuffle() } else { rebuildUpNextFromSource() } } } /// True when playing a cloud track via StreamingPlayer (vs local via AudioEngine). var isCloudPlayback = false /// The cloud track info when streaming (non-SwiftData, transient). var currentCloudTrack: ChadTrack? var isBuffering: Bool { streamingPlayer.isBuffering } /// Repeat mode. enum RepeatMode: String, CaseIterable { case off = "Off" case all = "Repeat All" case one = "Repeat One" var icon: String { switch self { case .off: return "repeat" case .all: return "repeat" case .one: return "repeat.1" } } } var repeatMode: RepeatMode = .off // MARK: - Queue var nowPlayingEntry: QueueEntry? var userQueue: [QueueEntry] = [] var upNext: [QueueEntry] = [] var history: [QueueEntry] = [] /// Undo state for queue replacement var showUndoToast = false var undoMessage = "" @ObservationIgnored private var previousQueueSnapshot: QueueSnapshot? @ObservationIgnored private var undoTimer: Timer? private struct QueueSnapshot { let nowPlaying: QueueEntry? let userQueue: [QueueEntry] let upNext: [QueueEntry] } /// ModelContext for resolving SwiftData track IDs — set from the view layer @ObservationIgnored var modelContext: ModelContext? // MARK: - Synced State var isPlaying: Bool = false var currentTime: TimeInterval = 0 var duration: TimeInterval = 0 var currentTrack: Track? var volume: Float { get { audioEngine.volume } set { audioEngine.volume = newValue } } var progress: Double { guard duration > 0 else { return 0 } return currentTime / duration } var currentTimeFormatted: String { formatTime(currentTime) } var durationFormatted: String { formatTime(duration) } var remainingTimeFormatted: String { "-" + formatTime(duration - currentTime) } @ObservationIgnored private var syncTimer: Timer? @ObservationIgnored private var stateSaveCounter = 0 @ObservationIgnored private var nowPlayingCounter = 0 init() { restoreQueue() restoreEQ() startSyncTimer() audioEngine.onPlaybackFinished = { [weak self] in self?.playNext() } streamingPlayer.onPlaybackFinished = { [weak self] in self?.playNext() } } private func startSyncTimer() { syncTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in Task { @MainActor in self?.syncFromEngine() } } } private func syncFromEngine() { if isCloudPlayback { let sp = streamingPlayer if isPlaying != sp.isPlaying { isPlaying = sp.isPlaying } if abs(currentTime - sp.currentTime) > 0.01 { currentTime = sp.currentTime } if duration != sp.duration { duration = sp.duration } } else { let engine = audioEngine engine.updateCurrentTime() if isPlaying != engine.isPlaying { isPlaying = engine.isPlaying } if abs(currentTime - engine.currentTime) > 0.01 { currentTime = engine.currentTime } if duration != engine.duration { duration = engine.duration } if currentTrack !== engine.currentTrack { currentTrack = engine.currentTrack } } stateSaveCounter += 1 if stateSaveCounter >= 60 { stateSaveCounter = 0 savePlaybackState() } nowPlayingCounter += 1 if nowPlayingCounter >= 30 { nowPlayingCounter = 0 updateNowPlaying() } } private func savePlaybackState() { AppState.savePlaybackState( playlistID: currentPlaylist?.id, entryID: currentPlayingEntryID, trackFilePath: currentTrack?.filePath, playbackTime: currentTime ) } private func updateNowPlaying() { MediaKeyHandler.shared.updateNowPlaying( track: currentTrack, isPlaying: isPlaying, currentTime: currentTime, duration: duration ) } // MARK: - Track Loading & Playback func loadAndPlay(_ track: Track, entryID: UUID? = nil, playlist: Playlist? = nil) { // Cloud track — route to StreamingPlayer if track.isCloud, let streamPath = track.cloudStreamPath { let client = ChadMusicAPIClient.shared guard let url = client.streamURL(for: streamPath) else { logger.error("loadAndPlay: Failed to build stream URL for cloud track") return } // Stop local playback audioEngine.stop() waveformSamples = [] isCloudPlayback = true currentCloudTrack = nil currentTrack = track currentPlayingEntryID = entryID if let playlist { currentPlaylist = playlist } streamingPlayer.loadAndPlay( track: ChadTrack( id: track.cloudTrackId ?? "", title: track.title, artist: track.artist, albumArtist: nil, album: track.album, duration: track.duration, no: nil, url: streamPath, bitRate: nil, year: track.year, cover: nil ), streamURL: url, authHeaders: client.authHeaders ) syncFromEngine() return } // Local track — use AudioEngine if isCloudPlayback { streamingPlayer.stop() isCloudPlayback = false currentCloudTrack = nil } do { logger.notice("loadAndPlay: \(track.title) path=\(track.filePath)") logger.notice("loadAndPlay: fileURL=\(track.fileURL.path)") logger.notice("loadAndPlay: exists=\(FileManager.default.fileExists(atPath: track.fileURL.path))") try audioEngine.loadTrack(track) audioEngine.play() currentPlayingEntryID = entryID if let playlist { currentPlaylist = playlist } syncFromEngine() savePlaybackState() loadWaveform(for: track) resetSkipCounter() logger.notice("loadAndPlay: success, playing") } catch { logger.error("loadAndPlay: FAILED: \(error)") print("PlayerViewModel: Failed to load track: \(error)") } } /// Play a cloud track directly via StreamingPlayer (from CloudBrowserView). func loadAndPlayCloud(_ track: ChadTrack, streamURL: URL, authHeaders: [String: String]) { // Stop local playback first audioEngine.stop() currentTrack = nil currentPlayingEntryID = nil currentPlaylist = nil waveformSamples = [] isCloudPlayback = true currentCloudTrack = track streamingPlayer.loadAndPlay(track: track, streamURL: streamURL, authHeaders: authHeaders) resetSkipCounter() syncFromEngine() } func togglePlayPause() { if isCloudPlayback { streamingPlayer.togglePlayPause() } else { audioEngine.togglePlayPause() } syncFromEngine() } func stop() { if isCloudPlayback { streamingPlayer.stop() isCloudPlayback = false currentCloudTrack = nil } audioEngine.stop() waveformSamples = [] currentPlayingEntryID = nil syncFromEngine() } // MARK: - Playlist Navigation (Queue-based) func playNext() { // Repeat One: replay current if repeatMode == .one, let current = nowPlayingEntry { playQueueEntry(current) return } // Push current to history if let current = nowPlayingEntry { history.insert(current, at: 0) if history.count > 50 { history = Array(history.prefix(50)) } } // Pop from userQueue first, then upNext if !userQueue.isEmpty { let next = userQueue.removeFirst() nowPlayingEntry = next playQueueEntry(next) persistQueue() return } if !upNext.isEmpty { let next = upNext.removeFirst() nowPlayingEntry = next playQueueEntry(next) persistQueue() return } // Both empty — check repeat all if repeatMode == .all, let playlist = currentPlaylist { rebuildUpNextFromPlaylist(playlist, afterEntryID: nil) if !upNext.isEmpty { let next = upNext.removeFirst() nowPlayingEntry = next playQueueEntry(next) persistQueue() return } } // Nothing left nowPlayingEntry = nil stop() persistQueue() } func playPrevious() { if currentTime > 3 { seek(to: 0) return } guard !history.isEmpty else { return } let prev = history.removeFirst() // Push current nowPlaying to front of upNext if let current = nowPlayingEntry { upNext.insert(current, at: 0) } nowPlayingEntry = prev playQueueEntry(prev) persistQueue() } // MARK: - Queue Actions /// Add entry to end of user queue. func addToQueue(_ entry: QueueEntry) { userQueue.append(entry) persistQueue() // Auto-play if nothing is currently playing if !isPlaying { playNext() } } /// Insert entry at front of user queue ("Play Next"). func playNextInQueue(_ entry: QueueEntry) { userQueue.insert(entry, at: 0) persistQueue() } /// Play a track from playlist context — sets as nowPlaying, fills upNext with remainder. func playFromPlaylist(track: Track, entryID: UUID, playlist: Playlist) { let hadQueue = nowPlayingEntry != nil || !userQueue.isEmpty || !upNext.isEmpty if hadQueue { previousQueueSnapshot = QueueSnapshot( nowPlaying: nowPlayingEntry, userQueue: userQueue, upNext: upNext ) } let entry = QueueEntry.from(track: track) nowPlayingEntry = entry userQueue = [] history = [] rebuildUpNextFromPlaylist(playlist, afterEntryID: entryID) currentPlaylist = playlist currentPlayingEntryID = entryID playQueueEntry(entry) persistQueue() if hadQueue { showUndoToastBriefly("Queue replaced") } } /// Play a cloud track directly — sets as nowPlaying, clears upNext. func playCloudTrackDirectly(_ track: ChadTrack, streamURL: URL, authHeaders: [String: String]) { let hadQueue = nowPlayingEntry != nil || !userQueue.isEmpty || !upNext.isEmpty if hadQueue { previousQueueSnapshot = QueueSnapshot( nowPlaying: nowPlayingEntry, userQueue: userQueue, upNext: upNext ) } let entry = QueueEntry.from(cloudTrack: track) nowPlayingEntry = entry userQueue = [] upNext = [] history = [] currentPlaylist = nil currentPlayingEntryID = nil loadAndPlayCloud(track, streamURL: streamURL, authHeaders: authHeaders) persistQueue() if hadQueue { showUndoToastBriefly("Queue replaced") } } func removeFromQueue(entry: QueueEntry) { userQueue.removeAll { $0.id == entry.id } upNext.removeAll { $0.id == entry.id } persistQueue() } func clearQueue() { userQueue.removeAll() upNext.removeAll() persistQueue() } func moveUserQueueEntry(from source: IndexSet, to destination: Int) { userQueue.move(fromOffsets: source, toOffset: destination) persistQueue() } func moveUpNextEntry(from source: IndexSet, to destination: Int) { upNext.move(fromOffsets: source, toOffset: destination) persistQueue() } func undoQueueReplacement() { guard let snapshot = previousQueueSnapshot else { return } nowPlayingEntry = snapshot.nowPlaying userQueue = snapshot.userQueue upNext = snapshot.upNext previousQueueSnapshot = nil showUndoToast = false undoTimer?.invalidate() if let entry = nowPlayingEntry { playQueueEntry(entry) } persistQueue() } // MARK: - Queue Entry Playback private func playQueueEntry(_ entry: QueueEntry) { switch entry.source { case .swiftDataTrack(let trackPersistentID, let isCloud, let cloudStreamPath): guard let ctx = modelContext else { logger.error("playQueueEntry: no modelContext set") return } // Fetch track by UUID string guard let trackID = UUID(uuidString: trackPersistentID) else { logger.error("playQueueEntry: invalid UUID string: \(trackPersistentID)") skipBrokenEntry() return } let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == trackID } ) guard let track = (try? ctx.fetch(descriptor))?.first else { logger.error("playQueueEntry: track not found for ID \(trackPersistentID)") skipBrokenEntry() return } loadAndPlay(track, entryID: currentPlayingEntryID, playlist: currentPlaylist) case .cloudDirect(_, let streamPath): let client = ChadMusicAPIClient.shared guard let url = client.streamURL(for: streamPath) else { logger.error("playQueueEntry: failed to build stream URL for \(streamPath)") skipBrokenEntry() return } let chadTrack = ChadTrack( id: entry.id.uuidString, title: entry.title, artist: entry.artist, albumArtist: nil, album: nil, duration: entry.duration, no: nil, url: streamPath, bitRate: nil, year: nil, cover: nil ) loadAndPlayCloud(chadTrack, streamURL: url, authHeaders: client.authHeaders) } } @ObservationIgnored private var skipCount = 0 private static let maxSkips = 20 private func skipBrokenEntry() { skipCount += 1 guard skipCount <= Self.maxSkips else { logger.error("skipBrokenEntry: exceeded \(Self.maxSkips) skips, stopping to prevent infinite loop") skipCount = 0 userQueue.removeAll() upNext.removeAll() stop() return } playNext() } /// Reset skip counter on successful playback start private func resetSkipCounter() { skipCount = 0 } // MARK: - Queue Helpers private func rebuildUpNextFromPlaylist(_ playlist: Playlist, afterEntryID: UUID?) { let entries = playlist.sortedEntries var startIndex = 0 if let afterID = afterEntryID, let idx = entries.firstIndex(where: { $0.id == afterID }) { startIndex = idx + 1 } upNext = entries[startIndex...].compactMap { entry -> QueueEntry? in guard let track = entry.track else { return nil } return QueueEntry.from(track: track) } if shuffleEnabled { upNext.shuffle() } } private func rebuildUpNextFromSource() { guard let playlist = currentPlaylist else { return } rebuildUpNextFromPlaylist(playlist, afterEntryID: currentPlayingEntryID) } private func showUndoToastBriefly(_ message: String) { undoMessage = message showUndoToast = true undoTimer?.invalidate() undoTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { [weak self] _ in Task { @MainActor in self?.showUndoToast = false self?.previousQueueSnapshot = nil } } } // MARK: - Queue Persistence private static let queueKey = "mixboard.queueState" private struct PersistedQueue: Codable { let nowPlaying: QueueEntry? let userQueue: [QueueEntry] let upNext: [QueueEntry] } private func persistQueue() { let state = PersistedQueue(nowPlaying: nowPlayingEntry, userQueue: userQueue, upNext: upNext) if let data = try? JSONEncoder().encode(state) { UserDefaults.standard.set(data, forKey: Self.queueKey) } } private func restoreQueue() { guard let data = UserDefaults.standard.data(forKey: Self.queueKey), let state = try? JSONDecoder().decode(PersistedQueue.self, from: data) else { return } nowPlayingEntry = state.nowPlaying userQueue = state.userQueue upNext = state.upNext } func seek(to time: TimeInterval) { if isCloudPlayback { streamingPlayer.seek(to: time) } else { audioEngine.seek(to: time) } } func seekToProgress(_ progress: Double) { let time = progress * duration seek(to: time) } func skipForward(_ seconds: TimeInterval = 10) { if isCloudPlayback { streamingPlayer.seek(by: seconds) } else { audioEngine.seek(by: seconds) } } func skipBackward(_ seconds: TimeInterval = 10) { if isCloudPlayback { streamingPlayer.seek(by: -seconds) } else { audioEngine.seek(by: -seconds) } } // MARK: - Waveform func loadWaveform(for track: Track) { if let cached = track.waveformData, let decoded = WaveformGenerator.decodeCachedWaveform(from: cached) { waveformSamples = decoded return } isLoadingWaveform = true Task { do { let samples = try await WaveformGenerator.generateWaveform(for: track) waveformSamples = samples } catch { print("PlayerViewModel: Waveform generation failed: \(error)") waveformSamples = [] } isLoadingWaveform = false } } // MARK: - EQ Controls /// Apply persisted EQ values to the audio engine on startup. private func restoreEQ() { eqLow = UserDefaults.standard.float(forKey: "eq.low") eqMid = UserDefaults.standard.float(forKey: "eq.mid") eqHigh = UserDefaults.standard.float(forKey: "eq.high") audioEngine.setEQ(band: 0, gain: eqLow) audioEngine.setEQ(band: 1, gain: eqMid) audioEngine.setEQ(band: 2, gain: eqHigh) } /// Set all three EQ bands and update the audio engine. func setEQ(low: Float, mid: Float, high: Float) { eqLow = low eqMid = mid eqHigh = high audioEngine.setEQ(band: 0, gain: low) audioEngine.setEQ(band: 1, gain: mid) audioEngine.setEQ(band: 2, gain: high) // Persist to UserDefaults UserDefaults.standard.set(low, forKey: "eq.low") UserDefaults.standard.set(mid, forKey: "eq.mid") UserDefaults.standard.set(high, forKey: "eq.high") } /// Apply a named preset. func applyEQPreset(_ preset: EQPreset) { guard let gains = preset.gains else { return } setEQ(low: gains.low, mid: gains.mid, high: gains.high) } /// Reset all bands to flat (0 dB). func resetEQ() { applyEQPreset(.flat) } // MARK: - Helpers private func formatTime(_ time: TimeInterval) -> String { let total = max(0, Int(time)) let minutes = total / 60 let seconds = total % 60 return String(format: "%d:%02d", minutes, seconds) } }