331 lines
11 KiB
Swift
331 lines
11 KiB
Swift
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<SongProject>,
|
|
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<String> {
|
|
Binding {
|
|
project.sunoOutput?.lyricsText ?? ""
|
|
} set: { newValue in
|
|
ensureSunoOutput()
|
|
project.sunoOutput?.lyricsText = newValue
|
|
}
|
|
}
|
|
|
|
private var sunoStylePromptBinding: Binding<String> {
|
|
Binding {
|
|
project.sunoOutput?.stylePrompt ?? ""
|
|
} set: { newValue in
|
|
ensureSunoOutput()
|
|
project.sunoOutput?.stylePrompt = newValue
|
|
}
|
|
}
|
|
|
|
private func sectionLyricsBinding(for sectionID: String) -> Binding<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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)
|
|
}
|
|
}
|