ExportSheet.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. import SwiftUI
  2. /// Sheet for exporting a playlist — session files or stitched single file.
  3. struct ExportSheet: View {
  4. let playlist: Playlist
  5. @Environment(PlaylistViewModel.self) private var playlistVM
  6. @Environment(\.dismiss) private var dismiss
  7. @State private var selectedTab = 0
  8. var body: some View {
  9. VStack(spacing: 0) {
  10. // Header
  11. Text("Export \"\(playlist.name)\"")
  12. .font(.headline)
  13. .padding(.top, 16)
  14. .padding(.bottom, 8)
  15. // Tab picker
  16. Picker("Export Mode", selection: $selectedTab) {
  17. Text("Session Files").tag(0)
  18. Text("Stitch to Single File").tag(1)
  19. }
  20. .pickerStyle(.segmented)
  21. .padding(.horizontal, 20)
  22. .padding(.bottom, 8)
  23. Divider()
  24. if selectedTab == 0 {
  25. SessionExportTab(playlist: playlist, dismiss: dismiss)
  26. } else {
  27. StitchExportTab(playlist: playlist, dismiss: dismiss)
  28. }
  29. }
  30. .frame(width: 500, height: 580)
  31. }
  32. }
  33. // MARK: - Session Export Tab (existing multi-file export)
  34. private struct SessionExportTab: View {
  35. let playlist: Playlist
  36. let dismiss: DismissAction
  37. @Environment(PlaylistViewModel.self) private var playlistVM
  38. @State private var selectedFormat: MixExporter.ExportFormat = .audition
  39. @State private var copyFiles = true
  40. @State private var includeCuePoints = true
  41. @State private var includeCrossfades = true
  42. @State private var renameFiles = false
  43. @State private var fileNameTemplate = FileNameTemplate.defaultTemplate
  44. @State private var isExporting = false
  45. @State private var exportComplete = false
  46. @State private var exportError: String?
  47. // Cloud download state
  48. @State private var showCloudConfirmation = false
  49. @State private var isDownloading = false
  50. @State private var downloadedCount = 0
  51. @State private var downloadTotal = 0
  52. @State private var downloadFailures: [(title: String, error: String)] = []
  53. @State private var showFailureReport = false
  54. @State private var exportTask: Task<Void, Never>?
  55. @State private var pendingSaveURL: URL?
  56. private var cloudTrackCount: Int {
  57. playlist.sortedEntries.filter { $0.track?.isCloud == true }.count
  58. }
  59. var body: some View {
  60. VStack(spacing: 0) {
  61. ScrollView {
  62. VStack(alignment: .leading, spacing: 16) {
  63. Text("Export a session file that references your audio tracks.")
  64. .font(.caption)
  65. .foregroundStyle(.secondary)
  66. // Cloud tracks notice
  67. if cloudTrackCount > 0 {
  68. HStack(spacing: 6) {
  69. Image(systemName: "cloud.fill")
  70. .foregroundStyle(.blue)
  71. Text("\(cloudTrackCount) cloud track\(cloudTrackCount == 1 ? "" : "s") will be downloaded for export")
  72. .font(.caption)
  73. .foregroundStyle(.secondary)
  74. }
  75. .padding(8)
  76. .background(Color.blue.opacity(0.08))
  77. .cornerRadius(6)
  78. }
  79. // Format picker
  80. VStack(alignment: .leading, spacing: 6) {
  81. Text("Format")
  82. .font(.subheadline.bold())
  83. ForEach(MixExporter.ExportFormat.allCases) { format in
  84. FormatOptionRow(format: format, isSelected: selectedFormat == format)
  85. .onTapGesture { selectedFormat = format }
  86. }
  87. }
  88. Divider()
  89. // Options
  90. VStack(alignment: .leading, spacing: 8) {
  91. Text("Options")
  92. .font(.subheadline.bold())
  93. Toggle("Copy audio files to export folder", isOn: $copyFiles)
  94. .font(.caption)
  95. Toggle("Include cue points as markers", isOn: $includeCuePoints)
  96. .font(.caption)
  97. Toggle("Include crossfade information", isOn: $includeCrossfades)
  98. .font(.caption)
  99. }
  100. // File renaming
  101. if copyFiles {
  102. Divider()
  103. FileNameTemplateEditor(
  104. renameFiles: $renameFiles,
  105. template: $fileNameTemplate
  106. )
  107. }
  108. }
  109. .padding(16)
  110. }
  111. Divider()
  112. // Download progress
  113. if isDownloading {
  114. HStack(spacing: 8) {
  115. ProgressView()
  116. .controlSize(.small)
  117. Text("Downloading cloud tracks... (\(downloadedCount)/\(downloadTotal))")
  118. .font(.caption)
  119. .foregroundStyle(.secondary)
  120. }
  121. .padding(.horizontal, 16)
  122. .padding(.vertical, 4)
  123. }
  124. statusAndActions(
  125. isExporting: isExporting || isDownloading,
  126. exportComplete: exportComplete,
  127. exportError: exportError,
  128. onExport: { initiateExport() },
  129. onCancel: {
  130. exportTask?.cancel()
  131. exportTask = nil
  132. dismiss()
  133. },
  134. disabled: playlist.entries.isEmpty
  135. )
  136. }
  137. .alert("Download Cloud Tracks", isPresented: $showCloudConfirmation) {
  138. Button("Download & Export") { performExport() }
  139. Button("Cancel", role: .cancel) {}
  140. } message: {
  141. Text("\(cloudTrackCount) cloud track\(cloudTrackCount == 1 ? "" : "s") need\(cloudTrackCount == 1 ? "s" : "") to be downloaded before export. This may take a while for large playlists.")
  142. }
  143. .alert("Some Downloads Failed", isPresented: $showFailureReport) {
  144. Button("OK") {}
  145. } message: {
  146. let names = downloadFailures.map(\.title).joined(separator: "\n")
  147. Text("The following tracks could not be downloaded and were skipped:\n\n\(names)")
  148. }
  149. }
  150. private func initiateExport() {
  151. let panel = NSSavePanel()
  152. panel.title = "Export Session"
  153. panel.nameFieldStringValue = "\(playlist.name).\(selectedFormat.fileExtension)"
  154. panel.allowedContentTypes = [.data]
  155. panel.canCreateDirectories = true
  156. guard panel.runModal() == .OK, let url = panel.url else { return }
  157. pendingSaveURL = url
  158. if cloudTrackCount > 0 {
  159. showCloudConfirmation = true
  160. } else {
  161. performExport()
  162. }
  163. }
  164. private func performExport() {
  165. guard let url = pendingSaveURL else { return }
  166. exportTask = Task { @MainActor in
  167. isExporting = true
  168. exportError = nil
  169. exportComplete = false
  170. downloadFailures = []
  171. var options = ExportOptions.default
  172. options.copyAudioFiles = copyFiles
  173. options.includeCuePoints = includeCuePoints
  174. options.includeCrossfades = includeCrossfades
  175. options.fileNameTemplate = renameFiles ? fileNameTemplate : nil
  176. let tempDir = FileManager.default.temporaryDirectory
  177. .appendingPathComponent("MixBoardCloudExport-\(UUID().uuidString)", isDirectory: true)
  178. do {
  179. // Phase 1: Download cloud tracks
  180. let cloudEntries = playlist.sortedEntries.filter { $0.track?.isCloud == true }
  181. if !cloudEntries.isEmpty {
  182. let apiClient = ChadMusicAPIClient.shared
  183. let authHeaders = apiClient.authHeaders
  184. // Build download list — resolve stream URLs on MainActor
  185. var downloadList: [(track: Track, streamURL: URL)] = []
  186. for entry in cloudEntries {
  187. guard let track = entry.track,
  188. let streamPath = track.cloudStreamPath,
  189. let streamURL = apiClient.streamURL(for: streamPath) else { continue }
  190. downloadList.append((track: track, streamURL: streamURL))
  191. }
  192. if !downloadList.isEmpty {
  193. isDownloading = true
  194. downloadTotal = downloadList.count
  195. downloadedCount = 0
  196. let result = await DownloadService.downloadBatch(
  197. tracks: downloadList,
  198. authHeaders: authHeaders,
  199. to: tempDir,
  200. onProgress: { completed, total in
  201. downloadedCount = completed
  202. downloadTotal = total
  203. }
  204. )
  205. isDownloading = false
  206. guard !Task.isCancelled else {
  207. cleanUp(tempDir)
  208. return
  209. }
  210. options.downloadedFiles = result.downloaded
  211. downloadFailures = result.failures
  212. }
  213. }
  214. guard !Task.isCancelled else {
  215. cleanUp(tempDir)
  216. return
  217. }
  218. // Phase 2: Export
  219. try MixExporter.export(
  220. playlist: playlist,
  221. format: selectedFormat,
  222. to: url,
  223. options: options
  224. )
  225. cleanUp(tempDir)
  226. if !downloadFailures.isEmpty {
  227. isExporting = false
  228. showFailureReport = true
  229. } else {
  230. playlistVM.showStatus("Exported \(selectedFormat.name) successfully")
  231. dismiss()
  232. }
  233. } catch {
  234. cleanUp(tempDir)
  235. if !Task.isCancelled {
  236. exportError = error.localizedDescription
  237. }
  238. }
  239. isExporting = false
  240. }
  241. }
  242. private func cleanUp(_ directory: URL) {
  243. try? FileManager.default.removeItem(at: directory)
  244. }
  245. }
  246. // MARK: - Stitch Export Tab (single combined file)
  247. private struct StitchExportTab: View {
  248. let playlist: Playlist
  249. let dismiss: DismissAction
  250. @Environment(PlaylistViewModel.self) private var playlistVM
  251. @State private var bitDepth = 24
  252. @State private var usePlaylistCrossfades = true
  253. @State private var gapDuration: Double = 0
  254. @State private var generateCueSheet = true
  255. @State private var generateAuditionMarkers = true
  256. @State private var generateAuditionSession = true
  257. @State private var isExporting = false
  258. @State private var exportComplete = false
  259. @State private var exportError: String?
  260. @State private var exportProgress: String = ""
  261. var body: some View {
  262. VStack(spacing: 0) {
  263. ScrollView {
  264. VStack(alignment: .leading, spacing: 16) {
  265. Text("Combine all tracks into one WAV file with markers at each track boundary. Open in Adobe Audition to fine-tune your mix.")
  266. .font(.caption)
  267. .foregroundStyle(.secondary)
  268. // Output format
  269. VStack(alignment: .leading, spacing: 8) {
  270. Text("Output")
  271. .font(.subheadline.bold())
  272. Picker("Bit Depth", selection: $bitDepth) {
  273. Text("16-bit").tag(16)
  274. Text("24-bit").tag(24)
  275. Text("32-bit").tag(32)
  276. }
  277. .pickerStyle(.segmented)
  278. .frame(width: 240)
  279. }
  280. Divider()
  281. // Transitions
  282. VStack(alignment: .leading, spacing: 8) {
  283. Text("Transitions")
  284. .font(.subheadline.bold())
  285. Toggle("Use playlist crossfade settings", isOn: $usePlaylistCrossfades)
  286. .font(.caption)
  287. if !usePlaylistCrossfades {
  288. HStack {
  289. Text("Gap between tracks:")
  290. .font(.caption)
  291. Slider(value: $gapDuration, in: 0...5, step: 0.5)
  292. .frame(width: 120)
  293. Text("\(String(format: "%.1f", gapDuration))s")
  294. .font(.system(size: 11, design: .monospaced))
  295. }
  296. }
  297. }
  298. Divider()
  299. // Companion files
  300. VStack(alignment: .leading, spacing: 8) {
  301. Text("Companion Files")
  302. .font(.subheadline.bold())
  303. Toggle("Audition session (.sesx) with markers", isOn: $generateAuditionSession)
  304. .font(.caption)
  305. Toggle("Audition markers (.csv) for import", isOn: $generateAuditionMarkers)
  306. .font(.caption)
  307. Toggle("Cue sheet (.cue) with track indices", isOn: $generateCueSheet)
  308. .font(.caption)
  309. }
  310. // Info box
  311. VStack(alignment: .leading, spacing: 4) {
  312. Label("How it works", systemImage: "info.circle")
  313. .font(.caption.bold())
  314. Text("1. All tracks are concatenated into a single WAV file")
  315. .font(.caption2)
  316. Text("2. Marker files note where each original track starts/ends")
  317. .font(.caption2)
  318. Text("3. Open the .sesx in Audition — markers show track boundaries")
  319. .font(.caption2)
  320. Text("4. Split at markers, adjust crossfades, apply effects")
  321. .font(.caption2)
  322. }
  323. .padding(10)
  324. .background(.quaternary)
  325. .cornerRadius(6)
  326. }
  327. .padding(16)
  328. }
  329. Divider()
  330. if !exportProgress.isEmpty {
  331. Text(exportProgress)
  332. .font(.caption)
  333. .foregroundStyle(.secondary)
  334. .padding(.horizontal, 16)
  335. .padding(.top, 4)
  336. }
  337. statusAndActions(
  338. isExporting: isExporting,
  339. exportComplete: exportComplete,
  340. exportError: exportError,
  341. onExport: { showStitchSavePanel() },
  342. onCancel: { dismiss() },
  343. disabled: playlist.entries.isEmpty
  344. )
  345. }
  346. }
  347. private func showStitchSavePanel() {
  348. let panel = NSSavePanel()
  349. panel.title = "Stitch & Export"
  350. panel.nameFieldStringValue = "\(playlist.name).wav"
  351. panel.allowedContentTypes = [.wav]
  352. panel.canCreateDirectories = true
  353. if panel.runModal() == .OK, let url = panel.url {
  354. performStitch(to: url)
  355. }
  356. }
  357. private func performStitch(to url: URL) {
  358. isExporting = true
  359. exportError = nil
  360. exportComplete = false
  361. exportProgress = "Stitching audio files..."
  362. Task { @MainActor in
  363. do {
  364. var options = AudioStitcher.StitchOptions.default
  365. options.bitDepth = bitDepth
  366. options.usePlaylistCrossfades = usePlaylistCrossfades
  367. options.gapDuration = gapDuration
  368. let result = try await AudioStitcher.stitch(
  369. playlist: playlist,
  370. to: url,
  371. options: options
  372. )
  373. exportProgress = "Writing companion files..."
  374. let baseName = url.deletingPathExtension().lastPathComponent
  375. let dir = url.deletingLastPathComponent()
  376. // Companion files
  377. if generateAuditionSession {
  378. let sesxURL = dir.appendingPathComponent("\(baseName).sesx")
  379. try AudioStitcher.writeAuditionSession(
  380. result.markers,
  381. audioFilePath: url.path,
  382. audioFileName: url.lastPathComponent,
  383. playlistName: playlist.name,
  384. sampleRate: result.sampleRate,
  385. totalDuration: result.totalDuration,
  386. to: sesxURL
  387. )
  388. }
  389. if generateAuditionMarkers {
  390. let csvURL = dir.appendingPathComponent("\(baseName)_markers.csv")
  391. try AudioStitcher.writeAuditionMarkers(result.markers, to: csvURL)
  392. }
  393. if generateCueSheet {
  394. let cueURL = dir.appendingPathComponent("\(baseName).cue")
  395. try AudioStitcher.writeCueSheet(
  396. result.markers,
  397. audioFileName: url.lastPathComponent,
  398. playlistName: playlist.name,
  399. to: cueURL
  400. )
  401. }
  402. exportProgress = ""
  403. playlistVM.showStatus("Stitched \(result.markers.count) tracks to \(url.lastPathComponent)")
  404. dismiss()
  405. } catch {
  406. exportError = error.localizedDescription
  407. exportProgress = ""
  408. }
  409. isExporting = false
  410. }
  411. }
  412. }
  413. // MARK: - Shared Status & Actions Bar
  414. private func statusAndActions(
  415. isExporting: Bool,
  416. exportComplete: Bool,
  417. exportError: String?,
  418. onExport: @escaping () -> Void,
  419. onCancel: @escaping () -> Void,
  420. disabled: Bool
  421. ) -> some View {
  422. VStack(spacing: 4) {
  423. if let error = exportError {
  424. Text(error)
  425. .font(.caption)
  426. .foregroundStyle(.red)
  427. .padding(.horizontal, 16)
  428. }
  429. if exportComplete {
  430. Text("Export completed successfully!")
  431. .font(.caption)
  432. .foregroundStyle(.green)
  433. .padding(.horizontal, 16)
  434. }
  435. HStack {
  436. Button("Cancel") { onCancel() }
  437. .keyboardShortcut(.cancelAction)
  438. Spacer()
  439. if isExporting {
  440. ProgressView()
  441. .controlSize(.small)
  442. .padding(.trailing, 8)
  443. }
  444. Button("Export...") { onExport() }
  445. .keyboardShortcut(.defaultAction)
  446. .disabled(isExporting || disabled)
  447. }
  448. .padding(.horizontal, 16)
  449. .padding(.vertical, 10)
  450. }
  451. }
  452. // MARK: - Format Option Row
  453. private struct FormatOptionRow: View {
  454. let format: MixExporter.ExportFormat
  455. let isSelected: Bool
  456. var body: some View {
  457. HStack(spacing: 10) {
  458. Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
  459. .foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
  460. .font(.caption)
  461. Text(format.name)
  462. .font(.caption)
  463. Spacer()
  464. Text(".\(format.fileExtension)")
  465. .font(.caption2)
  466. .foregroundStyle(.tertiary)
  467. }
  468. .padding(.vertical, 4)
  469. .padding(.horizontal, 8)
  470. .background(isSelected ? Color.accentColor.opacity(0.08) : Color.clear)
  471. .cornerRadius(4)
  472. .contentShape(Rectangle())
  473. }
  474. }
  475. // MARK: - File Name Template Editor
  476. private struct FileNameTemplateEditor: View {
  477. @Binding var renameFiles: Bool
  478. @Binding var template: String
  479. var body: some View {
  480. VStack(alignment: .leading, spacing: 8) {
  481. Text("File Naming")
  482. .font(.subheadline.bold())
  483. Toggle("Rename copied files", isOn: $renameFiles)
  484. .font(.caption)
  485. if renameFiles {
  486. // Template input
  487. HStack {
  488. Text("Template:")
  489. .font(.caption)
  490. .foregroundStyle(.secondary)
  491. TextField("Template", text: $template)
  492. .textFieldStyle(.roundedBorder)
  493. .font(.system(size: 11, design: .monospaced))
  494. }
  495. // Preview
  496. HStack(spacing: 4) {
  497. Text("Preview:")
  498. .font(.caption)
  499. .foregroundStyle(.secondary)
  500. Text(FileNameTemplate.preview(template: template) + ".mp3")
  501. .font(.system(size: 10, design: .monospaced))
  502. .foregroundStyle(.primary)
  503. .lineLimit(1)
  504. }
  505. // Presets
  506. HStack(spacing: 4) {
  507. Text("Presets:")
  508. .font(.caption)
  509. .foregroundStyle(.secondary)
  510. ForEach(FileNameTemplate.presets.prefix(4), id: \.name) { preset in
  511. Button(preset.name) {
  512. template = preset.template
  513. }
  514. .font(.system(size: 9))
  515. .buttonStyle(.plain)
  516. .padding(.horizontal, 4)
  517. .padding(.vertical, 2)
  518. .background(Color.gray.opacity(0.15))
  519. .cornerRadius(3)
  520. }
  521. }
  522. // Available variables
  523. DisclosureGroup("Available variables") {
  524. LazyVGrid(columns: [GridItem(.adaptive(minimum: 180))], spacing: 2) {
  525. ForEach(FileNameTemplate.availableVariables, id: \.token) { variable in
  526. HStack(spacing: 4) {
  527. Button(variable.token) {
  528. template += variable.token
  529. }
  530. .font(.system(size: 10, design: .monospaced))
  531. .buttonStyle(.plain)
  532. .foregroundStyle(Color.accentColor)
  533. Text(variable.description)
  534. .font(.system(size: 9))
  535. .foregroundStyle(.tertiary)
  536. Spacer()
  537. }
  538. }
  539. }
  540. }
  541. .font(.caption)
  542. }
  543. }
  544. }
  545. }