SidebarView.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. import SwiftData
  2. import SwiftUI
  3. import UniformTypeIdentifiers
  4. /// Sidebar — library sections, queue, playlist folders and playlists with drag & drop.
  5. struct SidebarView: View {
  6. @Binding var selection: SidebarSection?
  7. @Binding var showNewPlaylistSheet: Bool
  8. @Environment(PlaylistViewModel.self) private var playlistVM
  9. @Environment(\.modelContext) private var modelContext
  10. @EnvironmentObject private var theme: AppTheme
  11. @Query(sort: \Track.title) private var allTracks: [Track]
  12. @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
  13. @Query(sort: \PlaylistFolder.dateCreated) private var folders: [PlaylistFolder]
  14. @State private var showNewFolderAlert = false
  15. @State private var newFolderName = ""
  16. @AppStorage("playbackMode") private var playbackMode: String = "queue"
  17. /// Playlists not in any folder.
  18. private var unfolderedPlaylists: [Playlist] {
  19. allPlaylists.filter { $0.folder == nil }
  20. }
  21. var body: some View {
  22. List(selection: $selection) {
  23. // ── Library ──────────────────────────────
  24. Section("Library") {
  25. ForEach(LibraryDestination.allCases, id: \.self) { dest in
  26. Label(dest.displayName, systemImage: dest.icon)
  27. .tag(SidebarSection.library(dest))
  28. }
  29. if playbackMode == "queue" {
  30. Label("Queue", systemImage: "list.bullet")
  31. .tag(SidebarSection.queue)
  32. .accessibilityIdentifier("queueButton")
  33. }
  34. }
  35. // ── Playlists ────────────────────────────
  36. Section("Playlists") {
  37. // Folders
  38. ForEach(folders) { folder in
  39. FolderRowView(
  40. folder: folder,
  41. selection: $selection,
  42. onDrop: { providers, playlist in
  43. handleDrop(providers: providers, playlist: playlist)
  44. }
  45. )
  46. }
  47. // Playlists not in a folder
  48. ForEach(unfolderedPlaylists) { playlist in
  49. playlistRow(playlist)
  50. }
  51. // Actions
  52. HStack(spacing: 16) {
  53. Button {
  54. showNewPlaylistSheet = true
  55. } label: {
  56. Image(systemName: "plus.circle.fill")
  57. .font(.system(size: 18))
  58. }
  59. .buttonStyle(.plain)
  60. .help("New Playlist")
  61. .accessibilityIdentifier("newPlaylistButton")
  62. Button {
  63. newFolderName = ""
  64. showNewFolderAlert = true
  65. } label: {
  66. Image(systemName: "folder.badge.plus")
  67. .font(.system(size: 18))
  68. }
  69. .buttonStyle(.plain)
  70. .help("New Folder")
  71. }
  72. .foregroundStyle(theme.secondaryText)
  73. }
  74. }
  75. .listStyle(.sidebar)
  76. .navigationTitle("MixBoard")
  77. .accessibilityIdentifier("sidebar")
  78. .alert("New Folder", isPresented: $showNewFolderAlert) {
  79. TextField("Folder name", text: $newFolderName)
  80. Button("Cancel", role: .cancel) {}
  81. Button("Create") {
  82. guard !newFolderName.isEmpty else { return }
  83. let folder = PlaylistFolder(name: newFolderName)
  84. modelContext.insert(folder)
  85. try? modelContext.save()
  86. }
  87. }
  88. }
  89. // MARK: - Playlist Row
  90. private func playlistRow(_ playlist: Playlist) -> some View {
  91. PlaylistRow(playlist: playlist)
  92. .tag(SidebarSection.playlist(playlist))
  93. .draggable(playlist.id.uuidString)
  94. .onDrop(of: [.chadTrack, .chadAlbum, .utf8PlainText], isTargeted: nil) { providers in
  95. handleDrop(providers: providers, playlist: playlist)
  96. return true
  97. }
  98. .contextMenu {
  99. playlistContextMenu(playlist)
  100. }
  101. }
  102. private func handleDrop(providers: [NSItemProvider], playlist: Playlist) {
  103. print("SidebarView: handleDrop called with \(providers.count) providers for playlist '\(playlist.name)'")
  104. for provider in providers {
  105. print(" Provider types: \(provider.registeredTypeIdentifiers)")
  106. // Cloud track
  107. if provider.hasItemConformingToTypeIdentifier("com.mixboard.chad-track") {
  108. print(" → Matched chad-track")
  109. provider.loadDataRepresentation(forTypeIdentifier: "com.mixboard.chad-track") { data, error in
  110. if let error { print(" → Load error: \(error)"); return }
  111. guard let data, let track = try? JSONDecoder().decode(ChadTrack.self, from: data) else {
  112. print(" → Decode failed")
  113. return
  114. }
  115. print(" → Decoded track: \(track.title)")
  116. Task { @MainActor in
  117. self.addCloudTracksToPlaylist([track], playlist: playlist)
  118. }
  119. }
  120. }
  121. // Cloud album
  122. else if provider.hasItemConformingToTypeIdentifier("com.mixboard.chad-album") {
  123. provider.loadDataRepresentation(forTypeIdentifier: "com.mixboard.chad-album") { data, _ in
  124. guard let data, let album = try? JSONDecoder().decode(ChadAlbum.self, from: data) else { return }
  125. Task { @MainActor in
  126. addCloudAlbumsToPlaylist([album], playlist: playlist)
  127. }
  128. }
  129. }
  130. // Local track ID (string)
  131. else if provider.hasItemConformingToTypeIdentifier("public.utf8-plain-text") {
  132. provider.loadItem(forTypeIdentifier: "public.utf8-plain-text") { item, _ in
  133. guard let data = item as? Data, let idString = String(data: data, encoding: .utf8) else { return }
  134. Task { @MainActor in
  135. addTracksToPlaylist(trackIDs: [idString], playlist: playlist)
  136. }
  137. }
  138. }
  139. }
  140. }
  141. @ViewBuilder
  142. private func playlistContextMenu(_ playlist: Playlist) -> some View {
  143. // Set as mix target
  144. let mixShortcuts: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
  145. Menu("Set as Mix Target") {
  146. ForEach(0..<3, id: \.self) { slot in
  147. let isCurrent = playlistVM.mixTargets[slot]?.id == playlist.id
  148. let hint = KeyboardShortcutConfig.shared.binding(for: mixShortcuts[slot]).displayString
  149. Button(isCurrent ? "✓ Mix \(slot + 1)" : "Mix \(slot + 1) (\(hint))") {
  150. playlistVM.setMixTarget(slot, playlist: playlist)
  151. playlistVM.showStatus("\"\(playlist.name)\" → Mix \(slot + 1)")
  152. }
  153. }
  154. }
  155. Divider()
  156. // Move to folder
  157. if !folders.isEmpty {
  158. Menu("Move to Folder") {
  159. ForEach(folders) { folder in
  160. Button(folder.name) {
  161. playlist.folder = folder
  162. try? modelContext.save()
  163. }
  164. }
  165. Divider()
  166. Button("No Folder") {
  167. playlist.folder = nil
  168. try? modelContext.save()
  169. }
  170. }
  171. }
  172. Divider()
  173. Button("Delete Playlist", role: .destructive) {
  174. playlistVM.deletePlaylist(playlist, context: modelContext)
  175. }
  176. }
  177. private func addTracksToPlaylist(trackIDs: [String], playlist: Playlist) {
  178. for idString in trackIDs {
  179. guard let uuid = UUID(uuidString: idString),
  180. let track = allTracks.first(where: { $0.id == uuid }) else { continue }
  181. playlistVM.addTrack(track, to: playlist, context: modelContext)
  182. }
  183. }
  184. private func addCloudTracksToPlaylist(_ chadTracks: [ChadTrack], playlist: Playlist) {
  185. for chadTrack in chadTracks {
  186. let cloudId = chadTrack.id
  187. let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.cloudTrackId == cloudId })
  188. let existing = try? modelContext.fetch(descriptor).first
  189. let track = existing ?? Track.fromCloud(chadTrack)
  190. if existing == nil {
  191. modelContext.insert(track)
  192. }
  193. playlist.addTrack(track)
  194. }
  195. }
  196. private func addCloudAlbumsToPlaylist(_ albums: [ChadAlbum], playlist: Playlist) {
  197. Task {
  198. let client = ChadMusicAPIClient.shared
  199. for album in albums {
  200. guard let tracks = try? await client.fetchAlbumTracks(albumId: album.id) else { continue }
  201. addCloudTracksToPlaylist(tracks, playlist: playlist)
  202. }
  203. }
  204. }
  205. }
  206. // MARK: - Folder Row
  207. private struct FolderRowView: View {
  208. let folder: PlaylistFolder
  209. @Binding var selection: SidebarSection?
  210. let onDrop: ([NSItemProvider], Playlist) -> Void
  211. @Environment(PlaylistViewModel.self) private var playlistVM
  212. @Environment(\.modelContext) private var modelContext
  213. @EnvironmentObject private var theme: AppTheme
  214. @Query(sort: \PlaylistFolder.dateCreated) private var allFolders: [PlaylistFolder]
  215. @State private var isExpanded: Bool = true
  216. @State private var showRenameAlert = false
  217. @State private var renameName = ""
  218. var body: some View {
  219. DisclosureGroup(isExpanded: $isExpanded) {
  220. ForEach(folder.sortedPlaylists) { playlist in
  221. PlaylistRow(playlist: playlist)
  222. .tag(SidebarSection.playlist(playlist))
  223. .draggable(playlist.id.uuidString)
  224. .onDrop(of: [.chadTrack, .chadAlbum, .utf8PlainText], isTargeted: nil) { providers in
  225. onDrop(providers, playlist)
  226. return true
  227. }
  228. .contextMenu {
  229. // Set as mix target
  230. let mixShortcuts: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
  231. Menu("Set as Mix Target") {
  232. ForEach(0..<3, id: \.self) { slot in
  233. let isCurrent = playlistVM.mixTargets[slot]?.id == playlist.id
  234. let hint = KeyboardShortcutConfig.shared.binding(for: mixShortcuts[slot]).displayString
  235. Button(isCurrent ? "✓ Mix \(slot + 1)" : "Mix \(slot + 1) (\(hint))") {
  236. playlistVM.setMixTarget(slot, playlist: playlist)
  237. playlistVM.showStatus("\"\(playlist.name)\" → Mix \(slot + 1)")
  238. }
  239. }
  240. }
  241. Divider()
  242. Button("Remove from Folder") {
  243. playlist.folder = nil
  244. try? modelContext.save()
  245. }
  246. // Move to different folder
  247. let otherFolders = allFolders.filter { $0.id != folder.id }
  248. if !otherFolders.isEmpty {
  249. Menu("Move to Folder") {
  250. ForEach(otherFolders) { f in
  251. Button(f.name) {
  252. playlist.folder = f
  253. try? modelContext.save()
  254. }
  255. }
  256. }
  257. }
  258. Divider()
  259. Button("Delete Playlist", role: .destructive) {
  260. modelContext.delete(playlist)
  261. try? modelContext.save()
  262. }
  263. }
  264. }
  265. } label: {
  266. HStack(spacing: 6) {
  267. Image(systemName: "folder.fill")
  268. .foregroundStyle(theme.accent)
  269. .font(.system(size: 14))
  270. Text(folder.name)
  271. .foregroundStyle(theme.primaryText)
  272. Text("(\(folder.playlists.count))")
  273. .font(.caption2)
  274. .foregroundStyle(theme.tertiaryText)
  275. }
  276. .contentShape(Rectangle())
  277. .contextMenu {
  278. Button("Rename Folder...") {
  279. renameName = folder.name
  280. showRenameAlert = true
  281. }
  282. Divider()
  283. Button("Delete Folder", role: .destructive) {
  284. for pl in folder.playlists {
  285. pl.folder = nil
  286. }
  287. modelContext.delete(folder)
  288. try? modelContext.save()
  289. }
  290. }
  291. }
  292. .dropDestination(for: String.self) { items, _ in
  293. handlePlaylistDrop(items)
  294. return true
  295. }
  296. .alert("Rename Folder", isPresented: $showRenameAlert) {
  297. TextField("Folder name", text: $renameName)
  298. Button("Cancel", role: .cancel) {}
  299. Button("Rename") {
  300. folder.name = renameName
  301. try? modelContext.save()
  302. }
  303. }
  304. .onAppear {
  305. isExpanded = folder.isExpanded
  306. }
  307. .onChange(of: isExpanded) { _, newValue in
  308. folder.isExpanded = newValue
  309. try? modelContext.save()
  310. }
  311. }
  312. private func handlePlaylistDrop(_ items: [String]) {
  313. // Items are playlist UUID strings — move them into this folder
  314. for idString in items {
  315. guard let uuid = UUID(uuidString: idString) else { continue }
  316. let descriptor = FetchDescriptor<Playlist>(predicate: #Predicate { $0.id == uuid })
  317. if let playlist = try? modelContext.fetch(descriptor).first {
  318. playlist.folder = folder
  319. }
  320. }
  321. try? modelContext.save()
  322. }
  323. }
  324. // MARK: - Playlist Row
  325. private struct PlaylistRow: View {
  326. let playlist: Playlist
  327. @EnvironmentObject private var theme: AppTheme
  328. @Environment(PlaylistViewModel.self) private var playlistVM
  329. /// Which mix slot(s) this playlist is assigned to (0, 1, 2), if any.
  330. private var assignedSlots: [Int] {
  331. (0..<3).filter { playlistVM.mixTargets[$0]?.id == playlist.id }
  332. }
  333. var body: some View {
  334. HStack {
  335. Image(systemName: assignedSlots.isEmpty ? "music.note.list" : "target")
  336. .foregroundStyle(assignedSlots.isEmpty ? (Color(hex: playlist.color) ?? theme.accent) : theme.accent)
  337. VStack(alignment: .leading, spacing: 2) {
  338. Text(playlist.name)
  339. .foregroundStyle(theme.primaryText)
  340. .lineLimit(1)
  341. Text("\(playlist.trackCount) tracks · \(playlist.formattedTotalDuration)")
  342. .font(.caption2)
  343. .foregroundStyle(theme.secondaryText)
  344. }
  345. Spacer(minLength: 4)
  346. // Show mix slot badges
  347. ForEach(assignedSlots, id: \.self) { slot in
  348. Text("\(slot + 1)")
  349. .font(.system(size: 9, weight: .bold, design: .rounded))
  350. .frame(width: 16, height: 16)
  351. .foregroundStyle(mixTargetColors[slot])
  352. .background(mixTargetColors[slot].opacity(0.15))
  353. .clipShape(RoundedRectangle(cornerRadius: 3))
  354. }
  355. }
  356. }
  357. }
  358. // MARK: - Color Extension
  359. extension Color {
  360. init?(hex: String) {
  361. let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
  362. var int: UInt64 = 0
  363. Scanner(string: hex).scanHexInt64(&int)
  364. let r, g, b: Double
  365. switch hex.count {
  366. case 6:
  367. r = Double((int >> 16) & 0xFF) / 255
  368. g = Double((int >> 8) & 0xFF) / 255
  369. b = Double(int & 0xFF) / 255
  370. default:
  371. return nil
  372. }
  373. self.init(red: r, green: g, blue: b)
  374. }
  375. }