PlaylistView.swift 52 KB

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