SidebarView.swift 16 KB

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