Files
music-assistant/Sources/MusicAssistantApp/Integrations/SunoHandoffSheet.swift
T
2026-09-14 22:51:16 +02:00

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);
})();
"""
}
}
}