import MusicAssistantCore import SwiftUI struct SongStructureEditorView: View { @Binding var project: SongProject @State private var newSectionTitle = "" @State private var newSectionType: SongSectionType = .verse var body: some View { VStack(alignment: .leading, spacing: 12) { if project.sections.isEmpty { Text("No sections yet") .foregroundStyle(.secondary) } else { ForEach(project.orderedSections) { section in SongSectionEditorCard( section: binding(for: section), moveUpAction: { moveSection(id: section.id, by: -1) }, moveDownAction: { moveSection(id: section.id, by: 1) }, removeAction: { removeSection(id: section.id) } ) } } Divider() VStack(alignment: .leading, spacing: 8) { Picker("Type", selection: $newSectionType) { ForEach(SongSectionType.editorCases, id: \.self) { type in Text(type.displayName).tag(type) } } HStack { TextField("New section title", text: $newSectionTitle) .onSubmit(addSection) Button(action: addSection) { Label("Add section", systemImage: "plus.circle.fill") .labelStyle(.iconOnly) } .buttonStyle(.borderless) .disabled(newSectionTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .help("Add section") } } } } private func binding(for section: SongSection) -> Binding { Binding { project.sections.first { $0.id == section.id } ?? section } set: { updatedSection in guard let index = project.sections.firstIndex(where: { $0.id == section.id }) else { return } project.sections[index] = updatedSection project.normalizeSectionOrder() } } private func addSection() { let trimmedTitle = newSectionTitle.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedTitle.isEmpty else { return } project.appendSection( SongSection( type: newSectionType, title: trimmedTitle ) ) newSectionTitle = "" } private func moveSection(id sectionID: String, by offset: Int) { let orderedSections = project.orderedSections guard let currentIndex = orderedSections.firstIndex(where: { $0.id == sectionID }) else { return } project.moveSection(id: sectionID, toOrder: currentIndex + offset) } private func removeSection(id sectionID: String) { project.sections.removeAll { $0.id == sectionID } project.normalizeSectionOrder() } } private struct SongSectionEditorCard: View { @Binding var section: SongSection let moveUpAction: () -> Void let moveDownAction: () -> Void let removeAction: () -> Void var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Text(section.title.isEmpty ? "Untitled section" : section.title) .font(.headline) .lineLimit(1) Spacer() Button(action: moveUpAction) { Label("Move up", systemImage: "chevron.up") .labelStyle(.iconOnly) } .buttonStyle(.borderless) .help("Move up") Button(action: moveDownAction) { Label("Move down", systemImage: "chevron.down") .labelStyle(.iconOnly) } .buttonStyle(.borderless) .help("Move down") Button(action: removeAction) { Label("Remove", systemImage: "trash") .labelStyle(.iconOnly) } .buttonStyle(.borderless) .foregroundStyle(.red) .help("Remove") } Picker("Type", selection: $section.type) { ForEach(SongSectionType.editorCases, id: \.self) { type in Text(type.displayName).tag(type) } } TextField("Title", text: $section.title) TextField("Start seconds", text: optionalTimeBinding(\.startTime)) TextField("End seconds", text: optionalTimeBinding(\.endTime)) TextField("Lyrics or notes", text: $section.lyrics, axis: .vertical) .lineLimit(2...4) } .padding(10) .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) } private func optionalTimeBinding(_ keyPath: WritableKeyPath) -> Binding { Binding { guard let value = section[keyPath: keyPath] else { return "" } return String(format: "%.0f", value) } set: { newValue in let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines) section[keyPath: keyPath] = trimmedValue.isEmpty ? nil : TimeInterval(trimmedValue) } } } extension SongSectionType { static let editorCases: [SongSectionType] = [ .intro, .verse, .preChorus, .chorus, .bridge, .outro, .custom ] var displayName: String { switch self { case .intro: return "Intro" case .verse: return "Verse" case .preChorus: return "Pre-Chorus" case .chorus: return "Chorus" case .bridge: return "Bridge" case .outro: return "Outro" case .custom: return "Custom" } } } #Preview { SongStructureEditorPreview() } private struct SongStructureEditorPreview: View { @State private var project = SongProject( title: "Structure Preview", idea: "Edit sections", sections: [ SongSection(type: .intro, title: "Intro"), SongSection(type: .verse, title: "Verse", order: 1, startTime: 12, endTime: 42), SongSection(type: .chorus, title: "Chorus", order: 2) ] ) var body: some View { Form { SongStructureEditorView(project: $project) } .formStyle(.grouped) .frame(width: 360) } }