PlaylistView.swift 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387
  1. import SwiftData
  2. import SwiftUI
  3. import UniformTypeIdentifiers
  4. private extension UTType {
  5. /// OGG Vorbis audio — not built-in, so we define it manually.
  6. static let oggVorbis = UTType(filenameExtension: "ogg") ?? UTType.audio
  7. }
  8. /// Playlist view — manage tracks in a mix with transitions and export.
  9. struct PlaylistView: View {
  10. let playlist: Playlist
  11. @Environment(PlayerViewModel.self) private var playerVM
  12. @Environment(PlaylistViewModel.self) private var playlistVM
  13. @EnvironmentObject private var libraryManager: LibraryManager
  14. @Environment(\.modelContext) private var modelContext
  15. @Query(sort: \Track.title) private var allTracks: [Track]
  16. @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
  17. @ObservedObject private var viewConfig = PlaylistViewConfig.shared
  18. @State private var showExportSheet = false
  19. @State private var showAddTracksSheet = false
  20. @State private var showGroupEditor = false
  21. @State private var draggedEntry: PlaylistEntry?
  22. @State private var isDropTargeted = false
  23. var body: some View {
  24. VStack(spacing: 0) {
  25. // Playlist header
  26. PlaylistHeader(
  27. playlist: playlist,
  28. mixDuration: playlistVM.mixDuration(for: playlist),
  29. onExport: { showExportSheet = true },
  30. onAddTracks: { showAddTracksSheet = true },
  31. onAddFiles: { addFilesFromDisk() },
  32. onAddFolder: { addFolderFromDisk() },
  33. onEditGrouping: { showGroupEditor = true },
  34. viewConfig: viewConfig
  35. )
  36. Divider()
  37. // Track list
  38. if playlist.entries.isEmpty {
  39. EmptyPlaylistView(
  40. onAddTracks: { showAddTracksSheet = true },
  41. onAddFiles: { addFilesFromDisk() },
  42. onAddFolder: { addFolderFromDisk() }
  43. )
  44. } else {
  45. PlaylistEntryList(
  46. playlist: playlist,
  47. draggedEntry: $draggedEntry,
  48. viewConfig: viewConfig
  49. )
  50. }
  51. }
  52. .overlay {
  53. if isDropTargeted {
  54. RoundedRectangle(cornerRadius: 8)
  55. .stroke(Color.accentColor, lineWidth: 3)
  56. .background(Color.accentColor.opacity(0.08))
  57. .padding(4)
  58. .allowsHitTesting(false)
  59. }
  60. }
  61. .onDrop(of: [.fileURL], isTargeted: $isDropTargeted) { providers in
  62. handleDrop(providers)
  63. return true
  64. }
  65. .sheet(isPresented: $showExportSheet) {
  66. ExportSheet(playlist: playlist)
  67. }
  68. .sheet(isPresented: $showAddTracksSheet) {
  69. AddTracksSheet(playlist: playlist, allTracks: allTracks)
  70. }
  71. .sheet(isPresented: $showGroupEditor) {
  72. GroupTemplateEditorSheet(playlist: playlist)
  73. }
  74. }
  75. // MARK: - Add Files from Disk
  76. private func addFilesFromDisk() {
  77. let panel = NSOpenPanel()
  78. panel.canChooseFiles = true
  79. panel.canChooseDirectories = false
  80. panel.allowsMultipleSelection = true
  81. panel.allowedContentTypes = [.audio, .mp3, .wav, .aiff, .oggVorbis]
  82. panel.message = "Select audio files to add to \"\(playlist.name)\""
  83. if panel.runModal() == .OK {
  84. Task {
  85. await playlistVM.importFilesToPlaylist(
  86. urls: panel.urls,
  87. playlist: playlist,
  88. libraryManager: libraryManager,
  89. context: modelContext
  90. )
  91. }
  92. }
  93. }
  94. private func addFolderFromDisk() {
  95. let panel = NSOpenPanel()
  96. panel.canChooseFiles = false
  97. panel.canChooseDirectories = true
  98. panel.allowsMultipleSelection = true
  99. panel.message = "Select a folder to scan for audio files"
  100. if panel.runModal() == .OK {
  101. let urls = expandDirectories(panel.urls)
  102. Task {
  103. await playlistVM.importFilesToPlaylist(
  104. urls: urls,
  105. playlist: playlist,
  106. libraryManager: libraryManager,
  107. context: modelContext
  108. )
  109. }
  110. }
  111. }
  112. private func handleDrop(_ providers: [NSItemProvider]) {
  113. for provider in providers {
  114. provider.loadItem(forTypeIdentifier: "public.file-url") { data, _ in
  115. guard let data = data as? Data,
  116. let urlString = String(data: data, encoding: .utf8),
  117. let url = URL(string: urlString) else { return }
  118. let urls = expandDirectories([url])
  119. Task { @MainActor in
  120. await playlistVM.importFilesToPlaylist(
  121. urls: urls,
  122. playlist: playlist,
  123. libraryManager: libraryManager,
  124. context: modelContext
  125. )
  126. }
  127. }
  128. }
  129. }
  130. private func expandDirectories(_ urls: [URL]) -> [URL] {
  131. var result: [URL] = []
  132. let fm = FileManager.default
  133. for url in urls {
  134. var isDir: ObjCBool = false
  135. if fm.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue {
  136. if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) {
  137. for case let fileURL as URL in enumerator {
  138. if MetadataService.isSupportedAudioFile(fileURL) {
  139. result.append(fileURL)
  140. }
  141. }
  142. }
  143. } else if MetadataService.isSupportedAudioFile(url) {
  144. result.append(url)
  145. }
  146. }
  147. // Sort by full path with numeric sorting to preserve folder structure
  148. // (like iOS FolderBrowserView) — e.g. "01/01.mp3" < "01/02.mp3" < "02/01.mp3"
  149. return result.sorted { $0.path.compare($1.path, options: [.numeric, .caseInsensitive]) == .orderedAscending }
  150. }
  151. }
  152. // MARK: - Playlist Header (compact toolbar)
  153. private struct PlaylistHeader: View {
  154. let playlist: Playlist
  155. let mixDuration: TimeInterval
  156. let onExport: () -> Void
  157. let onAddTracks: () -> Void
  158. let onAddFiles: () -> Void
  159. let onAddFolder: () -> Void
  160. let onEditGrouping: () -> Void
  161. @ObservedObject var viewConfig: PlaylistViewConfig
  162. @EnvironmentObject private var theme: AppTheme
  163. var body: some View {
  164. HStack(spacing: 6) {
  165. // Playlist name
  166. Text(playlist.name)
  167. .font(.system(size: 13, weight: .semibold))
  168. .foregroundStyle(theme.primaryText)
  169. .lineLimit(1)
  170. // Stats
  171. Text("[\(playlist.trackCount) tracks · \(formatDuration(mixDuration))]")
  172. .font(.system(size: 11))
  173. .foregroundStyle(theme.secondaryText)
  174. Spacer()
  175. // Toolbar buttons
  176. HStack(spacing: 6) {
  177. Menu {
  178. Button("Add Files...") { onAddFiles() }
  179. Button("Add Folder...") { onAddFolder() }
  180. Divider()
  181. Button("From Library...") { onAddTracks() }
  182. } label: {
  183. Label("Add", systemImage: "plus")
  184. }
  185. .fixedSize()
  186. Menu {
  187. ForEach(GroupTemplateResolver.presets, id: \.template) { preset in
  188. Button {
  189. playlist.groupTemplate = preset.template
  190. } label: {
  191. HStack {
  192. Text(preset.name)
  193. if playlist.groupTemplate == preset.template {
  194. Image(systemName: "checkmark")
  195. }
  196. }
  197. }
  198. }
  199. Divider()
  200. Button("Custom...") { onEditGrouping() }
  201. } label: {
  202. Label(
  203. playlist.groupTemplate.isEmpty ? "Group" : "Grouped",
  204. systemImage: "rectangle.3.group"
  205. )
  206. }
  207. .fixedSize()
  208. Button {
  209. NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
  210. } label: {
  211. Label("Settings", systemImage: "gearshape")
  212. }
  213. PlaylistDownloadButton(playlist: playlist)
  214. PlaylistUploadButton(playlist: playlist)
  215. Button { onExport() } label: {
  216. Label("Export", systemImage: "square.and.arrow.up")
  217. }
  218. .disabled(playlist.entries.isEmpty)
  219. }
  220. .controlSize(.small)
  221. Button {
  222. NotificationCenter.default.post(name: .toggleBrowsePanel, object: nil)
  223. } label: {
  224. Image(systemName: "cloud.fill")
  225. .font(.system(size: 20))
  226. .foregroundStyle(theme.secondaryText)
  227. .frame(width: 32, height: 28)
  228. }
  229. .buttonStyle(.plain)
  230. .help("Chad Music (⌘B)")
  231. }
  232. .padding(.horizontal, 10)
  233. .padding(.vertical, 7)
  234. .background(theme.toolbarBackground)
  235. }
  236. private func formatDuration(_ duration: TimeInterval) -> String {
  237. let total = Int(duration)
  238. let hours = total / 3600
  239. let minutes = (total % 3600) / 60
  240. let seconds = total % 60
  241. if hours > 0 {
  242. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  243. }
  244. return String(format: "%d:%02d", minutes, seconds)
  245. }
  246. }
  247. // MARK: - Playlist Entry List (with Grouping)
  248. private struct PlaylistEntryList: View {
  249. let playlist: Playlist
  250. @Binding var draggedEntry: PlaylistEntry?
  251. @ObservedObject var viewConfig: PlaylistViewConfig
  252. @Environment(PlayerViewModel.self) private var playerVM
  253. @Environment(PlaylistViewModel.self) private var playlistVM
  254. @EnvironmentObject private var libraryManager: LibraryManager
  255. @Environment(\.modelContext) private var modelContext
  256. @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
  257. @State private var selectedEntryIDs: Set<UUID> = []
  258. @State private var scrollTarget: UUID?
  259. @State private var editingNotesTrack: Track?
  260. @AppStorage("playbackMode") private var playbackMode: String = "queue"
  261. /// Group entries by the playlist's groupTemplate.
  262. private var groupedEntries: [(key: String, entries: [(index: Int, entry: PlaylistEntry)])] {
  263. let sorted = playlist.sortedEntries
  264. let indexed = sorted.enumerated().map { (index: $0.offset, entry: $0.element) }
  265. guard !playlist.groupTemplate.isEmpty else {
  266. return [("", indexed)]
  267. }
  268. // Group consecutively by resolved template (preserves playlist order)
  269. var groups: [(String, [(index: Int, entry: PlaylistEntry)])] = []
  270. var currentHeader = ""
  271. var currentEntries: [(index: Int, entry: PlaylistEntry)] = []
  272. for item in indexed {
  273. let header: String
  274. if let track = item.entry.track {
  275. header = GroupTemplateResolver.resolve(template: playlist.groupTemplate, for: track)
  276. } else {
  277. header = "Unknown"
  278. }
  279. if header != currentHeader {
  280. if !currentEntries.isEmpty {
  281. groups.append((currentHeader, currentEntries))
  282. }
  283. currentHeader = header
  284. currentEntries = [item]
  285. } else {
  286. currentEntries.append(item)
  287. }
  288. }
  289. if !currentEntries.isEmpty {
  290. groups.append((currentHeader, currentEntries))
  291. }
  292. return groups.map { (key: $0.0, entries: $0.1) }
  293. }
  294. var body: some View {
  295. VStack(spacing: 0) {
  296. // Selection toolbar
  297. if !selectedEntryIDs.isEmpty {
  298. SelectionToolbar(
  299. count: selectedEntryIDs.count,
  300. onSelectAll: { selectedEntryIDs = Set(playlist.entries.map(\.id)) },
  301. onDeselect: { selectedEntryIDs.removeAll() },
  302. onRemove: {
  303. let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
  304. for entry in toRemove {
  305. playlistVM.removeEntry(entry, from: playlist, context: modelContext)
  306. }
  307. selectedEntryIDs.removeAll()
  308. }
  309. )
  310. Divider()
  311. }
  312. // Column headers
  313. ColumnHeaderRow(viewConfig: viewConfig)
  314. Divider()
  315. List(selection: $selectedEntryIDs) {
  316. ForEach(groupedEntries, id: \.key) { group in
  317. if !playlist.groupTemplate.isEmpty && !group.key.isEmpty {
  318. Section {
  319. groupContent(group.entries)
  320. } header: {
  321. let tracks = group.entries.compactMap(\.entry.track)
  322. GroupHeaderView(
  323. title: group.key,
  324. trackCount: group.entries.count,
  325. firstTrack: group.entries.first?.entry.track,
  326. showArtwork: viewConfig.showArtwork,
  327. tracks: tracks
  328. )
  329. }
  330. } else {
  331. groupContent(group.entries)
  332. }
  333. }
  334. }
  335. .listStyle(.inset)
  336. // Enter key always plays selected track (like foobar2000)
  337. .onKeyPress(.return) {
  338. playSelectedTrack()
  339. return .handled
  340. }
  341. // Arrow keys for navigation
  342. .onKeyPress(.upArrow) {
  343. moveSelection(by: -1)
  344. return .handled
  345. }
  346. .onKeyPress(.downArrow) {
  347. moveSelection(by: 1)
  348. return .handled
  349. }
  350. // Backspace/Delete key removes selected entries (onDeleteCommand is the macOS-native way)
  351. .onDeleteCommand {
  352. removeSelectedEntries()
  353. }
  354. // Cursor follows playback: select playing entry
  355. .onChange(of: playerVM.currentPlayingEntryID) { _, newID in
  356. guard viewConfig.cursorFollowsPlayback, let entryID = newID else { return }
  357. selectedEntryIDs = [entryID]
  358. }
  359. // Sync cursor position to PlayerViewModel for "Playback follows cursor"
  360. .onChange(of: selectedEntryIDs) { _, newIDs in
  361. playerVM.cursorEntryID = newIDs.first
  362. }
  363. // When PlayerViewModel moves cursor (auto-advance), update the UI selection
  364. .onChange(of: playerVM.cursorEntryID) { _, newID in
  365. if let newID, !selectedEntryIDs.contains(newID) {
  366. selectedEntryIDs = [newID]
  367. }
  368. }
  369. .sheet(item: $editingNotesTrack) { track in
  370. TrackNotesSheet(track: track)
  371. }
  372. // Double-click to play (via NSEvent monitor in MediaKeyHandler)
  373. .onReceive(NotificationCenter.default.publisher(for: .doubleClickPlayTrack)) { _ in
  374. playSelectedTrack()
  375. }
  376. // When this playlist view appears, restore cursor to playing entry if applicable
  377. .onAppear {
  378. if selectedEntryIDs.isEmpty,
  379. let playingID = playerVM.currentPlayingEntryID,
  380. playlist.sortedEntries.contains(where: { $0.id == playingID }) {
  381. selectedEntryIDs = [playingID]
  382. playerVM.cursorEntryID = playingID
  383. }
  384. }
  385. }
  386. }
  387. /// Play the first selected entry.
  388. private func playSelectedTrack() {
  389. guard let firstID = selectedEntryIDs.first,
  390. let entry = playlist.sortedEntries.first(where: { $0.id == firstID }),
  391. let track = entry.track else { return }
  392. playerVM.loadAndPlay(track, entryID: entry.id, playlist: playlist)
  393. }
  394. /// Remove all selected entries from the playlist.
  395. private func removeSelectedEntries() {
  396. guard !selectedEntryIDs.isEmpty else { return }
  397. let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
  398. for entry in toRemove {
  399. playlistVM.removeEntry(entry, from: playlist, context: modelContext)
  400. }
  401. selectedEntryIDs.removeAll()
  402. }
  403. /// Move selection up or down by `offset` positions.
  404. private func moveSelection(by offset: Int) {
  405. let sorted = playlist.sortedEntries
  406. guard !sorted.isEmpty else { return }
  407. if let currentID = selectedEntryIDs.first,
  408. let currentIndex = sorted.firstIndex(where: { $0.id == currentID }) {
  409. let newIndex = max(0, min(sorted.count - 1, currentIndex + offset))
  410. selectedEntryIDs = [sorted[newIndex].id]
  411. } else {
  412. // Nothing selected — select first or last
  413. let entry = offset > 0 ? sorted.first! : sorted.last!
  414. selectedEntryIDs = [entry.id]
  415. }
  416. }
  417. @ViewBuilder
  418. private func groupContent(_ entries: [(index: Int, entry: PlaylistEntry)]) -> some View {
  419. ForEach(entries, id: \.entry.id) { item in
  420. ConfigurableEntryRow(
  421. entry: item.entry,
  422. index: item.index,
  423. isLast: item.index == playlist.entries.count - 1,
  424. viewConfig: viewConfig,
  425. isPlaying: playerVM.currentPlayingEntryID == item.entry.id
  426. )
  427. .tag(item.entry.id)
  428. .id(item.entry.id)
  429. .draggable(item.entry.track?.id.uuidString ?? "")
  430. .contextMenu {
  431. if let track = item.entry.track {
  432. Button("Play") { playerVM.loadAndPlay(track, entryID: item.entry.id, playlist: playlist) }
  433. Divider()
  434. if playbackMode == "queue" {
  435. Button {
  436. playerVM.playNextInQueue(QueueEntry.from(track: track))
  437. } label: {
  438. Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
  439. }
  440. Button {
  441. playerVM.addToQueue(QueueEntry.from(track: track))
  442. } label: {
  443. Label("Add to Queue", systemImage: "text.append")
  444. }
  445. Divider()
  446. }
  447. // Add to other playlists
  448. let otherPlaylists = allPlaylists.filter { $0.id != playlist.id }
  449. if !otherPlaylists.isEmpty {
  450. Menu("Add to Playlist") {
  451. ForEach(otherPlaylists) { targetPlaylist in
  452. Button(targetPlaylist.name) {
  453. playlistVM.addTrack(track, to: targetPlaylist, context: modelContext)
  454. }
  455. }
  456. }
  457. }
  458. // Download actions for cloud tracks
  459. if track.isCloud {
  460. Divider()
  461. switch track.downloadState {
  462. case .none:
  463. Button {
  464. DownloadManager.shared.download(track: track, apiClient: ChadMusicAPIClient.shared)
  465. } label: {
  466. Label("Download", systemImage: "arrow.down.circle")
  467. }
  468. case .downloading:
  469. Button {
  470. DownloadManager.shared.cancel(track: track)
  471. } label: {
  472. Label("Cancel Download", systemImage: "stop.circle")
  473. }
  474. case .downloaded:
  475. Button(role: .destructive) {
  476. DownloadManager.shared.removeDownload(track: track)
  477. } label: {
  478. Label("Remove Download", systemImage: "trash")
  479. }
  480. case .error:
  481. Button {
  482. DownloadManager.shared.download(track: track, apiClient: ChadMusicAPIClient.shared)
  483. } label: {
  484. Label("Retry Download", systemImage: "arrow.clockwise")
  485. }
  486. }
  487. }
  488. // Upload local track to cloud
  489. if !track.isCloud,
  490. !track.filePath.isEmpty,
  491. ChadMusicAPIClient.shared.isConfigured {
  492. Divider()
  493. Button {
  494. UploadService.shared.startUpload(
  495. track: track,
  496. apiClient: ChadMusicAPIClient.shared
  497. )
  498. } label: {
  499. Label("Upload to Cloud", systemImage: "arrow.up.to.cloud")
  500. }
  501. }
  502. }
  503. Divider()
  504. Button {
  505. viewConfig.cursorFollowsPlayback = true
  506. viewConfig.playbackFollowsCursor = false
  507. } label: {
  508. HStack {
  509. Text("Cursor follows playback")
  510. if viewConfig.cursorFollowsPlayback {
  511. Spacer()
  512. Image(systemName: "checkmark")
  513. }
  514. }
  515. }
  516. Button {
  517. viewConfig.playbackFollowsCursor = true
  518. viewConfig.cursorFollowsPlayback = false
  519. } label: {
  520. HStack {
  521. Text("Playback follows cursor")
  522. if viewConfig.playbackFollowsCursor {
  523. Spacer()
  524. Image(systemName: "checkmark")
  525. }
  526. }
  527. }
  528. if let track = item.entry.track {
  529. Divider()
  530. Button("Analyze BPM & Key") {
  531. Task { await libraryManager.analyzeTrack(track) }
  532. }
  533. Button("Rescan Metadata") {
  534. Task {
  535. await libraryManager.rescanMetadata(track)
  536. try? modelContext.save()
  537. }
  538. }
  539. Button("Edit Notes...") {
  540. editingNotesTrack = track
  541. }
  542. // Quick add to mix targets
  543. let otherMixSlots = (0..<3).filter { playlistVM.mixTargets[$0] != nil && playlistVM.mixTargets[$0]?.id != playlist.id }
  544. if !otherMixSlots.isEmpty {
  545. let mixShortcuts: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
  546. Menu("Add to Mix") {
  547. ForEach(otherMixSlots, id: \.self) { slot in
  548. let hint = KeyboardShortcutConfig.shared.binding(for: mixShortcuts[slot]).displayString
  549. Button("\(playlistVM.mixTargetName(slot)) (\(hint))") {
  550. _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
  551. }
  552. }
  553. }
  554. }
  555. }
  556. Divider()
  557. if selectedEntryIDs.count > 1 {
  558. Button("Remove \(selectedEntryIDs.count) Selected", role: .destructive) {
  559. let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
  560. for e in toRemove {
  561. playlistVM.removeEntry(e, from: playlist, context: modelContext)
  562. }
  563. selectedEntryIDs.removeAll()
  564. }
  565. } else {
  566. Button("Remove from Playlist", role: .destructive) {
  567. playlistVM.removeEntry(item.entry, from: playlist, context: modelContext)
  568. }
  569. }
  570. }
  571. }
  572. .onMove { source, destination in
  573. if let first = source.first {
  574. playlistVM.moveEntry(in: playlist, from: first, to: destination, context: modelContext)
  575. }
  576. }
  577. }
  578. }
  579. // MARK: - Selection Toolbar
  580. private struct SelectionToolbar: View {
  581. let count: Int
  582. let onSelectAll: () -> Void
  583. let onDeselect: () -> Void
  584. let onRemove: () -> Void
  585. @EnvironmentObject private var theme: AppTheme
  586. var body: some View {
  587. HStack(spacing: 8) {
  588. Text("\(count) selected")
  589. .font(.system(size: theme.smallFontSize + 1))
  590. .foregroundStyle(theme.secondaryText)
  591. Spacer()
  592. Button("All", action: onSelectAll).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
  593. Button("None", action: onDeselect).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
  594. Button("Remove", role: .destructive, action: onRemove).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
  595. }
  596. .padding(.horizontal, 8)
  597. .padding(.vertical, 2)
  598. .background(theme.toolbarBackground)
  599. }
  600. }
  601. // MARK: - Group Header with Artwork
  602. private struct GroupHeaderView: View {
  603. let title: String
  604. let trackCount: Int
  605. let firstTrack: Track?
  606. let showArtwork: Bool
  607. var tracks: [Track] = []
  608. @State private var isHovering = false
  609. @EnvironmentObject private var theme: AppTheme
  610. var body: some View {
  611. HStack(spacing: 6) {
  612. if showArtwork, let track = firstTrack {
  613. ArtworkView(track: track, size: 18)
  614. }
  615. Text(title)
  616. .font(.system(size: theme.dataFontSize, weight: .bold))
  617. .foregroundStyle(theme.groupHeaderText)
  618. Text("(\(trackCount))")
  619. .font(.system(size: theme.smallFontSize + 1))
  620. .foregroundStyle(theme.tertiaryText)
  621. uploadStatusBadge
  622. Spacer()
  623. groupCloudActionButton
  624. }
  625. .frame(maxWidth: .infinity, alignment: .leading)
  626. .padding(.vertical, 2)
  627. .contentShape(Rectangle())
  628. .onHover { isHovering = $0 }
  629. .contextMenu {
  630. let eligible = tracks.filter {
  631. !$0.isCloud && !$0.filePath.isEmpty
  632. && $0.uploadState != .uploaded
  633. && ChadMusicAPIClient.shared.isConfigured
  634. }
  635. if !eligible.isEmpty {
  636. Button {
  637. UploadService.shared.uploadBatch(tracks: eligible, apiClient: ChadMusicAPIClient.shared)
  638. } label: {
  639. Label("Upload All to Cloud", systemImage: "arrow.up.to.cloud")
  640. }
  641. }
  642. let failed = tracks.filter { $0.uploadState == .error }
  643. if !failed.isEmpty {
  644. Button {
  645. UploadService.shared.uploadBatch(tracks: failed, apiClient: ChadMusicAPIClient.shared)
  646. } label: {
  647. Label("Retry Failed Uploads", systemImage: "arrow.clockwise")
  648. }
  649. }
  650. if eligible.isEmpty && failed.isEmpty {
  651. Text("All tracks uploaded")
  652. .foregroundStyle(.secondary)
  653. }
  654. }
  655. }
  656. // MARK: - Status badge (left side, informational)
  657. @ViewBuilder
  658. private var uploadStatusBadge: some View {
  659. let localTracks = tracks.filter { !$0.isCloud && !$0.filePath.isEmpty }
  660. let uploading = localTracks.filter { $0.uploadState == .uploading }.count
  661. let uploaded = localTracks.filter { $0.uploadState == .uploaded }.count
  662. let errors = localTracks.filter { $0.uploadState == .error }.count
  663. let total = localTracks.count
  664. if uploading > 0 {
  665. HStack(spacing: 2) {
  666. Image(systemName: "arrow.up.circle.fill")
  667. .font(.system(size: 11))
  668. .foregroundStyle(.orange)
  669. Text("\(uploaded + uploading)/\(total)")
  670. .font(.system(size: 10, design: .monospaced))
  671. .foregroundStyle(.secondary)
  672. }
  673. .help("Uploading \(uploading) track\(uploading == 1 ? "" : "s") to cloud")
  674. } else if errors > 0 {
  675. HStack(spacing: 2) {
  676. Image(systemName: "exclamationmark.circle.fill")
  677. .font(.system(size: 11))
  678. .foregroundStyle(.red)
  679. Text("\(uploaded)/\(total)")
  680. .font(.system(size: 10, design: .monospaced))
  681. .foregroundStyle(.secondary)
  682. }
  683. .help("\(errors) upload\(errors == 1 ? "" : "s") failed")
  684. } else if uploaded > 0 && uploaded == total {
  685. Image(systemName: "checkmark.circle.fill")
  686. .font(.system(size: 11))
  687. .foregroundStyle(.green)
  688. .help("All tracks uploaded to cloud")
  689. } else if uploaded > 0 {
  690. HStack(spacing: 2) {
  691. Image(systemName: "arrow.up.circle")
  692. .font(.system(size: 11))
  693. .foregroundStyle(.secondary)
  694. Text("\(uploaded)/\(total)")
  695. .font(.system(size: 10, design: .monospaced))
  696. .foregroundStyle(.secondary)
  697. }
  698. .help("\(uploaded) of \(total) tracks uploaded to cloud")
  699. }
  700. }
  701. // MARK: - Action button (right edge, prominent)
  702. @ViewBuilder
  703. private var groupCloudActionButton: some View {
  704. let localTracks = tracks.filter { !$0.isCloud && !$0.filePath.isEmpty }
  705. let uploading = localTracks.filter { $0.uploadState == .uploading }.count
  706. let uploaded = localTracks.filter { $0.uploadState == .uploaded }.count
  707. let total = localTracks.count
  708. if ChadMusicAPIClient.shared.isConfigured && total > 0 && uploaded < total {
  709. if uploading > 0 {
  710. Button {
  711. UploadService.shared.cancel()
  712. } label: {
  713. Image(systemName: "stop.circle.fill")
  714. .font(.system(size: 16))
  715. .foregroundStyle(.orange)
  716. .frame(width: 28, height: 28)
  717. .contentShape(Rectangle())
  718. }
  719. .buttonStyle(.plain)
  720. .help("Cancel upload")
  721. } else {
  722. Button {
  723. let eligible = localTracks.filter { $0.uploadState != .uploaded }
  724. UploadService.shared.uploadBatch(tracks: eligible, apiClient: ChadMusicAPIClient.shared)
  725. } label: {
  726. Image(systemName: "arrow.up.circle")
  727. .font(.system(size: 16))
  728. .foregroundStyle(isHovering ? Color.accentColor : .secondary)
  729. .frame(width: 28, height: 28)
  730. .contentShape(Rectangle())
  731. }
  732. .buttonStyle(.plain)
  733. .help("Upload \(total - uploaded) tracks to cloud")
  734. }
  735. }
  736. }
  737. }
  738. // MARK: - Column Header Row
  739. private struct ColumnHeaderRow: View {
  740. @ObservedObject var viewConfig: PlaylistViewConfig
  741. @EnvironmentObject private var theme: AppTheme
  742. private let f = Font.system(size: 10, weight: .medium)
  743. private let fMono = Font.system(size: 10, weight: .medium, design: .monospaced)
  744. private var columns: [PlaylistViewConfig.Column] {
  745. viewConfig.visibleColumns
  746. }
  747. var body: some View {
  748. HStack(spacing: 0) {
  749. // # column or playing indicator space
  750. if columns.contains(.trackNumber) {
  751. Text("#")
  752. .font(fMono)
  753. .foregroundStyle(theme.secondaryText)
  754. .frame(width: 32, alignment: .trailing)
  755. .padding(.trailing, 4)
  756. }
  757. // Artwork spacer
  758. if columns.contains(.artwork) && viewConfig.showArtwork {
  759. Color.clear
  760. .frame(width: 18)
  761. .padding(.trailing, 4)
  762. }
  763. // Artist / Title combined header
  764. if columns.contains(.artist) || columns.contains(.title) {
  765. let parts = [
  766. columns.contains(.artist) ? "Artist" : nil,
  767. columns.contains(.title) ? "Title" : nil
  768. ].compactMap { $0 }
  769. Text(parts.joined(separator: " / "))
  770. .font(f)
  771. .foregroundStyle(theme.secondaryText)
  772. .lineLimit(1)
  773. }
  774. Spacer(minLength: 8)
  775. if columns.contains(.album) {
  776. Text("Album")
  777. .font(f)
  778. .foregroundStyle(theme.secondaryText)
  779. .frame(maxWidth: 150, alignment: .leading)
  780. .padding(.trailing, 8)
  781. }
  782. if columns.contains(.genre) {
  783. Text("Genre")
  784. .font(f)
  785. .foregroundStyle(theme.secondaryText)
  786. .frame(width: 70, alignment: .leading)
  787. }
  788. if columns.contains(.bpm) {
  789. Text("BPM")
  790. .font(fMono)
  791. .foregroundStyle(theme.secondaryText)
  792. .frame(width: 45, alignment: .trailing)
  793. }
  794. if columns.contains(.key) {
  795. Text("Key")
  796. .font(f)
  797. .foregroundStyle(theme.secondaryText)
  798. .frame(width: 42, alignment: .center)
  799. }
  800. if columns.contains(.duration) {
  801. Text("Time")
  802. .font(fMono)
  803. .foregroundStyle(theme.secondaryText)
  804. .frame(width: 58, alignment: .trailing)
  805. }
  806. if columns.contains(.format) {
  807. Text("Fmt")
  808. .font(.system(size: 9, weight: .medium))
  809. .foregroundStyle(theme.secondaryText)
  810. .frame(width: 38, alignment: .center)
  811. }
  812. if columns.contains(.sampleRate) {
  813. Text("Rate")
  814. .font(.system(size: 9, weight: .medium, design: .monospaced))
  815. .foregroundStyle(theme.secondaryText)
  816. .frame(width: 58, alignment: .trailing)
  817. }
  818. if columns.contains(.bitDepth) {
  819. Text("Bit")
  820. .font(.system(size: 9, weight: .medium, design: .monospaced))
  821. .foregroundStyle(theme.secondaryText)
  822. .frame(width: 20, alignment: .trailing)
  823. }
  824. if columns.contains(.fileSize) {
  825. Text("Size")
  826. .font(.system(size: 9, weight: .medium, design: .monospaced))
  827. .foregroundStyle(theme.secondaryText)
  828. .frame(width: 65, alignment: .trailing)
  829. }
  830. if columns.contains(.rating) {
  831. Text("Rating")
  832. .font(.system(size: 9, weight: .medium))
  833. .foregroundStyle(theme.secondaryText)
  834. .frame(width: 50, alignment: .center)
  835. }
  836. if columns.contains(.playCount) {
  837. Text("Plays")
  838. .font(.system(size: 9, weight: .medium, design: .monospaced))
  839. .foregroundStyle(theme.secondaryText)
  840. .frame(width: 25, alignment: .trailing)
  841. }
  842. }
  843. .frame(height: 22)
  844. .padding(.horizontal, 20)
  845. .background(theme.columnHeaderBackground)
  846. }
  847. }
  848. // MARK: - Configurable Entry Row
  849. private struct ConfigurableEntryRow: View {
  850. let entry: PlaylistEntry
  851. let index: Int
  852. let isLast: Bool
  853. @ObservedObject var viewConfig: PlaylistViewConfig
  854. var isPlaying: Bool = false
  855. @Environment(PlayerViewModel.self) private var playerVM
  856. @Environment(PlaylistViewModel.self) private var playlistVM
  857. @Environment(\.modelContext) private var modelContext
  858. @State private var crossfade: Double = 0
  859. @State private var gain: Double = 0
  860. private var f: Font { .system(size: theme.dataFontSize) }
  861. private var fMono: Font { .system(size: theme.dataFontSize, design: .monospaced) }
  862. private var columns: [PlaylistViewConfig.Column] {
  863. viewConfig.visibleColumns
  864. }
  865. @EnvironmentObject private var theme: AppTheme
  866. var body: some View {
  867. HStack(spacing: 0) {
  868. if let track = entry.track {
  869. // Playing indicator (narrow)
  870. if isPlaying {
  871. Text("▶")
  872. .font(.system(size: 8))
  873. .foregroundStyle(theme.playingHighlight)
  874. .frame(width: 12)
  875. } else if columns.contains(.trackNumber) {
  876. Text("\(index + 1)")
  877. .font(fMono)
  878. .foregroundStyle(theme.tertiaryText)
  879. .frame(width: 32, alignment: .trailing)
  880. .padding(.trailing, 4)
  881. }
  882. // Artwork (small)
  883. if columns.contains(.artwork) && viewConfig.showArtwork {
  884. ArtworkView(track: track, size: 18)
  885. .padding(.trailing, 4)
  886. }
  887. // Artist - Title (main text, takes remaining space)
  888. if columns.contains(.artist) && !track.artist.isEmpty {
  889. Text(track.artist)
  890. .font(f)
  891. .foregroundStyle(theme.secondaryText)
  892. .lineLimit(1)
  893. Text(" – ")
  894. .font(f)
  895. .foregroundStyle(theme.tertiaryText)
  896. }
  897. if columns.contains(.title) {
  898. Text(track.title)
  899. .font(f.weight(isPlaying ? .bold : .regular))
  900. .foregroundStyle(isPlaying ? theme.playingHighlight : theme.primaryText)
  901. .lineLimit(1)
  902. }
  903. if track.isCloud {
  904. if track.downloadState == .downloaded || track.localCachePath != nil {
  905. Image(systemName: "arrow.down.circle.fill")
  906. .font(.system(size: 11))
  907. .foregroundStyle(.green)
  908. } else {
  909. Image(systemName: "cloud.fill")
  910. .font(.system(size: 11))
  911. .foregroundStyle(Color.accentColor.opacity(0.85))
  912. }
  913. }
  914. Spacer(minLength: 8)
  915. // Album
  916. if columns.contains(.album) && !track.album.isEmpty {
  917. Text(track.album)
  918. .font(f)
  919. .foregroundStyle(theme.tertiaryText)
  920. .lineLimit(1)
  921. .frame(maxWidth: 150, alignment: .leading)
  922. .padding(.trailing, 8)
  923. }
  924. // Genre
  925. if columns.contains(.genre) && !track.genre.isEmpty {
  926. Text(track.genre)
  927. .font(f)
  928. .foregroundStyle(theme.tertiaryText)
  929. .frame(width: 70, alignment: .leading)
  930. }
  931. // BPM
  932. if columns.contains(.bpm) {
  933. Text(track.bpm.map { String(format: "%.0f", $0) } ?? "")
  934. .font(fMono)
  935. .foregroundStyle(theme.secondaryText)
  936. .frame(width: 45, alignment: .trailing)
  937. }
  938. // Key
  939. if columns.contains(.key) {
  940. Text(track.musicalKey ?? "")
  941. .font(f)
  942. .foregroundStyle(theme.secondaryText)
  943. .frame(width: 42, alignment: .center)
  944. }
  945. // Duration
  946. if columns.contains(.duration) {
  947. Text(track.formattedDuration)
  948. .font(fMono)
  949. .foregroundStyle(theme.secondaryText)
  950. .frame(width: 58, alignment: .trailing)
  951. }
  952. // Format
  953. if columns.contains(.format) {
  954. Text(track.fileFormat)
  955. .font(.system(size: theme.smallFontSize))
  956. .foregroundStyle(theme.tertiaryText)
  957. .frame(width: 38, alignment: .center)
  958. }
  959. // Sample Rate
  960. if columns.contains(.sampleRate) {
  961. Text("\(Int(track.sampleRate))Hz")
  962. .font(.system(size: theme.smallFontSize, design: .monospaced))
  963. .foregroundStyle(theme.tertiaryText)
  964. .frame(width: 58, alignment: .trailing)
  965. }
  966. // Bit Depth
  967. if columns.contains(.bitDepth) {
  968. Text("\(track.bitDepth)")
  969. .font(.system(size: theme.smallFontSize, design: .monospaced))
  970. .foregroundStyle(theme.tertiaryText)
  971. .frame(width: 20, alignment: .trailing)
  972. }
  973. // File Size
  974. if columns.contains(.fileSize) {
  975. Text(track.formattedFileSize)
  976. .font(.system(size: theme.smallFontSize, design: .monospaced))
  977. .foregroundStyle(theme.tertiaryText)
  978. .frame(width: 65, alignment: .trailing)
  979. }
  980. // Rating
  981. if columns.contains(.rating) && track.rating > 0 {
  982. Text(String(repeating: "★", count: track.rating))
  983. .font(.system(size: theme.smallFontSize))
  984. .foregroundStyle(.yellow)
  985. .frame(width: 50, alignment: .center)
  986. }
  987. // Play Count
  988. if columns.contains(.playCount) && track.playCount > 0 {
  989. Text("\(track.playCount)×")
  990. .font(.system(size: theme.smallFontSize, design: .monospaced))
  991. .foregroundStyle(theme.tertiaryText)
  992. .frame(width: 25, alignment: .trailing)
  993. }
  994. }
  995. }
  996. .frame(height: theme.rowHeight)
  997. .onAppear {
  998. crossfade = entry.crossfadeDuration
  999. gain = entry.gainAdjustment
  1000. }
  1001. }
  1002. }
  1003. // MARK: - Empty Playlist
  1004. private struct EmptyPlaylistView: View {
  1005. let onAddTracks: () -> Void
  1006. let onAddFiles: () -> Void
  1007. let onAddFolder: () -> Void
  1008. var body: some View {
  1009. VStack(spacing: 12) {
  1010. Spacer()
  1011. Text("Empty playlist")
  1012. .font(.system(size: 12))
  1013. .foregroundStyle(.secondary)
  1014. Text("Drop files here, or use Add menu above")
  1015. .font(.system(size: 11))
  1016. .foregroundStyle(.tertiary)
  1017. HStack(spacing: 8) {
  1018. Button("Add Files...") { onAddFiles() }
  1019. .font(.system(size: 11))
  1020. Button("Add Folder...") { onAddFolder() }
  1021. .font(.system(size: 11))
  1022. }
  1023. Spacer()
  1024. }
  1025. .frame(maxWidth: .infinity, maxHeight: .infinity)
  1026. }
  1027. }
  1028. // MARK: - Column Config Sheet
  1029. private struct ColumnConfigSheet: View {
  1030. @ObservedObject var viewConfig: PlaylistViewConfig
  1031. @Environment(\.dismiss) private var dismiss
  1032. var body: some View {
  1033. VStack(spacing: 0) {
  1034. HStack {
  1035. Text("Configure Playlist View")
  1036. .font(.headline)
  1037. Spacer()
  1038. Button("Done") { dismiss() }
  1039. .keyboardShortcut(.defaultAction)
  1040. }
  1041. .padding()
  1042. Divider()
  1043. ScrollView {
  1044. VStack(alignment: .leading, spacing: 20) {
  1045. // Artwork settings
  1046. VStack(alignment: .leading, spacing: 8) {
  1047. Text("Artwork")
  1048. .font(.subheadline.bold())
  1049. Toggle("Show artwork", isOn: $viewConfig.showArtwork)
  1050. if viewConfig.showArtwork {
  1051. Picker("Size", selection: $viewConfig.artworkSize) {
  1052. ForEach(PlaylistViewConfig.ArtworkSize.allCases) { size in
  1053. Text(size.rawValue).tag(size)
  1054. }
  1055. }
  1056. .pickerStyle(.segmented)
  1057. .frame(width: 250)
  1058. }
  1059. }
  1060. Divider()
  1061. // Playback behavior
  1062. VStack(alignment: .leading, spacing: 8) {
  1063. Text("Playback Behavior")
  1064. .font(.subheadline.bold())
  1065. Toggle("Cursor follows playback", isOn: $viewConfig.cursorFollowsPlayback)
  1066. Text("Auto-select and scroll to the currently playing track")
  1067. .font(.caption)
  1068. .foregroundStyle(.secondary)
  1069. Toggle("Playback follows cursor", isOn: $viewConfig.playbackFollowsCursor)
  1070. Text("Press Enter/Return to play the selected track")
  1071. .font(.caption)
  1072. .foregroundStyle(.secondary)
  1073. }
  1074. Divider()
  1075. // Visible columns
  1076. VStack(alignment: .leading, spacing: 8) {
  1077. HStack {
  1078. Text("Visible Columns")
  1079. .font(.subheadline.bold())
  1080. Spacer()
  1081. Button("Reset to Defaults") {
  1082. viewConfig.resetToDefaults()
  1083. }
  1084. .font(.caption)
  1085. }
  1086. Text("Check the columns you want to display. Drag to reorder.")
  1087. .font(.caption)
  1088. .foregroundStyle(.secondary)
  1089. LazyVGrid(columns: [GridItem(.adaptive(minimum: 140))], spacing: 6) {
  1090. ForEach(PlaylistViewConfig.Column.allCases) { column in
  1091. Toggle(column.rawValue, isOn: Binding(
  1092. get: { viewConfig.isColumnVisible(column) },
  1093. set: { _ in viewConfig.toggleColumn(column) }
  1094. ))
  1095. .toggleStyle(.checkbox)
  1096. .font(.caption)
  1097. }
  1098. }
  1099. }
  1100. }
  1101. .padding(20)
  1102. }
  1103. }
  1104. .frame(width: 450, height: 520)
  1105. }
  1106. }
  1107. // MARK: - Add Tracks Sheet
  1108. private struct AddTracksSheet: View {
  1109. let playlist: Playlist
  1110. let allTracks: [Track]
  1111. @Environment(PlaylistViewModel.self) private var playlistVM
  1112. @Environment(\.modelContext) private var modelContext
  1113. @Environment(\.dismiss) private var dismiss
  1114. @State private var searchText = ""
  1115. @State private var selectedTracks: Set<UUID> = []
  1116. var filteredTracks: [Track] {
  1117. if searchText.isEmpty { return allTracks }
  1118. let query = searchText.lowercased()
  1119. return allTracks.filter {
  1120. $0.title.lowercased().contains(query) ||
  1121. $0.artist.lowercased().contains(query)
  1122. }
  1123. }
  1124. var body: some View {
  1125. VStack(spacing: 0) {
  1126. HStack {
  1127. Text("Add Tracks to \(playlist.name)")
  1128. .font(.headline)
  1129. Spacer()
  1130. Text("\(selectedTracks.count) selected")
  1131. .foregroundStyle(.secondary)
  1132. }
  1133. .padding()
  1134. TextField("Search tracks...", text: $searchText)
  1135. .textFieldStyle(.roundedBorder)
  1136. .padding(.horizontal)
  1137. List(filteredTracks, selection: $selectedTracks) { track in
  1138. HStack {
  1139. TrackRow(track: track)
  1140. Spacer()
  1141. if let bpm = track.bpm {
  1142. Text("\(String(format: "%.0f", bpm))")
  1143. .font(.caption)
  1144. .foregroundStyle(.secondary)
  1145. }
  1146. Text(track.formattedDuration)
  1147. .font(.caption)
  1148. .foregroundStyle(.secondary)
  1149. }
  1150. }
  1151. .listStyle(.inset)
  1152. Divider()
  1153. HStack {
  1154. Button("Cancel") { dismiss() }
  1155. .keyboardShortcut(.cancelAction)
  1156. Spacer()
  1157. Button("Add \(selectedTracks.count) Track\(selectedTracks.count == 1 ? "" : "s")") {
  1158. let tracks = allTracks.filter { selectedTracks.contains($0.id) }
  1159. playlistVM.addTracks(tracks, to: playlist, context: modelContext)
  1160. dismiss()
  1161. }
  1162. .keyboardShortcut(.defaultAction)
  1163. .disabled(selectedTracks.isEmpty)
  1164. }
  1165. .padding()
  1166. }
  1167. .frame(width: 500, height: 600)
  1168. }
  1169. }
  1170. // MARK: - Track Notes Sheet
  1171. private struct TrackNotesSheet: View {
  1172. let track: Track
  1173. @Environment(\.dismiss) private var dismiss
  1174. @Environment(\.modelContext) private var modelContext
  1175. @State private var notes: String = ""
  1176. var body: some View {
  1177. VStack(spacing: 12) {
  1178. HStack {
  1179. VStack(alignment: .leading, spacing: 2) {
  1180. Text(track.title)
  1181. .font(.headline)
  1182. if !track.artist.isEmpty {
  1183. Text(track.artist)
  1184. .font(.subheadline)
  1185. .foregroundStyle(.secondary)
  1186. }
  1187. }
  1188. Spacer()
  1189. Button("Done") {
  1190. track.notes = notes
  1191. try? modelContext.save()
  1192. dismiss()
  1193. }
  1194. .keyboardShortcut(.defaultAction)
  1195. }
  1196. TextEditor(text: $notes)
  1197. .font(.system(size: 13))
  1198. .frame(minHeight: 100)
  1199. .scrollContentBackground(.hidden)
  1200. .padding(4)
  1201. .background(Color.gray.opacity(0.1))
  1202. .cornerRadius(6)
  1203. HStack {
  1204. Text("Notes are saved with the track and included in exports")
  1205. .font(.caption)
  1206. .foregroundStyle(.secondary)
  1207. Spacer()
  1208. }
  1209. }
  1210. .padding(16)
  1211. .frame(width: 400, height: 220)
  1212. .onAppear {
  1213. notes = track.notes
  1214. }
  1215. }
  1216. }
  1217. // MARK: - Double-Click Handler (NSViewRepresentable)