| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637 |
- 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<Void, Never>?
- @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)
- }
- }
- }
- }
|