import Foundation import SwiftData import UIKit /// Syncs playlist metadata between iOS and macOS via a shared JSON file in the app's documents. /// The JSON file can be shared through iCloud Drive, AirDrop, or any file transfer method. /// /// Sync format: each device writes its own playlists to a JSON file. /// The Mac app watches for this file and imports playlist data. /// Matching is done by filename (not file path, since paths differ between devices). @MainActor final class SyncManager: ObservableObject { @Published var lastSyncDate: Date? @Published var isSyncing = false @Published var syncError: String? /// The shared sync directory — lives in the app's documents for easy access. /// Users can share this via iCloud Drive or Files app. static var syncDirectory: URL { let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let syncDir = docs.appendingPathComponent("Sync", isDirectory: true) try? FileManager.default.createDirectory(at: syncDir, withIntermediateDirectories: true) return syncDir } /// The sync file that contains exported playlist data. static var syncFileURL: URL { syncDirectory.appendingPathComponent("mixboard-playlists.json") } // MARK: - Export Playlists (iOS → JSON → Mac) /// Export all playlists to the sync JSON file. func exportPlaylists(_ playlists: [Playlist]) { isSyncing = true syncError = nil do { let payload = SyncPayload( version: 1, exportedAt: Date(), exportedFrom: deviceName, playlists: playlists.map { SyncPlaylist(from: $0) } ) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(payload) try data.write(to: Self.syncFileURL, options: .atomic) lastSyncDate = Date() isSyncing = false } catch { syncError = error.localizedDescription isSyncing = false } } /// Import playlists from a sync JSON file (e.g. from Mac). func importPlaylists(from url: URL, context: ModelContext) throws -> [SyncPlaylist] { let data = try Data(contentsOf: url) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let payload = try decoder.decode(SyncPayload.self, from: data) return payload.playlists } /// Create actual Playlist models from synced data, matching tracks by filename. func mergeImportedPlaylists(_ syncPlaylists: [SyncPlaylist], existingTracks: [Track], context: ModelContext) -> (created: Int, matched: Int, unmatched: Int) { var totalCreated = 0 var totalMatched = 0 var totalUnmatched = 0 for sp in syncPlaylists { let playlist = Playlist(name: sp.name, notes: sp.notes, color: sp.color) playlist.targetBPM = sp.targetBPM context.insert(playlist) totalCreated += 1 for (position, se) in sp.entries.enumerated() { // Try to find matching track by filename let matchedTrack = existingTracks.first { $0.fileName == se.filename } if let track = matchedTrack { let entry = PlaylistEntry( position: position, track: track, crossfadeDuration: se.crossfadeDuration, startOffset: se.startOffset, endOffset: se.endOffset, gainAdjustment: se.gainAdjustment, notes: se.notes ) playlist.entries.append(entry) totalMatched += 1 } else { // Create a placeholder entry with no track (unmatched) let entry = PlaylistEntry( position: position, track: nil, crossfadeDuration: se.crossfadeDuration, notes: "⚠ File not found: \(se.filename)\n\(se.notes)" ) playlist.entries.append(entry) totalUnmatched += 1 } } } try? context.save() return (totalCreated, totalMatched, totalUnmatched) } // MARK: - Helpers private var deviceName: String { UIDevice.current.name } } // MARK: - Sync Data Models (Codable) struct SyncPayload: Codable { let version: Int let exportedAt: Date let exportedFrom: String let playlists: [SyncPlaylist] } struct SyncPlaylist: Codable, Identifiable { let id: UUID let name: String let notes: String let color: String let dateCreated: Date let dateModified: Date let targetBPM: Double? let entries: [SyncEntry] init(from playlist: Playlist) { self.id = playlist.id self.name = playlist.name self.notes = playlist.notes self.color = playlist.color self.dateCreated = playlist.dateCreated self.dateModified = playlist.dateModified self.targetBPM = playlist.targetBPM self.entries = playlist.sortedEntries.map { SyncEntry(from: $0) } } } struct SyncEntry: Codable { let id: UUID let position: Int let filename: String // matching key between devices let title: String let artist: String let album: String let duration: TimeInterval let bpm: Double? let musicalKey: String? let crossfadeDuration: TimeInterval let startOffset: TimeInterval let endOffset: TimeInterval let gainAdjustment: Double let notes: String init(from entry: PlaylistEntry) { self.id = entry.id self.position = entry.position self.filename = entry.track?.fileName ?? "unknown" self.title = entry.track?.title ?? "Unknown" self.artist = entry.track?.artist ?? "" self.album = entry.track?.album ?? "" self.duration = entry.track?.duration ?? 0 self.bpm = entry.track?.bpm self.musicalKey = entry.track?.musicalKey self.crossfadeDuration = entry.crossfadeDuration self.startOffset = entry.startOffset self.endOffset = entry.endOffset self.gainAdjustment = entry.gainAdjustment self.notes = entry.notes } }