PlaylistView.swift 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169
  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. Button { onExport() } label: {
  214. Label("Export", systemImage: "square.and.arrow.up")
  215. }
  216. .disabled(playlist.entries.isEmpty)
  217. }
  218. .controlSize(.small)
  219. }
  220. .padding(.horizontal, 10)
  221. .padding(.vertical, 7)
  222. .background(theme.toolbarBackground)
  223. }
  224. private func formatDuration(_ duration: TimeInterval) -> String {
  225. let total = Int(duration)
  226. let hours = total / 3600
  227. let minutes = (total % 3600) / 60
  228. let seconds = total % 60
  229. if hours > 0 {
  230. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  231. }
  232. return String(format: "%d:%02d", minutes, seconds)
  233. }
  234. }
  235. // MARK: - Playlist Entry List (with Grouping)
  236. private struct PlaylistEntryList: View {
  237. let playlist: Playlist
  238. @Binding var draggedEntry: PlaylistEntry?
  239. @ObservedObject var viewConfig: PlaylistViewConfig
  240. @Environment(PlayerViewModel.self) private var playerVM
  241. @Environment(PlaylistViewModel.self) private var playlistVM
  242. @EnvironmentObject private var libraryManager: LibraryManager
  243. @Environment(\.modelContext) private var modelContext
  244. @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
  245. @State private var selectedEntryIDs: Set<UUID> = []
  246. @State private var scrollTarget: UUID?
  247. @State private var editingNotesTrack: Track?
  248. /// Group entries by the playlist's groupTemplate.
  249. private var groupedEntries: [(key: String, entries: [(index: Int, entry: PlaylistEntry)])] {
  250. let sorted = playlist.sortedEntries
  251. let indexed = sorted.enumerated().map { (index: $0.offset, entry: $0.element) }
  252. guard !playlist.groupTemplate.isEmpty else {
  253. return [("", indexed)]
  254. }
  255. // Group consecutively by resolved template (preserves playlist order)
  256. var groups: [(String, [(index: Int, entry: PlaylistEntry)])] = []
  257. var currentHeader = ""
  258. var currentEntries: [(index: Int, entry: PlaylistEntry)] = []
  259. for item in indexed {
  260. let header: String
  261. if let track = item.entry.track {
  262. header = GroupTemplateResolver.resolve(template: playlist.groupTemplate, for: track)
  263. } else {
  264. header = "Unknown"
  265. }
  266. if header != currentHeader {
  267. if !currentEntries.isEmpty {
  268. groups.append((currentHeader, currentEntries))
  269. }
  270. currentHeader = header
  271. currentEntries = [item]
  272. } else {
  273. currentEntries.append(item)
  274. }
  275. }
  276. if !currentEntries.isEmpty {
  277. groups.append((currentHeader, currentEntries))
  278. }
  279. return groups.map { (key: $0.0, entries: $0.1) }
  280. }
  281. var body: some View {
  282. VStack(spacing: 0) {
  283. // Selection toolbar
  284. if !selectedEntryIDs.isEmpty {
  285. SelectionToolbar(
  286. count: selectedEntryIDs.count,
  287. onSelectAll: { selectedEntryIDs = Set(playlist.entries.map(\.id)) },
  288. onDeselect: { selectedEntryIDs.removeAll() },
  289. onRemove: {
  290. let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
  291. for entry in toRemove {
  292. playlistVM.removeEntry(entry, from: playlist, context: modelContext)
  293. }
  294. selectedEntryIDs.removeAll()
  295. }
  296. )
  297. Divider()
  298. }
  299. // Column headers
  300. ColumnHeaderRow(viewConfig: viewConfig)
  301. Divider()
  302. List(selection: $selectedEntryIDs) {
  303. ForEach(groupedEntries, id: \.key) { group in
  304. if !playlist.groupTemplate.isEmpty && !group.key.isEmpty {
  305. Section {
  306. groupContent(group.entries)
  307. } header: {
  308. GroupHeaderView(
  309. title: group.key,
  310. trackCount: group.entries.count,
  311. firstTrack: group.entries.first?.entry.track,
  312. showArtwork: viewConfig.showArtwork
  313. )
  314. }
  315. } else {
  316. groupContent(group.entries)
  317. }
  318. }
  319. }
  320. .listStyle(.inset)
  321. // Enter key always plays selected track (like foobar2000)
  322. .onKeyPress(.return) {
  323. playSelectedTrack()
  324. return .handled
  325. }
  326. // Arrow keys for navigation
  327. .onKeyPress(.upArrow) {
  328. moveSelection(by: -1)
  329. return .handled
  330. }
  331. .onKeyPress(.downArrow) {
  332. moveSelection(by: 1)
  333. return .handled
  334. }
  335. // Backspace/Delete key removes selected entries (onDeleteCommand is the macOS-native way)
  336. .onDeleteCommand {
  337. removeSelectedEntries()
  338. }
  339. // Cursor follows playback: select playing entry
  340. .onChange(of: playerVM.currentPlayingEntryID) { _, newID in
  341. guard viewConfig.cursorFollowsPlayback, let entryID = newID else { return }
  342. selectedEntryIDs = [entryID]
  343. }
  344. // Sync cursor position to PlayerViewModel for "Playback follows cursor"
  345. .onChange(of: selectedEntryIDs) { _, newIDs in
  346. playerVM.cursorEntryID = newIDs.first
  347. }
  348. // When PlayerViewModel moves cursor (auto-advance), update the UI selection
  349. .onChange(of: playerVM.cursorEntryID) { _, newID in
  350. if let newID, !selectedEntryIDs.contains(newID) {
  351. selectedEntryIDs = [newID]
  352. }
  353. }
  354. .sheet(item: $editingNotesTrack) { track in
  355. TrackNotesSheet(track: track)
  356. }
  357. // Double-click to play (via NSEvent monitor in MediaKeyHandler)
  358. .onReceive(NotificationCenter.default.publisher(for: .doubleClickPlayTrack)) { _ in
  359. playSelectedTrack()
  360. }
  361. // When this playlist view appears, restore cursor to playing entry if applicable
  362. .onAppear {
  363. if selectedEntryIDs.isEmpty,
  364. let playingID = playerVM.currentPlayingEntryID,
  365. playlist.sortedEntries.contains(where: { $0.id == playingID }) {
  366. selectedEntryIDs = [playingID]
  367. playerVM.cursorEntryID = playingID
  368. }
  369. }
  370. }
  371. }
  372. /// Play the first selected entry.
  373. private func playSelectedTrack() {
  374. guard let firstID = selectedEntryIDs.first,
  375. let entry = playlist.sortedEntries.first(where: { $0.id == firstID }),
  376. let track = entry.track else { return }
  377. playerVM.loadAndPlay(track, entryID: entry.id, playlist: playlist)
  378. }
  379. /// Remove all selected entries from the playlist.
  380. private func removeSelectedEntries() {
  381. guard !selectedEntryIDs.isEmpty else { return }
  382. let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
  383. for entry in toRemove {
  384. playlistVM.removeEntry(entry, from: playlist, context: modelContext)
  385. }
  386. selectedEntryIDs.removeAll()
  387. }
  388. /// Move selection up or down by `offset` positions.
  389. private func moveSelection(by offset: Int) {
  390. let sorted = playlist.sortedEntries
  391. guard !sorted.isEmpty else { return }
  392. if let currentID = selectedEntryIDs.first,
  393. let currentIndex = sorted.firstIndex(where: { $0.id == currentID }) {
  394. let newIndex = max(0, min(sorted.count - 1, currentIndex + offset))
  395. selectedEntryIDs = [sorted[newIndex].id]
  396. } else {
  397. // Nothing selected — select first or last
  398. let entry = offset > 0 ? sorted.first! : sorted.last!
  399. selectedEntryIDs = [entry.id]
  400. }
  401. }
  402. @ViewBuilder
  403. private func groupContent(_ entries: [(index: Int, entry: PlaylistEntry)]) -> some View {
  404. ForEach(entries, id: \.entry.id) { item in
  405. ConfigurableEntryRow(
  406. entry: item.entry,
  407. index: item.index,
  408. isLast: item.index == playlist.entries.count - 1,
  409. viewConfig: viewConfig,
  410. isPlaying: playerVM.currentPlayingEntryID == item.entry.id
  411. )
  412. .tag(item.entry.id)
  413. .id(item.entry.id)
  414. .draggable(item.entry.track?.id.uuidString ?? "")
  415. .contextMenu {
  416. if let track = item.entry.track {
  417. Button("Play") { playerVM.loadAndPlay(track, entryID: item.entry.id, playlist: playlist) }
  418. Divider()
  419. // Add to other playlists
  420. let otherPlaylists = allPlaylists.filter { $0.id != playlist.id }
  421. if !otherPlaylists.isEmpty {
  422. Menu("Add to Playlist") {
  423. ForEach(otherPlaylists) { targetPlaylist in
  424. Button(targetPlaylist.name) {
  425. playlistVM.addTrack(track, to: targetPlaylist, context: modelContext)
  426. }
  427. }
  428. }
  429. }
  430. }
  431. Divider()
  432. Button {
  433. viewConfig.cursorFollowsPlayback = true
  434. viewConfig.playbackFollowsCursor = false
  435. } label: {
  436. HStack {
  437. Text("Cursor follows playback")
  438. if viewConfig.cursorFollowsPlayback {
  439. Spacer()
  440. Image(systemName: "checkmark")
  441. }
  442. }
  443. }
  444. Button {
  445. viewConfig.playbackFollowsCursor = true
  446. viewConfig.cursorFollowsPlayback = false
  447. } label: {
  448. HStack {
  449. Text("Playback follows cursor")
  450. if viewConfig.playbackFollowsCursor {
  451. Spacer()
  452. Image(systemName: "checkmark")
  453. }
  454. }
  455. }
  456. if let track = item.entry.track {
  457. Divider()
  458. Button("Analyze BPM & Key") {
  459. Task { await libraryManager.analyzeTrack(track) }
  460. }
  461. Button("Rescan Metadata") {
  462. Task {
  463. await libraryManager.rescanMetadata(track)
  464. try? modelContext.save()
  465. }
  466. }
  467. Button("Edit Notes...") {
  468. editingNotesTrack = track
  469. }
  470. // Quick add to mix targets
  471. let otherMixSlots = (0..<3).filter { playlistVM.mixTargets[$0] != nil && playlistVM.mixTargets[$0]?.id != playlist.id }
  472. if !otherMixSlots.isEmpty {
  473. let mixShortcuts: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
  474. Menu("Add to Mix") {
  475. ForEach(otherMixSlots, id: \.self) { slot in
  476. let hint = KeyboardShortcutConfig.shared.binding(for: mixShortcuts[slot]).displayString
  477. Button("\(playlistVM.mixTargetName(slot)) (\(hint))") {
  478. _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
  479. }
  480. }
  481. }
  482. }
  483. }
  484. Divider()
  485. if selectedEntryIDs.count > 1 {
  486. Button("Remove \(selectedEntryIDs.count) Selected", role: .destructive) {
  487. let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
  488. for e in toRemove {
  489. playlistVM.removeEntry(e, from: playlist, context: modelContext)
  490. }
  491. selectedEntryIDs.removeAll()
  492. }
  493. } else {
  494. Button("Remove from Playlist", role: .destructive) {
  495. playlistVM.removeEntry(item.entry, from: playlist, context: modelContext)
  496. }
  497. }
  498. }
  499. }
  500. .onMove { source, destination in
  501. if let first = source.first {
  502. playlistVM.moveEntry(in: playlist, from: first, to: destination, context: modelContext)
  503. }
  504. }
  505. }
  506. }
  507. // MARK: - Selection Toolbar
  508. private struct SelectionToolbar: View {
  509. let count: Int
  510. let onSelectAll: () -> Void
  511. let onDeselect: () -> Void
  512. let onRemove: () -> Void
  513. @EnvironmentObject private var theme: AppTheme
  514. var body: some View {
  515. HStack(spacing: 8) {
  516. Text("\(count) selected")
  517. .font(.system(size: theme.smallFontSize + 1))
  518. .foregroundStyle(theme.secondaryText)
  519. Spacer()
  520. Button("All", action: onSelectAll).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
  521. Button("None", action: onDeselect).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
  522. Button("Remove", role: .destructive, action: onRemove).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
  523. }
  524. .padding(.horizontal, 8)
  525. .padding(.vertical, 2)
  526. .background(theme.toolbarBackground)
  527. }
  528. }
  529. // MARK: - Group Header with Artwork
  530. private struct GroupHeaderView: View {
  531. let title: String
  532. let trackCount: Int
  533. let firstTrack: Track?
  534. let showArtwork: Bool
  535. @EnvironmentObject private var theme: AppTheme
  536. var body: some View {
  537. HStack(spacing: 6) {
  538. if showArtwork, let track = firstTrack {
  539. ArtworkView(track: track, size: 18)
  540. }
  541. Text(title)
  542. .font(.system(size: theme.dataFontSize, weight: .bold))
  543. .foregroundStyle(theme.groupHeaderText)
  544. Text("(\(trackCount))")
  545. .font(.system(size: theme.smallFontSize + 1))
  546. .foregroundStyle(theme.tertiaryText)
  547. }
  548. .padding(.vertical, 2)
  549. }
  550. }
  551. // MARK: - Column Header Row
  552. private struct ColumnHeaderRow: View {
  553. @ObservedObject var viewConfig: PlaylistViewConfig
  554. @EnvironmentObject private var theme: AppTheme
  555. private let f = Font.system(size: 10, weight: .medium)
  556. private let fMono = Font.system(size: 10, weight: .medium, design: .monospaced)
  557. private var columns: [PlaylistViewConfig.Column] {
  558. viewConfig.visibleColumns
  559. }
  560. var body: some View {
  561. HStack(spacing: 0) {
  562. // # column or playing indicator space
  563. if columns.contains(.trackNumber) {
  564. Text("#")
  565. .font(fMono)
  566. .foregroundStyle(theme.secondaryText)
  567. .frame(width: 32, alignment: .trailing)
  568. .padding(.trailing, 4)
  569. }
  570. // Artwork spacer
  571. if columns.contains(.artwork) && viewConfig.showArtwork {
  572. Color.clear
  573. .frame(width: 18)
  574. .padding(.trailing, 4)
  575. }
  576. // Artist / Title combined header
  577. if columns.contains(.artist) || columns.contains(.title) {
  578. let parts = [
  579. columns.contains(.artist) ? "Artist" : nil,
  580. columns.contains(.title) ? "Title" : nil
  581. ].compactMap { $0 }
  582. Text(parts.joined(separator: " / "))
  583. .font(f)
  584. .foregroundStyle(theme.secondaryText)
  585. .lineLimit(1)
  586. }
  587. Spacer(minLength: 8)
  588. if columns.contains(.album) {
  589. Text("Album")
  590. .font(f)
  591. .foregroundStyle(theme.secondaryText)
  592. .frame(maxWidth: 150, alignment: .leading)
  593. .padding(.trailing, 8)
  594. }
  595. if columns.contains(.genre) {
  596. Text("Genre")
  597. .font(f)
  598. .foregroundStyle(theme.secondaryText)
  599. .frame(width: 70, alignment: .leading)
  600. }
  601. if columns.contains(.bpm) {
  602. Text("BPM")
  603. .font(fMono)
  604. .foregroundStyle(theme.secondaryText)
  605. .frame(width: 45, alignment: .trailing)
  606. }
  607. if columns.contains(.key) {
  608. Text("Key")
  609. .font(f)
  610. .foregroundStyle(theme.secondaryText)
  611. .frame(width: 42, alignment: .center)
  612. }
  613. if columns.contains(.duration) {
  614. Text("Time")
  615. .font(fMono)
  616. .foregroundStyle(theme.secondaryText)
  617. .frame(width: 58, alignment: .trailing)
  618. }
  619. if columns.contains(.format) {
  620. Text("Fmt")
  621. .font(.system(size: 9, weight: .medium))
  622. .foregroundStyle(theme.secondaryText)
  623. .frame(width: 38, alignment: .center)
  624. }
  625. if columns.contains(.sampleRate) {
  626. Text("Rate")
  627. .font(.system(size: 9, weight: .medium, design: .monospaced))
  628. .foregroundStyle(theme.secondaryText)
  629. .frame(width: 58, alignment: .trailing)
  630. }
  631. if columns.contains(.bitDepth) {
  632. Text("Bit")
  633. .font(.system(size: 9, weight: .medium, design: .monospaced))
  634. .foregroundStyle(theme.secondaryText)
  635. .frame(width: 20, alignment: .trailing)
  636. }
  637. if columns.contains(.fileSize) {
  638. Text("Size")
  639. .font(.system(size: 9, weight: .medium, design: .monospaced))
  640. .foregroundStyle(theme.secondaryText)
  641. .frame(width: 65, alignment: .trailing)
  642. }
  643. if columns.contains(.rating) {
  644. Text("Rating")
  645. .font(.system(size: 9, weight: .medium))
  646. .foregroundStyle(theme.secondaryText)
  647. .frame(width: 50, alignment: .center)
  648. }
  649. if columns.contains(.playCount) {
  650. Text("Plays")
  651. .font(.system(size: 9, weight: .medium, design: .monospaced))
  652. .foregroundStyle(theme.secondaryText)
  653. .frame(width: 25, alignment: .trailing)
  654. }
  655. }
  656. .frame(height: 22)
  657. .padding(.horizontal, 20)
  658. .background(theme.columnHeaderBackground)
  659. }
  660. }
  661. // MARK: - Configurable Entry Row
  662. private struct ConfigurableEntryRow: View {
  663. let entry: PlaylistEntry
  664. let index: Int
  665. let isLast: Bool
  666. @ObservedObject var viewConfig: PlaylistViewConfig
  667. var isPlaying: Bool = false
  668. @Environment(PlayerViewModel.self) private var playerVM
  669. @Environment(PlaylistViewModel.self) private var playlistVM
  670. @Environment(\.modelContext) private var modelContext
  671. @State private var crossfade: Double = 0
  672. @State private var gain: Double = 0
  673. private var f: Font { .system(size: theme.dataFontSize) }
  674. private var fMono: Font { .system(size: theme.dataFontSize, design: .monospaced) }
  675. private var columns: [PlaylistViewConfig.Column] {
  676. viewConfig.visibleColumns
  677. }
  678. @EnvironmentObject private var theme: AppTheme
  679. var body: some View {
  680. HStack(spacing: 0) {
  681. if let track = entry.track {
  682. // Playing indicator (narrow)
  683. if isPlaying {
  684. Text("▶")
  685. .font(.system(size: 8))
  686. .foregroundStyle(theme.playingHighlight)
  687. .frame(width: 12)
  688. } else if columns.contains(.trackNumber) {
  689. Text("\(index + 1)")
  690. .font(fMono)
  691. .foregroundStyle(theme.tertiaryText)
  692. .frame(width: 32, alignment: .trailing)
  693. .padding(.trailing, 4)
  694. }
  695. // Artwork (small)
  696. if columns.contains(.artwork) && viewConfig.showArtwork {
  697. ArtworkView(track: track, size: 18)
  698. .padding(.trailing, 4)
  699. }
  700. // Artist - Title (main text, takes remaining space)
  701. if columns.contains(.artist) && !track.artist.isEmpty {
  702. Text(track.artist)
  703. .font(f)
  704. .foregroundStyle(theme.secondaryText)
  705. .lineLimit(1)
  706. Text(" – ")
  707. .font(f)
  708. .foregroundStyle(theme.tertiaryText)
  709. }
  710. if columns.contains(.title) {
  711. Text(track.title)
  712. .font(f.weight(isPlaying ? .bold : .regular))
  713. .foregroundStyle(isPlaying ? theme.playingHighlight : theme.primaryText)
  714. .lineLimit(1)
  715. }
  716. Spacer(minLength: 8)
  717. // Album
  718. if columns.contains(.album) && !track.album.isEmpty {
  719. Text(track.album)
  720. .font(f)
  721. .foregroundStyle(theme.tertiaryText)
  722. .lineLimit(1)
  723. .frame(maxWidth: 150, alignment: .leading)
  724. .padding(.trailing, 8)
  725. }
  726. // Genre
  727. if columns.contains(.genre) && !track.genre.isEmpty {
  728. Text(track.genre)
  729. .font(f)
  730. .foregroundStyle(theme.tertiaryText)
  731. .frame(width: 70, alignment: .leading)
  732. }
  733. // BPM
  734. if columns.contains(.bpm) {
  735. Text(track.bpm.map { String(format: "%.0f", $0) } ?? "")
  736. .font(fMono)
  737. .foregroundStyle(theme.secondaryText)
  738. .frame(width: 45, alignment: .trailing)
  739. }
  740. // Key
  741. if columns.contains(.key) {
  742. Text(track.musicalKey ?? "")
  743. .font(f)
  744. .foregroundStyle(theme.secondaryText)
  745. .frame(width: 42, alignment: .center)
  746. }
  747. // Duration
  748. if columns.contains(.duration) {
  749. Text(track.formattedDuration)
  750. .font(fMono)
  751. .foregroundStyle(theme.secondaryText)
  752. .frame(width: 58, alignment: .trailing)
  753. }
  754. // Format
  755. if columns.contains(.format) {
  756. Text(track.fileFormat)
  757. .font(.system(size: theme.smallFontSize))
  758. .foregroundStyle(theme.tertiaryText)
  759. .frame(width: 38, alignment: .center)
  760. }
  761. // Sample Rate
  762. if columns.contains(.sampleRate) {
  763. Text("\(Int(track.sampleRate))Hz")
  764. .font(.system(size: theme.smallFontSize, design: .monospaced))
  765. .foregroundStyle(theme.tertiaryText)
  766. .frame(width: 58, alignment: .trailing)
  767. }
  768. // Bit Depth
  769. if columns.contains(.bitDepth) {
  770. Text("\(track.bitDepth)")
  771. .font(.system(size: theme.smallFontSize, design: .monospaced))
  772. .foregroundStyle(theme.tertiaryText)
  773. .frame(width: 20, alignment: .trailing)
  774. }
  775. // File Size
  776. if columns.contains(.fileSize) {
  777. Text(track.formattedFileSize)
  778. .font(.system(size: theme.smallFontSize, design: .monospaced))
  779. .foregroundStyle(theme.tertiaryText)
  780. .frame(width: 65, alignment: .trailing)
  781. }
  782. // Rating
  783. if columns.contains(.rating) && track.rating > 0 {
  784. Text(String(repeating: "★", count: track.rating))
  785. .font(.system(size: theme.smallFontSize))
  786. .foregroundStyle(.yellow)
  787. .frame(width: 50, alignment: .center)
  788. }
  789. // Play Count
  790. if columns.contains(.playCount) && track.playCount > 0 {
  791. Text("\(track.playCount)×")
  792. .font(.system(size: theme.smallFontSize, design: .monospaced))
  793. .foregroundStyle(theme.tertiaryText)
  794. .frame(width: 25, alignment: .trailing)
  795. }
  796. }
  797. }
  798. .frame(height: theme.rowHeight)
  799. .onAppear {
  800. crossfade = entry.crossfadeDuration
  801. gain = entry.gainAdjustment
  802. }
  803. }
  804. }
  805. // MARK: - Empty Playlist
  806. private struct EmptyPlaylistView: View {
  807. let onAddTracks: () -> Void
  808. let onAddFiles: () -> Void
  809. let onAddFolder: () -> Void
  810. var body: some View {
  811. VStack(spacing: 12) {
  812. Spacer()
  813. Text("Empty playlist")
  814. .font(.system(size: 12))
  815. .foregroundStyle(.secondary)
  816. Text("Drop files here, or use Add menu above")
  817. .font(.system(size: 11))
  818. .foregroundStyle(.tertiary)
  819. HStack(spacing: 8) {
  820. Button("Add Files...") { onAddFiles() }
  821. .font(.system(size: 11))
  822. Button("Add Folder...") { onAddFolder() }
  823. .font(.system(size: 11))
  824. }
  825. Spacer()
  826. }
  827. .frame(maxWidth: .infinity, maxHeight: .infinity)
  828. }
  829. }
  830. // MARK: - Column Config Sheet
  831. private struct ColumnConfigSheet: View {
  832. @ObservedObject var viewConfig: PlaylistViewConfig
  833. @Environment(\.dismiss) private var dismiss
  834. var body: some View {
  835. VStack(spacing: 0) {
  836. HStack {
  837. Text("Configure Playlist View")
  838. .font(.headline)
  839. Spacer()
  840. Button("Done") { dismiss() }
  841. .keyboardShortcut(.defaultAction)
  842. }
  843. .padding()
  844. Divider()
  845. ScrollView {
  846. VStack(alignment: .leading, spacing: 20) {
  847. // Artwork settings
  848. VStack(alignment: .leading, spacing: 8) {
  849. Text("Artwork")
  850. .font(.subheadline.bold())
  851. Toggle("Show artwork", isOn: $viewConfig.showArtwork)
  852. if viewConfig.showArtwork {
  853. Picker("Size", selection: $viewConfig.artworkSize) {
  854. ForEach(PlaylistViewConfig.ArtworkSize.allCases) { size in
  855. Text(size.rawValue).tag(size)
  856. }
  857. }
  858. .pickerStyle(.segmented)
  859. .frame(width: 250)
  860. }
  861. }
  862. Divider()
  863. // Playback behavior
  864. VStack(alignment: .leading, spacing: 8) {
  865. Text("Playback Behavior")
  866. .font(.subheadline.bold())
  867. Toggle("Cursor follows playback", isOn: $viewConfig.cursorFollowsPlayback)
  868. Text("Auto-select and scroll to the currently playing track")
  869. .font(.caption)
  870. .foregroundStyle(.secondary)
  871. Toggle("Playback follows cursor", isOn: $viewConfig.playbackFollowsCursor)
  872. Text("Press Enter/Return to play the selected track")
  873. .font(.caption)
  874. .foregroundStyle(.secondary)
  875. }
  876. Divider()
  877. // Visible columns
  878. VStack(alignment: .leading, spacing: 8) {
  879. HStack {
  880. Text("Visible Columns")
  881. .font(.subheadline.bold())
  882. Spacer()
  883. Button("Reset to Defaults") {
  884. viewConfig.resetToDefaults()
  885. }
  886. .font(.caption)
  887. }
  888. Text("Check the columns you want to display. Drag to reorder.")
  889. .font(.caption)
  890. .foregroundStyle(.secondary)
  891. LazyVGrid(columns: [GridItem(.adaptive(minimum: 140))], spacing: 6) {
  892. ForEach(PlaylistViewConfig.Column.allCases) { column in
  893. Toggle(column.rawValue, isOn: Binding(
  894. get: { viewConfig.isColumnVisible(column) },
  895. set: { _ in viewConfig.toggleColumn(column) }
  896. ))
  897. .toggleStyle(.checkbox)
  898. .font(.caption)
  899. }
  900. }
  901. }
  902. }
  903. .padding(20)
  904. }
  905. }
  906. .frame(width: 450, height: 520)
  907. }
  908. }
  909. // MARK: - Add Tracks Sheet
  910. private struct AddTracksSheet: View {
  911. let playlist: Playlist
  912. let allTracks: [Track]
  913. @Environment(PlaylistViewModel.self) private var playlistVM
  914. @Environment(\.modelContext) private var modelContext
  915. @Environment(\.dismiss) private var dismiss
  916. @State private var searchText = ""
  917. @State private var selectedTracks: Set<UUID> = []
  918. var filteredTracks: [Track] {
  919. if searchText.isEmpty { return allTracks }
  920. let query = searchText.lowercased()
  921. return allTracks.filter {
  922. $0.title.lowercased().contains(query) ||
  923. $0.artist.lowercased().contains(query)
  924. }
  925. }
  926. var body: some View {
  927. VStack(spacing: 0) {
  928. HStack {
  929. Text("Add Tracks to \(playlist.name)")
  930. .font(.headline)
  931. Spacer()
  932. Text("\(selectedTracks.count) selected")
  933. .foregroundStyle(.secondary)
  934. }
  935. .padding()
  936. TextField("Search tracks...", text: $searchText)
  937. .textFieldStyle(.roundedBorder)
  938. .padding(.horizontal)
  939. List(filteredTracks, selection: $selectedTracks) { track in
  940. HStack {
  941. TrackRow(track: track)
  942. Spacer()
  943. if let bpm = track.bpm {
  944. Text("\(String(format: "%.0f", bpm))")
  945. .font(.caption)
  946. .foregroundStyle(.secondary)
  947. }
  948. Text(track.formattedDuration)
  949. .font(.caption)
  950. .foregroundStyle(.secondary)
  951. }
  952. }
  953. .listStyle(.inset)
  954. Divider()
  955. HStack {
  956. Button("Cancel") { dismiss() }
  957. .keyboardShortcut(.cancelAction)
  958. Spacer()
  959. Button("Add \(selectedTracks.count) Track\(selectedTracks.count == 1 ? "" : "s")") {
  960. let tracks = allTracks.filter { selectedTracks.contains($0.id) }
  961. playlistVM.addTracks(tracks, to: playlist, context: modelContext)
  962. dismiss()
  963. }
  964. .keyboardShortcut(.defaultAction)
  965. .disabled(selectedTracks.isEmpty)
  966. }
  967. .padding()
  968. }
  969. .frame(width: 500, height: 600)
  970. }
  971. }
  972. // MARK: - Track Notes Sheet
  973. private struct TrackNotesSheet: View {
  974. let track: Track
  975. @Environment(\.dismiss) private var dismiss
  976. @Environment(\.modelContext) private var modelContext
  977. @State private var notes: String = ""
  978. var body: some View {
  979. VStack(spacing: 12) {
  980. HStack {
  981. VStack(alignment: .leading, spacing: 2) {
  982. Text(track.title)
  983. .font(.headline)
  984. if !track.artist.isEmpty {
  985. Text(track.artist)
  986. .font(.subheadline)
  987. .foregroundStyle(.secondary)
  988. }
  989. }
  990. Spacer()
  991. Button("Done") {
  992. track.notes = notes
  993. try? modelContext.save()
  994. dismiss()
  995. }
  996. .keyboardShortcut(.defaultAction)
  997. }
  998. TextEditor(text: $notes)
  999. .font(.system(size: 13))
  1000. .frame(minHeight: 100)
  1001. .scrollContentBackground(.hidden)
  1002. .padding(4)
  1003. .background(Color.gray.opacity(0.1))
  1004. .cornerRadius(6)
  1005. HStack {
  1006. Text("Notes are saved with the track and included in exports")
  1007. .font(.caption)
  1008. .foregroundStyle(.secondary)
  1009. Spacer()
  1010. }
  1011. }
  1012. .padding(16)
  1013. .frame(width: 400, height: 220)
  1014. .onAppear {
  1015. notes = track.notes
  1016. }
  1017. }
  1018. }
  1019. // MARK: - Double-Click Handler (NSViewRepresentable)