Playlist.swift 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. import Foundation
  2. import SwiftData
  3. /// A playlist — an ordered collection of tracks for building a mix.
  4. @Model
  5. final class Playlist {
  6. var id: UUID
  7. var name: String
  8. var notes: String
  9. var dateCreated: Date
  10. var dateModified: Date
  11. var targetBPM: Double? // target BPM for the mix
  12. var color: String // hex color for UI
  13. /// Template for grouping tracks in the playlist view.
  14. /// Uses {Artist}, {Album}, {Genre}, {Year}, {Folder} placeholders.
  15. /// Example: "{Album} ({Year})" → "Only Built 4 Cuban Linx (1995)"
  16. /// Empty string = no grouping.
  17. var groupTemplate: String
  18. @Relationship(deleteRule: .cascade, inverse: \PlaylistEntry.playlist)
  19. var entries: [PlaylistEntry]
  20. /// The folder this playlist belongs to (nil = top level).
  21. var folder: PlaylistFolder?
  22. /// Entries sorted by position.
  23. var sortedEntries: [PlaylistEntry] {
  24. entries.sorted { $0.position < $1.position }
  25. }
  26. var totalDuration: TimeInterval {
  27. // Sum duration from entries that have valid tracks
  28. // Note: avoid KeyPath traversal (.track?.duration) which crashes on invalidated SwiftData models
  29. var total: TimeInterval = 0
  30. for entry in entries {
  31. guard let track = entry.track else { continue }
  32. total += track.duration
  33. }
  34. return total
  35. }
  36. var formattedTotalDuration: String {
  37. let total = Int(totalDuration)
  38. let hours = total / 3600
  39. let minutes = (total % 3600) / 60
  40. let seconds = total % 60
  41. if hours > 0 {
  42. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  43. }
  44. return String(format: "%d:%02d", minutes, seconds)
  45. }
  46. var trackCount: Int { entries.count }
  47. init(
  48. name: String,
  49. notes: String = "",
  50. color: String = "#2196F3"
  51. ) {
  52. self.id = UUID()
  53. self.name = name
  54. self.notes = notes
  55. self.dateCreated = Date()
  56. self.dateModified = Date()
  57. self.targetBPM = nil
  58. self.color = color
  59. self.groupTemplate = ""
  60. self.entries = []
  61. }
  62. func addTrack(_ track: Track, crossfadeDuration: TimeInterval = 0) {
  63. let position = entries.count
  64. let entry = PlaylistEntry(
  65. position: position,
  66. track: track,
  67. crossfadeDuration: crossfadeDuration
  68. )
  69. entries.append(entry)
  70. dateModified = Date()
  71. }
  72. func removeEntry(at position: Int) {
  73. entries.removeAll { $0.position == position }
  74. for (index, entry) in sortedEntries.enumerated() {
  75. entry.position = index
  76. }
  77. dateModified = Date()
  78. }
  79. func moveEntry(from source: Int, to destination: Int) {
  80. var sorted = sortedEntries
  81. let entry = sorted.remove(at: source)
  82. sorted.insert(entry, at: destination)
  83. for (index, e) in sorted.enumerated() {
  84. e.position = index
  85. }
  86. dateModified = Date()
  87. }
  88. }
  89. /// A single entry in a playlist, linking a track with transition info.
  90. @Model
  91. final class PlaylistEntry {
  92. var id: UUID
  93. var position: Int
  94. var track: Track?
  95. // Transition settings
  96. var crossfadeDuration: TimeInterval // seconds of overlap with next track
  97. var startOffset: TimeInterval // start playback from this point
  98. var endOffset: TimeInterval // stop playback at this point (0 = end of track)
  99. var gainAdjustment: Double // dB adjustment (-12 to +12)
  100. var notes: String
  101. var playlist: Playlist?
  102. /// Effective duration considering start/end offsets.
  103. var effectiveDuration: TimeInterval {
  104. guard let track else { return 0 }
  105. let end = endOffset > 0 ? endOffset : track.duration
  106. return max(0, end - startOffset)
  107. }
  108. init(
  109. position: Int,
  110. track: Track?,
  111. crossfadeDuration: TimeInterval = 0,
  112. startOffset: TimeInterval = 0,
  113. endOffset: TimeInterval = 0,
  114. gainAdjustment: Double = 0,
  115. notes: String = ""
  116. ) {
  117. self.id = UUID()
  118. self.position = position
  119. self.track = track
  120. self.crossfadeDuration = crossfadeDuration
  121. self.startOffset = startOffset
  122. self.endOffset = endOffset
  123. self.gainAdjustment = gainAdjustment
  124. self.notes = notes
  125. }
  126. }