Files
music-assistant/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift
T

360 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(spacing: 10) {
Image(systemName: "slider.horizontal.3")
.font(.body.weight(.semibold))
.foregroundStyle(.tint)
.frame(width: 32, height: 32)
.background(.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
Text("Inspector")
.font(.headline)
Text("Song controls")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
Task {
await save()
}
} label: {
Label("Save", systemImage: "tray.and.arrow.down")
}
.keyboardShortcut("s", modifiers: .command)
.disabled(isSaving)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(.bar)
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 })
}
}