PlayerViewModel.swift 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805
  1. import Foundation
  2. import SwiftData
  3. import SwiftUI
  4. /// ViewModel wrapping the AudioEngine with additional UI state.
  5. @MainActor
  6. @Observable
  7. final class PlayerViewModel {
  8. let audioEngine = AudioEngine()
  9. let streamingPlayer = StreamingPlayer()
  10. /// True when playing a cloud track via StreamingPlayer (vs local via AudioEngine).
  11. var isCloudPlayback = false
  12. /// The cloud track info when streaming (non-SwiftData, transient).
  13. var currentCloudTrack: ChadTrack?
  14. /// True when the streaming player is buffering.
  15. var isBuffering: Bool { streamingPlayer.isBuffering }
  16. // MARK: - UI State
  17. var showingWaveform = true
  18. var waveformSamples: [WaveformGenerator.WaveformSample] = []
  19. var isLoadingWaveform = false
  20. /// ID of the currently playing playlist entry (if playing from a playlist).
  21. var currentPlayingEntryID: UUID?
  22. /// The playlist currently being played through.
  23. var currentPlaylist: Playlist?
  24. /// The currently selected (cursor) entry ID — updated by PlaylistEntryList.
  25. var cursorEntryID: UUID?
  26. /// Shuffle mode.
  27. var shuffleEnabled: Bool = false {
  28. didSet {
  29. if shuffleEnabled {
  30. upNext.shuffle()
  31. } else {
  32. rebuildUpNextFromSource()
  33. }
  34. }
  35. }
  36. /// Repeat mode.
  37. enum RepeatMode: String, CaseIterable {
  38. case off = "Off"
  39. case all = "Repeat All"
  40. case one = "Repeat One"
  41. }
  42. var repeatMode: RepeatMode = .off
  43. // MARK: - Queue
  44. var nowPlayingEntry: QueueEntry?
  45. var userQueue: [QueueEntry] = []
  46. var upNext: [QueueEntry] = []
  47. var history: [QueueEntry] = []
  48. /// ModelContext for resolving SwiftData track IDs — set from the view layer.
  49. @ObservationIgnored var modelContext: ModelContext?
  50. // MARK: - Playback Mode
  51. /// "playlist" = foobar-style (advance through playlist), "queue" = Spotify-style (queue system).
  52. @ObservationIgnored
  53. var playbackMode: String {
  54. get { UserDefaults.standard.string(forKey: "playbackMode") ?? "queue" }
  55. set { UserDefaults.standard.set(newValue, forKey: "playbackMode") }
  56. }
  57. // MARK: - Synced State (updated from AudioEngine)
  58. var isPlaying: Bool = false
  59. var currentTime: TimeInterval = 0
  60. var duration: TimeInterval = 0
  61. var currentTrack: Track?
  62. var volume: Float {
  63. get { isCloudPlayback ? streamingPlayer.volume : audioEngine.volume }
  64. set {
  65. audioEngine.volume = newValue
  66. streamingPlayer.volume = newValue
  67. }
  68. }
  69. var progress: Double {
  70. guard duration > 0 else { return 0 }
  71. return currentTime / duration
  72. }
  73. var currentTimeFormatted: String {
  74. formatTime(currentTime)
  75. }
  76. var durationFormatted: String {
  77. formatTime(duration)
  78. }
  79. var remainingTimeFormatted: String {
  80. "-" + formatTime(duration - currentTime)
  81. }
  82. @ObservationIgnored private var syncTimer: Timer?
  83. @ObservationIgnored private var stateSaveCounter = 0
  84. @ObservationIgnored private var nowPlayingCounter = 0
  85. init() {
  86. restoreQueue()
  87. startSyncTimer()
  88. audioEngine.onPlaybackFinished = { [weak self] in
  89. self?.playNext()
  90. }
  91. streamingPlayer.onPlaybackFinished = { [weak self] in
  92. self?.playNext()
  93. }
  94. streamingPlayer.onPlaybackError = { [weak self] msg in
  95. print("PlayerViewModel: Stream error: \(msg)")
  96. self?.stop()
  97. }
  98. }
  99. /// Periodically sync state from AudioEngine to trigger SwiftUI updates.
  100. private func startSyncTimer() {
  101. syncTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
  102. Task { @MainActor in
  103. self?.syncFromEngine()
  104. }
  105. }
  106. }
  107. private func syncFromEngine() {
  108. if isCloudPlayback {
  109. // Sync from StreamingPlayer
  110. let sp = streamingPlayer
  111. if isPlaying != sp.isPlaying { isPlaying = sp.isPlaying }
  112. if abs(currentTime - sp.currentTime) > 0.01 { currentTime = sp.currentTime }
  113. if duration != sp.duration { duration = sp.duration }
  114. } else {
  115. // Sync from AudioEngine
  116. let engine = audioEngine
  117. engine.updateCurrentTime()
  118. if isPlaying != engine.isPlaying { isPlaying = engine.isPlaying }
  119. if abs(currentTime - engine.currentTime) > 0.01 { currentTime = engine.currentTime }
  120. if duration != engine.duration { duration = engine.duration }
  121. if currentTrack !== engine.currentTrack { currentTrack = engine.currentTrack }
  122. }
  123. // Save state every ~2 seconds (every 60 sync ticks at 30fps)
  124. stateSaveCounter += 1
  125. if stateSaveCounter >= 60 {
  126. stateSaveCounter = 0
  127. savePlaybackState()
  128. }
  129. // Update Now Playing info every ~1 second
  130. nowPlayingCounter += 1
  131. if nowPlayingCounter >= 30 {
  132. nowPlayingCounter = 0
  133. updateNowPlaying()
  134. }
  135. }
  136. /// Persist current playback state to UserDefaults.
  137. private func savePlaybackState() {
  138. AppState.savePlaybackState(
  139. playlistID: currentPlaylist?.id,
  140. entryID: currentPlayingEntryID,
  141. trackFilePath: currentTrack?.filePath,
  142. playbackTime: currentTime
  143. )
  144. }
  145. /// Update macOS Now Playing info center (media keys, control center widget).
  146. private func updateNowPlaying() {
  147. MediaKeyHandler.shared.updateNowPlaying(
  148. track: currentTrack,
  149. isPlaying: isPlaying,
  150. currentTime: currentTime,
  151. duration: duration
  152. )
  153. }
  154. // MARK: - Track Loading & Playback
  155. func loadAndPlay(_ track: Track, entryID: UUID? = nil, playlist: Playlist? = nil) {
  156. // Set up queue entry for now playing
  157. let queueEntry = QueueEntry.from(track: track)
  158. nowPlayingEntry = queueEntry
  159. // When starting from a playlist, reset queue state
  160. if playlist != nil {
  161. history = []
  162. }
  163. // Check for playable local file first (covers both local library tracks and downloaded cloud tracks)
  164. if track.hasPlayableLocalFile {
  165. if isCloudPlayback {
  166. streamingPlayer.stop()
  167. isCloudPlayback = false
  168. currentCloudTrack = nil
  169. }
  170. do {
  171. try audioEngine.loadTrack(track, fileURL: track.playableFileURL)
  172. audioEngine.play()
  173. currentPlayingEntryID = entryID
  174. if let playlist {
  175. currentPlaylist = playlist
  176. rebuildUpNextFromPlaylist(playlist, afterEntryID: entryID)
  177. }
  178. syncFromEngine()
  179. savePlaybackState()
  180. loadWaveform(for: track)
  181. persistQueue()
  182. } catch {
  183. // Format fallback: if AudioEngine can't open the file (e.g., OGG), try StreamingPlayer
  184. if track.isCloud, let cachePath = track.localCachePath {
  185. print("PlayerViewModel: AudioEngine failed for downloaded file, falling back to StreamingPlayer: \(error)")
  186. playViaStreamingPlayer(track: track, url: URL(fileURLWithPath: cachePath), entryID: entryID, playlist: playlist)
  187. } else {
  188. print("PlayerViewModel: Failed to load track: \(error)")
  189. }
  190. }
  191. return
  192. }
  193. // Cloud track without local file — route to StreamingPlayer
  194. if track.isCloud, let streamPath = track.cloudStreamPath {
  195. let client = ChadMusicAPIClient.shared
  196. guard let url = client.streamURL(for: streamPath) else {
  197. print("PlayerViewModel: Failed to build stream URL for cloud track")
  198. return
  199. }
  200. playViaStreamingPlayer(track: track, url: url, entryID: entryID, playlist: playlist)
  201. return
  202. }
  203. // Local track — use AudioEngine
  204. if isCloudPlayback {
  205. streamingPlayer.stop()
  206. isCloudPlayback = false
  207. currentCloudTrack = nil
  208. }
  209. do {
  210. try audioEngine.loadTrack(track)
  211. audioEngine.play()
  212. currentPlayingEntryID = entryID
  213. if let playlist {
  214. currentPlaylist = playlist
  215. rebuildUpNextFromPlaylist(playlist, afterEntryID: entryID)
  216. }
  217. syncFromEngine()
  218. savePlaybackState()
  219. loadWaveform(for: track)
  220. persistQueue()
  221. } catch {
  222. print("PlayerViewModel: Failed to load track: \(error)")
  223. }
  224. }
  225. /// Route playback through StreamingPlayer (for cloud streaming or format fallback).
  226. private func playViaStreamingPlayer(track: Track, url: URL, entryID: UUID?, playlist: Playlist?) {
  227. audioEngine.stop()
  228. waveformSamples = []
  229. isCloudPlayback = true
  230. currentCloudTrack = nil
  231. currentTrack = track
  232. currentPlayingEntryID = entryID
  233. if let playlist {
  234. currentPlaylist = playlist
  235. rebuildUpNextFromPlaylist(playlist, afterEntryID: entryID)
  236. }
  237. streamingPlayer.loadAndPlay(
  238. track: ChadTrack(
  239. id: track.cloudTrackId ?? "",
  240. title: track.title ?? "—",
  241. artist: track.artist,
  242. albumArtist: nil,
  243. album: track.album,
  244. duration: track.duration,
  245. no: nil,
  246. url: track.cloudStreamPath ?? url.absoluteString,
  247. bitRate: nil,
  248. year: track.year,
  249. cover: nil
  250. ),
  251. streamURL: url,
  252. authHeaders: ChadMusicAPIClient.shared.authHeaders
  253. )
  254. syncFromEngine()
  255. persistQueue()
  256. }
  257. /// Play a cloud track via StreamingPlayer.
  258. func loadAndPlayCloud(_ track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
  259. // Stop local playback first
  260. audioEngine.stop()
  261. currentTrack = nil
  262. currentPlayingEntryID = nil
  263. currentPlaylist = nil
  264. waveformSamples = []
  265. isCloudPlayback = true
  266. currentCloudTrack = track
  267. streamingPlayer.loadAndPlay(track: track, streamURL: streamURL, authHeaders: authHeaders)
  268. syncFromEngine()
  269. }
  270. /// Display title — works for both local and cloud tracks.
  271. var displayTitle: String {
  272. if isCloudPlayback, let ct = currentCloudTrack {
  273. return ct.title
  274. }
  275. return currentTrack?.title ?? "—"
  276. }
  277. /// Display artist — works for both local and cloud tracks.
  278. var displayArtist: String {
  279. if isCloudPlayback, let ct = currentCloudTrack {
  280. return ct.artist ?? ""
  281. }
  282. return currentTrack?.artist ?? ""
  283. }
  284. func togglePlayPause() {
  285. if isCloudPlayback {
  286. streamingPlayer.togglePlayPause()
  287. } else {
  288. audioEngine.togglePlayPause()
  289. }
  290. syncFromEngine()
  291. }
  292. func stop() {
  293. if isCloudPlayback {
  294. streamingPlayer.stop()
  295. isCloudPlayback = false
  296. currentCloudTrack = nil
  297. } else {
  298. audioEngine.stop()
  299. }
  300. waveformSamples = []
  301. currentPlayingEntryID = nil
  302. syncFromEngine()
  303. }
  304. // MARK: - Playlist Navigation (Queue-based)
  305. /// Advance to the next track using the 3-part queue: userQueue → upNext → repeat.
  306. func playNext() {
  307. if playbackMode == "playlist" {
  308. playNextInPlaylist()
  309. } else {
  310. playNextInQueueMode()
  311. }
  312. }
  313. /// Playlist mode: advance to next entry in the current playlist (foobar-style).
  314. private func playNextInPlaylist() {
  315. if repeatMode == .one, let current = nowPlayingEntry {
  316. playQueueEntry(current)
  317. return
  318. }
  319. guard let playlist = currentPlaylist,
  320. let currentID = currentPlayingEntryID else {
  321. stop()
  322. return
  323. }
  324. let entries = playlist.sortedEntries
  325. guard let currentIndex = entries.firstIndex(where: { $0.id == currentID }) else {
  326. stop()
  327. return
  328. }
  329. let nextIndex = currentIndex + 1
  330. if nextIndex < entries.count, let track = entries[nextIndex].track {
  331. loadAndPlay(track, entryID: entries[nextIndex].id, playlist: playlist)
  332. } else if repeatMode == .all, let firstTrack = entries.first?.track {
  333. loadAndPlay(firstTrack, entryID: entries.first!.id, playlist: playlist)
  334. } else {
  335. stop()
  336. }
  337. }
  338. /// Queue mode: advance using userQueue → upNext → repeat (Spotify-style).
  339. private func playNextInQueueMode() {
  340. // Repeat One: replay current
  341. if repeatMode == .one, let current = nowPlayingEntry {
  342. playQueueEntry(current)
  343. return
  344. }
  345. // Push current to history
  346. if let current = nowPlayingEntry {
  347. history.insert(current, at: 0)
  348. if history.count > 50 { history = Array(history.prefix(50)) }
  349. }
  350. // Pop from userQueue first, then upNext
  351. if !userQueue.isEmpty {
  352. let next = userQueue.removeFirst()
  353. nowPlayingEntry = next
  354. playQueueEntry(next)
  355. persistQueue()
  356. return
  357. }
  358. if !upNext.isEmpty {
  359. let next = upNext.removeFirst()
  360. nowPlayingEntry = next
  361. playQueueEntry(next)
  362. persistQueue()
  363. return
  364. }
  365. // Both empty — check repeat all
  366. if repeatMode == .all, let playlist = currentPlaylist {
  367. rebuildUpNextFromPlaylist(playlist, afterEntryID: nil)
  368. if !upNext.isEmpty {
  369. let next = upNext.removeFirst()
  370. nowPlayingEntry = next
  371. playQueueEntry(next)
  372. persistQueue()
  373. return
  374. }
  375. }
  376. // Nothing left
  377. nowPlayingEntry = nil
  378. stop()
  379. persistQueue()
  380. }
  381. /// Go back to the previous track.
  382. func playPrevious() {
  383. if currentTime > 3 {
  384. seek(to: 0)
  385. return
  386. }
  387. // Try history first
  388. if !history.isEmpty {
  389. let prev = history.removeFirst()
  390. // Push current nowPlaying to front of upNext
  391. if let current = nowPlayingEntry {
  392. upNext.insert(current, at: 0)
  393. }
  394. nowPlayingEntry = prev
  395. playQueueEntry(prev)
  396. persistQueue()
  397. return
  398. }
  399. // Fallback: navigate by playlist position
  400. guard let playlist = currentPlaylist,
  401. let currentID = currentPlayingEntryID else { return }
  402. let entries = playlist.sortedEntries
  403. guard let currentIndex = entries.firstIndex(where: { $0.id == currentID }),
  404. currentIndex > 0,
  405. let prevTrack = entries[currentIndex - 1].track else { return }
  406. loadAndPlay(prevTrack, entryID: entries[currentIndex - 1].id, playlist: playlist)
  407. }
  408. // MARK: - Queue Actions
  409. /// Add entry to end of user queue.
  410. func addToQueue(_ entry: QueueEntry) {
  411. userQueue.append(entry)
  412. persistQueue()
  413. // Auto-play if nothing is currently playing
  414. if !isPlaying && nowPlayingEntry == nil {
  415. playNext()
  416. }
  417. }
  418. /// Insert entry at front of user queue ("Play Next").
  419. func playNextInQueue(_ entry: QueueEntry) {
  420. userQueue.insert(entry, at: 0)
  421. persistQueue()
  422. }
  423. /// Play a track from playlist context — sets as nowPlaying, fills upNext with remainder.
  424. func playFromPlaylist(track: Track, entryID: UUID, playlist: Playlist) {
  425. let entry = QueueEntry.from(track: track)
  426. nowPlayingEntry = entry
  427. userQueue = []
  428. history = []
  429. rebuildUpNextFromPlaylist(playlist, afterEntryID: entryID)
  430. currentPlaylist = playlist
  431. currentPlayingEntryID = entryID
  432. playQueueEntry(entry)
  433. persistQueue()
  434. }
  435. /// Play a cloud track directly — sets as nowPlaying, clears upNext.
  436. func playCloudTrackDirectly(_ track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
  437. let entry = QueueEntry.from(cloudTrack: track)
  438. nowPlayingEntry = entry
  439. userQueue = []
  440. upNext = []
  441. history = []
  442. currentPlaylist = nil
  443. currentPlayingEntryID = nil
  444. loadAndPlayCloud(track, streamURL: streamURL, authHeaders: authHeaders)
  445. persistQueue()
  446. }
  447. func removeFromQueue(entry: QueueEntry) {
  448. userQueue.removeAll { $0.id == entry.id }
  449. upNext.removeAll { $0.id == entry.id }
  450. persistQueue()
  451. }
  452. func clearQueue() {
  453. userQueue.removeAll()
  454. upNext.removeAll()
  455. persistQueue()
  456. }
  457. func moveUserQueueEntry(from source: IndexSet, to destination: Int) {
  458. userQueue.move(fromOffsets: source, toOffset: destination)
  459. persistQueue()
  460. }
  461. func moveUpNextEntry(from source: IndexSet, to destination: Int) {
  462. upNext.move(fromOffsets: source, toOffset: destination)
  463. persistQueue()
  464. }
  465. // MARK: - Queue Entry Playback
  466. private func playQueueEntry(_ entry: QueueEntry) {
  467. switch entry.source {
  468. case .swiftDataTrack(let trackPersistentID, _, _):
  469. // Try to resolve via modelContext
  470. if let ctx = modelContext, let trackID = UUID(uuidString: trackPersistentID) {
  471. let descriptor = FetchDescriptor<Track>(
  472. predicate: #Predicate<Track> { $0.id == trackID }
  473. )
  474. if let track = (try? ctx.fetch(descriptor))?.first {
  475. loadAndPlayDirect(track)
  476. return
  477. }
  478. }
  479. // Fallback: resolve from the current playlist entries
  480. if let playlist = currentPlaylist,
  481. let trackID = UUID(uuidString: trackPersistentID),
  482. let playlistEntry = playlist.sortedEntries.first(where: { $0.track?.id == trackID }),
  483. let track = playlistEntry.track {
  484. currentPlayingEntryID = playlistEntry.id
  485. loadAndPlayDirect(track)
  486. return
  487. }
  488. print("PlayerViewModel: playQueueEntry — track not found: \(trackPersistentID)")
  489. skipBrokenEntry()
  490. case .cloudDirect(_, let streamPath):
  491. let client = ChadMusicAPIClient.shared
  492. guard let url = client.streamURL(for: streamPath) else {
  493. print("PlayerViewModel: playQueueEntry — failed to build stream URL: \(streamPath)")
  494. skipBrokenEntry()
  495. return
  496. }
  497. let chadTrack = ChadTrack(
  498. id: entry.id.uuidString,
  499. title: entry.title,
  500. artist: entry.artist,
  501. albumArtist: nil,
  502. album: nil,
  503. duration: entry.duration,
  504. no: nil,
  505. url: streamPath,
  506. bitRate: nil,
  507. year: nil,
  508. cover: nil
  509. )
  510. loadAndPlayCloud(chadTrack, streamURL: url, authHeaders: client.authHeaders)
  511. }
  512. }
  513. /// Internal: play a track without modifying queue state (used by playQueueEntry).
  514. private func loadAndPlayDirect(_ track: Track) {
  515. // Check for playable local file first (local library or downloaded cloud track)
  516. if track.hasPlayableLocalFile {
  517. if isCloudPlayback {
  518. streamingPlayer.stop()
  519. isCloudPlayback = false
  520. currentCloudTrack = nil
  521. }
  522. do {
  523. try audioEngine.loadTrack(track, fileURL: track.playableFileURL)
  524. audioEngine.play()
  525. syncFromEngine()
  526. savePlaybackState()
  527. loadWaveform(for: track)
  528. } catch {
  529. // Format fallback for downloaded cloud tracks
  530. if track.isCloud, let cachePath = track.localCachePath {
  531. print("PlayerViewModel: AudioEngine failed for downloaded file, falling back to StreamingPlayer: \(error)")
  532. playViaStreamingPlayer(track: track, url: URL(fileURLWithPath: cachePath), entryID: nil, playlist: nil)
  533. } else {
  534. print("PlayerViewModel: Failed to load track: \(error)")
  535. }
  536. }
  537. return
  538. }
  539. // Cloud track without local file — stream
  540. if track.isCloud, let streamPath = track.cloudStreamPath {
  541. let client = ChadMusicAPIClient.shared
  542. guard let url = client.streamURL(for: streamPath) else { return }
  543. audioEngine.stop()
  544. waveformSamples = []
  545. isCloudPlayback = true
  546. currentCloudTrack = nil
  547. currentTrack = track
  548. streamingPlayer.loadAndPlay(
  549. track: ChadTrack(
  550. id: track.cloudTrackId ?? "",
  551. title: track.title ?? "—",
  552. artist: track.artist,
  553. albumArtist: nil,
  554. album: track.album,
  555. duration: track.duration,
  556. no: nil,
  557. url: streamPath,
  558. bitRate: nil,
  559. year: track.year,
  560. cover: nil
  561. ),
  562. streamURL: url,
  563. authHeaders: client.authHeaders
  564. )
  565. syncFromEngine()
  566. return
  567. }
  568. // Local library track
  569. if isCloudPlayback {
  570. streamingPlayer.stop()
  571. isCloudPlayback = false
  572. currentCloudTrack = nil
  573. }
  574. do {
  575. try audioEngine.loadTrack(track)
  576. audioEngine.play()
  577. syncFromEngine()
  578. savePlaybackState()
  579. loadWaveform(for: track)
  580. } catch {
  581. print("PlayerViewModel: Failed to load track: \(error)")
  582. }
  583. }
  584. @ObservationIgnored private var skipCount = 0
  585. private static let maxSkips = 20
  586. private func skipBrokenEntry() {
  587. skipCount += 1
  588. guard skipCount <= Self.maxSkips else {
  589. print("PlayerViewModel: exceeded \(Self.maxSkips) skips, stopping")
  590. skipCount = 0
  591. userQueue.removeAll()
  592. upNext.removeAll()
  593. stop()
  594. return
  595. }
  596. playNext()
  597. }
  598. // MARK: - Queue Helpers
  599. private func rebuildUpNextFromPlaylist(_ playlist: Playlist, afterEntryID: UUID?) {
  600. let entries = playlist.sortedEntries
  601. var startIndex = 0
  602. if let afterID = afterEntryID,
  603. let idx = entries.firstIndex(where: { $0.id == afterID }) {
  604. startIndex = idx + 1
  605. }
  606. upNext = entries[startIndex...].compactMap { entry -> QueueEntry? in
  607. guard let track = entry.track else { return nil }
  608. return QueueEntry.from(track: track)
  609. }
  610. if shuffleEnabled {
  611. upNext.shuffle()
  612. }
  613. }
  614. private func rebuildUpNextFromSource() {
  615. guard let playlist = currentPlaylist else { return }
  616. rebuildUpNextFromPlaylist(playlist, afterEntryID: currentPlayingEntryID)
  617. }
  618. // MARK: - Queue Persistence
  619. private static let queueKey = "mixboard.queueState"
  620. private struct PersistedQueue: Codable {
  621. let nowPlaying: QueueEntry?
  622. let userQueue: [QueueEntry]
  623. let upNext: [QueueEntry]
  624. }
  625. private func persistQueue() {
  626. let state = PersistedQueue(nowPlaying: nowPlayingEntry, userQueue: userQueue, upNext: upNext)
  627. if let data = try? JSONEncoder().encode(state) {
  628. UserDefaults.standard.set(data, forKey: Self.queueKey)
  629. }
  630. }
  631. private func restoreQueue() {
  632. guard let data = UserDefaults.standard.data(forKey: Self.queueKey),
  633. let state = try? JSONDecoder().decode(PersistedQueue.self, from: data) else { return }
  634. nowPlayingEntry = state.nowPlaying
  635. userQueue = state.userQueue
  636. upNext = state.upNext
  637. }
  638. func seek(to time: TimeInterval) {
  639. if isCloudPlayback {
  640. streamingPlayer.seek(to: time)
  641. } else {
  642. audioEngine.seek(to: time)
  643. }
  644. }
  645. func seekToProgress(_ progress: Double) {
  646. let time = progress * duration
  647. seek(to: time)
  648. }
  649. func skipForward(_ seconds: TimeInterval = 10) {
  650. if isCloudPlayback {
  651. streamingPlayer.seek(by: seconds)
  652. } else {
  653. audioEngine.seek(by: seconds)
  654. }
  655. }
  656. func skipBackward(_ seconds: TimeInterval = 10) {
  657. if isCloudPlayback {
  658. streamingPlayer.seek(by: -seconds)
  659. } else {
  660. audioEngine.seek(by: -seconds)
  661. }
  662. }
  663. // MARK: - EQ
  664. func setLowEQ(_ gain: Float) {
  665. audioEngine.setEQ(band: 0, gain: gain)
  666. }
  667. func setMidEQ(_ gain: Float) {
  668. audioEngine.setEQ(band: 1, gain: gain)
  669. }
  670. func setHighEQ(_ gain: Float) {
  671. audioEngine.setEQ(band: 2, gain: gain)
  672. }
  673. // MARK: - Waveform
  674. func loadWaveform(for track: Track) {
  675. // Check cache first
  676. if let cached = track.waveformData,
  677. let decoded = WaveformGenerator.decodeCachedWaveform(from: cached) {
  678. waveformSamples = decoded
  679. return
  680. }
  681. isLoadingWaveform = true
  682. Task {
  683. do {
  684. let samples = try await WaveformGenerator.generateWaveform(for: track)
  685. waveformSamples = samples
  686. } catch {
  687. print("PlayerViewModel: Waveform generation failed: \(error)")
  688. waveformSamples = []
  689. }
  690. isLoadingWaveform = false
  691. }
  692. }
  693. // MARK: - Helpers
  694. private func formatTime(_ time: TimeInterval) -> String {
  695. let total = max(0, Int(time))
  696. let minutes = total / 60
  697. let seconds = total % 60
  698. return String(format: "%d:%02d", minutes, seconds)
  699. }
  700. }