import MusicAssistantCore import SwiftUI struct ProjectInspectorView: View { @Binding var project: SongProject let saveProject: (SongProject) async -> Bool @State private var newGenreName = "" @State private var newInstrumentName = "" @State private var newVocalistLabel = "" @State private var newLanguageName = "" @State private var isInstrumentBrowserPresented = false @State private var isSaving = false @State private var saveStatus: SaveStatus? var body: some View { VStack(spacing: 0) { HStack { Label("Inspector", systemImage: "slider.horizontal.3") .font(.headline) Spacer() Button { Task { await save() } } label: { Label("Save", systemImage: "tray.and.arrow.down") } .disabled(isSaving) } .padding(16) Divider() Form { Section("Overview") { TextField("Title", text: $project.title) TextField("Idea", text: $project.idea, axis: .vertical) .lineLimit(2...4) Picker("AI mode", selection: $project.conversationMode) { Text("Auto").tag(ConversationMode.auto) Text("Discuss").tag(ConversationMode.discuss) } .pickerStyle(.segmented) TextField("Duration seconds", text: durationSecondsBinding) TextField("Duration note", text: durationDescriptionBinding) } Section("Genres") { EditableTokenList( values: project.genres.map(\.name), removeAction: { index in project.deselectGenreStyle(id: project.genres[index].id) } ) AddTextRow( placeholder: "Add genre", text: $newGenreName, addAction: addGenre ) } Section("Instruments") { Button { isInstrumentBrowserPresented = true } label: { Label("Browse catalog", systemImage: "magnifyingglass") } ForEach($project.instruments, id: \.instrumentId) { $instrument in Toggle(isOn: $instrument.selected) { Text(instrument.variant?.isEmpty == false ? instrument.variant ?? instrument.instrumentId : instrument.instrumentId) .lineLimit(1) } } AddTextRow( placeholder: "Add instrument", text: $newInstrumentName, addAction: addInstrument ) } Section("Vocalists") { EditableTokenList( values: project.vocalists.map(\.label), removeAction: { index in project.removeVocalist(id: project.vocalists[index].id) } ) AddTextRow( placeholder: "Add vocalist", text: $newVocalistLabel, addAction: addVocalist ) } Section("Languages") { EditableTokenList( values: project.languages.map(\.name), removeAction: { index in project.removeLanguage(id: project.languages[index].id) } ) AddTextRow( placeholder: "Add language", text: $newLanguageName, addAction: addLanguage ) } Section("Arabic Pronunciation") { ArabicPronunciationSettingsView( settings: $project.arabicPronunciationSettings ) } Section("Song Structure") { SongStructureEditorView(project: $project) } Section("Musical Parameters") { MusicalParametersEditorView(project: $project) } Section("Arrangement") { ArrangementEditorView(project: $project) } Section("Final Review") { FinalReviewView(project: $project) } } .formStyle(.grouped) if let saveStatus { Divider() Label(saveStatus.title, systemImage: saveStatus.systemImage) .font(.caption) .foregroundStyle(saveStatus.foregroundStyle) .padding(.horizontal, 16) .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) } } .background(Color(nsColor: .windowBackgroundColor)) .sheet(isPresented: $isInstrumentBrowserPresented) { InstrumentBrowserView(project: $project) } } private var durationSecondsBinding: Binding { Binding { project.duration?.seconds.map(String.init) ?? "" } set: { newValue in var duration = project.duration ?? SongDuration() duration.seconds = Int(newValue.trimmingCharacters(in: .whitespacesAndNewlines)) project.duration = duration.isEmpty ? nil : duration } } private var durationDescriptionBinding: Binding { Binding { project.duration?.description ?? "" } set: { newValue in let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines) var duration = project.duration ?? SongDuration() duration.description = trimmedValue.isEmpty ? nil : trimmedValue project.duration = duration.isEmpty ? nil : duration } } private func addGenre() { let name = trimmed(newGenreName) guard !name.isEmpty else { return } project.selectGenreStyle(GenreStyle(id: stableID(from: name), name: name)) newGenreName = "" } private func addInstrument() { let name = trimmed(newInstrumentName) guard !name.isEmpty else { return } project.selectInstrument(InstrumentTrack(instrumentId: stableID(from: name), variant: name)) newInstrumentName = "" } private func addVocalist() { let label = trimmed(newVocalistLabel) guard !label.isEmpty else { return } project.upsertVocalist(Vocalist(id: stableID(from: label), label: label)) newVocalistLabel = "" } private func addLanguage() { let name = trimmed(newLanguageName) guard !name.isEmpty else { return } project.upsertLanguage(LanguageSelection(id: stableID(from: name), name: name)) newLanguageName = "" } private func save() async { isSaving = true let didSave = await saveProject(project) isSaving = false saveStatus = didSave ? .saved : .failed } private func trimmed(_ value: String) -> String { value.trimmingCharacters(in: .whitespacesAndNewlines) } private func stableID(from value: String) -> String { let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")) let lowercasedValue = value.lowercased() let scalars = lowercasedValue.unicodeScalars.map { scalar in allowedCharacters.contains(scalar) ? String(scalar) : "-" } let collapsed = scalars.joined() .split(separator: "-") .joined(separator: "-") return collapsed.isEmpty ? UUID().uuidString : collapsed } } private struct EditableTokenList: View { let values: [String] let removeAction: (Int) -> Void var body: some View { if values.isEmpty { Text("None yet") .foregroundStyle(.secondary) } else { ForEach(Array(values.enumerated()), id: \.offset) { index, value in HStack { Text(value) .lineLimit(1) Spacer() Button { removeAction(index) } label: { Label("Remove", systemImage: "minus.circle") .labelStyle(.iconOnly) } .buttonStyle(.borderless) .help("Remove") } } } } } private struct AddTextRow: View { let placeholder: String @Binding var text: String let addAction: () -> Void var body: some View { HStack { TextField(placeholder, text: $text) .onSubmit(addAction) Button(action: addAction) { Label("Add", systemImage: "plus.circle.fill") .labelStyle(.iconOnly) } .buttonStyle(.borderless) .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .help("Add") } } } private enum SaveStatus { case saved case failed var title: String { switch self { case .saved: return "Saved" case .failed: return "Save failed" } } var systemImage: String { switch self { case .saved: return "checkmark.circle.fill" case .failed: return "exclamationmark.triangle.fill" } } var foregroundStyle: Color { switch self { case .saved: return .green case .failed: return .red } } } private extension SongDuration { var isEmpty: Bool { seconds == nil && (description?.isEmpty ?? true) } } #Preview { ProjectInspectorPreview() } private struct ProjectInspectorPreview: View { @State private var project = SongProject( title: "Inspector Preview", idea: "A compact editor for the current song project.", duration: SongDuration(seconds: 180, description: "about three minutes"), languages: [LanguageSelection(id: "english", name: "English")], genres: [GenreStyle(id: "pop", name: "Pop")], bpm: ManualAutoValue(mode: .manual, value: 96), key: ManualAutoValue(mode: .auto), scale: ManualAutoValue(mode: .manual, value: "minor"), maqam: ManualAutoValue(mode: .auto), sections: [ SongSection(type: .intro, title: "Intro"), SongSection(type: .verse, title: "Verse", order: 1) ], instruments: [ InstrumentTrack( instrumentId: "piano", variant: "Piano", placements: [ InstrumentPlacement(sectionId: nil, startTime: 0, endTime: 12) ] ) ], vocalists: [Vocalist(id: "lead", label: "Lead vocalist")], lyrics: Lyrics(text: "Draft lyric idea"), sunoOutput: SunoOutput( lyricsText: "Draft lyric idea", stylePrompt: "Pop with piano" ) ) var body: some View { ProjectInspectorView(project: $project, saveProject: { _ in true }) } }