import Foundation import SwiftData import SwiftUI /// ViewModel wrapping the AudioEngine with additional UI state. @MainActor @Observable final class PlayerViewModel { let audioEngine = AudioEngine() let streamingPlayer = StreamingPlayer() /// 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? /// True when the streaming player is buffering. var isBuffering: Bool { streamingPlayer.isBuffering } // MARK: - UI State var showingWaveform = true var waveformSamples: [WaveformGenerator.WaveformSample] = [] var isLoadingWaveform = false /// ID of the currently playing playlist entry (if playing from a playlist). var currentPlayingEntryID: UUID? /// The playlist currently being played through. var currentPlaylist: Playlist? /// The currently selected (cursor) entry ID — updated by PlaylistEntryList. var cursorEntryID: UUID? /// Shuffle mode. var shuffleEnabled: Bool = false { didSet { if shuffleEnabled { upNext.shuffle() } else { rebuildUpNextFromSource() } } } /// Repeat mode. enum RepeatMode: String, CaseIterable { case off = "Off" case all = "Repeat All" case one = "Repeat One" } var repeatMode: RepeatMode = .off // MARK: - Queue var nowPlayingEntry: QueueEntry? var userQueue: [QueueEntry] = [] var upNext: [QueueEntry] = [] var history: [QueueEntry] = [] /// ModelContext for resolving SwiftData track IDs — set from the view layer. @ObservationIgnored var modelContext: ModelContext? // MARK: - Playback Mode /// "playlist" = foobar-style (advance through playlist), "queue" = Spotify-style (queue system). @ObservationIgnored var playbackMode: String { get { UserDefaults.standard.string(forKey: "playbackMode") ?? "queue" } set { UserDefaults.standard.set(newValue, forKey: "playbackMode") } } // MARK: - Synced State (updated from AudioEngine) var isPlaying: Bool = false var currentTime: TimeInterval = 0 var duration: TimeInterval = 0 var currentTrack: Track? var volume: Float { get { isCloudPlayback ? streamingPlayer.volume : audioEngine.volume } set { audioEngine.volume = newValue streamingPlayer.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() startSyncTimer() audioEngine.onPlaybackFinished = { [weak self] in self?.playNext() } streamingPlayer.onPlaybackFinished = { [weak self] in self?.playNext() } streamingPlayer.onPlaybackError = { [weak self] msg in print("PlayerViewModel: Stream error: \(msg)") self?.stop() } } /// Periodically sync state from AudioEngine to trigger SwiftUI updates. 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 { // Sync from StreamingPlayer 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 { // Sync from AudioEngine 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 } } // Save state every ~2 seconds (every 60 sync ticks at 30fps) stateSaveCounter += 1 if stateSaveCounter >= 60 { stateSaveCounter = 0 savePlaybackState() } // Update Now Playing info every ~1 second nowPlayingCounter += 1 if nowPlayingCounter >= 30 { nowPlayingCounter = 0 updateNowPlaying() } } /// Persist current playback state to UserDefaults. private func savePlaybackState() { AppState.savePlaybackState( playlistID: currentPlaylist?.id, entryID: currentPlayingEntryID, trackFilePath: currentTrack?.filePath, playbackTime: currentTime ) } /// Update macOS Now Playing info center (media keys, control center widget). 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) { // Set up queue entry for now playing let queueEntry = QueueEntry.from(track: track) nowPlayingEntry = queueEntry // When starting from a playlist, reset queue state if playlist != nil { history = [] } // Check for playable local file first (covers both local library tracks and downloaded cloud tracks) if track.hasPlayableLocalFile { if isCloudPlayback { streamingPlayer.stop() isCloudPlayback = false currentCloudTrack = nil } do { try audioEngine.loadTrack(track, fileURL: track.playableFileURL) audioEngine.play() currentPlayingEntryID = entryID if let playlist { currentPlaylist = playlist rebuildUpNextFromPlaylist(playlist, afterEntryID: entryID) } syncFromEngine() savePlaybackState() loadWaveform(for: track) persistQueue() } catch { // Format fallback: if AudioEngine can't open the file (e.g., OGG), try StreamingPlayer if track.isCloud, let cachePath = track.localCachePath { print("PlayerViewModel: AudioEngine failed for downloaded file, falling back to StreamingPlayer: \(error)") playViaStreamingPlayer(track: track, url: URL(fileURLWithPath: cachePath), entryID: entryID, playlist: playlist) } else { print("PlayerViewModel: Failed to load track: \(error)") } } return } // Cloud track without local file — route to StreamingPlayer if track.isCloud, let streamPath = track.cloudStreamPath { let client = ChadMusicAPIClient.shared guard let url = client.streamURL(for: streamPath) else { print("PlayerViewModel: Failed to build stream URL for cloud track") return } playViaStreamingPlayer(track: track, url: url, entryID: entryID, playlist: playlist) return } // Local track — use AudioEngine if isCloudPlayback { streamingPlayer.stop() isCloudPlayback = false currentCloudTrack = nil } do { try audioEngine.loadTrack(track) audioEngine.play() currentPlayingEntryID = entryID if let playlist { currentPlaylist = playlist rebuildUpNextFromPlaylist(playlist, afterEntryID: entryID) } syncFromEngine() savePlaybackState() loadWaveform(for: track) persistQueue() } catch { print("PlayerViewModel: Failed to load track: \(error)") } } /// Route playback through StreamingPlayer (for cloud streaming or format fallback). private func playViaStreamingPlayer(track: Track, url: URL, entryID: UUID?, playlist: Playlist?) { audioEngine.stop() waveformSamples = [] isCloudPlayback = true currentCloudTrack = nil currentTrack = track currentPlayingEntryID = entryID if let playlist { currentPlaylist = playlist rebuildUpNextFromPlaylist(playlist, afterEntryID: entryID) } streamingPlayer.loadAndPlay( track: ChadTrack( id: track.cloudTrackId ?? "", title: track.title ?? "—", artist: track.artist, albumArtist: nil, album: track.album, duration: track.duration, no: nil, url: track.cloudStreamPath ?? url.absoluteString, bitRate: nil, year: track.year, cover: nil ), streamURL: url, authHeaders: ChadMusicAPIClient.shared.authHeaders ) syncFromEngine() persistQueue() } /// Play a cloud track via StreamingPlayer. 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) syncFromEngine() } /// Display title — works for both local and cloud tracks. var displayTitle: String { if isCloudPlayback, let ct = currentCloudTrack { return ct.title } return currentTrack?.title ?? "—" } /// Display artist — works for both local and cloud tracks. var displayArtist: String { if isCloudPlayback, let ct = currentCloudTrack { return ct.artist ?? "" } return currentTrack?.artist ?? "" } func togglePlayPause() { if isCloudPlayback { streamingPlayer.togglePlayPause() } else { audioEngine.togglePlayPause() } syncFromEngine() } func stop() { if isCloudPlayback { streamingPlayer.stop() isCloudPlayback = false currentCloudTrack = nil } else { audioEngine.stop() } waveformSamples = [] currentPlayingEntryID = nil syncFromEngine() } // MARK: - Playlist Navigation (Queue-based) /// Advance to the next track using the 3-part queue: userQueue → upNext → repeat. func playNext() { if playbackMode == "playlist" { playNextInPlaylist() } else { playNextInQueueMode() } } /// Playlist mode: advance to next entry in the current playlist (foobar-style). private func playNextInPlaylist() { if repeatMode == .one, let current = nowPlayingEntry { playQueueEntry(current) return } guard let playlist = currentPlaylist, let currentID = currentPlayingEntryID else { stop() return } let entries = playlist.sortedEntries guard let currentIndex = entries.firstIndex(where: { $0.id == currentID }) else { stop() return } let nextIndex = currentIndex + 1 if nextIndex < entries.count, let track = entries[nextIndex].track { loadAndPlay(track, entryID: entries[nextIndex].id, playlist: playlist) } else if repeatMode == .all, let firstTrack = entries.first?.track { loadAndPlay(firstTrack, entryID: entries.first!.id, playlist: playlist) } else { stop() } } /// Queue mode: advance using userQueue → upNext → repeat (Spotify-style). private func playNextInQueueMode() { // 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() } /// Go back to the previous track. func playPrevious() { if currentTime > 3 { seek(to: 0) return } // Try history first if !history.isEmpty { 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() return } // Fallback: navigate by playlist position guard let playlist = currentPlaylist, let currentID = currentPlayingEntryID else { return } let entries = playlist.sortedEntries guard let currentIndex = entries.firstIndex(where: { $0.id == currentID }), currentIndex > 0, let prevTrack = entries[currentIndex - 1].track else { return } loadAndPlay(prevTrack, entryID: entries[currentIndex - 1].id, playlist: playlist) } // 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 && nowPlayingEntry == nil { 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 entry = QueueEntry.from(track: track) nowPlayingEntry = entry userQueue = [] history = [] rebuildUpNextFromPlaylist(playlist, afterEntryID: entryID) currentPlaylist = playlist currentPlayingEntryID = entryID playQueueEntry(entry) persistQueue() } /// Play a cloud track directly — sets as nowPlaying, clears upNext. func playCloudTrackDirectly(_ track: ChadTrack, streamURL: URL, authHeaders: [String: String]) { let entry = QueueEntry.from(cloudTrack: track) nowPlayingEntry = entry userQueue = [] upNext = [] history = [] currentPlaylist = nil currentPlayingEntryID = nil loadAndPlayCloud(track, streamURL: streamURL, authHeaders: authHeaders) persistQueue() } 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() } // MARK: - Queue Entry Playback private func playQueueEntry(_ entry: QueueEntry) { switch entry.source { case .swiftDataTrack(let trackPersistentID, _, _): // Try to resolve via modelContext if let ctx = modelContext, let trackID = UUID(uuidString: trackPersistentID) { let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == trackID } ) if let track = (try? ctx.fetch(descriptor))?.first { loadAndPlayDirect(track) return } } // Fallback: resolve from the current playlist entries if let playlist = currentPlaylist, let trackID = UUID(uuidString: trackPersistentID), let playlistEntry = playlist.sortedEntries.first(where: { $0.track?.id == trackID }), let track = playlistEntry.track { currentPlayingEntryID = playlistEntry.id loadAndPlayDirect(track) return } print("PlayerViewModel: playQueueEntry — track not found: \(trackPersistentID)") skipBrokenEntry() case .cloudDirect(_, let streamPath): let client = ChadMusicAPIClient.shared guard let url = client.streamURL(for: streamPath) else { print("PlayerViewModel: playQueueEntry — failed to build stream URL: \(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) } } /// Internal: play a track without modifying queue state (used by playQueueEntry). private func loadAndPlayDirect(_ track: Track) { // Check for playable local file first (local library or downloaded cloud track) if track.hasPlayableLocalFile { if isCloudPlayback { streamingPlayer.stop() isCloudPlayback = false currentCloudTrack = nil } do { try audioEngine.loadTrack(track, fileURL: track.playableFileURL) audioEngine.play() syncFromEngine() savePlaybackState() loadWaveform(for: track) } catch { // Format fallback for downloaded cloud tracks if track.isCloud, let cachePath = track.localCachePath { print("PlayerViewModel: AudioEngine failed for downloaded file, falling back to StreamingPlayer: \(error)") playViaStreamingPlayer(track: track, url: URL(fileURLWithPath: cachePath), entryID: nil, playlist: nil) } else { print("PlayerViewModel: Failed to load track: \(error)") } } return } // Cloud track without local file — stream if track.isCloud, let streamPath = track.cloudStreamPath { let client = ChadMusicAPIClient.shared guard let url = client.streamURL(for: streamPath) else { return } audioEngine.stop() waveformSamples = [] isCloudPlayback = true currentCloudTrack = nil currentTrack = track 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 library track if isCloudPlayback { streamingPlayer.stop() isCloudPlayback = false currentCloudTrack = nil } do { try audioEngine.loadTrack(track) audioEngine.play() syncFromEngine() savePlaybackState() loadWaveform(for: track) } catch { print("PlayerViewModel: Failed to load track: \(error)") } } @ObservationIgnored private var skipCount = 0 private static let maxSkips = 20 private func skipBrokenEntry() { skipCount += 1 guard skipCount <= Self.maxSkips else { print("PlayerViewModel: exceeded \(Self.maxSkips) skips, stopping") skipCount = 0 userQueue.removeAll() upNext.removeAll() stop() return } playNext() } // 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) } // 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: - EQ func setLowEQ(_ gain: Float) { audioEngine.setEQ(band: 0, gain: gain) } func setMidEQ(_ gain: Float) { audioEngine.setEQ(band: 1, gain: gain) } func setHighEQ(_ gain: Float) { audioEngine.setEQ(band: 2, gain: gain) } // MARK: - Waveform func loadWaveform(for track: Track) { // Check cache first 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: - 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) } }