211 lines
6.8 KiB
Swift
211 lines
6.8 KiB
Swift
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<SongSection> {
|
|
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<SongSection, TimeInterval?>) -> Binding<String> {
|
|
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)
|
|
}
|
|
}
|