| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185 |
- 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
- }
- }
|