Initial commit
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user