import AppKit import MusicAssistantCore import SwiftUI struct FinalReviewView: View { @Binding var project: SongProject @Environment(\.openURL) private var openURL private let sunoFieldFiller: any SunoFieldFilling @State private var newFieldKey = "" @State private var newFieldValue = "" @State private var handoffStatus: HandoffStatus? init( project: Binding, sunoFieldFiller: any SunoFieldFilling = MacOSSunoAccessibilityFieldFiller() ) { _project = project self.sunoFieldFiller = sunoFieldFiller } var body: some View { VStack(alignment: .leading, spacing: 12) { ArabicLyricsReviewView(project: $project) Divider() TextField("Approved lyrics", text: $project.lyrics.text, axis: .vertical) .lineLimit(4...10) if !project.orderedSections.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Section lyrics") .font(.headline) ForEach(project.orderedSections) { section in TextField(section.title, text: sectionLyricsBinding(for: section.id), axis: .vertical) .lineLimit(2...5) } } } Divider() HStack { Text("Suno fields") .font(.headline) Spacer() Button { ensureSunoOutput() } label: { Label("Prepare fields", systemImage: "wand.and.stars") } .disabled(project.sunoOutput != nil) Button(action: sendToSuno) { Label("Send to Suno", systemImage: "arrow.up.forward.app") } } if let handoffStatus { Label(handoffStatus.title, systemImage: handoffStatus.systemImage) .font(.caption) .foregroundStyle(handoffStatus.foregroundStyle) } if let fieldsToCopy, let output = project.sunoOutput { HStack(spacing: 8) { ForEach(fieldsToCopy) { field in Button("Copy \(field.displayName)") { copy(field, from: output) } .buttonStyle(.bordered) } } } TextField("Suno lyrics", text: sunoLyricsBinding, axis: .vertical) .lineLimit(4...10) TextField("Suno style prompt", text: sunoStylePromptBinding, axis: .vertical) .lineLimit(3...8) if !additionalFieldKeys.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Additional fields") .font(.headline) ForEach(additionalFieldKeys, id: \.self) { key in HStack(alignment: .top, spacing: 8) { TextField("Field", text: additionalFieldKeyBinding(for: key)) TextField("Value", text: additionalFieldValueBinding(for: key), axis: .vertical) .lineLimit(1...4) Button { project.sunoOutput?.additionalFields.removeValue(forKey: key) } label: { Label("Remove field", systemImage: "minus.circle") .labelStyle(.iconOnly) } .buttonStyle(.borderless) .help("Remove field") } } } } HStack { TextField("Field name", text: $newFieldKey) TextField("Value", text: $newFieldValue) Button(action: addAdditionalField) { Label("Add field", systemImage: "plus.circle.fill") .labelStyle(.iconOnly) } .buttonStyle(.borderless) .disabled(newFieldKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .help("Add field") } } } private var additionalFieldKeys: [String] { guard let additionalFields = project.sunoOutput?.additionalFields else { return [] } return additionalFields.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending } } private var sunoLyricsBinding: Binding { Binding { project.sunoOutput?.lyricsText ?? "" } set: { newValue in ensureSunoOutput() project.sunoOutput?.lyricsText = newValue } } private var sunoStylePromptBinding: Binding { Binding { project.sunoOutput?.stylePrompt ?? "" } set: { newValue in ensureSunoOutput() project.sunoOutput?.stylePrompt = newValue } } private func sectionLyricsBinding(for sectionID: String) -> Binding { Binding { project.lyrics.sectionTexts[sectionID] ?? "" } set: { newValue in if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { project.lyrics.sectionTexts.removeValue(forKey: sectionID) } else { project.lyrics.sectionTexts[sectionID] = newValue } } } private func additionalFieldKeyBinding(for key: String) -> Binding { Binding { key } set: { newKey in let trimmedKey = newKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedKey.isEmpty, trimmedKey != key else { return } ensureSunoOutput() let value = project.sunoOutput?.additionalFields.removeValue(forKey: key) ?? "" project.sunoOutput?.additionalFields[trimmedKey] = value } } private func additionalFieldValueBinding(for key: String) -> Binding { Binding { project.sunoOutput?.additionalFields[key] ?? "" } set: { newValue in ensureSunoOutput() project.sunoOutput?.additionalFields[key] = newValue } } private func addAdditionalField() { let key = newFieldKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { return } ensureSunoOutput() project.sunoOutput?.additionalFields[key] = newFieldValue newFieldKey = "" newFieldValue = "" } private func ensureSunoOutput() { guard project.sunoOutput == nil else { return } let compiledOutput = (try? SongProjectPromptCompiler().compile(project: project)) ?? CompiledSunoOutput(lyricsText: project.lyrics.text, stylePrompt: "") project.sunoOutput = SunoOutput( lyricsText: compiledOutput.lyricsText, stylePrompt: compiledOutput.stylePrompt, generatedAt: Date() ) } private func sendToSuno() { ensureSunoOutput() guard let output = project.sunoOutput else { return } do { try CompiledSunoOutputValidator().validate( CompiledSunoOutput(lyricsText: output.lyricsText, stylePrompt: output.stylePrompt) ) } catch let validationError as LocalizedError { handoffStatus = .failed(validationError.errorDescription ?? "Unable to prepare the Suno handoff.") return } catch { handoffStatus = .failed("Unable to prepare the Suno handoff.") return } openURL(SunoHandoffDestination.createPageURL) { accepted in guard accepted else { handoffStatus = .failed("Unable to open the Suno Create page.") return } let payload = SunoHandoffPayload( title: project.title, lyrics: output.lyricsText, stylePrompt: output.stylePrompt ) Task { handoffStatus = .fieldFill(await sunoFieldFiller.fill(payload)) } } } private var fieldsToCopy: [SunoHandoffField]? { guard case let .fieldFill(report) = handoffStatus else { return nil } let fields = SunoHandoffField.allCases.filter { if case .copyRequired = report.results[$0] { return true } return false } return fields.isEmpty ? nil : fields } private func copy(_ field: SunoHandoffField, from output: SunoOutput) { let value: String switch field { case .title: value = project.title case .lyrics: value = output.lyricsText case .stylePrompt: value = output.stylePrompt } NSPasteboard.general.clearContents() NSPasteboard.general.setString(value, forType: .string) } } private enum HandoffStatus: Equatable { case fieldFill(SunoFieldFillReport) case failed(String) var title: String { switch self { case let .fieldFill(report): let fieldsToCopy = report.results.compactMap { field, result in if case .copyRequired = result { return field.displayName } return nil } return fieldsToCopy.isEmpty ? "Suno fields filled in your browser." : "Copy these fields into Suno: \(fieldsToCopy.joined(separator: ", "))." case let .failed(message): return message } } var systemImage: String { switch self { case let .fieldFill(report): return report.results.values.contains { if case .copyRequired = $0 { return true }; return false } ? "exclamationmark.triangle.fill" : "checkmark.circle.fill" case .failed: return "exclamationmark.triangle.fill" } } var foregroundStyle: Color { switch self { case let .fieldFill(report): return report.results.values.contains { if case .copyRequired = $0 { return true }; return false } ? .orange : .green case .failed: return .red } } } #Preview { FinalReviewPreview() } private struct FinalReviewPreview: View { @State private var project = SongProject( title: "Review", idea: "Review generated fields", sections: [ SongSection(id: "verse", type: .verse, title: "Verse"), SongSection(id: "chorus", type: .chorus, title: "Chorus", order: 1) ], lyrics: Lyrics(text: "Draft lyrics", sectionTexts: ["verse": "Verse text"]), sunoOutput: SunoOutput( lyricsText: "Draft lyrics", stylePrompt: "Cinematic pop with oud", additionalFields: ["mood": "Hopeful"] ) ) var body: some View { Form { FinalReviewView(project: $project) } .formStyle(.grouped) .frame(width: 420) } }