PlaylistViewModel.swift 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  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 showExportSheet = false
  10. var exportError: String?
  11. /// Temporary status message shown as a toast.
  12. var statusMessage: String?
  13. /// Three mix target slots — configurable in Settings.
  14. var mixTargets: [Playlist?] = [nil, nil, nil]
  15. /// Legacy single target (uses slot 0).
  16. var targetPlaylist: Playlist? {
  17. get { mixTargets[0] }
  18. set { setMixTarget(0, playlist: newValue) }
  19. }
  20. /// Set a mix target at the given slot (0, 1, 2).
  21. func setMixTarget(_ slot: Int, playlist: Playlist?) {
  22. guard slot >= 0 && slot < 3 else { return }
  23. mixTargets[slot] = playlist
  24. if let id = playlist?.id {
  25. UserDefaults.standard.set(id.uuidString, forKey: "mixTarget\(slot)ID")
  26. } else {
  27. UserDefaults.standard.removeObject(forKey: "mixTarget\(slot)ID")
  28. }
  29. }
  30. /// Get the display name for a mix slot.
  31. func mixTargetName(_ slot: Int) -> String {
  32. guard slot >= 0 && slot < 3 else { return "Mix \(slot + 1)" }
  33. return mixTargets[slot]?.name ?? "Mix \(slot + 1)"
  34. }
  35. func restoreTargetPlaylist(from playlists: [Playlist]) {
  36. for slot in 0..<3 {
  37. guard mixTargets[slot] == nil,
  38. let savedID = UserDefaults.standard.string(forKey: "mixTarget\(slot)ID"),
  39. let uuid = UUID(uuidString: savedID),
  40. let playlist = playlists.first(where: { $0.id == uuid }) else { continue }
  41. mixTargets[slot] = playlist
  42. }
  43. }
  44. var defaultCrossfadeDuration: TimeInterval = 2.0
  45. func showStatus(_ message: String, duration: TimeInterval = 4.0) {
  46. statusMessage = message
  47. Task { @MainActor in
  48. try? await Task.sleep(for: .seconds(duration))
  49. if self.statusMessage == message {
  50. self.statusMessage = nil
  51. }
  52. }
  53. }
  54. // MARK: - Quick Add
  55. /// Add a track to a specific mix slot (0, 1, 2).
  56. func quickAddToMix(slot: Int, track: Track, context: ModelContext) -> Bool {
  57. guard slot >= 0 && slot < 3 else { return false }
  58. guard let target = mixTargets[slot] else {
  59. showStatus("Mix \(slot + 1) not set — go to Settings → Mix Targets")
  60. return false
  61. }
  62. if isDuplicate(track: track, in: target, context: context) {
  63. showStatus("Already in \(target.name)")
  64. return false
  65. }
  66. addTrack(track, to: target, context: context)
  67. showStatus("→ \(target.name)")
  68. return true
  69. }
  70. func quickAddToTarget(track: Track, context: ModelContext) -> Bool {
  71. return quickAddToMix(slot: 0, track: track, context: context)
  72. }
  73. /// Safe duplicate check using a fetch query instead of traversing relationships.
  74. func isDuplicate(track: Track, in playlist: Playlist, context: ModelContext? = nil) -> Bool {
  75. // If we have a context, use a safe fetch-based check
  76. if let context {
  77. let trackID = track.id
  78. let playlistID = playlist.id
  79. let descriptor = FetchDescriptor<PlaylistEntry>(
  80. predicate: #Predicate<PlaylistEntry> { entry in
  81. entry.playlist?.id == playlistID && entry.track?.id == trackID
  82. }
  83. )
  84. let count = (try? context.fetchCount(descriptor)) ?? 0
  85. return count > 0
  86. }
  87. // Fallback: try relationship traversal but catch crashes
  88. let trackID = track.id
  89. return playlist.entries.contains { entry in
  90. guard let t = entry.track else { return false }
  91. return t.id == trackID
  92. }
  93. }
  94. // MARK: - Playlist CRUD
  95. func createPlaylist(name: String, context: ModelContext) -> Playlist {
  96. let playlist = Playlist(name: name)
  97. context.insert(playlist)
  98. try? context.save()
  99. return playlist
  100. }
  101. func deletePlaylist(_ playlist: Playlist, context: ModelContext) {
  102. if selectedPlaylist?.id == playlist.id {
  103. selectedPlaylist = nil
  104. }
  105. context.delete(playlist)
  106. try? context.save()
  107. }
  108. func renamePlaylist(_ playlist: Playlist, to name: String, context: ModelContext) {
  109. playlist.name = name
  110. playlist.dateModified = Date()
  111. try? context.save()
  112. }
  113. // MARK: - Track Management
  114. func addTrack(_ track: Track, to playlist: Playlist, context: ModelContext, warnDuplicate: Bool = true) {
  115. if warnDuplicate && isDuplicate(track: track, in: playlist, context: context) {
  116. showStatus("⚠ Already in \(playlist.name)")
  117. return
  118. }
  119. playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration)
  120. try? context.save()
  121. }
  122. func addTracks(_ tracks: [Track], to playlist: Playlist, context: ModelContext) {
  123. // Sort by full file path to preserve folder structure ordering
  124. // Use numeric-aware sorting so "12.1.1" comes before "12.1.2"
  125. let sorted = tracks.sorted {
  126. $0.filePath.compare($1.filePath, options: [.numeric, .caseInsensitive]) == .orderedAscending
  127. }
  128. // Log first 10 paths to verify ordering
  129. for (i, t) in sorted.prefix(10).enumerated() {
  130. print("addTracks sorted[\(i)]: \(t.filePath)")
  131. }
  132. var added = 0
  133. var skipped = 0
  134. for track in sorted {
  135. if isDuplicate(track: track, in: playlist, context: context) {
  136. skipped += 1
  137. } else {
  138. playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration)
  139. added += 1
  140. }
  141. }
  142. try? context.save()
  143. if skipped > 0 {
  144. showStatus("Added \(added) tracks, \(skipped) duplicates skipped")
  145. }
  146. }
  147. func removeEntry(_ entry: PlaylistEntry, from playlist: Playlist, context: ModelContext) {
  148. playlist.removeEntry(at: entry.position)
  149. try? context.save()
  150. }
  151. func moveEntry(in playlist: Playlist, from source: Int, to destination: Int, context: ModelContext) {
  152. playlist.moveEntry(from: source, to: destination)
  153. try? context.save()
  154. }
  155. func updateCrossfade(for entry: PlaylistEntry, duration: TimeInterval, context: ModelContext) {
  156. entry.crossfadeDuration = duration
  157. try? context.save()
  158. }
  159. func updateGain(for entry: PlaylistEntry, gain: Double, context: ModelContext) {
  160. entry.gainAdjustment = gain
  161. try? context.save()
  162. }
  163. // MARK: - Cue Points
  164. func addCuePoint(to track: Track, at timestamp: TimeInterval, type: CuePointType = .marker, name: String = "", context: ModelContext) {
  165. let cuePoint = CuePoint(name: name, timestamp: timestamp, type: type)
  166. track.cuePoints.append(cuePoint)
  167. try? context.save()
  168. }
  169. func removeCuePoint(_ cuePoint: CuePoint, from track: Track, context: ModelContext) {
  170. track.cuePoints.removeAll { $0.id == cuePoint.id }
  171. context.delete(cuePoint)
  172. try? context.save()
  173. }
  174. // MARK: - Import Files to Playlist
  175. func importFilesToPlaylist(
  176. urls: [URL],
  177. playlist: Playlist,
  178. libraryManager: LibraryManager,
  179. context: ModelContext
  180. ) async {
  181. for url in urls where MetadataService.isSupportedAudioFile(url) {
  182. let accessing = url.startAccessingSecurityScopedResource()
  183. defer { if accessing { url.stopAccessingSecurityScopedResource() } }
  184. do {
  185. try await libraryManager.importFile(url, context: context)
  186. } catch {
  187. print("PlaylistViewModel: import failed for \(url.lastPathComponent): \(error)")
  188. }
  189. let fileName = url.lastPathComponent
  190. let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.fileName == fileName })
  191. if let track = try? context.fetch(descriptor).first {
  192. playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration)
  193. }
  194. }
  195. try? context.save()
  196. }
  197. /// Calculate the total effective mix duration including crossfades.
  198. func mixDuration(for playlist: Playlist) -> TimeInterval {
  199. let entries = playlist.sortedEntries
  200. var total: TimeInterval = 0
  201. for (index, entry) in entries.enumerated() {
  202. total += entry.effectiveDuration
  203. if index > 0 {
  204. total -= entry.crossfadeDuration
  205. }
  206. }
  207. return max(0, total)
  208. }
  209. }