import Foundation 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 /// Repeat mode. enum RepeatMode: String, CaseIterable { case off = "Off" case all = "Repeat All" case one = "Repeat One" } var repeatMode: RepeatMode = .off // 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() { 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) { // Cloud track — 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 } // Stop local playback audioEngine.stop() waveformSamples = [] isCloudPlayback = true currentCloudTrack = nil // no ChadTrack object — using Track directly 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 { try audioEngine.loadTrack(track) audioEngine.play() currentPlayingEntryID = entryID if let playlist { currentPlaylist = playlist } syncFromEngine() savePlaybackState() loadWaveform(for: track) } catch { print("PlayerViewModel: Failed to load track: \(error)") } } /// 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 /// Advance to the next track in the current playlist. func playNext() { guard let playlist = currentPlaylist, let currentID = currentPlayingEntryID else { return } let entries = playlist.sortedEntries // "Playback follows cursor": play the cursor track if it's different from current if PlaylistViewConfig.shared.playbackFollowsCursor, let cursorID = cursorEntryID, cursorID != currentID, let cursorEntry = entries.first(where: { $0.id == cursorID }), let track = cursorEntry.track { loadAndPlay(track, entryID: cursorEntry.id, playlist: playlist) // Don't move cursor — user put it there intentionally return } guard let currentIndex = entries.firstIndex(where: { $0.id == currentID }) else { return } // Repeat One: replay same track if repeatMode == .one, let track = entries[currentIndex].track { loadAndPlay(track, entryID: entries[currentIndex].id) cursorEntryID = entries[currentIndex].id return } // Shuffle: pick a random different track if shuffleEnabled && entries.count > 1 { var randomIndex = currentIndex while randomIndex == currentIndex { randomIndex = Int.random(in: 0.. 3, let track = currentTrack { seek(to: 0) return } let prevIndex = currentIndex - 1 guard prevIndex >= 0, let prevTrack = entries[prevIndex].track else { return } loadAndPlay(prevTrack, entryID: entries[prevIndex].id) } 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) } }