| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243 |
- 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<PlaylistEntry>(
- predicate: #Predicate<PlaylistEntry> { 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<Track>(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)
- }
- }
|