PlayerViewModel.swift 21 KB

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