PlayerViewModel.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import Foundation
  2. import SwiftUI
  3. /// ViewModel wrapping the AudioEngine with additional UI state.
  4. @MainActor
  5. @Observable
  6. final class PlayerViewModel {
  7. let audioEngine = AudioEngine()
  8. let streamingPlayer = StreamingPlayer()
  9. /// True when playing a cloud track via StreamingPlayer (vs local via AudioEngine).
  10. var isCloudPlayback = false
  11. /// The cloud track info when streaming (non-SwiftData, transient).
  12. var currentCloudTrack: ChadTrack?
  13. /// True when the streaming player is buffering.
  14. var isBuffering: Bool { streamingPlayer.isBuffering }
  15. // MARK: - UI State
  16. var showingWaveform = true
  17. var waveformSamples: [WaveformGenerator.WaveformSample] = []
  18. var isLoadingWaveform = false
  19. /// ID of the currently playing playlist entry (if playing from a playlist).
  20. var currentPlayingEntryID: UUID?
  21. /// The playlist currently being played through.
  22. var currentPlaylist: Playlist?
  23. /// The currently selected (cursor) entry ID — updated by PlaylistEntryList.
  24. var cursorEntryID: UUID?
  25. /// Shuffle mode.
  26. var shuffleEnabled: Bool = false
  27. /// Repeat mode.
  28. enum RepeatMode: String, CaseIterable {
  29. case off = "Off"
  30. case all = "Repeat All"
  31. case one = "Repeat One"
  32. }
  33. var repeatMode: RepeatMode = .off
  34. // MARK: - Synced State (updated from AudioEngine)
  35. var isPlaying: Bool = false
  36. var currentTime: TimeInterval = 0
  37. var duration: TimeInterval = 0
  38. var currentTrack: Track?
  39. var volume: Float {
  40. get { isCloudPlayback ? streamingPlayer.volume : audioEngine.volume }
  41. set {
  42. audioEngine.volume = newValue
  43. streamingPlayer.volume = newValue
  44. }
  45. }
  46. var progress: Double {
  47. guard duration > 0 else { return 0 }
  48. return currentTime / duration
  49. }
  50. var currentTimeFormatted: String {
  51. formatTime(currentTime)
  52. }
  53. var durationFormatted: String {
  54. formatTime(duration)
  55. }
  56. var remainingTimeFormatted: String {
  57. "-" + formatTime(duration - currentTime)
  58. }
  59. @ObservationIgnored private var syncTimer: Timer?
  60. @ObservationIgnored private var stateSaveCounter = 0
  61. @ObservationIgnored private var nowPlayingCounter = 0
  62. init() {
  63. startSyncTimer()
  64. audioEngine.onPlaybackFinished = { [weak self] in
  65. self?.playNext()
  66. }
  67. streamingPlayer.onPlaybackFinished = { [weak self] in
  68. self?.playNext()
  69. }
  70. streamingPlayer.onPlaybackError = { [weak self] msg in
  71. print("PlayerViewModel: Stream error: \(msg)")
  72. self?.stop()
  73. }
  74. }
  75. /// Periodically sync state from AudioEngine to trigger SwiftUI updates.
  76. private func startSyncTimer() {
  77. syncTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
  78. Task { @MainActor in
  79. self?.syncFromEngine()
  80. }
  81. }
  82. }
  83. private func syncFromEngine() {
  84. if isCloudPlayback {
  85. // Sync from StreamingPlayer
  86. let sp = streamingPlayer
  87. if isPlaying != sp.isPlaying { isPlaying = sp.isPlaying }
  88. if abs(currentTime - sp.currentTime) > 0.01 { currentTime = sp.currentTime }
  89. if duration != sp.duration { duration = sp.duration }
  90. } else {
  91. // Sync from AudioEngine
  92. let engine = audioEngine
  93. engine.updateCurrentTime()
  94. if isPlaying != engine.isPlaying { isPlaying = engine.isPlaying }
  95. if abs(currentTime - engine.currentTime) > 0.01 { currentTime = engine.currentTime }
  96. if duration != engine.duration { duration = engine.duration }
  97. if currentTrack !== engine.currentTrack { currentTrack = engine.currentTrack }
  98. }
  99. // Save state every ~2 seconds (every 60 sync ticks at 30fps)
  100. stateSaveCounter += 1
  101. if stateSaveCounter >= 60 {
  102. stateSaveCounter = 0
  103. savePlaybackState()
  104. }
  105. // Update Now Playing info every ~1 second
  106. nowPlayingCounter += 1
  107. if nowPlayingCounter >= 30 {
  108. nowPlayingCounter = 0
  109. updateNowPlaying()
  110. }
  111. }
  112. /// Persist current playback state to UserDefaults.
  113. private func savePlaybackState() {
  114. AppState.savePlaybackState(
  115. playlistID: currentPlaylist?.id,
  116. entryID: currentPlayingEntryID,
  117. trackFilePath: currentTrack?.filePath,
  118. playbackTime: currentTime
  119. )
  120. }
  121. /// Update macOS Now Playing info center (media keys, control center widget).
  122. private func updateNowPlaying() {
  123. MediaKeyHandler.shared.updateNowPlaying(
  124. track: currentTrack,
  125. isPlaying: isPlaying,
  126. currentTime: currentTime,
  127. duration: duration
  128. )
  129. }
  130. // MARK: - Track Loading & Playback
  131. func loadAndPlay(_ track: Track, entryID: UUID? = nil, playlist: Playlist? = nil) {
  132. // Cloud track — route to StreamingPlayer
  133. if track.isCloud, let streamPath = track.cloudStreamPath {
  134. let client = ChadMusicAPIClient.shared
  135. guard let url = client.streamURL(for: streamPath) else {
  136. print("PlayerViewModel: Failed to build stream URL for cloud track")
  137. return
  138. }
  139. // Stop local playback
  140. audioEngine.stop()
  141. waveformSamples = []
  142. isCloudPlayback = true
  143. currentCloudTrack = nil // no ChadTrack object — using Track directly
  144. currentTrack = track
  145. currentPlayingEntryID = entryID
  146. if let playlist { currentPlaylist = playlist }
  147. streamingPlayer.loadAndPlay(
  148. track: ChadTrack(
  149. id: track.cloudTrackId ?? "",
  150. title: track.title ?? "—",
  151. artist: track.artist,
  152. albumArtist: nil,
  153. album: track.album,
  154. duration: track.duration,
  155. no: nil,
  156. url: streamPath,
  157. bitRate: nil,
  158. year: track.year,
  159. cover: nil
  160. ),
  161. streamURL: url,
  162. authHeaders: client.authHeaders
  163. )
  164. syncFromEngine()
  165. return
  166. }
  167. // Local track — use AudioEngine
  168. if isCloudPlayback {
  169. streamingPlayer.stop()
  170. isCloudPlayback = false
  171. currentCloudTrack = nil
  172. }
  173. do {
  174. try audioEngine.loadTrack(track)
  175. audioEngine.play()
  176. currentPlayingEntryID = entryID
  177. if let playlist { currentPlaylist = playlist }
  178. syncFromEngine()
  179. savePlaybackState()
  180. loadWaveform(for: track)
  181. } catch {
  182. print("PlayerViewModel: Failed to load track: \(error)")
  183. }
  184. }
  185. /// Play a cloud track via StreamingPlayer.
  186. func loadAndPlayCloud(_ track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
  187. // Stop local playback first
  188. audioEngine.stop()
  189. currentTrack = nil
  190. currentPlayingEntryID = nil
  191. currentPlaylist = nil
  192. waveformSamples = []
  193. isCloudPlayback = true
  194. currentCloudTrack = track
  195. streamingPlayer.loadAndPlay(track: track, streamURL: streamURL, authHeaders: authHeaders)
  196. syncFromEngine()
  197. }
  198. /// Display title — works for both local and cloud tracks.
  199. var displayTitle: String {
  200. if isCloudPlayback, let ct = currentCloudTrack {
  201. return ct.title
  202. }
  203. return currentTrack?.title ?? "—"
  204. }
  205. /// Display artist — works for both local and cloud tracks.
  206. var displayArtist: String {
  207. if isCloudPlayback, let ct = currentCloudTrack {
  208. return ct.artist ?? ""
  209. }
  210. return currentTrack?.artist ?? ""
  211. }
  212. func togglePlayPause() {
  213. if isCloudPlayback {
  214. streamingPlayer.togglePlayPause()
  215. } else {
  216. audioEngine.togglePlayPause()
  217. }
  218. syncFromEngine()
  219. }
  220. func stop() {
  221. if isCloudPlayback {
  222. streamingPlayer.stop()
  223. isCloudPlayback = false
  224. currentCloudTrack = nil
  225. } else {
  226. audioEngine.stop()
  227. }
  228. waveformSamples = []
  229. currentPlayingEntryID = nil
  230. syncFromEngine()
  231. }
  232. // MARK: - Playlist Navigation
  233. /// Advance to the next track in the current playlist.
  234. func playNext() {
  235. guard let playlist = currentPlaylist,
  236. let currentID = currentPlayingEntryID else { return }
  237. let entries = playlist.sortedEntries
  238. // "Playback follows cursor": play the cursor track if it's different from current
  239. if PlaylistViewConfig.shared.playbackFollowsCursor,
  240. let cursorID = cursorEntryID,
  241. cursorID != currentID,
  242. let cursorEntry = entries.first(where: { $0.id == cursorID }),
  243. let track = cursorEntry.track {
  244. loadAndPlay(track, entryID: cursorEntry.id, playlist: playlist)
  245. // Don't move cursor — user put it there intentionally
  246. return
  247. }
  248. guard let currentIndex = entries.firstIndex(where: { $0.id == currentID }) else { return }
  249. // Repeat One: replay same track
  250. if repeatMode == .one, let track = entries[currentIndex].track {
  251. loadAndPlay(track, entryID: entries[currentIndex].id)
  252. cursorEntryID = entries[currentIndex].id
  253. return
  254. }
  255. // Shuffle: pick a random different track
  256. if shuffleEnabled && entries.count > 1 {
  257. var randomIndex = currentIndex
  258. while randomIndex == currentIndex {
  259. randomIndex = Int.random(in: 0..<entries.count)
  260. }
  261. if let track = entries[randomIndex].track {
  262. loadAndPlay(track, entryID: entries[randomIndex].id)
  263. cursorEntryID = entries[randomIndex].id
  264. }
  265. return
  266. }
  267. // Normal sequential
  268. let nextIndex = currentIndex + 1
  269. if nextIndex < entries.count, let nextTrack = entries[nextIndex].track {
  270. loadAndPlay(nextTrack, entryID: entries[nextIndex].id)
  271. cursorEntryID = entries[nextIndex].id
  272. } else if repeatMode == .all, let firstTrack = entries.first?.track {
  273. // Wrap around
  274. loadAndPlay(firstTrack, entryID: entries[0].id)
  275. cursorEntryID = entries[0].id
  276. } else {
  277. // End of playlist
  278. stop()
  279. }
  280. }
  281. /// Go back to the previous track in the current playlist.
  282. func playPrevious() {
  283. guard let playlist = currentPlaylist,
  284. let currentID = currentPlayingEntryID else { return }
  285. let entries = playlist.sortedEntries
  286. // "Playback follows cursor": play the cursor track if it's different from current
  287. if PlaylistViewConfig.shared.playbackFollowsCursor,
  288. let cursorID = cursorEntryID,
  289. cursorID != currentID,
  290. let cursorEntry = entries.first(where: { $0.id == cursorID }),
  291. let track = cursorEntry.track {
  292. loadAndPlay(track, entryID: cursorEntry.id, playlist: playlist)
  293. return
  294. }
  295. guard let currentIndex = entries.firstIndex(where: { $0.id == currentID }) else { return }
  296. // If more than 3 seconds in, restart current track; otherwise go to previous
  297. if currentTime > 3, let track = currentTrack {
  298. seek(to: 0)
  299. return
  300. }
  301. let prevIndex = currentIndex - 1
  302. guard prevIndex >= 0,
  303. let prevTrack = entries[prevIndex].track else { return }
  304. loadAndPlay(prevTrack, entryID: entries[prevIndex].id)
  305. }
  306. func seek(to time: TimeInterval) {
  307. if isCloudPlayback {
  308. streamingPlayer.seek(to: time)
  309. } else {
  310. audioEngine.seek(to: time)
  311. }
  312. }
  313. func seekToProgress(_ progress: Double) {
  314. let time = progress * duration
  315. seek(to: time)
  316. }
  317. func skipForward(_ seconds: TimeInterval = 10) {
  318. if isCloudPlayback {
  319. streamingPlayer.seek(by: seconds)
  320. } else {
  321. audioEngine.seek(by: seconds)
  322. }
  323. }
  324. func skipBackward(_ seconds: TimeInterval = 10) {
  325. if isCloudPlayback {
  326. streamingPlayer.seek(by: -seconds)
  327. } else {
  328. audioEngine.seek(by: -seconds)
  329. }
  330. }
  331. // MARK: - EQ
  332. func setLowEQ(_ gain: Float) {
  333. audioEngine.setEQ(band: 0, gain: gain)
  334. }
  335. func setMidEQ(_ gain: Float) {
  336. audioEngine.setEQ(band: 1, gain: gain)
  337. }
  338. func setHighEQ(_ gain: Float) {
  339. audioEngine.setEQ(band: 2, gain: gain)
  340. }
  341. // MARK: - Waveform
  342. func loadWaveform(for track: Track) {
  343. // Check cache first
  344. if let cached = track.waveformData,
  345. let decoded = WaveformGenerator.decodeCachedWaveform(from: cached) {
  346. waveformSamples = decoded
  347. return
  348. }
  349. isLoadingWaveform = true
  350. Task {
  351. do {
  352. let samples = try await WaveformGenerator.generateWaveform(for: track)
  353. waveformSamples = samples
  354. } catch {
  355. print("PlayerViewModel: Waveform generation failed: \(error)")
  356. waveformSamples = []
  357. }
  358. isLoadingWaveform = false
  359. }
  360. }
  361. // MARK: - Helpers
  362. private func formatTime(_ time: TimeInterval) -> String {
  363. let total = max(0, Int(time))
  364. let minutes = total / 60
  365. let seconds = total % 60
  366. return String(format: "%d:%02d", minutes, seconds)
  367. }
  368. }