347 lines
12 KiB
Swift
347 lines
12 KiB
Swift
import MusicAssistantCore
|
|
import SwiftUI
|
|
|
|
struct ProjectInspectorView: View {
|
|
@Binding var project: SongProject
|
|
let saveProject: (SongProject) async -> Bool
|
|
|
|
@State private var newInstrumentName = ""
|
|
@State private var isInstrumentBrowserPresented = false
|
|
@State private var isGenreStyleBrowserPresented = false
|
|
@State private var isVocalistBrowserPresented = false
|
|
@State private var isLanguageBrowserPresented = 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")
|
|
}
|
|
.keyboardShortcut("s", modifiers: .command)
|
|
.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") {
|
|
Button {
|
|
isGenreStyleBrowserPresented = true
|
|
} label: {
|
|
Label("Browse genres and styles", systemImage: "music.note.list")
|
|
}
|
|
|
|
EditableTokenList(
|
|
values: project.genres.map(\.name),
|
|
removeAction: { index in
|
|
project.deselectGenreStyle(id: project.genres[index].id)
|
|
}
|
|
)
|
|
}
|
|
|
|
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") {
|
|
Button {
|
|
isVocalistBrowserPresented = true
|
|
} label: {
|
|
Label("Browse vocalists", systemImage: "person.2")
|
|
}
|
|
|
|
EditableTokenList(
|
|
values: project.vocalists.map(\.label),
|
|
removeAction: { index in
|
|
project.removeVocalist(id: project.vocalists[index].id)
|
|
}
|
|
)
|
|
}
|
|
|
|
Section("Languages") {
|
|
Button {
|
|
isLanguageBrowserPresented = true
|
|
} label: {
|
|
Label("Browse languages", systemImage: "character.book.closed")
|
|
}
|
|
|
|
EditableTokenList(
|
|
values: project.languages.map(\.name),
|
|
removeAction: { index in
|
|
project.removeLanguage(id: project.languages[index].id)
|
|
}
|
|
)
|
|
}
|
|
|
|
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, saveProject: saveProject)
|
|
}
|
|
.sheet(isPresented: $isGenreStyleBrowserPresented) {
|
|
GenreStyleBrowserView(project: $project, saveProject: saveProject)
|
|
}
|
|
.sheet(isPresented: $isVocalistBrowserPresented) {
|
|
VocalistBrowserView(project: $project, saveProject: saveProject)
|
|
}
|
|
.sheet(isPresented: $isLanguageBrowserPresented) {
|
|
LanguageBrowserView(project: $project, saveProject: saveProject)
|
|
}
|
|
}
|
|
|
|
private var durationSecondsBinding: Binding<String> {
|
|
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<String> {
|
|
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 addInstrument() {
|
|
let name = trimmed(newInstrumentName)
|
|
guard !name.isEmpty else { return }
|
|
project.selectInstrument(InstrumentTrack(instrumentId: stableID(from: name), variant: name))
|
|
newInstrumentName = ""
|
|
}
|
|
|
|
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 })
|
|
}
|
|
}
|