TrackRow.swift 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. import SwiftUI
  2. /// Compact track row for lists — adapts to current skin.
  3. /// Shows 3 quick-add mix buttons on the right side.
  4. struct TrackRow: View {
  5. let track: Track
  6. @EnvironmentObject private var theme: AppTheme
  7. @Environment(PlayerViewModel.self) private var playerVM
  8. @Environment(PlaylistViewModel.self) private var playlistVM
  9. @Environment(\.modelContext) private var modelContext
  10. private var isCurrentlyPlaying: Bool {
  11. playerVM.currentTrack?.id == track.id
  12. }
  13. private static let mixColors: [Color] = [
  14. Color(red: 0.95, green: 0.3, blue: 0.3), // Red
  15. Color(red: 0.3, green: 0.75, blue: 0.95), // Blue
  16. Color(red: 0.95, green: 0.75, blue: 0.2), // Yellow/Gold
  17. ]
  18. var body: some View {
  19. HStack(spacing: 10) {
  20. // Album art
  21. ArtworkThumbnail(track: track)
  22. .frame(width: 44, height: 44)
  23. // Track info
  24. VStack(alignment: .leading, spacing: 2) {
  25. Text(track.title)
  26. .font(.system(size: theme.dataFontSize, weight: isCurrentlyPlaying ? .semibold : .regular))
  27. .foregroundStyle(isCurrentlyPlaying ? theme.playingHighlight : theme.primaryText)
  28. .lineLimit(1)
  29. HStack(spacing: 6) {
  30. if !track.artist.isEmpty {
  31. Text(track.artist)
  32. .font(.system(size: theme.smallFontSize))
  33. .foregroundStyle(isCurrentlyPlaying ? theme.playingHighlight.opacity(0.7) : theme.secondaryText)
  34. .lineLimit(1)
  35. }
  36. if let bpm = track.bpm {
  37. Text("•")
  38. .foregroundStyle(theme.tertiaryText)
  39. Text("\(String(format: "%.0f", bpm)) BPM")
  40. .font(.system(size: theme.smallFontSize, design: .monospaced))
  41. .foregroundStyle(theme.tertiaryText)
  42. }
  43. if let key = track.musicalKey {
  44. Text(key)
  45. .font(.system(size: theme.smallFontSize, design: .monospaced))
  46. .foregroundStyle(theme.tertiaryText)
  47. }
  48. }
  49. }
  50. Spacer(minLength: 4)
  51. // 3 Mix buttons
  52. HStack(spacing: 4) {
  53. ForEach(0..<3, id: \.self) { slot in
  54. Button {
  55. _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
  56. } label: {
  57. Text("\(slot + 1)")
  58. .font(.system(size: 11, weight: .bold, design: .rounded))
  59. .frame(width: 26, height: 26)
  60. .foregroundStyle(
  61. playlistVM.mixTargets[slot] != nil
  62. ? Self.mixColors[slot]
  63. : theme.tertiaryText
  64. )
  65. .background(
  66. playlistVM.mixTargets[slot] != nil
  67. ? Self.mixColors[slot].opacity(0.15)
  68. : theme.tertiaryText.opacity(0.08)
  69. )
  70. .clipShape(RoundedRectangle(cornerRadius: 6))
  71. }
  72. .buttonStyle(.plain)
  73. }
  74. }
  75. // Duration + format
  76. VStack(alignment: .trailing, spacing: 2) {
  77. Text(track.formattedDuration)
  78. .font(.system(size: theme.smallFontSize, design: .monospaced))
  79. .foregroundStyle(theme.secondaryText)
  80. Text(track.fileFormat)
  81. .font(.system(size: 9, weight: .medium, design: .monospaced))
  82. .foregroundStyle(theme.tertiaryText)
  83. .padding(.horizontal, 4)
  84. .padding(.vertical, 1)
  85. .background(theme.tertiaryText.opacity(0.15))
  86. .clipShape(RoundedRectangle(cornerRadius: 3))
  87. }
  88. if isCurrentlyPlaying && playerVM.isPlaying {
  89. Image(systemName: "speaker.wave.2.fill")
  90. .font(.caption)
  91. .foregroundStyle(theme.playingHighlight)
  92. }
  93. }
  94. .padding(.vertical, 4)
  95. .contextMenu {
  96. Button {
  97. playerVM.loadAndPlay(track)
  98. playerVM.showNowPlaying = true
  99. } label: {
  100. Label("Play Now", systemImage: "play.fill")
  101. }
  102. Button {
  103. playerVM.playNextInQueue(QueueEntry.from(track: track))
  104. } label: {
  105. Label("Play Next", systemImage: "text.insert")
  106. }
  107. Button {
  108. playerVM.addToQueue(QueueEntry.from(track: track))
  109. } label: {
  110. Label("Add to Queue", systemImage: "text.append")
  111. }
  112. }
  113. }
  114. }
  115. // MARK: - Artwork Thumbnail
  116. struct ArtworkThumbnail: View {
  117. let track: Track
  118. @EnvironmentObject private var theme: AppTheme
  119. @State private var artwork: UIImage?
  120. var body: some View {
  121. Group {
  122. if let image = artwork {
  123. Image(uiImage: image)
  124. .resizable()
  125. .aspectRatio(contentMode: .fill)
  126. } else {
  127. ZStack {
  128. RoundedRectangle(cornerRadius: theme.cornerRadius / 2)
  129. .fill(theme.cardBackground)
  130. Image(systemName: "music.note")
  131. .font(.system(size: 16))
  132. .foregroundStyle(theme.tertiaryText)
  133. }
  134. }
  135. }
  136. .clipShape(RoundedRectangle(cornerRadius: theme.cornerRadius / 2))
  137. .task {
  138. let url = track.fileURL
  139. artwork = await ArtworkService.shared.artwork(for: url)
  140. }
  141. }
  142. }