Files
music-assistant/Sources/MusicAssistantApp/Presentation/ArrangementEditorView.swift
T
2026-09-13 20:21:10 +02:00

194 lines
6.5 KiB
Swift

import MusicAssistantCore
import SwiftUI
struct ArrangementEditorView: View {
@Binding var project: SongProject
var body: some View {
VStack(alignment: .leading, spacing: 12) {
let selectedInstrumentIndices = project.instruments.indices.filter { project.instruments[$0].selected }
if selectedInstrumentIndices.isEmpty {
Text("Select instruments before arranging entries.")
.foregroundStyle(.secondary)
} else {
ForEach(selectedInstrumentIndices, id: \.self) { index in
InstrumentArrangementCard(
instrument: $project.instruments[index],
sections: project.orderedSections
)
}
}
}
}
}
private struct InstrumentArrangementCard: View {
@Binding var instrument: InstrumentTrack
let sections: [SongSection]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(instrumentDisplayName)
.font(.headline)
.lineLimit(1)
Spacer()
Button(action: addPlacement) {
Label("Add placement", systemImage: "plus.circle.fill")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.help("Add placement")
}
Toggle("Auto arrangement", isOn: $instrument.autoArrangementEnabled)
TextField("Role", text: optionalStringBinding(\.role))
TextField("Playing style", text: optionalStringBinding(\.playingStyle))
if instrument.placements.isEmpty {
Text("No placements yet")
.foregroundStyle(.secondary)
} else {
ForEach(instrument.placements.indices, id: \.self) { placementIndex in
InstrumentPlacementEditor(
placement: $instrument.placements[placementIndex],
sections: sections,
removeAction: {
instrument.placements.remove(at: placementIndex)
}
)
}
}
}
.padding(10)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
}
private var instrumentDisplayName: String {
if let variant = instrument.variant, !variant.isEmpty {
return variant
}
return instrument.instrumentId
}
private func addPlacement() {
instrument.placements.append(
InstrumentPlacement(sectionId: sections.first?.id)
)
}
private func optionalStringBinding(_ keyPath: WritableKeyPath<InstrumentTrack, String?>) -> Binding<String> {
Binding {
instrument[keyPath: keyPath] ?? ""
} set: { newValue in
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
instrument[keyPath: keyPath] = trimmedValue.isEmpty ? nil : trimmedValue
}
}
}
private struct InstrumentPlacementEditor: View {
@Binding var placement: InstrumentPlacement
let sections: [SongSection]
let removeAction: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Picker("Section", selection: sectionIDBinding) {
Text("No section").tag("")
ForEach(sections) { section in
Text(sectionLabel(for: section)).tag(section.id)
}
}
Spacer()
Button(action: removeAction) {
Label("Remove placement", systemImage: "trash")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.foregroundStyle(.red)
.help("Remove placement")
}
TextField("Start seconds", text: optionalTimeBinding(\.startTime))
TextField("End seconds", text: optionalTimeBinding(\.endTime))
TextField("Direction", text: optionalStringBinding(\.direction), axis: .vertical)
.lineLimit(1...3)
}
.padding(8)
.background(Color(nsColor: .windowBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
}
private var sectionIDBinding: Binding<String> {
Binding {
placement.sectionId ?? ""
} set: { newValue in
placement.sectionId = newValue.isEmpty ? nil : newValue
}
}
private func optionalTimeBinding(_ keyPath: WritableKeyPath<InstrumentPlacement, TimeInterval?>) -> Binding<String> {
Binding {
guard let value = placement[keyPath: keyPath] else { return "" }
return String(format: "%.0f", value)
} set: { newValue in
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
placement[keyPath: keyPath] = trimmedValue.isEmpty ? nil : TimeInterval(trimmedValue)
}
}
private func optionalStringBinding(_ keyPath: WritableKeyPath<InstrumentPlacement, String?>) -> Binding<String> {
Binding {
placement[keyPath: keyPath] ?? ""
} set: { newValue in
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
placement[keyPath: keyPath] = trimmedValue.isEmpty ? nil : trimmedValue
}
}
private func sectionLabel(for section: SongSection) -> String {
"\(section.order + 1). \(section.title) (\(section.type.displayName))"
}
}
#Preview {
ArrangementEditorPreview()
}
private struct ArrangementEditorPreview: View {
@State private var project = SongProject(
title: "Arrangement Preview",
idea: "Place instruments",
sections: [
SongSection(type: .intro, title: "Intro"),
SongSection(type: .verse, title: "Verse", order: 1)
],
instruments: [
InstrumentTrack(
instrumentId: "piano",
selected: true,
variant: "Piano",
role: "Harmony bed",
placements: [
InstrumentPlacement(sectionId: nil, startTime: 0, endTime: 12, direction: "Soft opening")
]
)
]
)
var body: some View {
Form {
ArrangementEditorView(project: $project)
}
.formStyle(.grouped)
.frame(width: 380)
}
}