ExportSheet.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  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. var body: some View {
  48. VStack(spacing: 0) {
  49. ScrollView {
  50. VStack(alignment: .leading, spacing: 16) {
  51. Text("Export a session file that references your audio tracks.")
  52. .font(.caption)
  53. .foregroundStyle(.secondary)
  54. // Format picker
  55. VStack(alignment: .leading, spacing: 6) {
  56. Text("Format")
  57. .font(.subheadline.bold())
  58. ForEach(MixExporter.ExportFormat.allCases) { format in
  59. FormatOptionRow(format: format, isSelected: selectedFormat == format)
  60. .onTapGesture { selectedFormat = format }
  61. }
  62. }
  63. Divider()
  64. // Options
  65. VStack(alignment: .leading, spacing: 8) {
  66. Text("Options")
  67. .font(.subheadline.bold())
  68. Toggle("Copy audio files to export folder", isOn: $copyFiles)
  69. .font(.caption)
  70. Toggle("Include cue points as markers", isOn: $includeCuePoints)
  71. .font(.caption)
  72. Toggle("Include crossfade information", isOn: $includeCrossfades)
  73. .font(.caption)
  74. }
  75. // File renaming
  76. if copyFiles {
  77. Divider()
  78. FileNameTemplateEditor(
  79. renameFiles: $renameFiles,
  80. template: $fileNameTemplate
  81. )
  82. }
  83. }
  84. .padding(16)
  85. }
  86. Divider()
  87. statusAndActions(
  88. isExporting: isExporting,
  89. exportComplete: exportComplete,
  90. exportError: exportError,
  91. onExport: { showSessionSavePanel() },
  92. onCancel: { dismiss() },
  93. disabled: playlist.entries.isEmpty
  94. )
  95. }
  96. }
  97. private func showSessionSavePanel() {
  98. let panel = NSSavePanel()
  99. panel.title = "Export Session"
  100. panel.nameFieldStringValue = "\(playlist.name).\(selectedFormat.fileExtension)"
  101. panel.allowedContentTypes = [.data]
  102. panel.canCreateDirectories = true
  103. if panel.runModal() == .OK, let url = panel.url {
  104. isExporting = true
  105. exportError = nil
  106. exportComplete = false
  107. var options = ExportOptions.default
  108. options.copyAudioFiles = copyFiles
  109. options.includeCuePoints = includeCuePoints
  110. options.includeCrossfades = includeCrossfades
  111. options.fileNameTemplate = renameFiles ? fileNameTemplate : nil
  112. do {
  113. try MixExporter.export(playlist: playlist, format: selectedFormat, to: url, options: options)
  114. playlistVM.showStatus("Exported \(selectedFormat.name) successfully")
  115. dismiss()
  116. } catch {
  117. exportError = error.localizedDescription
  118. }
  119. isExporting = false
  120. }
  121. }
  122. }
  123. // MARK: - Stitch Export Tab (single combined file)
  124. private struct StitchExportTab: View {
  125. let playlist: Playlist
  126. let dismiss: DismissAction
  127. @Environment(PlaylistViewModel.self) private var playlistVM
  128. @State private var bitDepth = 24
  129. @State private var usePlaylistCrossfades = true
  130. @State private var gapDuration: Double = 0
  131. @State private var generateCueSheet = true
  132. @State private var generateAuditionMarkers = true
  133. @State private var generateAuditionSession = true
  134. @State private var isExporting = false
  135. @State private var exportComplete = false
  136. @State private var exportError: String?
  137. @State private var exportProgress: String = ""
  138. var body: some View {
  139. VStack(spacing: 0) {
  140. ScrollView {
  141. VStack(alignment: .leading, spacing: 16) {
  142. Text("Combine all tracks into one WAV file with markers at each track boundary. Open in Adobe Audition to fine-tune your mix.")
  143. .font(.caption)
  144. .foregroundStyle(.secondary)
  145. // Output format
  146. VStack(alignment: .leading, spacing: 8) {
  147. Text("Output")
  148. .font(.subheadline.bold())
  149. Picker("Bit Depth", selection: $bitDepth) {
  150. Text("16-bit").tag(16)
  151. Text("24-bit").tag(24)
  152. Text("32-bit").tag(32)
  153. }
  154. .pickerStyle(.segmented)
  155. .frame(width: 240)
  156. }
  157. Divider()
  158. // Transitions
  159. VStack(alignment: .leading, spacing: 8) {
  160. Text("Transitions")
  161. .font(.subheadline.bold())
  162. Toggle("Use playlist crossfade settings", isOn: $usePlaylistCrossfades)
  163. .font(.caption)
  164. if !usePlaylistCrossfades {
  165. HStack {
  166. Text("Gap between tracks:")
  167. .font(.caption)
  168. Slider(value: $gapDuration, in: 0...5, step: 0.5)
  169. .frame(width: 120)
  170. Text("\(String(format: "%.1f", gapDuration))s")
  171. .font(.system(size: 11, design: .monospaced))
  172. }
  173. }
  174. }
  175. Divider()
  176. // Companion files
  177. VStack(alignment: .leading, spacing: 8) {
  178. Text("Companion Files")
  179. .font(.subheadline.bold())
  180. Toggle("Audition session (.sesx) with markers", isOn: $generateAuditionSession)
  181. .font(.caption)
  182. Toggle("Audition markers (.csv) for import", isOn: $generateAuditionMarkers)
  183. .font(.caption)
  184. Toggle("Cue sheet (.cue) with track indices", isOn: $generateCueSheet)
  185. .font(.caption)
  186. }
  187. // Info box
  188. VStack(alignment: .leading, spacing: 4) {
  189. Label("How it works", systemImage: "info.circle")
  190. .font(.caption.bold())
  191. Text("1. All tracks are concatenated into a single WAV file")
  192. .font(.caption2)
  193. Text("2. Marker files note where each original track starts/ends")
  194. .font(.caption2)
  195. Text("3. Open the .sesx in Audition — markers show track boundaries")
  196. .font(.caption2)
  197. Text("4. Split at markers, adjust crossfades, apply effects")
  198. .font(.caption2)
  199. }
  200. .padding(10)
  201. .background(.quaternary)
  202. .cornerRadius(6)
  203. }
  204. .padding(16)
  205. }
  206. Divider()
  207. if !exportProgress.isEmpty {
  208. Text(exportProgress)
  209. .font(.caption)
  210. .foregroundStyle(.secondary)
  211. .padding(.horizontal, 16)
  212. .padding(.top, 4)
  213. }
  214. statusAndActions(
  215. isExporting: isExporting,
  216. exportComplete: exportComplete,
  217. exportError: exportError,
  218. onExport: { showStitchSavePanel() },
  219. onCancel: { dismiss() },
  220. disabled: playlist.entries.isEmpty
  221. )
  222. }
  223. }
  224. private func showStitchSavePanel() {
  225. let panel = NSSavePanel()
  226. panel.title = "Stitch & Export"
  227. panel.nameFieldStringValue = "\(playlist.name).wav"
  228. panel.allowedContentTypes = [.wav]
  229. panel.canCreateDirectories = true
  230. if panel.runModal() == .OK, let url = panel.url {
  231. performStitch(to: url)
  232. }
  233. }
  234. private func performStitch(to url: URL) {
  235. isExporting = true
  236. exportError = nil
  237. exportComplete = false
  238. exportProgress = "Stitching audio files..."
  239. Task { @MainActor in
  240. do {
  241. var options = AudioStitcher.StitchOptions.default
  242. options.bitDepth = bitDepth
  243. options.usePlaylistCrossfades = usePlaylistCrossfades
  244. options.gapDuration = gapDuration
  245. let result = try await AudioStitcher.stitch(
  246. playlist: playlist,
  247. to: url,
  248. options: options
  249. )
  250. exportProgress = "Writing companion files..."
  251. let baseName = url.deletingPathExtension().lastPathComponent
  252. let dir = url.deletingLastPathComponent()
  253. // Companion files
  254. if generateAuditionSession {
  255. let sesxURL = dir.appendingPathComponent("\(baseName).sesx")
  256. try AudioStitcher.writeAuditionSession(
  257. result.markers,
  258. audioFilePath: url.path,
  259. audioFileName: url.lastPathComponent,
  260. playlistName: playlist.name,
  261. sampleRate: result.sampleRate,
  262. totalDuration: result.totalDuration,
  263. to: sesxURL
  264. )
  265. }
  266. if generateAuditionMarkers {
  267. let csvURL = dir.appendingPathComponent("\(baseName)_markers.csv")
  268. try AudioStitcher.writeAuditionMarkers(result.markers, to: csvURL)
  269. }
  270. if generateCueSheet {
  271. let cueURL = dir.appendingPathComponent("\(baseName).cue")
  272. try AudioStitcher.writeCueSheet(
  273. result.markers,
  274. audioFileName: url.lastPathComponent,
  275. playlistName: playlist.name,
  276. to: cueURL
  277. )
  278. }
  279. exportProgress = ""
  280. playlistVM.showStatus("Stitched \(result.markers.count) tracks to \(url.lastPathComponent)")
  281. dismiss()
  282. } catch {
  283. exportError = error.localizedDescription
  284. exportProgress = ""
  285. }
  286. isExporting = false
  287. }
  288. }
  289. }
  290. // MARK: - Shared Status & Actions Bar
  291. private func statusAndActions(
  292. isExporting: Bool,
  293. exportComplete: Bool,
  294. exportError: String?,
  295. onExport: @escaping () -> Void,
  296. onCancel: @escaping () -> Void,
  297. disabled: Bool
  298. ) -> some View {
  299. VStack(spacing: 4) {
  300. if let error = exportError {
  301. Text(error)
  302. .font(.caption)
  303. .foregroundStyle(.red)
  304. .padding(.horizontal, 16)
  305. }
  306. if exportComplete {
  307. Text("Export completed successfully!")
  308. .font(.caption)
  309. .foregroundStyle(.green)
  310. .padding(.horizontal, 16)
  311. }
  312. HStack {
  313. Button("Cancel") { onCancel() }
  314. .keyboardShortcut(.cancelAction)
  315. Spacer()
  316. if isExporting {
  317. ProgressView()
  318. .controlSize(.small)
  319. .padding(.trailing, 8)
  320. }
  321. Button("Export...") { onExport() }
  322. .keyboardShortcut(.defaultAction)
  323. .disabled(isExporting || disabled)
  324. }
  325. .padding(.horizontal, 16)
  326. .padding(.vertical, 10)
  327. }
  328. }
  329. // MARK: - Format Option Row
  330. private struct FormatOptionRow: View {
  331. let format: MixExporter.ExportFormat
  332. let isSelected: Bool
  333. var body: some View {
  334. HStack(spacing: 10) {
  335. Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
  336. .foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
  337. .font(.caption)
  338. Text(format.name)
  339. .font(.caption)
  340. Spacer()
  341. Text(".\(format.fileExtension)")
  342. .font(.caption2)
  343. .foregroundStyle(.tertiary)
  344. }
  345. .padding(.vertical, 4)
  346. .padding(.horizontal, 8)
  347. .background(isSelected ? Color.accentColor.opacity(0.08) : Color.clear)
  348. .cornerRadius(4)
  349. .contentShape(Rectangle())
  350. }
  351. }
  352. // MARK: - File Name Template Editor
  353. private struct FileNameTemplateEditor: View {
  354. @Binding var renameFiles: Bool
  355. @Binding var template: String
  356. var body: some View {
  357. VStack(alignment: .leading, spacing: 8) {
  358. Text("File Naming")
  359. .font(.subheadline.bold())
  360. Toggle("Rename copied files", isOn: $renameFiles)
  361. .font(.caption)
  362. if renameFiles {
  363. // Template input
  364. HStack {
  365. Text("Template:")
  366. .font(.caption)
  367. .foregroundStyle(.secondary)
  368. TextField("Template", text: $template)
  369. .textFieldStyle(.roundedBorder)
  370. .font(.system(size: 11, design: .monospaced))
  371. }
  372. // Preview
  373. HStack(spacing: 4) {
  374. Text("Preview:")
  375. .font(.caption)
  376. .foregroundStyle(.secondary)
  377. Text(FileNameTemplate.preview(template: template) + ".mp3")
  378. .font(.system(size: 10, design: .monospaced))
  379. .foregroundStyle(.primary)
  380. .lineLimit(1)
  381. }
  382. // Presets
  383. HStack(spacing: 4) {
  384. Text("Presets:")
  385. .font(.caption)
  386. .foregroundStyle(.secondary)
  387. ForEach(FileNameTemplate.presets.prefix(4), id: \.name) { preset in
  388. Button(preset.name) {
  389. template = preset.template
  390. }
  391. .font(.system(size: 9))
  392. .buttonStyle(.plain)
  393. .padding(.horizontal, 4)
  394. .padding(.vertical, 2)
  395. .background(Color.gray.opacity(0.15))
  396. .cornerRadius(3)
  397. }
  398. }
  399. // Available variables
  400. DisclosureGroup("Available variables") {
  401. LazyVGrid(columns: [GridItem(.adaptive(minimum: 180))], spacing: 2) {
  402. ForEach(FileNameTemplate.availableVariables, id: \.token) { variable in
  403. HStack(spacing: 4) {
  404. Button(variable.token) {
  405. template += variable.token
  406. }
  407. .font(.system(size: 10, design: .monospaced))
  408. .buttonStyle(.plain)
  409. .foregroundStyle(Color.accentColor)
  410. Text(variable.description)
  411. .font(.system(size: 9))
  412. .foregroundStyle(.tertiary)
  413. Spacer()
  414. }
  415. }
  416. }
  417. }
  418. .font(.caption)
  419. }
  420. }
  421. }
  422. }