PlaylistListView.swift 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import SwiftData
  2. import SwiftUI
  3. /// Main screen — list of playlists with Library and Settings in toolbar.
  4. struct PlaylistListView: View {
  5. @Environment(PlayerViewModel.self) private var playerVM
  6. @Environment(PlaylistViewModel.self) private var playlistVM
  7. @EnvironmentObject private var libraryManager: LibraryManager
  8. @EnvironmentObject private var theme: AppTheme
  9. @Environment(\.modelContext) private var modelContext
  10. @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
  11. @State private var showNewPlaylist = false
  12. @State private var newPlaylistName = ""
  13. @State private var showLibrary = false
  14. @State private var showSettings = false
  15. @State private var showCloudBrowser = false
  16. var body: some View {
  17. NavigationStack {
  18. Group {
  19. if playlists.isEmpty {
  20. emptyState
  21. } else {
  22. playlistList
  23. }
  24. }
  25. .navigationTitle("MixBoard")
  26. .toolbar {
  27. ToolbarItem(placement: .topBarLeading) {
  28. HStack(spacing: 12) {
  29. Button {
  30. showLibrary = true
  31. } label: {
  32. Image(systemName: "music.note.list")
  33. }
  34. .accessibilityIdentifier("libraryButton")
  35. Button {
  36. showCloudBrowser = true
  37. } label: {
  38. Image(systemName: "cloud.fill")
  39. }
  40. .accessibilityIdentifier("cloudBrowserButton")
  41. Button {
  42. showSettings = true
  43. } label: {
  44. Image(systemName: "gearshape")
  45. }
  46. .accessibilityIdentifier("settingsButton")
  47. }
  48. }
  49. ToolbarItem(placement: .topBarTrailing) {
  50. Button {
  51. showNewPlaylist = true
  52. } label: {
  53. Image(systemName: "plus")
  54. }
  55. .accessibilityIdentifier("newPlaylistButton")
  56. .accessibilityIdentifier("newPlaylistButton")
  57. }
  58. }
  59. .alert("New Playlist", isPresented: $showNewPlaylist) {
  60. TextField("Playlist name", text: $newPlaylistName)
  61. Button("Cancel", role: .cancel) { newPlaylistName = "" }
  62. Button("Create") {
  63. guard !newPlaylistName.isEmpty else { return }
  64. let pl = playlistVM.createPlaylist(name: newPlaylistName, context: modelContext)
  65. playlistVM.selectedPlaylist = pl
  66. newPlaylistName = ""
  67. }
  68. } message: {
  69. Text("Enter a name for your new playlist")
  70. }
  71. .sheet(isPresented: $showLibrary) {
  72. LibraryView()
  73. .environmentObject(theme)
  74. }
  75. .sheet(isPresented: $showSettings) {
  76. SettingsView()
  77. .environmentObject(theme)
  78. }
  79. .sheet(isPresented: $showCloudBrowser) {
  80. CloudBrowserView()
  81. .environmentObject(theme)
  82. }
  83. }
  84. }
  85. // MARK: - Playlist List
  86. private var playlistList: some View {
  87. List {
  88. ForEach(playlists) { playlist in
  89. NavigationLink {
  90. PlaylistDetailView(playlist: playlist)
  91. } label: {
  92. PlaylistRowView(playlist: playlist)
  93. }
  94. .swipeActions(edge: .trailing) {
  95. Button(role: .destructive) {
  96. playlistVM.deletePlaylist(playlist, context: modelContext)
  97. } label: {
  98. Label("Delete", systemImage: "trash")
  99. }
  100. }
  101. .swipeActions(edge: .leading) {
  102. Button {
  103. playlistVM.targetPlaylist = playlist
  104. playlistVM.showStatus("Target: \(playlist.name)")
  105. } label: {
  106. Label("Target", systemImage: "star.fill")
  107. }
  108. .tint(.orange)
  109. }
  110. .contextMenu {
  111. Button {
  112. playlistVM.targetPlaylist = playlist
  113. playlistVM.showStatus("Target: \(playlist.name)")
  114. } label: {
  115. Label(
  116. playlistVM.targetPlaylist?.id == playlist.id ? "Current Target" : "Set as Target",
  117. systemImage: "star.fill"
  118. )
  119. }
  120. Button(role: .destructive) {
  121. playlistVM.deletePlaylist(playlist, context: modelContext)
  122. } label: {
  123. Label("Delete", systemImage: "trash")
  124. }
  125. }
  126. }
  127. }
  128. .listStyle(.insetGrouped)
  129. .accessibilityIdentifier("playlistList")
  130. }
  131. // MARK: - Empty State
  132. private var emptyState: some View {
  133. VStack(spacing: 20) {
  134. Spacer()
  135. Image(systemName: "music.note.list")
  136. .font(.system(size: 60))
  137. .foregroundStyle(theme.tertiaryText)
  138. Text("No playlists yet")
  139. .font(.title2)
  140. .foregroundStyle(theme.secondaryText)
  141. .accessibilityIdentifier("emptyStateTitle")
  142. Text("Create a playlist to start building your mix")
  143. .font(.subheadline)
  144. .foregroundStyle(theme.tertiaryText)
  145. Button {
  146. showNewPlaylist = true
  147. } label: {
  148. Label("New Playlist", systemImage: "plus.circle.fill")
  149. .font(.headline)
  150. .padding(.horizontal, 24)
  151. .padding(.vertical, 12)
  152. }
  153. .buttonStyle(.borderedProminent)
  154. .tint(theme.accent)
  155. .accessibilityIdentifier("emptyStateNewPlaylistButton")
  156. Spacer()
  157. }
  158. .accessibilityIdentifier("emptyState")
  159. }
  160. }
  161. // MARK: - Playlist Row
  162. struct PlaylistRowView: View {
  163. let playlist: Playlist
  164. @Environment(PlaylistViewModel.self) private var playlistVM
  165. @EnvironmentObject private var theme: AppTheme
  166. private var isTarget: Bool {
  167. guard let target = playlistVM.mixTargets.first(where: { $0?.id == playlist.id }) else { return false }
  168. return target != nil
  169. }
  170. var body: some View {
  171. HStack(spacing: 12) {
  172. // Color indicator
  173. Circle()
  174. .fill(Color(hex: playlist.color) ?? theme.accent)
  175. .frame(width: 12, height: 12)
  176. VStack(alignment: .leading, spacing: 2) {
  177. HStack(spacing: 6) {
  178. Text(playlist.name)
  179. .font(.headline)
  180. .foregroundStyle(theme.primaryText)
  181. if isTarget {
  182. Image(systemName: "star.fill")
  183. .font(.caption2)
  184. .foregroundStyle(.orange)
  185. }
  186. }
  187. HStack(spacing: 8) {
  188. Text("\(playlist.trackCount) tracks")
  189. .font(.caption)
  190. .foregroundStyle(theme.secondaryText)
  191. }
  192. }
  193. Spacer()
  194. }
  195. .padding(.vertical, 4)
  196. .accessibilityIdentifier("playlistRow_\(playlist.name)")
  197. }
  198. }