PlaylistViewModel.swift 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. import Foundation
  2. import SwiftData
  3. import SwiftUI
  4. /// ViewModel for managing playlists and building mixes.
  5. @MainActor
  6. @Observable
  7. final class PlaylistViewModel {
  8. var selectedPlaylist: Playlist?
  9. var isExporting = false
  10. var exportFormat: MixExporter.ExportFormat = .audition
  11. var showExportSheet = false
  12. var exportError: String?
  13. /// Temporary status message shown on the main screen (auto-clears).
  14. var statusMessage: String?
  15. /// Three mix target slots — configurable in Settings.
  16. var mixTargets: [Playlist?] = [nil, nil, nil]
  17. /// Legacy single target (uses slot 0).
  18. var targetPlaylist: Playlist? {
  19. get { mixTargets[0] }
  20. set { setMixTarget(0, playlist: newValue) }
  21. }
  22. /// Set a mix target at the given slot (0, 1, 2).
  23. func setMixTarget(_ slot: Int, playlist: Playlist?) {
  24. guard slot >= 0 && slot < 3 else { return }
  25. mixTargets[slot] = playlist
  26. if let id = playlist?.id {
  27. UserDefaults.standard.set(id.uuidString, forKey: "mixTarget\(slot)ID")
  28. } else {
  29. UserDefaults.standard.removeObject(forKey: "mixTarget\(slot)ID")
  30. }
  31. }
  32. /// Get the display name for a mix slot.
  33. func mixTargetName(_ slot: Int) -> String {
  34. guard slot >= 0 && slot < 3 else { return "Mix \(slot + 1)" }
  35. return mixTargets[slot]?.name ?? "Mix \(slot + 1)"
  36. }
  37. /// Restore all mix target playlists from saved IDs.
  38. func restoreTargetPlaylist(from playlists: [Playlist]) {
  39. for slot in 0..<3 {
  40. guard mixTargets[slot] == nil,
  41. let savedID = UserDefaults.standard.string(forKey: "mixTarget\(slot)ID"),
  42. let uuid = UUID(uuidString: savedID),
  43. let playlist = playlists.first(where: { $0.id == uuid }) else { continue }
  44. mixTargets[slot] = playlist
  45. }
  46. // Legacy migration: if old single targetPlaylistID exists, use it for slot 0
  47. if mixTargets[0] == nil,
  48. let legacyID = UserDefaults.standard.string(forKey: "targetPlaylistID"),
  49. let uuid = UUID(uuidString: legacyID),
  50. let playlist = playlists.first(where: { $0.id == uuid }) {
  51. mixTargets[0] = playlist
  52. UserDefaults.standard.set(legacyID, forKey: "mixTarget0ID")
  53. UserDefaults.standard.removeObject(forKey: "targetPlaylistID")
  54. }
  55. }
  56. /// Default crossfade duration for new entries.
  57. var defaultCrossfadeDuration: TimeInterval = 2.0
  58. /// Show a temporary status message that auto-clears after a delay.
  59. func showStatus(_ message: String, duration: TimeInterval = 4.0) {
  60. statusMessage = message
  61. Task { @MainActor in
  62. try? await Task.sleep(for: .seconds(duration))
  63. if self.statusMessage == message {
  64. self.statusMessage = nil
  65. }
  66. }
  67. }
  68. // MARK: - Quick Add
  69. /// Add a track to a specific mix slot (0, 1, 2).
  70. func quickAddToMix(slot: Int, track: Track, context: ModelContext) -> Bool {
  71. guard slot >= 0 && slot < 3 else { return false }
  72. guard let target = mixTargets[slot] else {
  73. showStatus("Mix \(slot + 1) not set — go to Settings → Mix Targets")
  74. return false
  75. }
  76. if isDuplicate(track: track, in: target) {
  77. showStatus("⚠ \"\(track.title)\" is already in \(target.name)")
  78. return false
  79. }
  80. addTrack(track, to: target, context: context)
  81. showStatus("→ \(target.name)")
  82. return true
  83. }
  84. /// Legacy: add to slot 0 (⌘D).
  85. func quickAddToTarget(track: Track, context: ModelContext) -> Bool {
  86. return quickAddToMix(slot: 0, track: track, context: context)
  87. }
  88. /// Check if a track already exists in a playlist (by file path or cloud track ID).
  89. func isDuplicate(track: Track, in playlist: Playlist) -> Bool {
  90. if track.isCloud, let cloudId = track.cloudTrackId {
  91. return playlist.entries.contains { $0.track?.cloudTrackId == cloudId }
  92. }
  93. return playlist.entries.contains { $0.track?.filePath == track.filePath }
  94. }
  95. // MARK: - Playlist CRUD
  96. func createPlaylist(name: String, context: ModelContext) -> Playlist {
  97. let playlist = Playlist(name: name)
  98. context.insert(playlist)
  99. try? context.save()
  100. return playlist
  101. }
  102. func deletePlaylist(_ playlist: Playlist, context: ModelContext) {
  103. if selectedPlaylist?.id == playlist.id {
  104. selectedPlaylist = nil
  105. }
  106. context.delete(playlist)
  107. try? context.save()
  108. }
  109. func renamePlaylist(_ playlist: Playlist, to name: String, context: ModelContext) {
  110. playlist.name = name
  111. playlist.dateModified = Date()
  112. try? context.save()
  113. }
  114. // MARK: - Track Management in Playlist
  115. func addTrack(_ track: Track, to playlist: Playlist, context: ModelContext, warnDuplicate: Bool = true) {
  116. if warnDuplicate && isDuplicate(track: track, in: playlist) {
  117. showStatus("⚠ \"\(track.title)\" is already in \(playlist.name)")
  118. return
  119. }
  120. playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration)
  121. try? context.save()
  122. }
  123. func addTracks(_ tracks: [Track], to playlist: Playlist, context: ModelContext) {
  124. // Sort by filename for consistent ordering (especially when adding folders)
  125. let sorted = tracks.sorted { $0.fileURL.lastPathComponent.localizedCaseInsensitiveCompare($1.fileURL.lastPathComponent) == .orderedAscending }
  126. var added = 0
  127. var skipped = 0
  128. for track in sorted {
  129. if isDuplicate(track: track, in: playlist) {
  130. skipped += 1
  131. } else {
  132. playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration)
  133. added += 1
  134. }
  135. }
  136. try? context.save()
  137. if skipped > 0 {
  138. showStatus("Added \(added) tracks, \(skipped) duplicates skipped")
  139. }
  140. }
  141. func removeEntry(_ entry: PlaylistEntry, from playlist: Playlist, context: ModelContext) {
  142. playlist.removeEntry(at: entry.position)
  143. try? context.save()
  144. }
  145. func moveEntry(in playlist: Playlist, from source: Int, to destination: Int, context: ModelContext) {
  146. playlist.moveEntry(from: source, to: destination)
  147. try? context.save()
  148. }
  149. func updateCrossfade(for entry: PlaylistEntry, duration: TimeInterval, context: ModelContext) {
  150. entry.crossfadeDuration = duration
  151. try? context.save()
  152. }
  153. func updateGain(for entry: PlaylistEntry, gain: Double, context: ModelContext) {
  154. entry.gainAdjustment = gain
  155. try? context.save()
  156. }
  157. func updateStartOffset(for entry: PlaylistEntry, offset: TimeInterval, context: ModelContext) {
  158. entry.startOffset = offset
  159. try? context.save()
  160. }
  161. func updateEndOffset(for entry: PlaylistEntry, offset: TimeInterval, context: ModelContext) {
  162. entry.endOffset = offset
  163. try? context.save()
  164. }
  165. // MARK: - Cue Points
  166. /// Import audio files from disk directly into a playlist (imports to library + adds to playlist).
  167. func importFilesToPlaylist(
  168. urls: [URL],
  169. playlist: Playlist,
  170. libraryManager: LibraryManager,
  171. context: ModelContext
  172. ) async {
  173. for url in urls where MetadataService.isSupportedAudioFile(url) {
  174. do {
  175. try await libraryManager.importFile(url, context: context)
  176. } catch {
  177. print("PlaylistViewModel: import failed for \(url.lastPathComponent): \(error)")
  178. }
  179. // Find the track that was just imported (or already existed)
  180. let path = url.path
  181. let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.filePath == path })
  182. if let track = try? context.fetch(descriptor).first {
  183. playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration)
  184. // Auto-analyze BPM & key if not already done
  185. if !track.isAnalyzed {
  186. await libraryManager.analyzeTrack(track)
  187. }
  188. }
  189. }
  190. try? context.save()
  191. }
  192. func addCuePoint(to track: Track, at timestamp: TimeInterval, type: CuePointType = .marker, name: String = "", context: ModelContext) {
  193. let cuePoint = CuePoint(name: name, timestamp: timestamp, type: type)
  194. track.cuePoints.append(cuePoint)
  195. try? context.save()
  196. }
  197. func removeCuePoint(_ cuePoint: CuePoint, from track: Track, context: ModelContext) {
  198. track.cuePoints.removeAll { $0.id == cuePoint.id }
  199. context.delete(cuePoint)
  200. try? context.save()
  201. }
  202. // MARK: - Export
  203. func exportPlaylist(_ playlist: Playlist, format: MixExporter.ExportFormat, to url: URL) {
  204. isExporting = true
  205. exportError = nil
  206. do {
  207. var options = ExportOptions.default
  208. options.copyAudioFiles = true
  209. options.includeCuePoints = true
  210. options.includeCrossfades = true
  211. try MixExporter.export(
  212. playlist: playlist,
  213. format: format,
  214. to: url,
  215. options: options
  216. )
  217. isExporting = false
  218. } catch {
  219. exportError = error.localizedDescription
  220. isExporting = false
  221. }
  222. }
  223. /// Calculate the total effective mix duration including crossfades.
  224. func mixDuration(for playlist: Playlist) -> TimeInterval {
  225. let entries = playlist.sortedEntries
  226. var total: TimeInterval = 0
  227. for (index, entry) in entries.enumerated() {
  228. total += entry.effectiveDuration
  229. if index > 0 {
  230. total -= entry.crossfadeDuration
  231. }
  232. }
  233. return max(0, total)
  234. }
  235. }