| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432 |
- 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..<entries.count)
- }
- if let track = entries[randomIndex].track {
- loadAndPlay(track, entryID: entries[randomIndex].id)
- cursorEntryID = entries[randomIndex].id
- }
- return
- }
- // Normal sequential
- let nextIndex = currentIndex + 1
- if nextIndex < entries.count, let nextTrack = entries[nextIndex].track {
- loadAndPlay(nextTrack, entryID: entries[nextIndex].id)
- cursorEntryID = entries[nextIndex].id
- } else if repeatMode == .all, let firstTrack = entries.first?.track {
- // Wrap around
- loadAndPlay(firstTrack, entryID: entries[0].id)
- cursorEntryID = entries[0].id
- } else {
- // End of playlist
- stop()
- }
- }
- /// Go back to the previous track in the current playlist.
- func playPrevious() {
- 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)
- return
- }
- guard let currentIndex = entries.firstIndex(where: { $0.id == currentID }) else { return }
- // If more than 3 seconds in, restart current track; otherwise go to previous
- if currentTime > 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)
- }
- }
|