QueueView.swift 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. import SwiftUI
  2. /// Sheet 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. @Environment(\.dismiss) private var dismiss
  7. var body: some View {
  8. NavigationStack {
  9. List {
  10. // Now Playing
  11. if let nowPlaying = playerVM.nowPlayingEntry {
  12. Section("Now Playing") {
  13. queueRow(nowPlaying, isNowPlaying: true)
  14. }
  15. }
  16. // User Queue (manually added)
  17. if !playerVM.userQueue.isEmpty {
  18. Section("Next in Queue") {
  19. ForEach(playerVM.userQueue) { entry in
  20. queueRow(entry)
  21. }
  22. .onMove { source, destination in
  23. playerVM.moveUserQueueEntry(from: source, to: destination)
  24. }
  25. .onDelete { offsets in
  26. for index in offsets.sorted().reversed() {
  27. let entry = playerVM.userQueue[index]
  28. playerVM.removeFromQueue(entry: entry)
  29. }
  30. }
  31. }
  32. }
  33. // Up Next (auto from playlist)
  34. if !playerVM.upNext.isEmpty {
  35. Section("Up Next") {
  36. ForEach(playerVM.upNext) { entry in
  37. queueRow(entry)
  38. }
  39. .onMove { source, destination in
  40. playerVM.moveUpNextEntry(from: source, to: destination)
  41. }
  42. .onDelete { offsets in
  43. for index in offsets.sorted().reversed() {
  44. let entry = playerVM.upNext[index]
  45. playerVM.removeFromQueue(entry: entry)
  46. }
  47. }
  48. }
  49. }
  50. if playerVM.nowPlayingEntry == nil && playerVM.userQueue.isEmpty && playerVM.upNext.isEmpty {
  51. Section {
  52. VStack(spacing: 12) {
  53. Image(systemName: "list.bullet")
  54. .font(.system(size: 36))
  55. .foregroundStyle(theme.tertiaryText)
  56. Text("Queue is empty")
  57. .font(.title3)
  58. .foregroundStyle(theme.secondaryText)
  59. Text("Add tracks using \"Play Next\" or \"Add to Queue\" from any track's context menu.")
  60. .font(.caption)
  61. .foregroundStyle(theme.tertiaryText)
  62. .multilineTextAlignment(.center)
  63. }
  64. .frame(maxWidth: .infinity)
  65. .padding(.vertical, 40)
  66. }
  67. .listRowBackground(Color.clear)
  68. .accessibilityIdentifier("queue.emptyState")
  69. }
  70. }
  71. .listStyle(.insetGrouped)
  72. .environment(\.editMode, .constant(.active))
  73. .navigationTitle("Queue")
  74. .navigationBarTitleDisplayMode(.inline)
  75. .toolbar {
  76. ToolbarItem(placement: .topBarLeading) {
  77. if !playerVM.userQueue.isEmpty || !playerVM.upNext.isEmpty {
  78. Button("Clear") {
  79. playerVM.clearQueue()
  80. }
  81. .foregroundStyle(.red)
  82. .accessibilityIdentifier("queue.clearButton")
  83. }
  84. }
  85. ToolbarItem(placement: .topBarTrailing) {
  86. Button("Done") { dismiss() }
  87. }
  88. }
  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(theme.accent)
  100. case .swiftDataTrack(_, let isCloud, _):
  101. if isCloud {
  102. Image(systemName: "cloud.fill")
  103. .foregroundStyle(theme.accent)
  104. } else {
  105. Image(systemName: "music.note")
  106. .foregroundStyle(theme.tertiaryText)
  107. }
  108. }
  109. }
  110. .font(.caption)
  111. .frame(width: 24)
  112. VStack(alignment: .leading, spacing: 2) {
  113. Text(entry.title)
  114. .font(.subheadline.weight(isNowPlaying ? .semibold : .regular))
  115. .foregroundStyle(isNowPlaying ? theme.accent : theme.primaryText)
  116. .lineLimit(1)
  117. if !entry.artist.isEmpty {
  118. Text(entry.artist)
  119. .font(.caption)
  120. .foregroundStyle(theme.secondaryText)
  121. .lineLimit(1)
  122. }
  123. }
  124. Spacer()
  125. Text(entry.formattedDuration)
  126. .font(.caption.monospacedDigit())
  127. .foregroundStyle(theme.tertiaryText)
  128. if isNowPlaying && playerVM.isPlaying {
  129. Image(systemName: "speaker.wave.2.fill")
  130. .font(.caption)
  131. .foregroundStyle(theme.accent)
  132. }
  133. }
  134. .padding(.vertical, 2)
  135. }
  136. }