import SwiftUI /// Sheet for exporting a playlist — session files or stitched single file. struct ExportSheet: View { let playlist: Playlist @Environment(PlaylistViewModel.self) private var playlistVM @Environment(\.dismiss) private var dismiss @State private var selectedTab = 0 var body: some View { VStack(spacing: 0) { // Header Text("Export \"\(playlist.name)\"") .font(.headline) .padding(.top, 16) .padding(.bottom, 8) // Tab picker Picker("Export Mode", selection: $selectedTab) { Text("Session Files").tag(0) Text("Stitch to Single File").tag(1) } .pickerStyle(.segmented) .padding(.horizontal, 20) .padding(.bottom, 8) Divider() if selectedTab == 0 { SessionExportTab(playlist: playlist, dismiss: dismiss) } else { StitchExportTab(playlist: playlist, dismiss: dismiss) } } .frame(width: 500, height: 580) } } // MARK: - Session Export Tab (existing multi-file export) private struct SessionExportTab: View { let playlist: Playlist let dismiss: DismissAction @Environment(PlaylistViewModel.self) private var playlistVM @State private var selectedFormat: MixExporter.ExportFormat = .audition @State private var copyFiles = true @State private var includeCuePoints = true @State private var includeCrossfades = true @State private var renameFiles = false @State private var fileNameTemplate = FileNameTemplate.defaultTemplate @State private var isExporting = false @State private var exportComplete = false @State private var exportError: String? var body: some View { VStack(spacing: 0) { ScrollView { VStack(alignment: .leading, spacing: 16) { Text("Export a session file that references your audio tracks.") .font(.caption) .foregroundStyle(.secondary) // Format picker VStack(alignment: .leading, spacing: 6) { Text("Format") .font(.subheadline.bold()) ForEach(MixExporter.ExportFormat.allCases) { format in FormatOptionRow(format: format, isSelected: selectedFormat == format) .onTapGesture { selectedFormat = format } } } Divider() // Options VStack(alignment: .leading, spacing: 8) { Text("Options") .font(.subheadline.bold()) Toggle("Copy audio files to export folder", isOn: $copyFiles) .font(.caption) Toggle("Include cue points as markers", isOn: $includeCuePoints) .font(.caption) Toggle("Include crossfade information", isOn: $includeCrossfades) .font(.caption) } // File renaming if copyFiles { Divider() FileNameTemplateEditor( renameFiles: $renameFiles, template: $fileNameTemplate ) } } .padding(16) } Divider() statusAndActions( isExporting: isExporting, exportComplete: exportComplete, exportError: exportError, onExport: { showSessionSavePanel() }, onCancel: { dismiss() }, disabled: playlist.entries.isEmpty ) } } private func showSessionSavePanel() { let panel = NSSavePanel() panel.title = "Export Session" panel.nameFieldStringValue = "\(playlist.name).\(selectedFormat.fileExtension)" panel.allowedContentTypes = [.data] panel.canCreateDirectories = true if panel.runModal() == .OK, let url = panel.url { isExporting = true exportError = nil exportComplete = false var options = ExportOptions.default options.copyAudioFiles = copyFiles options.includeCuePoints = includeCuePoints options.includeCrossfades = includeCrossfades options.fileNameTemplate = renameFiles ? fileNameTemplate : nil do { try MixExporter.export(playlist: playlist, format: selectedFormat, to: url, options: options) playlistVM.showStatus("Exported \(selectedFormat.name) successfully") dismiss() } catch { exportError = error.localizedDescription } isExporting = false } } } // MARK: - Stitch Export Tab (single combined file) private struct StitchExportTab: View { let playlist: Playlist let dismiss: DismissAction @Environment(PlaylistViewModel.self) private var playlistVM @State private var bitDepth = 24 @State private var usePlaylistCrossfades = true @State private var gapDuration: Double = 0 @State private var generateCueSheet = true @State private var generateAuditionMarkers = true @State private var generateAuditionSession = true @State private var isExporting = false @State private var exportComplete = false @State private var exportError: String? @State private var exportProgress: String = "" var body: some View { VStack(spacing: 0) { ScrollView { VStack(alignment: .leading, spacing: 16) { Text("Combine all tracks into one WAV file with markers at each track boundary. Open in Adobe Audition to fine-tune your mix.") .font(.caption) .foregroundStyle(.secondary) // Output format VStack(alignment: .leading, spacing: 8) { Text("Output") .font(.subheadline.bold()) Picker("Bit Depth", selection: $bitDepth) { Text("16-bit").tag(16) Text("24-bit").tag(24) Text("32-bit").tag(32) } .pickerStyle(.segmented) .frame(width: 240) } Divider() // Transitions VStack(alignment: .leading, spacing: 8) { Text("Transitions") .font(.subheadline.bold()) Toggle("Use playlist crossfade settings", isOn: $usePlaylistCrossfades) .font(.caption) if !usePlaylistCrossfades { HStack { Text("Gap between tracks:") .font(.caption) Slider(value: $gapDuration, in: 0...5, step: 0.5) .frame(width: 120) Text("\(String(format: "%.1f", gapDuration))s") .font(.system(size: 11, design: .monospaced)) } } } Divider() // Companion files VStack(alignment: .leading, spacing: 8) { Text("Companion Files") .font(.subheadline.bold()) Toggle("Audition session (.sesx) with markers", isOn: $generateAuditionSession) .font(.caption) Toggle("Audition markers (.csv) for import", isOn: $generateAuditionMarkers) .font(.caption) Toggle("Cue sheet (.cue) with track indices", isOn: $generateCueSheet) .font(.caption) } // Info box VStack(alignment: .leading, spacing: 4) { Label("How it works", systemImage: "info.circle") .font(.caption.bold()) Text("1. All tracks are concatenated into a single WAV file") .font(.caption2) Text("2. Marker files note where each original track starts/ends") .font(.caption2) Text("3. Open the .sesx in Audition — markers show track boundaries") .font(.caption2) Text("4. Split at markers, adjust crossfades, apply effects") .font(.caption2) } .padding(10) .background(.quaternary) .cornerRadius(6) } .padding(16) } Divider() if !exportProgress.isEmpty { Text(exportProgress) .font(.caption) .foregroundStyle(.secondary) .padding(.horizontal, 16) .padding(.top, 4) } statusAndActions( isExporting: isExporting, exportComplete: exportComplete, exportError: exportError, onExport: { showStitchSavePanel() }, onCancel: { dismiss() }, disabled: playlist.entries.isEmpty ) } } private func showStitchSavePanel() { let panel = NSSavePanel() panel.title = "Stitch & Export" panel.nameFieldStringValue = "\(playlist.name).wav" panel.allowedContentTypes = [.wav] panel.canCreateDirectories = true if panel.runModal() == .OK, let url = panel.url { performStitch(to: url) } } private func performStitch(to url: URL) { isExporting = true exportError = nil exportComplete = false exportProgress = "Stitching audio files..." Task { @MainActor in do { var options = AudioStitcher.StitchOptions.default options.bitDepth = bitDepth options.usePlaylistCrossfades = usePlaylistCrossfades options.gapDuration = gapDuration let result = try await AudioStitcher.stitch( playlist: playlist, to: url, options: options ) exportProgress = "Writing companion files..." let baseName = url.deletingPathExtension().lastPathComponent let dir = url.deletingLastPathComponent() // Companion files if generateAuditionSession { let sesxURL = dir.appendingPathComponent("\(baseName).sesx") try AudioStitcher.writeAuditionSession( result.markers, audioFilePath: url.path, audioFileName: url.lastPathComponent, playlistName: playlist.name, sampleRate: result.sampleRate, totalDuration: result.totalDuration, to: sesxURL ) } if generateAuditionMarkers { let csvURL = dir.appendingPathComponent("\(baseName)_markers.csv") try AudioStitcher.writeAuditionMarkers(result.markers, to: csvURL) } if generateCueSheet { let cueURL = dir.appendingPathComponent("\(baseName).cue") try AudioStitcher.writeCueSheet( result.markers, audioFileName: url.lastPathComponent, playlistName: playlist.name, to: cueURL ) } exportProgress = "" playlistVM.showStatus("Stitched \(result.markers.count) tracks to \(url.lastPathComponent)") dismiss() } catch { exportError = error.localizedDescription exportProgress = "" } isExporting = false } } } // MARK: - Shared Status & Actions Bar private func statusAndActions( isExporting: Bool, exportComplete: Bool, exportError: String?, onExport: @escaping () -> Void, onCancel: @escaping () -> Void, disabled: Bool ) -> some View { VStack(spacing: 4) { if let error = exportError { Text(error) .font(.caption) .foregroundStyle(.red) .padding(.horizontal, 16) } if exportComplete { Text("Export completed successfully!") .font(.caption) .foregroundStyle(.green) .padding(.horizontal, 16) } HStack { Button("Cancel") { onCancel() } .keyboardShortcut(.cancelAction) Spacer() if isExporting { ProgressView() .controlSize(.small) .padding(.trailing, 8) } Button("Export...") { onExport() } .keyboardShortcut(.defaultAction) .disabled(isExporting || disabled) } .padding(.horizontal, 16) .padding(.vertical, 10) } } // MARK: - Format Option Row private struct FormatOptionRow: View { let format: MixExporter.ExportFormat let isSelected: Bool var body: some View { HStack(spacing: 10) { Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) .font(.caption) Text(format.name) .font(.caption) Spacer() Text(".\(format.fileExtension)") .font(.caption2) .foregroundStyle(.tertiary) } .padding(.vertical, 4) .padding(.horizontal, 8) .background(isSelected ? Color.accentColor.opacity(0.08) : Color.clear) .cornerRadius(4) .contentShape(Rectangle()) } } // MARK: - File Name Template Editor private struct FileNameTemplateEditor: View { @Binding var renameFiles: Bool @Binding var template: String var body: some View { VStack(alignment: .leading, spacing: 8) { Text("File Naming") .font(.subheadline.bold()) Toggle("Rename copied files", isOn: $renameFiles) .font(.caption) if renameFiles { // Template input HStack { Text("Template:") .font(.caption) .foregroundStyle(.secondary) TextField("Template", text: $template) .textFieldStyle(.roundedBorder) .font(.system(size: 11, design: .monospaced)) } // Preview HStack(spacing: 4) { Text("Preview:") .font(.caption) .foregroundStyle(.secondary) Text(FileNameTemplate.preview(template: template) + ".mp3") .font(.system(size: 10, design: .monospaced)) .foregroundStyle(.primary) .lineLimit(1) } // Presets HStack(spacing: 4) { Text("Presets:") .font(.caption) .foregroundStyle(.secondary) ForEach(FileNameTemplate.presets.prefix(4), id: \.name) { preset in Button(preset.name) { template = preset.template } .font(.system(size: 9)) .buttonStyle(.plain) .padding(.horizontal, 4) .padding(.vertical, 2) .background(Color.gray.opacity(0.15)) .cornerRadius(3) } } // Available variables DisclosureGroup("Available variables") { LazyVGrid(columns: [GridItem(.adaptive(minimum: 180))], spacing: 2) { ForEach(FileNameTemplate.availableVariables, id: \.token) { variable in HStack(spacing: 4) { Button(variable.token) { template += variable.token } .font(.system(size: 10, design: .monospaced)) .buttonStyle(.plain) .foregroundStyle(Color.accentColor) Text(variable.description) .font(.system(size: 9)) .foregroundStyle(.tertiary) Spacer() } } } } .font(.caption) } } } }