import SwiftData import SwiftUI import UniformTypeIdentifiers private extension UTType { /// OGG Vorbis audio — not built-in, so we define it manually. static let oggVorbis = UTType(filenameExtension: "ogg") ?? UTType.audio } /// Playlist view — manage tracks in a mix with transitions and export. struct PlaylistView: View { let playlist: Playlist @Environment(PlayerViewModel.self) private var playerVM @Environment(PlaylistViewModel.self) private var playlistVM @EnvironmentObject private var libraryManager: LibraryManager @Environment(\.modelContext) private var modelContext @Query(sort: \Track.title) private var allTracks: [Track] @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist] @ObservedObject private var viewConfig = PlaylistViewConfig.shared @State private var showExportSheet = false @State private var showAddTracksSheet = false @State private var showGroupEditor = false @State private var draggedEntry: PlaylistEntry? @State private var isDropTargeted = false var body: some View { VStack(spacing: 0) { // Playlist header PlaylistHeader( playlist: playlist, mixDuration: playlistVM.mixDuration(for: playlist), onExport: { showExportSheet = true }, onAddTracks: { showAddTracksSheet = true }, onAddFiles: { addFilesFromDisk() }, onAddFolder: { addFolderFromDisk() }, onEditGrouping: { showGroupEditor = true }, viewConfig: viewConfig ) Divider() // Track list if playlist.entries.isEmpty { EmptyPlaylistView( onAddTracks: { showAddTracksSheet = true }, onAddFiles: { addFilesFromDisk() }, onAddFolder: { addFolderFromDisk() } ) } else { PlaylistEntryList( playlist: playlist, draggedEntry: $draggedEntry, viewConfig: viewConfig ) } } .overlay { if isDropTargeted { RoundedRectangle(cornerRadius: 8) .stroke(Color.accentColor, lineWidth: 3) .background(Color.accentColor.opacity(0.08)) .padding(4) .allowsHitTesting(false) } } .onDrop(of: [.fileURL], isTargeted: $isDropTargeted) { providers in handleDrop(providers) return true } .sheet(isPresented: $showExportSheet) { ExportSheet(playlist: playlist) } .sheet(isPresented: $showAddTracksSheet) { AddTracksSheet(playlist: playlist, allTracks: allTracks) } .sheet(isPresented: $showGroupEditor) { GroupTemplateEditorSheet(playlist: playlist) } } // MARK: - Add Files from Disk private func addFilesFromDisk() { let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = true panel.allowedContentTypes = [.audio, .mp3, .wav, .aiff, .oggVorbis] panel.message = "Select audio files to add to \"\(playlist.name)\"" if panel.runModal() == .OK { Task { await playlistVM.importFilesToPlaylist( urls: panel.urls, playlist: playlist, libraryManager: libraryManager, context: modelContext ) } } } private func addFolderFromDisk() { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = true panel.message = "Select a folder to scan for audio files" if panel.runModal() == .OK { let urls = expandDirectories(panel.urls) Task { await playlistVM.importFilesToPlaylist( urls: urls, playlist: playlist, libraryManager: libraryManager, context: modelContext ) } } } private func handleDrop(_ providers: [NSItemProvider]) { for provider in providers { provider.loadItem(forTypeIdentifier: "public.file-url") { data, _ in guard let data = data as? Data, let urlString = String(data: data, encoding: .utf8), let url = URL(string: urlString) else { return } let urls = expandDirectories([url]) Task { @MainActor in await playlistVM.importFilesToPlaylist( urls: urls, playlist: playlist, libraryManager: libraryManager, context: modelContext ) } } } } private func expandDirectories(_ urls: [URL]) -> [URL] { var result: [URL] = [] let fm = FileManager.default for url in urls { var isDir: ObjCBool = false if fm.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue { if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) { for case let fileURL as URL in enumerator { if MetadataService.isSupportedAudioFile(fileURL) { result.append(fileURL) } } } } else if MetadataService.isSupportedAudioFile(url) { result.append(url) } } // Sort by full path with numeric sorting to preserve folder structure // (like iOS FolderBrowserView) — e.g. "01/01.mp3" < "01/02.mp3" < "02/01.mp3" return result.sorted { $0.path.compare($1.path, options: [.numeric, .caseInsensitive]) == .orderedAscending } } } // MARK: - Playlist Header (compact toolbar) private struct PlaylistHeader: View { let playlist: Playlist let mixDuration: TimeInterval let onExport: () -> Void let onAddTracks: () -> Void let onAddFiles: () -> Void let onAddFolder: () -> Void let onEditGrouping: () -> Void @ObservedObject var viewConfig: PlaylistViewConfig @EnvironmentObject private var theme: AppTheme var body: some View { HStack(spacing: 6) { // Playlist name Text(playlist.name) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(theme.primaryText) .lineLimit(1) // Stats Text("[\(playlist.trackCount) tracks · \(formatDuration(mixDuration))]") .font(.system(size: 11)) .foregroundStyle(theme.secondaryText) Spacer() // Toolbar buttons HStack(spacing: 6) { Menu { Button("Add Files...") { onAddFiles() } Button("Add Folder...") { onAddFolder() } Divider() Button("From Library...") { onAddTracks() } } label: { Label("Add", systemImage: "plus") } .fixedSize() Menu { ForEach(GroupTemplateResolver.presets, id: \.template) { preset in Button { playlist.groupTemplate = preset.template } label: { HStack { Text(preset.name) if playlist.groupTemplate == preset.template { Image(systemName: "checkmark") } } } } Divider() Button("Custom...") { onEditGrouping() } } label: { Label( playlist.groupTemplate.isEmpty ? "Group" : "Grouped", systemImage: "rectangle.3.group" ) } .fixedSize() Button { NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) } label: { Label("Settings", systemImage: "gearshape") } Button { onExport() } label: { Label("Export", systemImage: "square.and.arrow.up") } .disabled(playlist.entries.isEmpty) } .controlSize(.small) } .padding(.horizontal, 10) .padding(.vertical, 7) .background(theme.toolbarBackground) } private func formatDuration(_ duration: TimeInterval) -> String { let total = Int(duration) let hours = total / 3600 let minutes = (total % 3600) / 60 let seconds = total % 60 if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) } return String(format: "%d:%02d", minutes, seconds) } } // MARK: - Playlist Entry List (with Grouping) private struct PlaylistEntryList: View { let playlist: Playlist @Binding var draggedEntry: PlaylistEntry? @ObservedObject var viewConfig: PlaylistViewConfig @Environment(PlayerViewModel.self) private var playerVM @Environment(PlaylistViewModel.self) private var playlistVM @EnvironmentObject private var libraryManager: LibraryManager @Environment(\.modelContext) private var modelContext @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist] @State private var selectedEntryIDs: Set = [] @State private var scrollTarget: UUID? @State private var editingNotesTrack: Track? /// Group entries by the playlist's groupTemplate. private var groupedEntries: [(key: String, entries: [(index: Int, entry: PlaylistEntry)])] { let sorted = playlist.sortedEntries let indexed = sorted.enumerated().map { (index: $0.offset, entry: $0.element) } guard !playlist.groupTemplate.isEmpty else { return [("", indexed)] } // Group consecutively by resolved template (preserves playlist order) var groups: [(String, [(index: Int, entry: PlaylistEntry)])] = [] var currentHeader = "" var currentEntries: [(index: Int, entry: PlaylistEntry)] = [] for item in indexed { let header: String if let track = item.entry.track { header = GroupTemplateResolver.resolve(template: playlist.groupTemplate, for: track) } else { header = "Unknown" } if header != currentHeader { if !currentEntries.isEmpty { groups.append((currentHeader, currentEntries)) } currentHeader = header currentEntries = [item] } else { currentEntries.append(item) } } if !currentEntries.isEmpty { groups.append((currentHeader, currentEntries)) } return groups.map { (key: $0.0, entries: $0.1) } } var body: some View { VStack(spacing: 0) { // Selection toolbar if !selectedEntryIDs.isEmpty { SelectionToolbar( count: selectedEntryIDs.count, onSelectAll: { selectedEntryIDs = Set(playlist.entries.map(\.id)) }, onDeselect: { selectedEntryIDs.removeAll() }, onRemove: { let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) } for entry in toRemove { playlistVM.removeEntry(entry, from: playlist, context: modelContext) } selectedEntryIDs.removeAll() } ) Divider() } // Column headers ColumnHeaderRow(viewConfig: viewConfig) Divider() List(selection: $selectedEntryIDs) { ForEach(groupedEntries, id: \.key) { group in if !playlist.groupTemplate.isEmpty && !group.key.isEmpty { Section { groupContent(group.entries) } header: { GroupHeaderView( title: group.key, trackCount: group.entries.count, firstTrack: group.entries.first?.entry.track, showArtwork: viewConfig.showArtwork ) } } else { groupContent(group.entries) } } } .listStyle(.inset) // Enter key always plays selected track (like foobar2000) .onKeyPress(.return) { playSelectedTrack() return .handled } // Arrow keys for navigation .onKeyPress(.upArrow) { moveSelection(by: -1) return .handled } .onKeyPress(.downArrow) { moveSelection(by: 1) return .handled } // Backspace/Delete key removes selected entries (onDeleteCommand is the macOS-native way) .onDeleteCommand { removeSelectedEntries() } // Cursor follows playback: select playing entry .onChange(of: playerVM.currentPlayingEntryID) { _, newID in guard viewConfig.cursorFollowsPlayback, let entryID = newID else { return } selectedEntryIDs = [entryID] } // Sync cursor position to PlayerViewModel for "Playback follows cursor" .onChange(of: selectedEntryIDs) { _, newIDs in playerVM.cursorEntryID = newIDs.first } // When PlayerViewModel moves cursor (auto-advance), update the UI selection .onChange(of: playerVM.cursorEntryID) { _, newID in if let newID, !selectedEntryIDs.contains(newID) { selectedEntryIDs = [newID] } } .sheet(item: $editingNotesTrack) { track in TrackNotesSheet(track: track) } // Double-click to play (via NSEvent monitor in MediaKeyHandler) .onReceive(NotificationCenter.default.publisher(for: .doubleClickPlayTrack)) { _ in playSelectedTrack() } // When this playlist view appears, restore cursor to playing entry if applicable .onAppear { if selectedEntryIDs.isEmpty, let playingID = playerVM.currentPlayingEntryID, playlist.sortedEntries.contains(where: { $0.id == playingID }) { selectedEntryIDs = [playingID] playerVM.cursorEntryID = playingID } } } } /// Play the first selected entry. private func playSelectedTrack() { guard let firstID = selectedEntryIDs.first, let entry = playlist.sortedEntries.first(where: { $0.id == firstID }), let track = entry.track else { return } playerVM.loadAndPlay(track, entryID: entry.id, playlist: playlist) } /// Remove all selected entries from the playlist. private func removeSelectedEntries() { guard !selectedEntryIDs.isEmpty else { return } let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) } for entry in toRemove { playlistVM.removeEntry(entry, from: playlist, context: modelContext) } selectedEntryIDs.removeAll() } /// Move selection up or down by `offset` positions. private func moveSelection(by offset: Int) { let sorted = playlist.sortedEntries guard !sorted.isEmpty else { return } if let currentID = selectedEntryIDs.first, let currentIndex = sorted.firstIndex(where: { $0.id == currentID }) { let newIndex = max(0, min(sorted.count - 1, currentIndex + offset)) selectedEntryIDs = [sorted[newIndex].id] } else { // Nothing selected — select first or last let entry = offset > 0 ? sorted.first! : sorted.last! selectedEntryIDs = [entry.id] } } @ViewBuilder private func groupContent(_ entries: [(index: Int, entry: PlaylistEntry)]) -> some View { ForEach(entries, id: \.entry.id) { item in ConfigurableEntryRow( entry: item.entry, index: item.index, isLast: item.index == playlist.entries.count - 1, viewConfig: viewConfig, isPlaying: playerVM.currentPlayingEntryID == item.entry.id ) .tag(item.entry.id) .id(item.entry.id) .draggable(item.entry.track?.id.uuidString ?? "") .contextMenu { if let track = item.entry.track { Button("Play") { playerVM.loadAndPlay(track, entryID: item.entry.id, playlist: playlist) } Divider() // Add to other playlists let otherPlaylists = allPlaylists.filter { $0.id != playlist.id } if !otherPlaylists.isEmpty { Menu("Add to Playlist") { ForEach(otherPlaylists) { targetPlaylist in Button(targetPlaylist.name) { playlistVM.addTrack(track, to: targetPlaylist, context: modelContext) } } } } } Divider() Button { viewConfig.cursorFollowsPlayback = true viewConfig.playbackFollowsCursor = false } label: { HStack { Text("Cursor follows playback") if viewConfig.cursorFollowsPlayback { Spacer() Image(systemName: "checkmark") } } } Button { viewConfig.playbackFollowsCursor = true viewConfig.cursorFollowsPlayback = false } label: { HStack { Text("Playback follows cursor") if viewConfig.playbackFollowsCursor { Spacer() Image(systemName: "checkmark") } } } if let track = item.entry.track { Divider() Button("Analyze BPM & Key") { Task { await libraryManager.analyzeTrack(track) } } Button("Rescan Metadata") { Task { await libraryManager.rescanMetadata(track) try? modelContext.save() } } Button("Edit Notes...") { editingNotesTrack = track } // Quick add to mix targets let otherMixSlots = (0..<3).filter { playlistVM.mixTargets[$0] != nil && playlistVM.mixTargets[$0]?.id != playlist.id } if !otherMixSlots.isEmpty { let mixShortcuts: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3] Menu("Add to Mix") { ForEach(otherMixSlots, id: \.self) { slot in let hint = KeyboardShortcutConfig.shared.binding(for: mixShortcuts[slot]).displayString Button("\(playlistVM.mixTargetName(slot)) (\(hint))") { _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext) } } } } } Divider() if selectedEntryIDs.count > 1 { Button("Remove \(selectedEntryIDs.count) Selected", role: .destructive) { let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) } for e in toRemove { playlistVM.removeEntry(e, from: playlist, context: modelContext) } selectedEntryIDs.removeAll() } } else { Button("Remove from Playlist", role: .destructive) { playlistVM.removeEntry(item.entry, from: playlist, context: modelContext) } } } } .onMove { source, destination in if let first = source.first { playlistVM.moveEntry(in: playlist, from: first, to: destination, context: modelContext) } } } } // MARK: - Selection Toolbar private struct SelectionToolbar: View { let count: Int let onSelectAll: () -> Void let onDeselect: () -> Void let onRemove: () -> Void @EnvironmentObject private var theme: AppTheme var body: some View { HStack(spacing: 8) { Text("\(count) selected") .font(.system(size: theme.smallFontSize + 1)) .foregroundStyle(theme.secondaryText) Spacer() Button("All", action: onSelectAll).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain) Button("None", action: onDeselect).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain) Button("Remove", role: .destructive, action: onRemove).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain) } .padding(.horizontal, 8) .padding(.vertical, 2) .background(theme.toolbarBackground) } } // MARK: - Group Header with Artwork private struct GroupHeaderView: View { let title: String let trackCount: Int let firstTrack: Track? let showArtwork: Bool @EnvironmentObject private var theme: AppTheme var body: some View { HStack(spacing: 6) { if showArtwork, let track = firstTrack { ArtworkView(track: track, size: 18) } Text(title) .font(.system(size: theme.dataFontSize, weight: .bold)) .foregroundStyle(theme.groupHeaderText) Text("(\(trackCount))") .font(.system(size: theme.smallFontSize + 1)) .foregroundStyle(theme.tertiaryText) } .padding(.vertical, 2) } } // MARK: - Column Header Row private struct ColumnHeaderRow: View { @ObservedObject var viewConfig: PlaylistViewConfig @EnvironmentObject private var theme: AppTheme private let f = Font.system(size: 10, weight: .medium) private let fMono = Font.system(size: 10, weight: .medium, design: .monospaced) private var columns: [PlaylistViewConfig.Column] { viewConfig.visibleColumns } var body: some View { HStack(spacing: 0) { // # column or playing indicator space if columns.contains(.trackNumber) { Text("#") .font(fMono) .foregroundStyle(theme.secondaryText) .frame(width: 32, alignment: .trailing) .padding(.trailing, 4) } // Artwork spacer if columns.contains(.artwork) && viewConfig.showArtwork { Color.clear .frame(width: 18) .padding(.trailing, 4) } // Artist / Title combined header if columns.contains(.artist) || columns.contains(.title) { let parts = [ columns.contains(.artist) ? "Artist" : nil, columns.contains(.title) ? "Title" : nil ].compactMap { $0 } Text(parts.joined(separator: " / ")) .font(f) .foregroundStyle(theme.secondaryText) .lineLimit(1) } Spacer(minLength: 8) if columns.contains(.album) { Text("Album") .font(f) .foregroundStyle(theme.secondaryText) .frame(maxWidth: 150, alignment: .leading) .padding(.trailing, 8) } if columns.contains(.genre) { Text("Genre") .font(f) .foregroundStyle(theme.secondaryText) .frame(width: 70, alignment: .leading) } if columns.contains(.bpm) { Text("BPM") .font(fMono) .foregroundStyle(theme.secondaryText) .frame(width: 45, alignment: .trailing) } if columns.contains(.key) { Text("Key") .font(f) .foregroundStyle(theme.secondaryText) .frame(width: 42, alignment: .center) } if columns.contains(.duration) { Text("Time") .font(fMono) .foregroundStyle(theme.secondaryText) .frame(width: 58, alignment: .trailing) } if columns.contains(.format) { Text("Fmt") .font(.system(size: 9, weight: .medium)) .foregroundStyle(theme.secondaryText) .frame(width: 38, alignment: .center) } if columns.contains(.sampleRate) { Text("Rate") .font(.system(size: 9, weight: .medium, design: .monospaced)) .foregroundStyle(theme.secondaryText) .frame(width: 58, alignment: .trailing) } if columns.contains(.bitDepth) { Text("Bit") .font(.system(size: 9, weight: .medium, design: .monospaced)) .foregroundStyle(theme.secondaryText) .frame(width: 20, alignment: .trailing) } if columns.contains(.fileSize) { Text("Size") .font(.system(size: 9, weight: .medium, design: .monospaced)) .foregroundStyle(theme.secondaryText) .frame(width: 65, alignment: .trailing) } if columns.contains(.rating) { Text("Rating") .font(.system(size: 9, weight: .medium)) .foregroundStyle(theme.secondaryText) .frame(width: 50, alignment: .center) } if columns.contains(.playCount) { Text("Plays") .font(.system(size: 9, weight: .medium, design: .monospaced)) .foregroundStyle(theme.secondaryText) .frame(width: 25, alignment: .trailing) } } .frame(height: 22) .padding(.horizontal, 20) .background(theme.columnHeaderBackground) } } // MARK: - Configurable Entry Row private struct ConfigurableEntryRow: View { let entry: PlaylistEntry let index: Int let isLast: Bool @ObservedObject var viewConfig: PlaylistViewConfig var isPlaying: Bool = false @Environment(PlayerViewModel.self) private var playerVM @Environment(PlaylistViewModel.self) private var playlistVM @Environment(\.modelContext) private var modelContext @State private var crossfade: Double = 0 @State private var gain: Double = 0 private var f: Font { .system(size: theme.dataFontSize) } private var fMono: Font { .system(size: theme.dataFontSize, design: .monospaced) } private var columns: [PlaylistViewConfig.Column] { viewConfig.visibleColumns } @EnvironmentObject private var theme: AppTheme var body: some View { HStack(spacing: 0) { if let track = entry.track { // Playing indicator (narrow) if isPlaying { Text("▶") .font(.system(size: 8)) .foregroundStyle(theme.playingHighlight) .frame(width: 12) } else if columns.contains(.trackNumber) { Text("\(index + 1)") .font(fMono) .foregroundStyle(theme.tertiaryText) .frame(width: 32, alignment: .trailing) .padding(.trailing, 4) } // Artwork (small) if columns.contains(.artwork) && viewConfig.showArtwork { ArtworkView(track: track, size: 18) .padding(.trailing, 4) } // Artist - Title (main text, takes remaining space) if columns.contains(.artist) && !track.artist.isEmpty { Text(track.artist) .font(f) .foregroundStyle(theme.secondaryText) .lineLimit(1) Text(" – ") .font(f) .foregroundStyle(theme.tertiaryText) } if columns.contains(.title) { Text(track.title) .font(f.weight(isPlaying ? .bold : .regular)) .foregroundStyle(isPlaying ? theme.playingHighlight : theme.primaryText) .lineLimit(1) } Spacer(minLength: 8) // Album if columns.contains(.album) && !track.album.isEmpty { Text(track.album) .font(f) .foregroundStyle(theme.tertiaryText) .lineLimit(1) .frame(maxWidth: 150, alignment: .leading) .padding(.trailing, 8) } // Genre if columns.contains(.genre) && !track.genre.isEmpty { Text(track.genre) .font(f) .foregroundStyle(theme.tertiaryText) .frame(width: 70, alignment: .leading) } // BPM if columns.contains(.bpm) { Text(track.bpm.map { String(format: "%.0f", $0) } ?? "") .font(fMono) .foregroundStyle(theme.secondaryText) .frame(width: 45, alignment: .trailing) } // Key if columns.contains(.key) { Text(track.musicalKey ?? "") .font(f) .foregroundStyle(theme.secondaryText) .frame(width: 42, alignment: .center) } // Duration if columns.contains(.duration) { Text(track.formattedDuration) .font(fMono) .foregroundStyle(theme.secondaryText) .frame(width: 58, alignment: .trailing) } // Format if columns.contains(.format) { Text(track.fileFormat) .font(.system(size: theme.smallFontSize)) .foregroundStyle(theme.tertiaryText) .frame(width: 38, alignment: .center) } // Sample Rate if columns.contains(.sampleRate) { Text("\(Int(track.sampleRate))Hz") .font(.system(size: theme.smallFontSize, design: .monospaced)) .foregroundStyle(theme.tertiaryText) .frame(width: 58, alignment: .trailing) } // Bit Depth if columns.contains(.bitDepth) { Text("\(track.bitDepth)") .font(.system(size: theme.smallFontSize, design: .monospaced)) .foregroundStyle(theme.tertiaryText) .frame(width: 20, alignment: .trailing) } // File Size if columns.contains(.fileSize) { Text(track.formattedFileSize) .font(.system(size: theme.smallFontSize, design: .monospaced)) .foregroundStyle(theme.tertiaryText) .frame(width: 65, alignment: .trailing) } // Rating if columns.contains(.rating) && track.rating > 0 { Text(String(repeating: "★", count: track.rating)) .font(.system(size: theme.smallFontSize)) .foregroundStyle(.yellow) .frame(width: 50, alignment: .center) } // Play Count if columns.contains(.playCount) && track.playCount > 0 { Text("\(track.playCount)×") .font(.system(size: theme.smallFontSize, design: .monospaced)) .foregroundStyle(theme.tertiaryText) .frame(width: 25, alignment: .trailing) } } } .frame(height: theme.rowHeight) .onAppear { crossfade = entry.crossfadeDuration gain = entry.gainAdjustment } } } // MARK: - Empty Playlist private struct EmptyPlaylistView: View { let onAddTracks: () -> Void let onAddFiles: () -> Void let onAddFolder: () -> Void var body: some View { VStack(spacing: 12) { Spacer() Text("Empty playlist") .font(.system(size: 12)) .foregroundStyle(.secondary) Text("Drop files here, or use Add menu above") .font(.system(size: 11)) .foregroundStyle(.tertiary) HStack(spacing: 8) { Button("Add Files...") { onAddFiles() } .font(.system(size: 11)) Button("Add Folder...") { onAddFolder() } .font(.system(size: 11)) } Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } } // MARK: - Column Config Sheet private struct ColumnConfigSheet: View { @ObservedObject var viewConfig: PlaylistViewConfig @Environment(\.dismiss) private var dismiss var body: some View { VStack(spacing: 0) { HStack { Text("Configure Playlist View") .font(.headline) Spacer() Button("Done") { dismiss() } .keyboardShortcut(.defaultAction) } .padding() Divider() ScrollView { VStack(alignment: .leading, spacing: 20) { // Artwork settings VStack(alignment: .leading, spacing: 8) { Text("Artwork") .font(.subheadline.bold()) Toggle("Show artwork", isOn: $viewConfig.showArtwork) if viewConfig.showArtwork { Picker("Size", selection: $viewConfig.artworkSize) { ForEach(PlaylistViewConfig.ArtworkSize.allCases) { size in Text(size.rawValue).tag(size) } } .pickerStyle(.segmented) .frame(width: 250) } } Divider() // Playback behavior VStack(alignment: .leading, spacing: 8) { Text("Playback Behavior") .font(.subheadline.bold()) Toggle("Cursor follows playback", isOn: $viewConfig.cursorFollowsPlayback) Text("Auto-select and scroll to the currently playing track") .font(.caption) .foregroundStyle(.secondary) Toggle("Playback follows cursor", isOn: $viewConfig.playbackFollowsCursor) Text("Press Enter/Return to play the selected track") .font(.caption) .foregroundStyle(.secondary) } Divider() // Visible columns VStack(alignment: .leading, spacing: 8) { HStack { Text("Visible Columns") .font(.subheadline.bold()) Spacer() Button("Reset to Defaults") { viewConfig.resetToDefaults() } .font(.caption) } Text("Check the columns you want to display. Drag to reorder.") .font(.caption) .foregroundStyle(.secondary) LazyVGrid(columns: [GridItem(.adaptive(minimum: 140))], spacing: 6) { ForEach(PlaylistViewConfig.Column.allCases) { column in Toggle(column.rawValue, isOn: Binding( get: { viewConfig.isColumnVisible(column) }, set: { _ in viewConfig.toggleColumn(column) } )) .toggleStyle(.checkbox) .font(.caption) } } } } .padding(20) } } .frame(width: 450, height: 520) } } // MARK: - Add Tracks Sheet private struct AddTracksSheet: View { let playlist: Playlist let allTracks: [Track] @Environment(PlaylistViewModel.self) private var playlistVM @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss @State private var searchText = "" @State private var selectedTracks: Set = [] var filteredTracks: [Track] { if searchText.isEmpty { return allTracks } let query = searchText.lowercased() return allTracks.filter { $0.title.lowercased().contains(query) || $0.artist.lowercased().contains(query) } } var body: some View { VStack(spacing: 0) { HStack { Text("Add Tracks to \(playlist.name)") .font(.headline) Spacer() Text("\(selectedTracks.count) selected") .foregroundStyle(.secondary) } .padding() TextField("Search tracks...", text: $searchText) .textFieldStyle(.roundedBorder) .padding(.horizontal) List(filteredTracks, selection: $selectedTracks) { track in HStack { TrackRow(track: track) Spacer() if let bpm = track.bpm { Text("\(String(format: "%.0f", bpm))") .font(.caption) .foregroundStyle(.secondary) } Text(track.formattedDuration) .font(.caption) .foregroundStyle(.secondary) } } .listStyle(.inset) Divider() HStack { Button("Cancel") { dismiss() } .keyboardShortcut(.cancelAction) Spacer() Button("Add \(selectedTracks.count) Track\(selectedTracks.count == 1 ? "" : "s")") { let tracks = allTracks.filter { selectedTracks.contains($0.id) } playlistVM.addTracks(tracks, to: playlist, context: modelContext) dismiss() } .keyboardShortcut(.defaultAction) .disabled(selectedTracks.isEmpty) } .padding() } .frame(width: 500, height: 600) } } // MARK: - Track Notes Sheet private struct TrackNotesSheet: View { let track: Track @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @State private var notes: String = "" var body: some View { VStack(spacing: 12) { HStack { VStack(alignment: .leading, spacing: 2) { Text(track.title) .font(.headline) if !track.artist.isEmpty { Text(track.artist) .font(.subheadline) .foregroundStyle(.secondary) } } Spacer() Button("Done") { track.notes = notes try? modelContext.save() dismiss() } .keyboardShortcut(.defaultAction) } TextEditor(text: $notes) .font(.system(size: 13)) .frame(minHeight: 100) .scrollContentBackground(.hidden) .padding(4) .background(Color.gray.opacity(0.1)) .cornerRadius(6) HStack { Text("Notes are saved with the track and included in exports") .font(.caption) .foregroundStyle(.secondary) Spacer() } } .padding(16) .frame(width: 400, height: 220) .onAppear { notes = track.notes } } } // MARK: - Double-Click Handler (NSViewRepresentable)