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? // Cloud download state @State private var showCloudConfirmation = false @State private var isDownloading = false @State private var downloadedCount = 0 @State private var downloadTotal = 0 @State private var downloadFailures: [(title: String, error: String)] = [] @State private var showFailureReport = false @State private var exportTask: Task? @State private var pendingSaveURL: URL? private var cloudTrackCount: Int { playlist.sortedEntries.filter { $0.track?.isCloud == true }.count } 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) // Cloud tracks notice if cloudTrackCount > 0 { HStack(spacing: 6) { Image(systemName: "cloud.fill") .foregroundStyle(.blue) Text("\(cloudTrackCount) cloud track\(cloudTrackCount == 1 ? "" : "s") will be downloaded for export") .font(.caption) .foregroundStyle(.secondary) } .padding(8) .background(Color.blue.opacity(0.08)) .cornerRadius(6) } // 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() // Download progress if isDownloading { HStack(spacing: 8) { ProgressView() .controlSize(.small) Text("Downloading cloud tracks... (\(downloadedCount)/\(downloadTotal))") .font(.caption) .foregroundStyle(.secondary) } .padding(.horizontal, 16) .padding(.vertical, 4) } statusAndActions( isExporting: isExporting || isDownloading, exportComplete: exportComplete, exportError: exportError, onExport: { initiateExport() }, onCancel: { exportTask?.cancel() exportTask = nil dismiss() }, disabled: playlist.entries.isEmpty ) } .alert("Download Cloud Tracks", isPresented: $showCloudConfirmation) { Button("Download & Export") { performExport() } Button("Cancel", role: .cancel) {} } message: { Text("\(cloudTrackCount) cloud track\(cloudTrackCount == 1 ? "" : "s") need\(cloudTrackCount == 1 ? "s" : "") to be downloaded before export. This may take a while for large playlists.") } .alert("Some Downloads Failed", isPresented: $showFailureReport) { Button("OK") {} } message: { let names = downloadFailures.map(\.title).joined(separator: "\n") Text("The following tracks could not be downloaded and were skipped:\n\n\(names)") } } private func initiateExport() { let panel = NSSavePanel() panel.title = "Export Session" panel.nameFieldStringValue = "\(playlist.name).\(selectedFormat.fileExtension)" panel.allowedContentTypes = [.data] panel.canCreateDirectories = true guard panel.runModal() == .OK, let url = panel.url else { return } pendingSaveURL = url if cloudTrackCount > 0 { showCloudConfirmation = true } else { performExport() } } private func performExport() { guard let url = pendingSaveURL else { return } exportTask = Task { @MainActor in isExporting = true exportError = nil exportComplete = false downloadFailures = [] var options = ExportOptions.default options.copyAudioFiles = copyFiles options.includeCuePoints = includeCuePoints options.includeCrossfades = includeCrossfades options.fileNameTemplate = renameFiles ? fileNameTemplate : nil let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("MixBoardCloudExport-\(UUID().uuidString)", isDirectory: true) do { // Phase 1: Download cloud tracks let cloudEntries = playlist.sortedEntries.filter { $0.track?.isCloud == true } if !cloudEntries.isEmpty { let apiClient = ChadMusicAPIClient.shared let authHeaders = apiClient.authHeaders // Build download list — resolve stream URLs on MainActor var downloadList: [(track: Track, streamURL: URL)] = [] for entry in cloudEntries { guard let track = entry.track, let streamPath = track.cloudStreamPath, let streamURL = apiClient.streamURL(for: streamPath) else { continue } downloadList.append((track: track, streamURL: streamURL)) } if !downloadList.isEmpty { isDownloading = true downloadTotal = downloadList.count downloadedCount = 0 let result = await DownloadService.downloadBatch( tracks: downloadList, authHeaders: authHeaders, to: tempDir, onProgress: { completed, total in downloadedCount = completed downloadTotal = total } ) isDownloading = false guard !Task.isCancelled else { cleanUp(tempDir) return } options.downloadedFiles = result.downloaded downloadFailures = result.failures } } guard !Task.isCancelled else { cleanUp(tempDir) return } // Phase 2: Export try MixExporter.export( playlist: playlist, format: selectedFormat, to: url, options: options ) cleanUp(tempDir) if !downloadFailures.isEmpty { isExporting = false showFailureReport = true } else { playlistVM.showStatus("Exported \(selectedFormat.name) successfully") dismiss() } } catch { cleanUp(tempDir) if !Task.isCancelled { exportError = error.localizedDescription } } isExporting = false } } private func cleanUp(_ directory: URL) { try? FileManager.default.removeItem(at: directory) } } // 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) } } } }