SidebarView.swift 16 KB

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