Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Sonnet 5.5 committed 2026-10-24 17:08:51 -07:00
1 parent 894dd5a695
commit df5d847169
157 files changed
+3935 -742

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
import SwiftUI
/// The mixer bar's presets: order them, delete or add them, bring back deleted built-ins and unlock a locked one.
/// This list is also the rotation for the Mix button in CarPlay, on the lock screen and in the widgets.
struct StemEditorScreen: View {
@Environment(\.palette) private var p
@Environment(Ui.self) private var ui
/// The preset being made or edited; a blank one for New Preset.
@State private var editing: CustomPreset?
private var g: AppGraph { .shared }
var body: some View {
let s = g.settings
let hidden = MixPresets.hidden(s)
let order = MixPresets.ordered(s)
let shown = order.filter { !hidden.contains($0.id) }
let off = order.filter { hidden.contains($0.id) }
let mine = shown.filter(\.custom).count
List {
if let l = MixPresets.locked(s) {
Section {
HStack(spacing: 14) {
SettingsIcon(symbol: "lock.fill", bg: 0xFF9500)
Text(l.label).foregroundStyle(p.label)
Spacer()
Button("Unlock") { s.lockedPreset = "" }
}
.themedRow()
} header: { Text("Locked").foregroundStyle(p.label2) } footer: {
Text("Every song starts with this mix, whether or not Keep Stem Mix Between Songs is on. Long press a preset in the mixer to lock or unlock it.")
.foregroundStyle(p.label2)
}
}
Section {
ForEach(shown) { pr in
HStack(spacing: 14) {
SettingsIcon(symbol: pr.custom ? "person.fill" : "slider.horizontal.3", bg: pr.custom ? 0xAF52DE : 0x5AC8FA)
Text(pr.label).foregroundStyle(p.label)
Spacer()
if pr.custom { Image(systemName: "slider.vertical.3").foregroundStyle(p.label3) }
}
.contentShape(Rectangle())
.onTapGesture { if pr.custom { editing = CustomPreset.load(s).first { $0.name == pr.label } } }
.themedRow()
.deleteDisabled(pr == MixPresets.full)
}
.onMove { from, to in
var l = shown
l.move(fromOffsets: from, toOffset: to)
MixPresets.setOrder(s, l + off)
}
.onDelete { rows in
for pr in rows.map({ shown[$0] }) {
if pr.custom { CustomPreset.remove(pr.label) } else { MixPresets.setHidden(s, pr.id, true) }
if s.lockedPreset == pr.id { s.lockedPreset = "" }
}
}
Button { editing = CustomPreset(name: "", mix: [:]) } label: { Label("New Preset", systemImage: "plus.circle.fill") }
.themedRow()
} header: { Text("Mixer Bar").foregroundStyle(p.label2) } footer: {
Text("Drag to reorder and tap one of yours to change it. The Mix button in CarPlay, on the lock screen and in the widgets goes through this list in this order.")
.foregroundStyle(p.label2)
}
Section {
Button { MixPresets.restoreDefaults(s) } label: {
HStack(spacing: 14) {
SettingsIcon(symbol: "arrow.counterclockwise", bg: 0x34C759)
Text("Restore Deleted Presets").foregroundStyle(off.isEmpty ? p.label3 : p.label)
Spacer()
if !off.isEmpty { Text("\(off.count)").foregroundStyle(p.label2) }
}
}
.disabled(off.isEmpty)
.themedRow()
Button {
let what = mine == 1 ? "your preset" : "all \(mine) of your presets"
ui.overlays.confirm("Delete \(what)?", "This can’t be undone. The built-in presets stay.", "Delete") {
CustomPreset.removeAll()
}
} label: {
HStack(spacing: 14) {
SettingsIcon(symbol: "trash", bg: 0xFF3B30)
Text("Delete All Custom Presets").foregroundStyle(mine == 0 ? p.label3 : p.red)
}
}
.disabled(mine == 0)
.themedRow()
} footer: {
Text(off.isEmpty ? "Every built-in preset is in the list." : "Brings back \(off.map(\.label).joined(separator: ", ")).")
.foregroundStyle(p.label2)
}
}
.listStyle(.insetGrouped)
.themedList()
.environment(\.editMode, .constant(.active))
.navigationTitle("Stem Editor")
.sheet(item: $editing) { PresetSheet(original: $0) }
}
}
/// Builds a preset on the same faders and M / S buttons as the Now Playing mixer, plus a name.
private struct PresetSheet: View {
let original: CustomPreset
@Environment(\.dismiss) private var dismiss
@Environment(\.palette) private var p
@State private var name = ""
@State private var mix: [String: StemSetting] = [:]
var body: some View {
let n = name.trimmingCharacters(in: .whitespaces)
let anySolo = mix.values.contains { $0.solo }
NavigationStack {
List {
Section { TextField("Preset name", text: $name).themedRow() }
Section {
ForEach(StemPlaylists.allStems, id: \.self) { st in
let set = mix[st] ?? StemSetting()
let silent = set.muted || (anySolo && !set.solo)
let color = Color(hex: Stems.color(st))
FaderRow(dot: silent ? p.label3 : color, label: Stems.label(st), percent: Int(stemTaper(set.level) * 100), dim: silent) {
FaderSlider(value: set.level, fill: silent ? p.label3 : color, detent: StemSetting.unity) { mix[st, default: StemSetting()].level = $0 }
} trailing: {
ToggleBox(text: "M", on: set.muted, onColor: p.red) { mix[st, default: StemSetting()].muted.toggle() }
ToggleBox(text: "S", on: set.solo, onColor: Color(hex: 0xFFCC00)) { mix[st, default: StemSetting()].solo.toggle() }
}
.themedRow()
}
} header: {
HStack {
Text("Stems").foregroundStyle(p.label2)
Spacer()
Button("Reset") { mix = [:] }.font(.footnote)
}
} footer: {
Text("Stems a song doesn't have are skipped when the preset is applied.").foregroundStyle(p.label2)
}
}
.listStyle(.insetGrouped)
.themedList()
.navigationTitle(original.name.isEmpty ? "New Preset" : "Edit Preset")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
let s = AppGraph.shared.settings
let old = original.name
let locked = !old.isEmpty && s.lockedPreset == "c:" + old
if !old.isEmpty && old != n {
CustomPreset.remove(old)
s.presetOrder = s.presetOrder.split(separator: "\n").map { $0 == "c:" + old ? "c:" + n : String($0) }.joined(separator: "\n")
}
CustomPreset.add(n, mix)
if locked { s.lockedPreset = "c:" + n }
dismiss()
}
.disabled(n.isEmpty)
}
}
}
.onAppear { name = original.name; mix = original.mix }
}
}