| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- 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<Track>(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)
- }
- }
|