142 lines
5.6 KiB
Swift
142 lines
5.6 KiB
Swift
import MusicAssistantCore
|
|
import SwiftUI
|
|
import WebKit
|
|
|
|
struct SunoHandoffSheet: View {
|
|
let payload: SunoHandoffPayload
|
|
let onComplete: (SunoFieldFillReport) -> Void
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var report: SunoFieldFillReport?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text("Send to Suno")
|
|
.font(.headline)
|
|
Spacer()
|
|
Button("Close") {
|
|
dismiss()
|
|
}
|
|
}
|
|
.padding(12)
|
|
|
|
Divider()
|
|
|
|
SunoCreateWebView(payload: payload) { report in
|
|
self.report = report
|
|
onComplete(report)
|
|
}
|
|
|
|
if let report {
|
|
HStack {
|
|
Label(statusText(for: report), systemImage: statusIcon(for: report))
|
|
.font(.caption)
|
|
.foregroundStyle(statusColor(for: report))
|
|
Spacer()
|
|
}
|
|
.padding(12)
|
|
}
|
|
}
|
|
.frame(minWidth: 900, minHeight: 650)
|
|
}
|
|
|
|
private func statusText(for report: SunoFieldFillReport) -> String {
|
|
report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
|
|
? "Some fields need manual copying."
|
|
: "Suno fields are ready."
|
|
}
|
|
|
|
private func statusIcon(for report: SunoFieldFillReport) -> String {
|
|
report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
|
|
? "exclamationmark.triangle.fill"
|
|
: "checkmark.circle.fill"
|
|
}
|
|
|
|
private func statusColor(for report: SunoFieldFillReport) -> Color {
|
|
report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
|
|
? .orange
|
|
: .green
|
|
}
|
|
}
|
|
|
|
private struct SunoCreateWebView: NSViewRepresentable {
|
|
let payload: SunoHandoffPayload
|
|
let onReport: (SunoFieldFillReport) -> Void
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(payload: payload, onReport: onReport)
|
|
}
|
|
|
|
func makeNSView(context: Context) -> WKWebView {
|
|
let configuration = WKWebViewConfiguration()
|
|
configuration.websiteDataStore = .default()
|
|
let webView = WKWebView(frame: .zero, configuration: configuration)
|
|
webView.navigationDelegate = context.coordinator
|
|
webView.load(URLRequest(url: SunoHandoffDestination.createPageURL))
|
|
return webView
|
|
}
|
|
|
|
func updateNSView(_ webView: WKWebView, context: Context) {}
|
|
|
|
final class Coordinator: NSObject, WKNavigationDelegate {
|
|
let payload: SunoHandoffPayload
|
|
let onReport: (SunoFieldFillReport) -> Void
|
|
|
|
init(payload: SunoHandoffPayload, onReport: @escaping (SunoFieldFillReport) -> Void) {
|
|
self.payload = payload
|
|
self.onReport = onReport
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
webView.evaluateJavaScript(fillScript()) { value, _ in
|
|
guard let json = value as? String,
|
|
let data = json.data(using: .utf8),
|
|
let values = try? JSONSerialization.jsonObject(with: data) as? [String: Bool] else {
|
|
self.onReport(.copyRequired(for: SunoHandoffField.allCases, reason: "Suno fields could not be inspected."))
|
|
return
|
|
}
|
|
|
|
let results: [SunoHandoffField: SunoFieldFillResult] = Dictionary(
|
|
uniqueKeysWithValues: SunoHandoffField.allCases.map { field in
|
|
(field, values[field.rawValue] == true
|
|
? SunoFieldFillResult.filled
|
|
: SunoFieldFillResult.copyRequired("The \(field.displayName) field was not found."))
|
|
}
|
|
)
|
|
self.onReport(SunoFieldFillReport(results: results))
|
|
}
|
|
}
|
|
|
|
private func fillScript() -> String {
|
|
let payloadData = try! JSONEncoder().encode(payload)
|
|
let payloadJSON = String(data: payloadData, encoding: .utf8)!
|
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
.replacingOccurrences(of: "'", with: "\\'")
|
|
|
|
return """
|
|
(() => {
|
|
const payload = JSON.parse('\(payloadJSON)');
|
|
const fields = { title: false, lyrics: false, stylePrompt: false };
|
|
const custom = [...document.querySelectorAll('button,[role="button"]')].find(e => /custom/i.test(e.innerText));
|
|
if (custom) custom.click();
|
|
const candidates = [...document.querySelectorAll('input,textarea,[contenteditable="true"]')];
|
|
const label = e => [e.getAttribute('aria-label'), e.getAttribute('placeholder'), e.name, e.id].filter(Boolean).join(' ');
|
|
const set = (terms, value, key) => {
|
|
const element = candidates.find(e => terms.some(term => label(e).toLowerCase().includes(term)));
|
|
if (!element || !value) return;
|
|
if (element.isContentEditable) element.textContent = value; else element.value = value;
|
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
fields[key] = true;
|
|
};
|
|
set(['title'], payload.title, 'title');
|
|
set(['lyric'], payload.lyrics, 'lyrics');
|
|
set(['style','prompt'], payload.stylePrompt, 'stylePrompt');
|
|
return JSON.stringify(fields);
|
|
})();
|
|
"""
|
|
}
|
|
}
|
|
}
|