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