import Foundation import SwiftData import SwiftUI /// ViewModel for managing playlists and building mixes. @MainActor @Observable final class PlaylistViewModel { var selectedPlaylist: Playlist? var isExporting = false var exportFormat: MixExporter.ExportFormat = .audition var showExportSheet = false var exportError: String? /// Temporary status message shown on the main screen (auto-clears). var statusMessage: String? /// Three mix target slots — configurable in Settings. var mixTargets: [Playlist?] = [nil, nil, nil] /// Legacy single target (uses slot 0). var targetPlaylist: Playlist? { get { mixTargets[0] } set { setMixTarget(0, playlist: newValue) } } /// Set a mix target at the given slot (0, 1, 2). func setMixTarget(_ slot: Int, playlist: Playlist?) { guard slot >= 0 && slot < 3 else { return } mixTargets[slot] = playlist if let id = playlist?.id { UserDefaults.standard.set(id.uuidString, forKey: "mixTarget\(slot)ID") } else { UserDefaults.standard.removeObject(forKey: "mixTarget\(slot)ID") } } /// Get the display name for a mix slot. func mixTargetName(_ slot: Int) -> String { guard slot >= 0 && slot < 3 else { return "Mix \(slot + 1)" } return mixTargets[slot]?.name ?? "Mix \(slot + 1)" } /// Restore all mix target playlists from saved IDs. func restoreTargetPlaylist(from playlists: [Playlist]) { for slot in 0..<3 { guard mixTargets[slot] == nil, let savedID = UserDefaults.standard.string(forKey: "mixTarget\(slot)ID"), let uuid = UUID(uuidString: savedID), let playlist = playlists.first(where: { $0.id == uuid }) else { continue } mixTargets[slot] = playlist } // Legacy migration: if old single targetPlaylistID exists, use it for slot 0 if mixTargets[0] == nil, let legacyID = UserDefaults.standard.string(forKey: "targetPlaylistID"), let uuid = UUID(uuidString: legacyID), let playlist = playlists.first(where: { $0.id == uuid }) { mixTargets[0] = playlist UserDefaults.standard.set(legacyID, forKey: "mixTarget0ID") UserDefaults.standard.removeObject(forKey: "targetPlaylistID") } } /// Default crossfade duration for new entries. var defaultCrossfadeDuration: TimeInterval = 2.0 /// Show a temporary status message that auto-clears after a delay. func showStatus(_ message: String, duration: TimeInterval = 4.0) { statusMessage = message Task { @MainActor in try? await Task.sleep(for: .seconds(duration)) if self.statusMessage == message { self.statusMessage = nil } } } // MARK: - Quick Add /// Add a track to a specific mix slot (0, 1, 2). func quickAddToMix(slot: Int, track: Track, context: ModelContext) -> Bool { guard slot >= 0 && slot < 3 else { return false } guard let target = mixTargets[slot] else { showStatus("Mix \(slot + 1) not set — go to Settings → Mix Targets") return false } if isDuplicate(track: track, in: target) { showStatus("⚠ \"\(track.title)\" is already in \(target.name)") return false } addTrack(track, to: target, context: context) showStatus("→ \(target.name)") return true } /// Legacy: add to slot 0 (⌘D). func quickAddToTarget(track: Track, context: ModelContext) -> Bool { return quickAddToMix(slot: 0, track: track, context: context) } /// Check if a track already exists in a playlist (by file path or cloud track ID). func isDuplicate(track: Track, in playlist: Playlist) -> Bool { if track.isCloud, let cloudId = track.cloudTrackId { return playlist.entries.contains { $0.track?.cloudTrackId == cloudId } } return playlist.entries.contains { $0.track?.filePath == track.filePath } } // MARK: - Playlist CRUD func createPlaylist(name: String, context: ModelContext) -> Playlist { let playlist = Playlist(name: name) context.insert(playlist) try? context.save() return playlist } func deletePlaylist(_ playlist: Playlist, context: ModelContext) { if selectedPlaylist?.id == playlist.id { selectedPlaylist = nil } context.delete(playlist) try? context.save() } func renamePlaylist(_ playlist: Playlist, to name: String, context: ModelContext) { playlist.name = name playlist.dateModified = Date() try? context.save() } // MARK: - Track Management in Playlist func addTrack(_ track: Track, to playlist: Playlist, context: ModelContext, warnDuplicate: Bool = true) { if warnDuplicate && isDuplicate(track: track, in: playlist) { showStatus("⚠ \"\(track.title)\" is already in \(playlist.name)") return } playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration) try? context.save() } func addTracks(_ tracks: [Track], to playlist: Playlist, context: ModelContext) { // Sort by filename for consistent ordering (especially when adding folders) let sorted = tracks.sorted { $0.fileURL.lastPathComponent.localizedCaseInsensitiveCompare($1.fileURL.lastPathComponent) == .orderedAscending } var added = 0 var skipped = 0 for track in sorted { if isDuplicate(track: track, in: playlist) { skipped += 1 } else { playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration) added += 1 } } try? context.save() if skipped > 0 { showStatus("Added \(added) tracks, \(skipped) duplicates skipped") } } func removeEntry(_ entry: PlaylistEntry, from playlist: Playlist, context: ModelContext) { playlist.removeEntry(at: entry.position) try? context.save() } func moveEntry(in playlist: Playlist, from source: Int, to destination: Int, context: ModelContext) { playlist.moveEntry(from: source, to: destination) try? context.save() } func updateCrossfade(for entry: PlaylistEntry, duration: TimeInterval, context: ModelContext) { entry.crossfadeDuration = duration try? context.save() } func updateGain(for entry: PlaylistEntry, gain: Double, context: ModelContext) { entry.gainAdjustment = gain try? context.save() } func updateStartOffset(for entry: PlaylistEntry, offset: TimeInterval, context: ModelContext) { entry.startOffset = offset try? context.save() } func updateEndOffset(for entry: PlaylistEntry, offset: TimeInterval, context: ModelContext) { entry.endOffset = offset try? context.save() } // MARK: - Cue Points /// Import audio files from disk directly into a playlist (imports to library + adds to playlist). func importFilesToPlaylist( urls: [URL], playlist: Playlist, libraryManager: LibraryManager, context: ModelContext ) async { for url in urls where MetadataService.isSupportedAudioFile(url) { do { try await libraryManager.importFile(url, context: context) } catch { print("PlaylistViewModel: import failed for \(url.lastPathComponent): \(error)") } // Find the track that was just imported (or already existed) let path = url.path let descriptor = FetchDescriptor(predicate: #Predicate { $0.filePath == path }) if let track = try? context.fetch(descriptor).first { playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration) // Auto-analyze BPM & key if not already done if !track.isAnalyzed { await libraryManager.analyzeTrack(track) } } } try? context.save() } func addCuePoint(to track: Track, at timestamp: TimeInterval, type: CuePointType = .marker, name: String = "", context: ModelContext) { let cuePoint = CuePoint(name: name, timestamp: timestamp, type: type) track.cuePoints.append(cuePoint) try? context.save() } func removeCuePoint(_ cuePoint: CuePoint, from track: Track, context: ModelContext) { track.cuePoints.removeAll { $0.id == cuePoint.id } context.delete(cuePoint) try? context.save() } // MARK: - Export func exportPlaylist(_ playlist: Playlist, format: MixExporter.ExportFormat, to url: URL) { isExporting = true exportError = nil do { var options = ExportOptions.default options.copyAudioFiles = true options.includeCuePoints = true options.includeCrossfades = true try MixExporter.export( playlist: playlist, format: format, to: url, options: options ) isExporting = false } catch { exportError = error.localizedDescription isExporting = false } } /// Calculate the total effective mix duration including crossfades. func mixDuration(for playlist: Playlist) -> TimeInterval { let entries = playlist.sortedEntries var total: TimeInterval = 0 for (index, entry) in entries.enumerated() { total += entry.effectiveDuration if index > 0 { total -= entry.crossfadeDuration } } return max(0, total) } }