import Foundation import SwiftData import SwiftUI /// ViewModel for managing playlists and building mixes. @MainActor @Observable final class PlaylistViewModel { var selectedPlaylist: Playlist? var showExportSheet = false var exportError: String? /// Temporary status message shown as a toast. 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)" } 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 } } var defaultCrossfadeDuration: TimeInterval = 2.0 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, context: context) { showStatus("Already in \(target.name)") return false } addTrack(track, to: target, context: context) showStatus("→ \(target.name)") return true } func quickAddToTarget(track: Track, context: ModelContext) -> Bool { return quickAddToMix(slot: 0, track: track, context: context) } /// Safe duplicate check using a fetch query instead of traversing relationships. func isDuplicate(track: Track, in playlist: Playlist, context: ModelContext? = nil) -> Bool { // If we have a context, use a safe fetch-based check if let context { let trackID = track.id let playlistID = playlist.id let descriptor = FetchDescriptor( predicate: #Predicate { entry in entry.playlist?.id == playlistID && entry.track?.id == trackID } ) let count = (try? context.fetchCount(descriptor)) ?? 0 return count > 0 } // Fallback: try relationship traversal but catch crashes let trackID = track.id return playlist.entries.contains { entry in guard let t = entry.track else { return false } return t.id == trackID } } // 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 func addTrack(_ track: Track, to playlist: Playlist, context: ModelContext, warnDuplicate: Bool = true) { if warnDuplicate && isDuplicate(track: track, in: playlist, context: context) { showStatus("⚠ Already in \(playlist.name)") return } playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration) try? context.save() } func addTracks(_ tracks: [Track], to playlist: Playlist, context: ModelContext) { // Sort by full file path to preserve folder structure ordering // Use numeric-aware sorting so "12.1.1" comes before "12.1.2" let sorted = tracks.sorted { $0.filePath.compare($1.filePath, options: [.numeric, .caseInsensitive]) == .orderedAscending } // Log first 10 paths to verify ordering for (i, t) in sorted.prefix(10).enumerated() { print("addTracks sorted[\(i)]: \(t.filePath)") } var added = 0 var skipped = 0 for track in sorted { if isDuplicate(track: track, in: playlist, context: context) { 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() } // MARK: - Cue Points 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: - Import Files to Playlist func importFilesToPlaylist( urls: [URL], playlist: Playlist, libraryManager: LibraryManager, context: ModelContext ) async { for url in urls where MetadataService.isSupportedAudioFile(url) { let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } do { try await libraryManager.importFile(url, context: context) } catch { print("PlaylistViewModel: import failed for \(url.lastPathComponent): \(error)") } let fileName = url.lastPathComponent let descriptor = FetchDescriptor(predicate: #Predicate { $0.fileName == fileName }) if let track = try? context.fetch(descriptor).first { playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration) } } try? context.save() } /// 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) } }