PlayerViewModel.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
  1. import Foundation
  2. import os
  3. import SwiftData
  4. import SwiftUI
  5. private let logger = Logger(subsystem: "com.mixboard.MixBoardiOS", category: "PlayerVM")
  6. /// ViewModel wrapping the AudioEngine with additional UI state.
  7. @MainActor
  8. @Observable
  9. final class PlayerViewModel {
  10. let audioEngine = AudioEngine()
  11. let streamingPlayer = StreamingPlayer()
  12. // MARK: - UI State
  13. var showingWaveform = true
  14. var waveformSamples: [WaveformGenerator.WaveformSample] = []
  15. var isLoadingWaveform = false
  16. var showNowPlaying = false
  17. var showQueue = false
  18. /// ID of the currently playing playlist entry.
  19. var currentPlayingEntryID: UUID?
  20. /// The playlist currently being played through.
  21. var currentPlaylist: Playlist?
  22. /// Shuffle mode.
  23. var shuffleEnabled: Bool = false {
  24. didSet {
  25. if shuffleEnabled {
  26. upNext.shuffle()
  27. } else {
  28. rebuildUpNextFromSource()
  29. }
  30. }
  31. }
  32. /// True when playing a cloud track via StreamingPlayer (vs local via AudioEngine).
  33. var isCloudPlayback = false
  34. /// The cloud track info when streaming (non-SwiftData, transient).
  35. var currentCloudTrack: ChadTrack?
  36. var isBuffering: Bool { streamingPlayer.isBuffering }
  37. /// Repeat mode.
  38. enum RepeatMode: String, CaseIterable {
  39. case off = "Off"
  40. case all = "Repeat All"
  41. case one = "Repeat One"
  42. var icon: String {
  43. switch self {
  44. case .off: return "repeat"
  45. case .all: return "repeat"
  46. case .one: return "repeat.1"
  47. }
  48. }
  49. }
  50. var repeatMode: RepeatMode = .off
  51. // MARK: - Queue
  52. var nowPlayingEntry: QueueEntry?
  53. var userQueue: [QueueEntry] = []
  54. var upNext: [QueueEntry] = []
  55. var history: [QueueEntry] = []
  56. /// Undo state for queue replacement
  57. var showUndoToast = false
  58. var undoMessage = ""
  59. @ObservationIgnored private var previousQueueSnapshot: QueueSnapshot?
  60. @ObservationIgnored private var undoTimer: Timer?
  61. private struct QueueSnapshot {
  62. let nowPlaying: QueueEntry?
  63. let userQueue: [QueueEntry]
  64. let upNext: [QueueEntry]
  65. }
  66. /// ModelContext for resolving SwiftData track IDs — set from the view layer
  67. @ObservationIgnored var modelContext: ModelContext?
  68. // MARK: - Synced State
  69. var isPlaying: Bool = false
  70. var currentTime: TimeInterval = 0
  71. var duration: TimeInterval = 0
  72. var currentTrack: Track?
  73. var volume: Float {
  74. get { audioEngine.volume }
  75. set { audioEngine.volume = newValue }
  76. }
  77. var progress: Double {
  78. guard duration > 0 else { return 0 }
  79. return currentTime / duration
  80. }
  81. var currentTimeFormatted: String {
  82. formatTime(currentTime)
  83. }
  84. var durationFormatted: String {
  85. formatTime(duration)
  86. }
  87. var remainingTimeFormatted: String {
  88. "-" + formatTime(duration - currentTime)
  89. }
  90. @ObservationIgnored private var syncTimer: Timer?
  91. @ObservationIgnored private var stateSaveCounter = 0
  92. @ObservationIgnored private var nowPlayingCounter = 0
  93. init() {
  94. restoreQueue()
  95. startSyncTimer()
  96. audioEngine.onPlaybackFinished = { [weak self] in
  97. self?.playNext()
  98. }
  99. streamingPlayer.onPlaybackFinished = { [weak self] in
  100. self?.playNext()
  101. }
  102. }
  103. private func startSyncTimer() {
  104. syncTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
  105. Task { @MainActor in
  106. self?.syncFromEngine()
  107. }
  108. }
  109. }
  110. private func syncFromEngine() {
  111. if isCloudPlayback {
  112. let sp = streamingPlayer
  113. if isPlaying != sp.isPlaying { isPlaying = sp.isPlaying }
  114. if abs(currentTime - sp.currentTime) > 0.01 { currentTime = sp.currentTime }
  115. if duration != sp.duration { duration = sp.duration }
  116. } else {
  117. let engine = audioEngine
  118. engine.updateCurrentTime()
  119. if isPlaying != engine.isPlaying { isPlaying = engine.isPlaying }
  120. if abs(currentTime - engine.currentTime) > 0.01 { currentTime = engine.currentTime }
  121. if duration != engine.duration { duration = engine.duration }
  122. if currentTrack !== engine.currentTrack { currentTrack = engine.currentTrack }
  123. }
  124. stateSaveCounter += 1
  125. if stateSaveCounter >= 60 {
  126. stateSaveCounter = 0
  127. savePlaybackState()
  128. }
  129. nowPlayingCounter += 1
  130. if nowPlayingCounter >= 30 {
  131. nowPlayingCounter = 0
  132. updateNowPlaying()
  133. }
  134. }
  135. private func savePlaybackState() {
  136. AppState.savePlaybackState(
  137. playlistID: currentPlaylist?.id,
  138. entryID: currentPlayingEntryID,
  139. trackFilePath: currentTrack?.filePath,
  140. playbackTime: currentTime
  141. )
  142. }
  143. private func updateNowPlaying() {
  144. MediaKeyHandler.shared.updateNowPlaying(
  145. track: currentTrack,
  146. isPlaying: isPlaying,
  147. currentTime: currentTime,
  148. duration: duration
  149. )
  150. }
  151. // MARK: - Track Loading & Playback
  152. func loadAndPlay(_ track: Track, entryID: UUID? = nil, playlist: Playlist? = nil) {
  153. // Cloud track — route to StreamingPlayer
  154. if track.isCloud, let streamPath = track.cloudStreamPath {
  155. let client = ChadMusicAPIClient.shared
  156. guard let url = client.streamURL(for: streamPath) else {
  157. logger.error("loadAndPlay: Failed to build stream URL for cloud track")
  158. return
  159. }
  160. // Stop local playback
  161. audioEngine.stop()
  162. waveformSamples = []
  163. isCloudPlayback = true
  164. currentCloudTrack = nil
  165. currentTrack = track
  166. currentPlayingEntryID = entryID
  167. if let playlist { currentPlaylist = playlist }
  168. streamingPlayer.loadAndPlay(
  169. track: ChadTrack(
  170. id: track.cloudTrackId ?? "",
  171. title: track.title,
  172. artist: track.artist,
  173. albumArtist: nil,
  174. album: track.album,
  175. duration: track.duration,
  176. no: nil,
  177. url: streamPath,
  178. bitRate: nil,
  179. year: track.year,
  180. cover: nil
  181. ),
  182. streamURL: url,
  183. authHeaders: client.authHeaders
  184. )
  185. syncFromEngine()
  186. return
  187. }
  188. // Local track — use AudioEngine
  189. if isCloudPlayback {
  190. streamingPlayer.stop()
  191. isCloudPlayback = false
  192. currentCloudTrack = nil
  193. }
  194. do {
  195. logger.notice("loadAndPlay: \(track.title) path=\(track.filePath)")
  196. logger.notice("loadAndPlay: fileURL=\(track.fileURL.path)")
  197. logger.notice("loadAndPlay: exists=\(FileManager.default.fileExists(atPath: track.fileURL.path))")
  198. try audioEngine.loadTrack(track)
  199. audioEngine.play()
  200. currentPlayingEntryID = entryID
  201. if let playlist { currentPlaylist = playlist }
  202. syncFromEngine()
  203. savePlaybackState()
  204. loadWaveform(for: track)
  205. resetSkipCounter()
  206. logger.notice("loadAndPlay: success, playing")
  207. } catch {
  208. logger.error("loadAndPlay: FAILED: \(error)")
  209. print("PlayerViewModel: Failed to load track: \(error)")
  210. }
  211. }
  212. /// Play a cloud track directly via StreamingPlayer (from CloudBrowserView).
  213. func loadAndPlayCloud(_ track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
  214. // Stop local playback first
  215. audioEngine.stop()
  216. currentTrack = nil
  217. currentPlayingEntryID = nil
  218. currentPlaylist = nil
  219. waveformSamples = []
  220. isCloudPlayback = true
  221. currentCloudTrack = track
  222. streamingPlayer.loadAndPlay(track: track, streamURL: streamURL, authHeaders: authHeaders)
  223. resetSkipCounter()
  224. syncFromEngine()
  225. }
  226. func togglePlayPause() {
  227. if isCloudPlayback {
  228. streamingPlayer.togglePlayPause()
  229. } else {
  230. audioEngine.togglePlayPause()
  231. }
  232. syncFromEngine()
  233. }
  234. func stop() {
  235. if isCloudPlayback {
  236. streamingPlayer.stop()
  237. isCloudPlayback = false
  238. currentCloudTrack = nil
  239. }
  240. audioEngine.stop()
  241. waveformSamples = []
  242. currentPlayingEntryID = nil
  243. syncFromEngine()
  244. }
  245. // MARK: - Playlist Navigation (Queue-based)
  246. func playNext() {
  247. // Repeat One: replay current
  248. if repeatMode == .one, let current = nowPlayingEntry {
  249. playQueueEntry(current)
  250. return
  251. }
  252. // Push current to history
  253. if let current = nowPlayingEntry {
  254. history.insert(current, at: 0)
  255. if history.count > 50 { history = Array(history.prefix(50)) }
  256. }
  257. // Pop from userQueue first, then upNext
  258. if !userQueue.isEmpty {
  259. let next = userQueue.removeFirst()
  260. nowPlayingEntry = next
  261. playQueueEntry(next)
  262. persistQueue()
  263. return
  264. }
  265. if !upNext.isEmpty {
  266. let next = upNext.removeFirst()
  267. nowPlayingEntry = next
  268. playQueueEntry(next)
  269. persistQueue()
  270. return
  271. }
  272. // Both empty — check repeat all
  273. if repeatMode == .all, let playlist = currentPlaylist {
  274. rebuildUpNextFromPlaylist(playlist, afterEntryID: nil)
  275. if !upNext.isEmpty {
  276. let next = upNext.removeFirst()
  277. nowPlayingEntry = next
  278. playQueueEntry(next)
  279. persistQueue()
  280. return
  281. }
  282. }
  283. // Nothing left
  284. nowPlayingEntry = nil
  285. stop()
  286. persistQueue()
  287. }
  288. func playPrevious() {
  289. if currentTime > 3 {
  290. seek(to: 0)
  291. return
  292. }
  293. guard !history.isEmpty else { return }
  294. let prev = history.removeFirst()
  295. // Push current nowPlaying to front of upNext
  296. if let current = nowPlayingEntry {
  297. upNext.insert(current, at: 0)
  298. }
  299. nowPlayingEntry = prev
  300. playQueueEntry(prev)
  301. persistQueue()
  302. }
  303. // MARK: - Queue Actions
  304. /// Add entry to end of user queue.
  305. func addToQueue(_ entry: QueueEntry) {
  306. userQueue.append(entry)
  307. persistQueue()
  308. // Auto-play if nothing is currently playing
  309. if !isPlaying {
  310. playNext()
  311. }
  312. }
  313. /// Insert entry at front of user queue ("Play Next").
  314. func playNextInQueue(_ entry: QueueEntry) {
  315. userQueue.insert(entry, at: 0)
  316. persistQueue()
  317. }
  318. /// Play a track from playlist context — sets as nowPlaying, fills upNext with remainder.
  319. func playFromPlaylist(track: Track, entryID: UUID, playlist: Playlist) {
  320. let hadQueue = nowPlayingEntry != nil || !userQueue.isEmpty || !upNext.isEmpty
  321. if hadQueue {
  322. previousQueueSnapshot = QueueSnapshot(
  323. nowPlaying: nowPlayingEntry,
  324. userQueue: userQueue,
  325. upNext: upNext
  326. )
  327. }
  328. let entry = QueueEntry.from(track: track)
  329. nowPlayingEntry = entry
  330. userQueue = []
  331. history = []
  332. rebuildUpNextFromPlaylist(playlist, afterEntryID: entryID)
  333. currentPlaylist = playlist
  334. currentPlayingEntryID = entryID
  335. playQueueEntry(entry)
  336. persistQueue()
  337. if hadQueue {
  338. showUndoToastBriefly("Queue replaced")
  339. }
  340. }
  341. /// Play a cloud track directly — sets as nowPlaying, clears upNext.
  342. func playCloudTrackDirectly(_ track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
  343. let hadQueue = nowPlayingEntry != nil || !userQueue.isEmpty || !upNext.isEmpty
  344. if hadQueue {
  345. previousQueueSnapshot = QueueSnapshot(
  346. nowPlaying: nowPlayingEntry,
  347. userQueue: userQueue,
  348. upNext: upNext
  349. )
  350. }
  351. let entry = QueueEntry.from(cloudTrack: track)
  352. nowPlayingEntry = entry
  353. userQueue = []
  354. upNext = []
  355. history = []
  356. currentPlaylist = nil
  357. currentPlayingEntryID = nil
  358. loadAndPlayCloud(track, streamURL: streamURL, authHeaders: authHeaders)
  359. persistQueue()
  360. if hadQueue {
  361. showUndoToastBriefly("Queue replaced")
  362. }
  363. }
  364. func removeFromQueue(entry: QueueEntry) {
  365. userQueue.removeAll { $0.id == entry.id }
  366. upNext.removeAll { $0.id == entry.id }
  367. persistQueue()
  368. }
  369. func clearQueue() {
  370. userQueue.removeAll()
  371. upNext.removeAll()
  372. persistQueue()
  373. }
  374. func moveUserQueueEntry(from source: IndexSet, to destination: Int) {
  375. userQueue.move(fromOffsets: source, toOffset: destination)
  376. persistQueue()
  377. }
  378. func moveUpNextEntry(from source: IndexSet, to destination: Int) {
  379. upNext.move(fromOffsets: source, toOffset: destination)
  380. persistQueue()
  381. }
  382. func undoQueueReplacement() {
  383. guard let snapshot = previousQueueSnapshot else { return }
  384. nowPlayingEntry = snapshot.nowPlaying
  385. userQueue = snapshot.userQueue
  386. upNext = snapshot.upNext
  387. previousQueueSnapshot = nil
  388. showUndoToast = false
  389. undoTimer?.invalidate()
  390. if let entry = nowPlayingEntry {
  391. playQueueEntry(entry)
  392. }
  393. persistQueue()
  394. }
  395. // MARK: - Queue Entry Playback
  396. private func playQueueEntry(_ entry: QueueEntry) {
  397. switch entry.source {
  398. case .swiftDataTrack(let trackPersistentID, let isCloud, let cloudStreamPath):
  399. guard let ctx = modelContext else {
  400. logger.error("playQueueEntry: no modelContext set")
  401. return
  402. }
  403. // Fetch track by UUID string
  404. guard let trackID = UUID(uuidString: trackPersistentID) else {
  405. logger.error("playQueueEntry: invalid UUID string: \(trackPersistentID)")
  406. skipBrokenEntry()
  407. return
  408. }
  409. let descriptor = FetchDescriptor<Track>(
  410. predicate: #Predicate<Track> { $0.id == trackID }
  411. )
  412. guard let track = (try? ctx.fetch(descriptor))?.first else {
  413. logger.error("playQueueEntry: track not found for ID \(trackPersistentID)")
  414. skipBrokenEntry()
  415. return
  416. }
  417. loadAndPlay(track, entryID: currentPlayingEntryID, playlist: currentPlaylist)
  418. case .cloudDirect(_, let streamPath):
  419. let client = ChadMusicAPIClient.shared
  420. guard let url = client.streamURL(for: streamPath) else {
  421. logger.error("playQueueEntry: failed to build stream URL for \(streamPath)")
  422. skipBrokenEntry()
  423. return
  424. }
  425. let chadTrack = ChadTrack(
  426. id: entry.id.uuidString,
  427. title: entry.title,
  428. artist: entry.artist,
  429. albumArtist: nil,
  430. album: nil,
  431. duration: entry.duration,
  432. no: nil,
  433. url: streamPath,
  434. bitRate: nil,
  435. year: nil,
  436. cover: nil
  437. )
  438. loadAndPlayCloud(chadTrack, streamURL: url, authHeaders: client.authHeaders)
  439. }
  440. }
  441. @ObservationIgnored private var skipCount = 0
  442. private static let maxSkips = 20
  443. private func skipBrokenEntry() {
  444. skipCount += 1
  445. guard skipCount <= Self.maxSkips else {
  446. logger.error("skipBrokenEntry: exceeded \(Self.maxSkips) skips, stopping to prevent infinite loop")
  447. skipCount = 0
  448. userQueue.removeAll()
  449. upNext.removeAll()
  450. stop()
  451. return
  452. }
  453. playNext()
  454. }
  455. /// Reset skip counter on successful playback start
  456. private func resetSkipCounter() {
  457. skipCount = 0
  458. }
  459. // MARK: - Queue Helpers
  460. private func rebuildUpNextFromPlaylist(_ playlist: Playlist, afterEntryID: UUID?) {
  461. let entries = playlist.sortedEntries
  462. var startIndex = 0
  463. if let afterID = afterEntryID,
  464. let idx = entries.firstIndex(where: { $0.id == afterID }) {
  465. startIndex = idx + 1
  466. }
  467. upNext = entries[startIndex...].compactMap { entry -> QueueEntry? in
  468. guard let track = entry.track else { return nil }
  469. return QueueEntry.from(track: track)
  470. }
  471. if shuffleEnabled {
  472. upNext.shuffle()
  473. }
  474. }
  475. private func rebuildUpNextFromSource() {
  476. guard let playlist = currentPlaylist else { return }
  477. rebuildUpNextFromPlaylist(playlist, afterEntryID: currentPlayingEntryID)
  478. }
  479. private func showUndoToastBriefly(_ message: String) {
  480. undoMessage = message
  481. showUndoToast = true
  482. undoTimer?.invalidate()
  483. undoTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { [weak self] _ in
  484. Task { @MainActor in
  485. self?.showUndoToast = false
  486. self?.previousQueueSnapshot = nil
  487. }
  488. }
  489. }
  490. // MARK: - Queue Persistence
  491. private static let queueKey = "mixboard.queueState"
  492. private struct PersistedQueue: Codable {
  493. let nowPlaying: QueueEntry?
  494. let userQueue: [QueueEntry]
  495. let upNext: [QueueEntry]
  496. }
  497. private func persistQueue() {
  498. let state = PersistedQueue(nowPlaying: nowPlayingEntry, userQueue: userQueue, upNext: upNext)
  499. if let data = try? JSONEncoder().encode(state) {
  500. UserDefaults.standard.set(data, forKey: Self.queueKey)
  501. }
  502. }
  503. private func restoreQueue() {
  504. guard let data = UserDefaults.standard.data(forKey: Self.queueKey),
  505. let state = try? JSONDecoder().decode(PersistedQueue.self, from: data) else { return }
  506. nowPlayingEntry = state.nowPlaying
  507. userQueue = state.userQueue
  508. upNext = state.upNext
  509. }
  510. func seek(to time: TimeInterval) {
  511. if isCloudPlayback {
  512. streamingPlayer.seek(to: time)
  513. } else {
  514. audioEngine.seek(to: time)
  515. }
  516. }
  517. func seekToProgress(_ progress: Double) {
  518. let time = progress * duration
  519. seek(to: time)
  520. }
  521. func skipForward(_ seconds: TimeInterval = 10) {
  522. if isCloudPlayback {
  523. streamingPlayer.seek(by: seconds)
  524. } else {
  525. audioEngine.seek(by: seconds)
  526. }
  527. }
  528. func skipBackward(_ seconds: TimeInterval = 10) {
  529. if isCloudPlayback {
  530. streamingPlayer.seek(by: -seconds)
  531. } else {
  532. audioEngine.seek(by: -seconds)
  533. }
  534. }
  535. // MARK: - Waveform
  536. func loadWaveform(for track: Track) {
  537. if let cached = track.waveformData,
  538. let decoded = WaveformGenerator.decodeCachedWaveform(from: cached) {
  539. waveformSamples = decoded
  540. return
  541. }
  542. isLoadingWaveform = true
  543. Task {
  544. do {
  545. let samples = try await WaveformGenerator.generateWaveform(for: track)
  546. waveformSamples = samples
  547. } catch {
  548. print("PlayerViewModel: Waveform generation failed: \(error)")
  549. waveformSamples = []
  550. }
  551. isLoadingWaveform = false
  552. }
  553. }
  554. // MARK: - Helpers
  555. private func formatTime(_ time: TimeInterval) -> String {
  556. let total = max(0, Int(time))
  557. let minutes = total / 60
  558. let seconds = total % 60
  559. return String(format: "%d:%02d", minutes, seconds)
  560. }
  561. }