PlaylistListView.swift 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  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. @State private var navigationPath = NavigationPath()
  17. @State private var hasRestoredNavigation = false
  18. var body: some View {
  19. NavigationStack(path: $navigationPath) {
  20. Group {
  21. if playlists.isEmpty {
  22. emptyState
  23. } else {
  24. playlistList
  25. }
  26. }
  27. .navigationTitle("MixBoard")
  28. .toolbar {
  29. ToolbarItem(placement: .topBarLeading) {
  30. HStack(spacing: 12) {
  31. Button {
  32. showLibrary = true
  33. } label: {
  34. Image(systemName: "music.note.list")
  35. }
  36. .accessibilityIdentifier("libraryButton")
  37. Button {
  38. showCloudBrowser = true
  39. } label: {
  40. Image(systemName: "cloud.fill")
  41. }
  42. .accessibilityIdentifier("cloudBrowserButton")
  43. Button {
  44. showSettings = true
  45. } label: {
  46. Image(systemName: "gearshape")
  47. }
  48. .accessibilityIdentifier("settingsButton")
  49. }
  50. }
  51. ToolbarItem(placement: .topBarTrailing) {
  52. Button {
  53. showNewPlaylist = true
  54. } label: {
  55. Image(systemName: "plus")
  56. }
  57. .accessibilityIdentifier("newPlaylistButton")
  58. .accessibilityIdentifier("newPlaylistButton")
  59. }
  60. }
  61. .alert("New Playlist", isPresented: $showNewPlaylist) {
  62. TextField("Playlist name", text: $newPlaylistName)
  63. Button("Cancel", role: .cancel) { newPlaylistName = "" }
  64. Button("Create") {
  65. guard !newPlaylistName.isEmpty else { return }
  66. let pl = playlistVM.createPlaylist(name: newPlaylistName, context: modelContext)
  67. playlistVM.selectedPlaylist = pl
  68. newPlaylistName = ""
  69. }
  70. } message: {
  71. Text("Enter a name for your new playlist")
  72. }
  73. .sheet(isPresented: $showLibrary) {
  74. LibraryView()
  75. .environmentObject(theme)
  76. }
  77. .sheet(isPresented: $showSettings) {
  78. SettingsView()
  79. .environmentObject(theme)
  80. }
  81. .sheet(isPresented: $showCloudBrowser) {
  82. CloudBrowserView()
  83. .environmentObject(theme)
  84. }
  85. }
  86. }
  87. // MARK: - Playlist List
  88. private var playlistList: some View {
  89. List {
  90. ForEach(playlists) { playlist in
  91. NavigationLink(value: playlist.id) {
  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. .navigationDestination(for: UUID.self) { playlistID in
  131. if let playlist = playlists.first(where: { $0.id == playlistID }) {
  132. PlaylistDetailView(playlist: playlist)
  133. }
  134. }
  135. .onAppear {
  136. guard !hasRestoredNavigation else { return }
  137. hasRestoredNavigation = true
  138. // Restore last playlist on launch
  139. if let lastIDString = UserDefaults.standard.string(forKey: "appState.lastPlaylistID"),
  140. let lastID = UUID(uuidString: lastIDString),
  141. playlists.contains(where: { $0.id == lastID }) {
  142. navigationPath.append(lastID)
  143. }
  144. }
  145. }
  146. // MARK: - Empty State
  147. private var emptyState: some View {
  148. VStack(spacing: 20) {
  149. Spacer()
  150. Image(systemName: "music.note.list")
  151. .font(.system(size: 60))
  152. .foregroundStyle(theme.tertiaryText)
  153. Text("No playlists yet")
  154. .font(.title2)
  155. .foregroundStyle(theme.secondaryText)
  156. .accessibilityIdentifier("emptyStateTitle")
  157. Text("Create a playlist to start building your mix")
  158. .font(.subheadline)
  159. .foregroundStyle(theme.tertiaryText)
  160. Button {
  161. showNewPlaylist = true
  162. } label: {
  163. Label("New Playlist", systemImage: "plus.circle.fill")
  164. .font(.headline)
  165. .padding(.horizontal, 24)
  166. .padding(.vertical, 12)
  167. }
  168. .buttonStyle(.borderedProminent)
  169. .tint(theme.accent)
  170. .accessibilityIdentifier("emptyStateNewPlaylistButton")
  171. Spacer()
  172. }
  173. .accessibilityIdentifier("emptyState")
  174. }
  175. }
  176. // MARK: - Playlist Row
  177. struct PlaylistRowView: View {
  178. let playlist: Playlist
  179. @Environment(PlaylistViewModel.self) private var playlistVM
  180. @EnvironmentObject private var theme: AppTheme
  181. private var isTarget: Bool {
  182. guard let target = playlistVM.mixTargets.first(where: { $0?.id == playlist.id }) else { return false }
  183. return target != nil
  184. }
  185. var body: some View {
  186. HStack(spacing: 12) {
  187. // Color indicator
  188. Circle()
  189. .fill(Color(hex: playlist.color) ?? theme.accent)
  190. .frame(width: 12, height: 12)
  191. VStack(alignment: .leading, spacing: 2) {
  192. HStack(spacing: 6) {
  193. Text(playlist.name)
  194. .font(.headline)
  195. .foregroundStyle(theme.primaryText)
  196. if isTarget {
  197. Image(systemName: "star.fill")
  198. .font(.caption2)
  199. .foregroundStyle(.orange)
  200. }
  201. }
  202. HStack(spacing: 8) {
  203. Text("\(playlist.trackCount) tracks")
  204. .font(.caption)
  205. .foregroundStyle(theme.secondaryText)
  206. }
  207. }
  208. Spacer()
  209. }
  210. .padding(.vertical, 4)
  211. .accessibilityIdentifier("playlistRow_\(playlist.name)")
  212. }
  213. }