PlaylistView.swift 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261
  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. Button { onExport() } label: {
  218. Label("Export", systemImage: "square.and.arrow.up")
  219. }
  220. .disabled(playlist.entries.isEmpty)
  221. }
  222. .controlSize(.small)
  223. Button {
  224. NotificationCenter.default.post(name: .toggleBrowsePanel, object: nil)
  225. } label: {
  226. Image(systemName: "cloud.fill")
  227. .font(.system(size: 20))
  228. .foregroundStyle(isBrowsePanelOpen ? Color.accentColor : theme.secondaryText)
  229. .frame(width: 32, height: 28)
  230. }
  231. .buttonStyle(.plain)
  232. .help("Chad Music")
  233. }
  234. .padding(.horizontal, 10)
  235. .padding(.vertical, 7)
  236. .background(theme.toolbarBackground)
  237. }
  238. private func formatDuration(_ duration: TimeInterval) -> String {
  239. let total = Int(duration)
  240. let hours = total / 3600
  241. let minutes = (total % 3600) / 60
  242. let seconds = total % 60
  243. if hours > 0 {
  244. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  245. }
  246. return String(format: "%d:%02d", minutes, seconds)
  247. }
  248. }
  249. // MARK: - Playlist Entry List (with Grouping)
  250. private struct PlaylistEntryList: View {
  251. let playlist: Playlist
  252. @Binding var draggedEntry: PlaylistEntry?
  253. @ObservedObject var viewConfig: PlaylistViewConfig
  254. @Environment(PlayerViewModel.self) private var playerVM
  255. @Environment(PlaylistViewModel.self) private var playlistVM
  256. @EnvironmentObject private var libraryManager: LibraryManager
  257. @Environment(\.modelContext) private var modelContext
  258. @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
  259. @State private var selectedEntryIDs: Set<UUID> = []
  260. @State private var scrollTarget: UUID?
  261. @State private var editingNotesTrack: Track?
  262. @AppStorage("playbackMode") private var playbackMode: String = "queue"
  263. /// Group entries by the playlist's groupTemplate.
  264. private var groupedEntries: [(key: String, entries: [(index: Int, entry: PlaylistEntry)])] {
  265. let sorted = playlist.sortedEntries
  266. let indexed = sorted.enumerated().map { (index: $0.offset, entry: $0.element) }
  267. guard !playlist.groupTemplate.isEmpty else {
  268. return [("", indexed)]
  269. }
  270. // Group consecutively by resolved template (preserves playlist order)
  271. var groups: [(String, [(index: Int, entry: PlaylistEntry)])] = []
  272. var currentHeader = ""
  273. var currentEntries: [(index: Int, entry: PlaylistEntry)] = []
  274. for item in indexed {
  275. let header: String
  276. if let track = item.entry.track {
  277. header = GroupTemplateResolver.resolve(template: playlist.groupTemplate, for: track)
  278. } else {
  279. header = "Unknown"
  280. }
  281. if header != currentHeader {
  282. if !currentEntries.isEmpty {
  283. groups.append((currentHeader, currentEntries))
  284. }
  285. currentHeader = header
  286. currentEntries = [item]
  287. } else {
  288. currentEntries.append(item)
  289. }
  290. }
  291. if !currentEntries.isEmpty {
  292. groups.append((currentHeader, currentEntries))
  293. }
  294. return groups.map { (key: $0.0, entries: $0.1) }
  295. }
  296. var body: some View {
  297. VStack(spacing: 0) {
  298. // Selection toolbar
  299. if !selectedEntryIDs.isEmpty {
  300. SelectionToolbar(
  301. count: selectedEntryIDs.count,
  302. onSelectAll: { selectedEntryIDs = Set(playlist.entries.map(\.id)) },
  303. onDeselect: { selectedEntryIDs.removeAll() },
  304. onRemove: {
  305. let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
  306. for entry in toRemove {
  307. playlistVM.removeEntry(entry, from: playlist, context: modelContext)
  308. }
  309. selectedEntryIDs.removeAll()
  310. }
  311. )
  312. Divider()
  313. }
  314. // Column headers
  315. ColumnHeaderRow(viewConfig: viewConfig)
  316. Divider()
  317. List(selection: $selectedEntryIDs) {
  318. ForEach(groupedEntries, id: \.key) { group in
  319. if !playlist.groupTemplate.isEmpty && !group.key.isEmpty {
  320. Section {
  321. groupContent(group.entries)
  322. } header: {
  323. GroupHeaderView(
  324. title: group.key,
  325. trackCount: group.entries.count,
  326. firstTrack: group.entries.first?.entry.track,
  327. showArtwork: viewConfig.showArtwork
  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. FileManager.default.fileExists(atPath: track.filePath),
  492. ChadMusicAPIClient.shared.isConfigured {
  493. Divider()
  494. Button {
  495. UploadService.shared.startUpload(
  496. fileURL: URL(fileURLWithPath: track.filePath),
  497. apiClient: ChadMusicAPIClient.shared
  498. )
  499. } label: {
  500. Label("Upload to Cloud", systemImage: "arrow.up.to.cloud")
  501. }
  502. }
  503. }
  504. Divider()
  505. Button {
  506. viewConfig.cursorFollowsPlayback = true
  507. viewConfig.playbackFollowsCursor = false
  508. } label: {
  509. HStack {
  510. Text("Cursor follows playback")
  511. if viewConfig.cursorFollowsPlayback {
  512. Spacer()
  513. Image(systemName: "checkmark")
  514. }
  515. }
  516. }
  517. Button {
  518. viewConfig.playbackFollowsCursor = true
  519. viewConfig.cursorFollowsPlayback = false
  520. } label: {
  521. HStack {
  522. Text("Playback follows cursor")
  523. if viewConfig.playbackFollowsCursor {
  524. Spacer()
  525. Image(systemName: "checkmark")
  526. }
  527. }
  528. }
  529. if let track = item.entry.track {
  530. Divider()
  531. Button("Analyze BPM & Key") {
  532. Task { await libraryManager.analyzeTrack(track) }
  533. }
  534. Button("Rescan Metadata") {
  535. Task {
  536. await libraryManager.rescanMetadata(track)
  537. try? modelContext.save()
  538. }
  539. }
  540. Button("Edit Notes...") {
  541. editingNotesTrack = track
  542. }
  543. // Quick add to mix targets
  544. let otherMixSlots = (0..<3).filter { playlistVM.mixTargets[$0] != nil && playlistVM.mixTargets[$0]?.id != playlist.id }
  545. if !otherMixSlots.isEmpty {
  546. let mixShortcuts: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
  547. Menu("Add to Mix") {
  548. ForEach(otherMixSlots, id: \.self) { slot in
  549. let hint = KeyboardShortcutConfig.shared.binding(for: mixShortcuts[slot]).displayString
  550. Button("\(playlistVM.mixTargetName(slot)) (\(hint))") {
  551. _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
  552. }
  553. }
  554. }
  555. }
  556. }
  557. Divider()
  558. if selectedEntryIDs.count > 1 {
  559. Button("Remove \(selectedEntryIDs.count) Selected", role: .destructive) {
  560. let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
  561. for e in toRemove {
  562. playlistVM.removeEntry(e, from: playlist, context: modelContext)
  563. }
  564. selectedEntryIDs.removeAll()
  565. }
  566. } else {
  567. Button("Remove from Playlist", role: .destructive) {
  568. playlistVM.removeEntry(item.entry, from: playlist, context: modelContext)
  569. }
  570. }
  571. }
  572. }
  573. .onMove { source, destination in
  574. if let first = source.first {
  575. playlistVM.moveEntry(in: playlist, from: first, to: destination, context: modelContext)
  576. }
  577. }
  578. }
  579. }
  580. // MARK: - Selection Toolbar
  581. private struct SelectionToolbar: View {
  582. let count: Int
  583. let onSelectAll: () -> Void
  584. let onDeselect: () -> Void
  585. let onRemove: () -> Void
  586. @EnvironmentObject private var theme: AppTheme
  587. var body: some View {
  588. HStack(spacing: 8) {
  589. Text("\(count) selected")
  590. .font(.system(size: theme.smallFontSize + 1))
  591. .foregroundStyle(theme.secondaryText)
  592. Spacer()
  593. Button("All", action: onSelectAll).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
  594. Button("None", action: onDeselect).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
  595. Button("Remove", role: .destructive, action: onRemove).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
  596. }
  597. .padding(.horizontal, 8)
  598. .padding(.vertical, 2)
  599. .background(theme.toolbarBackground)
  600. }
  601. }
  602. // MARK: - Group Header with Artwork
  603. private struct GroupHeaderView: View {
  604. let title: String
  605. let trackCount: Int
  606. let firstTrack: Track?
  607. let showArtwork: Bool
  608. @EnvironmentObject private var theme: AppTheme
  609. var body: some View {
  610. HStack(spacing: 6) {
  611. if showArtwork, let track = firstTrack {
  612. ArtworkView(track: track, size: 18)
  613. }
  614. Text(title)
  615. .font(.system(size: theme.dataFontSize, weight: .bold))
  616. .foregroundStyle(theme.groupHeaderText)
  617. Text("(\(trackCount))")
  618. .font(.system(size: theme.smallFontSize + 1))
  619. .foregroundStyle(theme.tertiaryText)
  620. }
  621. .padding(.vertical, 2)
  622. }
  623. }
  624. // MARK: - Column Header Row
  625. private struct ColumnHeaderRow: View {
  626. @ObservedObject var viewConfig: PlaylistViewConfig
  627. @EnvironmentObject private var theme: AppTheme
  628. private let f = Font.system(size: 10, weight: .medium)
  629. private let fMono = Font.system(size: 10, weight: .medium, design: .monospaced)
  630. private var columns: [PlaylistViewConfig.Column] {
  631. viewConfig.visibleColumns
  632. }
  633. var body: some View {
  634. HStack(spacing: 0) {
  635. // # column or playing indicator space
  636. if columns.contains(.trackNumber) {
  637. Text("#")
  638. .font(fMono)
  639. .foregroundStyle(theme.secondaryText)
  640. .frame(width: 32, alignment: .trailing)
  641. .padding(.trailing, 4)
  642. }
  643. // Artwork spacer
  644. if columns.contains(.artwork) && viewConfig.showArtwork {
  645. Color.clear
  646. .frame(width: 18)
  647. .padding(.trailing, 4)
  648. }
  649. // Artist / Title combined header
  650. if columns.contains(.artist) || columns.contains(.title) {
  651. let parts = [
  652. columns.contains(.artist) ? "Artist" : nil,
  653. columns.contains(.title) ? "Title" : nil
  654. ].compactMap { $0 }
  655. Text(parts.joined(separator: " / "))
  656. .font(f)
  657. .foregroundStyle(theme.secondaryText)
  658. .lineLimit(1)
  659. }
  660. Spacer(minLength: 8)
  661. if columns.contains(.album) {
  662. Text("Album")
  663. .font(f)
  664. .foregroundStyle(theme.secondaryText)
  665. .frame(maxWidth: 150, alignment: .leading)
  666. .padding(.trailing, 8)
  667. }
  668. if columns.contains(.genre) {
  669. Text("Genre")
  670. .font(f)
  671. .foregroundStyle(theme.secondaryText)
  672. .frame(width: 70, alignment: .leading)
  673. }
  674. if columns.contains(.bpm) {
  675. Text("BPM")
  676. .font(fMono)
  677. .foregroundStyle(theme.secondaryText)
  678. .frame(width: 45, alignment: .trailing)
  679. }
  680. if columns.contains(.key) {
  681. Text("Key")
  682. .font(f)
  683. .foregroundStyle(theme.secondaryText)
  684. .frame(width: 42, alignment: .center)
  685. }
  686. if columns.contains(.duration) {
  687. Text("Time")
  688. .font(fMono)
  689. .foregroundStyle(theme.secondaryText)
  690. .frame(width: 58, alignment: .trailing)
  691. }
  692. if columns.contains(.format) {
  693. Text("Fmt")
  694. .font(.system(size: 9, weight: .medium))
  695. .foregroundStyle(theme.secondaryText)
  696. .frame(width: 38, alignment: .center)
  697. }
  698. if columns.contains(.sampleRate) {
  699. Text("Rate")
  700. .font(.system(size: 9, weight: .medium, design: .monospaced))
  701. .foregroundStyle(theme.secondaryText)
  702. .frame(width: 58, alignment: .trailing)
  703. }
  704. if columns.contains(.bitDepth) {
  705. Text("Bit")
  706. .font(.system(size: 9, weight: .medium, design: .monospaced))
  707. .foregroundStyle(theme.secondaryText)
  708. .frame(width: 20, alignment: .trailing)
  709. }
  710. if columns.contains(.fileSize) {
  711. Text("Size")
  712. .font(.system(size: 9, weight: .medium, design: .monospaced))
  713. .foregroundStyle(theme.secondaryText)
  714. .frame(width: 65, alignment: .trailing)
  715. }
  716. if columns.contains(.rating) {
  717. Text("Rating")
  718. .font(.system(size: 9, weight: .medium))
  719. .foregroundStyle(theme.secondaryText)
  720. .frame(width: 50, alignment: .center)
  721. }
  722. if columns.contains(.playCount) {
  723. Text("Plays")
  724. .font(.system(size: 9, weight: .medium, design: .monospaced))
  725. .foregroundStyle(theme.secondaryText)
  726. .frame(width: 25, alignment: .trailing)
  727. }
  728. }
  729. .frame(height: 22)
  730. .padding(.horizontal, 20)
  731. .background(theme.columnHeaderBackground)
  732. }
  733. }
  734. // MARK: - Configurable Entry Row
  735. private struct ConfigurableEntryRow: View {
  736. let entry: PlaylistEntry
  737. let index: Int
  738. let isLast: Bool
  739. @ObservedObject var viewConfig: PlaylistViewConfig
  740. var isPlaying: Bool = false
  741. @Environment(PlayerViewModel.self) private var playerVM
  742. @Environment(PlaylistViewModel.self) private var playlistVM
  743. @Environment(\.modelContext) private var modelContext
  744. @State private var crossfade: Double = 0
  745. @State private var gain: Double = 0
  746. private var f: Font { .system(size: theme.dataFontSize) }
  747. private var fMono: Font { .system(size: theme.dataFontSize, design: .monospaced) }
  748. private var columns: [PlaylistViewConfig.Column] {
  749. viewConfig.visibleColumns
  750. }
  751. @EnvironmentObject private var theme: AppTheme
  752. var body: some View {
  753. HStack(spacing: 0) {
  754. if let track = entry.track {
  755. // Playing indicator (narrow)
  756. if isPlaying {
  757. Text("▶")
  758. .font(.system(size: 8))
  759. .foregroundStyle(theme.playingHighlight)
  760. .frame(width: 12)
  761. } else if columns.contains(.trackNumber) {
  762. Text("\(index + 1)")
  763. .font(fMono)
  764. .foregroundStyle(theme.tertiaryText)
  765. .frame(width: 32, alignment: .trailing)
  766. .padding(.trailing, 4)
  767. }
  768. // Artwork (small)
  769. if columns.contains(.artwork) && viewConfig.showArtwork {
  770. ArtworkView(track: track, size: 18)
  771. .padding(.trailing, 4)
  772. }
  773. // Artist - Title (main text, takes remaining space)
  774. if columns.contains(.artist) && !track.artist.isEmpty {
  775. Text(track.artist)
  776. .font(f)
  777. .foregroundStyle(theme.secondaryText)
  778. .lineLimit(1)
  779. Text(" – ")
  780. .font(f)
  781. .foregroundStyle(theme.tertiaryText)
  782. }
  783. if columns.contains(.title) {
  784. Text(track.title)
  785. .font(f.weight(isPlaying ? .bold : .regular))
  786. .foregroundStyle(isPlaying ? theme.playingHighlight : theme.primaryText)
  787. .lineLimit(1)
  788. }
  789. if track.isCloud {
  790. if track.downloadState == .downloaded || track.localCachePath != nil {
  791. Image(systemName: "arrow.down.circle.fill")
  792. .font(.system(size: 11))
  793. .foregroundStyle(.green)
  794. } else {
  795. Image(systemName: "cloud.fill")
  796. .font(.system(size: 11))
  797. .foregroundStyle(Color.accentColor.opacity(0.85))
  798. }
  799. }
  800. Spacer(minLength: 8)
  801. // Album
  802. if columns.contains(.album) && !track.album.isEmpty {
  803. Text(track.album)
  804. .font(f)
  805. .foregroundStyle(theme.tertiaryText)
  806. .lineLimit(1)
  807. .frame(maxWidth: 150, alignment: .leading)
  808. .padding(.trailing, 8)
  809. }
  810. // Genre
  811. if columns.contains(.genre) && !track.genre.isEmpty {
  812. Text(track.genre)
  813. .font(f)
  814. .foregroundStyle(theme.tertiaryText)
  815. .frame(width: 70, alignment: .leading)
  816. }
  817. // BPM
  818. if columns.contains(.bpm) {
  819. Text(track.bpm.map { String(format: "%.0f", $0) } ?? "")
  820. .font(fMono)
  821. .foregroundStyle(theme.secondaryText)
  822. .frame(width: 45, alignment: .trailing)
  823. }
  824. // Key
  825. if columns.contains(.key) {
  826. Text(track.musicalKey ?? "")
  827. .font(f)
  828. .foregroundStyle(theme.secondaryText)
  829. .frame(width: 42, alignment: .center)
  830. }
  831. // Duration
  832. if columns.contains(.duration) {
  833. Text(track.formattedDuration)
  834. .font(fMono)
  835. .foregroundStyle(theme.secondaryText)
  836. .frame(width: 58, alignment: .trailing)
  837. }
  838. // Format
  839. if columns.contains(.format) {
  840. Text(track.fileFormat)
  841. .font(.system(size: theme.smallFontSize))
  842. .foregroundStyle(theme.tertiaryText)
  843. .frame(width: 38, alignment: .center)
  844. }
  845. // Sample Rate
  846. if columns.contains(.sampleRate) {
  847. Text("\(Int(track.sampleRate))Hz")
  848. .font(.system(size: theme.smallFontSize, design: .monospaced))
  849. .foregroundStyle(theme.tertiaryText)
  850. .frame(width: 58, alignment: .trailing)
  851. }
  852. // Bit Depth
  853. if columns.contains(.bitDepth) {
  854. Text("\(track.bitDepth)")
  855. .font(.system(size: theme.smallFontSize, design: .monospaced))
  856. .foregroundStyle(theme.tertiaryText)
  857. .frame(width: 20, alignment: .trailing)
  858. }
  859. // File Size
  860. if columns.contains(.fileSize) {
  861. Text(track.formattedFileSize)
  862. .font(.system(size: theme.smallFontSize, design: .monospaced))
  863. .foregroundStyle(theme.tertiaryText)
  864. .frame(width: 65, alignment: .trailing)
  865. }
  866. // Rating
  867. if columns.contains(.rating) && track.rating > 0 {
  868. Text(String(repeating: "★", count: track.rating))
  869. .font(.system(size: theme.smallFontSize))
  870. .foregroundStyle(.yellow)
  871. .frame(width: 50, alignment: .center)
  872. }
  873. // Play Count
  874. if columns.contains(.playCount) && track.playCount > 0 {
  875. Text("\(track.playCount)×")
  876. .font(.system(size: theme.smallFontSize, design: .monospaced))
  877. .foregroundStyle(theme.tertiaryText)
  878. .frame(width: 25, alignment: .trailing)
  879. }
  880. }
  881. }
  882. .frame(height: theme.rowHeight)
  883. .onAppear {
  884. crossfade = entry.crossfadeDuration
  885. gain = entry.gainAdjustment
  886. }
  887. }
  888. }
  889. // MARK: - Empty Playlist
  890. private struct EmptyPlaylistView: View {
  891. let onAddTracks: () -> Void
  892. let onAddFiles: () -> Void
  893. let onAddFolder: () -> Void
  894. var body: some View {
  895. VStack(spacing: 12) {
  896. Spacer()
  897. Text("Empty playlist")
  898. .font(.system(size: 12))
  899. .foregroundStyle(.secondary)
  900. Text("Drop files here, or use Add menu above")
  901. .font(.system(size: 11))
  902. .foregroundStyle(.tertiary)
  903. HStack(spacing: 8) {
  904. Button("Add Files...") { onAddFiles() }
  905. .font(.system(size: 11))
  906. Button("Add Folder...") { onAddFolder() }
  907. .font(.system(size: 11))
  908. }
  909. Spacer()
  910. }
  911. .frame(maxWidth: .infinity, maxHeight: .infinity)
  912. }
  913. }
  914. // MARK: - Column Config Sheet
  915. private struct ColumnConfigSheet: View {
  916. @ObservedObject var viewConfig: PlaylistViewConfig
  917. @Environment(\.dismiss) private var dismiss
  918. var body: some View {
  919. VStack(spacing: 0) {
  920. HStack {
  921. Text("Configure Playlist View")
  922. .font(.headline)
  923. Spacer()
  924. Button("Done") { dismiss() }
  925. .keyboardShortcut(.defaultAction)
  926. }
  927. .padding()
  928. Divider()
  929. ScrollView {
  930. VStack(alignment: .leading, spacing: 20) {
  931. // Artwork settings
  932. VStack(alignment: .leading, spacing: 8) {
  933. Text("Artwork")
  934. .font(.subheadline.bold())
  935. Toggle("Show artwork", isOn: $viewConfig.showArtwork)
  936. if viewConfig.showArtwork {
  937. Picker("Size", selection: $viewConfig.artworkSize) {
  938. ForEach(PlaylistViewConfig.ArtworkSize.allCases) { size in
  939. Text(size.rawValue).tag(size)
  940. }
  941. }
  942. .pickerStyle(.segmented)
  943. .frame(width: 250)
  944. }
  945. }
  946. Divider()
  947. // Playback behavior
  948. VStack(alignment: .leading, spacing: 8) {
  949. Text("Playback Behavior")
  950. .font(.subheadline.bold())
  951. Toggle("Cursor follows playback", isOn: $viewConfig.cursorFollowsPlayback)
  952. Text("Auto-select and scroll to the currently playing track")
  953. .font(.caption)
  954. .foregroundStyle(.secondary)
  955. Toggle("Playback follows cursor", isOn: $viewConfig.playbackFollowsCursor)
  956. Text("Press Enter/Return to play the selected track")
  957. .font(.caption)
  958. .foregroundStyle(.secondary)
  959. }
  960. Divider()
  961. // Visible columns
  962. VStack(alignment: .leading, spacing: 8) {
  963. HStack {
  964. Text("Visible Columns")
  965. .font(.subheadline.bold())
  966. Spacer()
  967. Button("Reset to Defaults") {
  968. viewConfig.resetToDefaults()
  969. }
  970. .font(.caption)
  971. }
  972. Text("Check the columns you want to display. Drag to reorder.")
  973. .font(.caption)
  974. .foregroundStyle(.secondary)
  975. LazyVGrid(columns: [GridItem(.adaptive(minimum: 140))], spacing: 6) {
  976. ForEach(PlaylistViewConfig.Column.allCases) { column in
  977. Toggle(column.rawValue, isOn: Binding(
  978. get: { viewConfig.isColumnVisible(column) },
  979. set: { _ in viewConfig.toggleColumn(column) }
  980. ))
  981. .toggleStyle(.checkbox)
  982. .font(.caption)
  983. }
  984. }
  985. }
  986. }
  987. .padding(20)
  988. }
  989. }
  990. .frame(width: 450, height: 520)
  991. }
  992. }
  993. // MARK: - Add Tracks Sheet
  994. private struct AddTracksSheet: View {
  995. let playlist: Playlist
  996. let allTracks: [Track]
  997. @Environment(PlaylistViewModel.self) private var playlistVM
  998. @Environment(\.modelContext) private var modelContext
  999. @Environment(\.dismiss) private var dismiss
  1000. @State private var searchText = ""
  1001. @State private var selectedTracks: Set<UUID> = []
  1002. var filteredTracks: [Track] {
  1003. if searchText.isEmpty { return allTracks }
  1004. let query = searchText.lowercased()
  1005. return allTracks.filter {
  1006. $0.title.lowercased().contains(query) ||
  1007. $0.artist.lowercased().contains(query)
  1008. }
  1009. }
  1010. var body: some View {
  1011. VStack(spacing: 0) {
  1012. HStack {
  1013. Text("Add Tracks to \(playlist.name)")
  1014. .font(.headline)
  1015. Spacer()
  1016. Text("\(selectedTracks.count) selected")
  1017. .foregroundStyle(.secondary)
  1018. }
  1019. .padding()
  1020. TextField("Search tracks...", text: $searchText)
  1021. .textFieldStyle(.roundedBorder)
  1022. .padding(.horizontal)
  1023. List(filteredTracks, selection: $selectedTracks) { track in
  1024. HStack {
  1025. TrackRow(track: track)
  1026. Spacer()
  1027. if let bpm = track.bpm {
  1028. Text("\(String(format: "%.0f", bpm))")
  1029. .font(.caption)
  1030. .foregroundStyle(.secondary)
  1031. }
  1032. Text(track.formattedDuration)
  1033. .font(.caption)
  1034. .foregroundStyle(.secondary)
  1035. }
  1036. }
  1037. .listStyle(.inset)
  1038. Divider()
  1039. HStack {
  1040. Button("Cancel") { dismiss() }
  1041. .keyboardShortcut(.cancelAction)
  1042. Spacer()
  1043. Button("Add \(selectedTracks.count) Track\(selectedTracks.count == 1 ? "" : "s")") {
  1044. let tracks = allTracks.filter { selectedTracks.contains($0.id) }
  1045. playlistVM.addTracks(tracks, to: playlist, context: modelContext)
  1046. dismiss()
  1047. }
  1048. .keyboardShortcut(.defaultAction)
  1049. .disabled(selectedTracks.isEmpty)
  1050. }
  1051. .padding()
  1052. }
  1053. .frame(width: 500, height: 600)
  1054. }
  1055. }
  1056. // MARK: - Track Notes Sheet
  1057. private struct TrackNotesSheet: View {
  1058. let track: Track
  1059. @Environment(\.dismiss) private var dismiss
  1060. @Environment(\.modelContext) private var modelContext
  1061. @State private var notes: String = ""
  1062. var body: some View {
  1063. VStack(spacing: 12) {
  1064. HStack {
  1065. VStack(alignment: .leading, spacing: 2) {
  1066. Text(track.title)
  1067. .font(.headline)
  1068. if !track.artist.isEmpty {
  1069. Text(track.artist)
  1070. .font(.subheadline)
  1071. .foregroundStyle(.secondary)
  1072. }
  1073. }
  1074. Spacer()
  1075. Button("Done") {
  1076. track.notes = notes
  1077. try? modelContext.save()
  1078. dismiss()
  1079. }
  1080. .keyboardShortcut(.defaultAction)
  1081. }
  1082. TextEditor(text: $notes)
  1083. .font(.system(size: 13))
  1084. .frame(minHeight: 100)
  1085. .scrollContentBackground(.hidden)
  1086. .padding(4)
  1087. .background(Color.gray.opacity(0.1))
  1088. .cornerRadius(6)
  1089. HStack {
  1090. Text("Notes are saved with the track and included in exports")
  1091. .font(.caption)
  1092. .foregroundStyle(.secondary)
  1093. Spacer()
  1094. }
  1095. }
  1096. .padding(16)
  1097. .frame(width: 400, height: 220)
  1098. .onAppear {
  1099. notes = track.notes
  1100. }
  1101. }
  1102. }
  1103. // MARK: - Double-Click Handler (NSViewRepresentable)