SidebarView.swift 17 KB

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