QueueView.swift 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import SwiftUI
  2. /// Queue panel showing the current playback queue: Now Playing, User Queue, Up Next.
  3. struct QueueView: View {
  4. @Environment(PlayerViewModel.self) private var playerVM
  5. @EnvironmentObject private var theme: AppTheme
  6. var body: some View {
  7. VStack(spacing: 0) {
  8. // Header
  9. HStack {
  10. Text("Queue")
  11. .font(.system(size: 13, weight: .semibold))
  12. Spacer()
  13. if !playerVM.userQueue.isEmpty || !playerVM.upNext.isEmpty {
  14. Button("Clear") {
  15. playerVM.clearQueue()
  16. }
  17. .font(.system(size: 11))
  18. .foregroundStyle(.red)
  19. .buttonStyle(.plain)
  20. }
  21. }
  22. .padding(.horizontal, 12)
  23. .padding(.vertical, 8)
  24. .background(.bar)
  25. Divider()
  26. // Queue content
  27. List {
  28. // Now Playing
  29. if let nowPlaying = playerVM.nowPlayingEntry {
  30. Section("Now Playing") {
  31. queueRow(nowPlaying, isNowPlaying: true)
  32. }
  33. }
  34. // User Queue (manually added)
  35. if !playerVM.userQueue.isEmpty {
  36. Section("Next in Queue") {
  37. ForEach(playerVM.userQueue) { entry in
  38. queueRow(entry)
  39. }
  40. .onMove { source, destination in
  41. playerVM.moveUserQueueEntry(from: source, to: destination)
  42. }
  43. .onDelete { offsets in
  44. for index in offsets.sorted().reversed() {
  45. let entry = playerVM.userQueue[index]
  46. playerVM.removeFromQueue(entry: entry)
  47. }
  48. }
  49. }
  50. }
  51. // Up Next (auto from playlist)
  52. if !playerVM.upNext.isEmpty {
  53. Section("Up Next") {
  54. ForEach(playerVM.upNext) { entry in
  55. queueRow(entry)
  56. }
  57. .onMove { source, destination in
  58. playerVM.moveUpNextEntry(from: source, to: destination)
  59. }
  60. .onDelete { offsets in
  61. for index in offsets.sorted().reversed() {
  62. let entry = playerVM.upNext[index]
  63. playerVM.removeFromQueue(entry: entry)
  64. }
  65. }
  66. }
  67. }
  68. // Empty state
  69. if playerVM.nowPlayingEntry == nil && playerVM.userQueue.isEmpty && playerVM.upNext.isEmpty {
  70. Section {
  71. VStack(spacing: 12) {
  72. Image(systemName: "list.bullet")
  73. .font(.system(size: 36))
  74. .foregroundStyle(.tertiary)
  75. Text("Queue is empty")
  76. .font(.title3)
  77. .foregroundStyle(.secondary)
  78. Text("Add tracks using \"Play Next\" or \"Add to Queue\" from any track's context menu.")
  79. .font(.caption)
  80. .foregroundStyle(.tertiary)
  81. .multilineTextAlignment(.center)
  82. }
  83. .frame(maxWidth: .infinity)
  84. .padding(.vertical, 40)
  85. }
  86. }
  87. }
  88. .listStyle(.inset)
  89. }
  90. }
  91. @ViewBuilder
  92. private func queueRow(_ entry: QueueEntry, isNowPlaying: Bool = false) -> some View {
  93. HStack(spacing: 12) {
  94. // Cloud indicator or music note
  95. Group {
  96. switch entry.source {
  97. case .cloudDirect:
  98. Image(systemName: "cloud.fill")
  99. .foregroundStyle(Color.accentColor)
  100. case .swiftDataTrack(_, let isCloud, _):
  101. if isCloud {
  102. Image(systemName: "cloud.fill")
  103. .foregroundStyle(Color.accentColor)
  104. } else {
  105. Image(systemName: "music.note")
  106. .foregroundStyle(.tertiary)
  107. }
  108. }
  109. }
  110. .font(.caption)
  111. .frame(width: 20)
  112. VStack(alignment: .leading, spacing: 2) {
  113. Text(entry.title)
  114. .font(.system(size: 12, weight: isNowPlaying ? .semibold : .regular))
  115. .foregroundStyle(isNowPlaying ? Color.accentColor : .primary)
  116. .lineLimit(1)
  117. if !entry.artist.isEmpty {
  118. Text(entry.artist)
  119. .font(.system(size: 11))
  120. .foregroundStyle(.secondary)
  121. .lineLimit(1)
  122. }
  123. }
  124. Spacer()
  125. Text(entry.formattedDuration)
  126. .font(.system(size: 11, design: .monospaced))
  127. .foregroundStyle(.tertiary)
  128. if isNowPlaying && playerVM.isPlaying {
  129. Image(systemName: "speaker.wave.2.fill")
  130. .font(.caption)
  131. .foregroundStyle(Color.accentColor)
  132. }
  133. }
  134. .padding(.vertical, 2)
  135. }
  136. }