SyncManager.swift 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import Foundation
  2. import SwiftData
  3. import UIKit
  4. /// Syncs playlist metadata between iOS and macOS via a shared JSON file in the app's documents.
  5. /// The JSON file can be shared through iCloud Drive, AirDrop, or any file transfer method.
  6. ///
  7. /// Sync format: each device writes its own playlists to a JSON file.
  8. /// The Mac app watches for this file and imports playlist data.
  9. /// Matching is done by filename (not file path, since paths differ between devices).
  10. @MainActor
  11. final class SyncManager: ObservableObject {
  12. @Published var lastSyncDate: Date?
  13. @Published var isSyncing = false
  14. @Published var syncError: String?
  15. /// The shared sync directory — lives in the app's documents for easy access.
  16. /// Users can share this via iCloud Drive or Files app.
  17. static var syncDirectory: URL {
  18. let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
  19. let syncDir = docs.appendingPathComponent("Sync", isDirectory: true)
  20. try? FileManager.default.createDirectory(at: syncDir, withIntermediateDirectories: true)
  21. return syncDir
  22. }
  23. /// The sync file that contains exported playlist data.
  24. static var syncFileURL: URL {
  25. syncDirectory.appendingPathComponent("mixboard-playlists.json")
  26. }
  27. // MARK: - Export Playlists (iOS → JSON → Mac)
  28. /// Export all playlists to the sync JSON file.
  29. func exportPlaylists(_ playlists: [Playlist]) {
  30. isSyncing = true
  31. syncError = nil
  32. do {
  33. let payload = SyncPayload(
  34. version: 1,
  35. exportedAt: Date(),
  36. exportedFrom: deviceName,
  37. playlists: playlists.map { SyncPlaylist(from: $0) }
  38. )
  39. let encoder = JSONEncoder()
  40. encoder.dateEncodingStrategy = .iso8601
  41. encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
  42. let data = try encoder.encode(payload)
  43. try data.write(to: Self.syncFileURL, options: .atomic)
  44. lastSyncDate = Date()
  45. isSyncing = false
  46. } catch {
  47. syncError = error.localizedDescription
  48. isSyncing = false
  49. }
  50. }
  51. /// Import playlists from a sync JSON file (e.g. from Mac).
  52. func importPlaylists(from url: URL, context: ModelContext) throws -> [SyncPlaylist] {
  53. let data = try Data(contentsOf: url)
  54. let decoder = JSONDecoder()
  55. decoder.dateDecodingStrategy = .iso8601
  56. let payload = try decoder.decode(SyncPayload.self, from: data)
  57. return payload.playlists
  58. }
  59. /// Create actual Playlist models from synced data, matching tracks by filename.
  60. func mergeImportedPlaylists(_ syncPlaylists: [SyncPlaylist], existingTracks: [Track], context: ModelContext) -> (created: Int, matched: Int, unmatched: Int) {
  61. var totalCreated = 0
  62. var totalMatched = 0
  63. var totalUnmatched = 0
  64. for sp in syncPlaylists {
  65. let playlist = Playlist(name: sp.name, notes: sp.notes, color: sp.color)
  66. playlist.targetBPM = sp.targetBPM
  67. context.insert(playlist)
  68. totalCreated += 1
  69. for (position, se) in sp.entries.enumerated() {
  70. // Try to find matching track by filename
  71. let matchedTrack = existingTracks.first { $0.fileName == se.filename }
  72. if let track = matchedTrack {
  73. let entry = PlaylistEntry(
  74. position: position,
  75. track: track,
  76. crossfadeDuration: se.crossfadeDuration,
  77. startOffset: se.startOffset,
  78. endOffset: se.endOffset,
  79. gainAdjustment: se.gainAdjustment,
  80. notes: se.notes
  81. )
  82. playlist.entries.append(entry)
  83. totalMatched += 1
  84. } else {
  85. // Create a placeholder entry with no track (unmatched)
  86. let entry = PlaylistEntry(
  87. position: position,
  88. track: nil,
  89. crossfadeDuration: se.crossfadeDuration,
  90. notes: "⚠ File not found: \(se.filename)\n\(se.notes)"
  91. )
  92. playlist.entries.append(entry)
  93. totalUnmatched += 1
  94. }
  95. }
  96. }
  97. try? context.save()
  98. return (totalCreated, totalMatched, totalUnmatched)
  99. }
  100. // MARK: - Helpers
  101. private var deviceName: String {
  102. UIDevice.current.name
  103. }
  104. }
  105. // MARK: - Sync Data Models (Codable)
  106. struct SyncPayload: Codable {
  107. let version: Int
  108. let exportedAt: Date
  109. let exportedFrom: String
  110. let playlists: [SyncPlaylist]
  111. }
  112. struct SyncPlaylist: Codable, Identifiable {
  113. let id: UUID
  114. let name: String
  115. let notes: String
  116. let color: String
  117. let dateCreated: Date
  118. let dateModified: Date
  119. let targetBPM: Double?
  120. let entries: [SyncEntry]
  121. init(from playlist: Playlist) {
  122. self.id = playlist.id
  123. self.name = playlist.name
  124. self.notes = playlist.notes
  125. self.color = playlist.color
  126. self.dateCreated = playlist.dateCreated
  127. self.dateModified = playlist.dateModified
  128. self.targetBPM = playlist.targetBPM
  129. self.entries = playlist.sortedEntries.map { SyncEntry(from: $0) }
  130. }
  131. }
  132. struct SyncEntry: Codable {
  133. let id: UUID
  134. let position: Int
  135. let filename: String // matching key between devices
  136. let title: String
  137. let artist: String
  138. let album: String
  139. let duration: TimeInterval
  140. let bpm: Double?
  141. let musicalKey: String?
  142. let crossfadeDuration: TimeInterval
  143. let startOffset: TimeInterval
  144. let endOffset: TimeInterval
  145. let gainAdjustment: Double
  146. let notes: String
  147. init(from entry: PlaylistEntry) {
  148. self.id = entry.id
  149. self.position = entry.position
  150. self.filename = entry.track?.fileName ?? "unknown"
  151. self.title = entry.track?.title ?? "Unknown"
  152. self.artist = entry.track?.artist ?? ""
  153. self.album = entry.track?.album ?? ""
  154. self.duration = entry.track?.duration ?? 0
  155. self.bpm = entry.track?.bpm
  156. self.musicalKey = entry.track?.musicalKey
  157. self.crossfadeDuration = entry.crossfadeDuration
  158. self.startOffset = entry.startOffset
  159. self.endOffset = entry.endOffset
  160. self.gainAdjustment = entry.gainAdjustment
  161. self.notes = entry.notes
  162. }
  163. }