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:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
@@ -2,28 +2,74 @@ import Foundation
|
||||
|
||||
/// A one-tap stem mix. [build] returns nil when the track lacks the stems it needs.
|
||||
struct MixPreset: Identifiable, Equatable {
|
||||
let label: String
|
||||
/// The stored name, which ids are built from. Show [label] instead.
|
||||
let name: String
|
||||
/// One the user saved, as opposed to a built-in.
|
||||
var custom = false
|
||||
let build: ([String]) -> [String: StemSetting]?
|
||||
var id: String { label }
|
||||
static func == (a: MixPreset, b: MixPreset) -> Bool { a.label == b.label }
|
||||
var id: String { (custom ? "c:" : "b:") + name }
|
||||
/// The name as shown, with A Cappella spelled the way Appearance says.
|
||||
var label: String { !custom && name == "A Cappella" && MixPresets.acapella ? "Acapella" : name }
|
||||
static func == (a: MixPreset, b: MixPreset) -> Bool { a.id == b.id }
|
||||
}
|
||||
|
||||
enum MixPresets {
|
||||
static let full = MixPreset(label: "Full Mix") { _ in [:] }
|
||||
static let karaoke = MixPreset(label: "Karaoke") { n in n.contains("vocals") ? ["vocals": StemSetting(muted: true)] : nil }
|
||||
static let full = MixPreset(name: "Full Mix") { _ in [:] }
|
||||
static let karaoke = MixPreset(name: "Karaoke") { n in n.contains("vocals") ? ["vocals": StemSetting(muted: true)] : nil }
|
||||
|
||||
static let all: [MixPreset] = [
|
||||
full,
|
||||
karaoke,
|
||||
MixPreset(label: "A Cappella") { n in n.contains("vocals") ? ["vocals": StemSetting(solo: true)] : nil },
|
||||
MixPreset(label: "No Drums") { n in n.contains("drums") ? ["drums": StemSetting(muted: true)] : nil },
|
||||
MixPreset(label: "Rhythm") { n in
|
||||
MixPreset(name: "A Cappella") { n in n.contains("vocals") ? ["vocals": StemSetting(solo: true)] : nil },
|
||||
MixPreset(name: "No Drums") { n in n.contains("drums") ? ["drums": StemSetting(muted: true)] : nil },
|
||||
MixPreset(name: "Rhythm") { n in
|
||||
n.contains("drums") && n.contains("bass") ? ["drums": StemSetting(solo: true), "bass": StemSetting(solo: true)] : nil
|
||||
},
|
||||
MixPreset(label: "No Guitar") { n in n.contains("guitar") ? ["guitar": StemSetting(muted: true)] : nil },
|
||||
MixPreset(label: "No Bass") { n in n.contains("bass") ? ["bass": StemSetting(muted: true)] : nil },
|
||||
MixPreset(name: "No Guitar") { n in n.contains("guitar") ? ["guitar": StemSetting(muted: true)] : nil },
|
||||
MixPreset(name: "No Bass") { n in n.contains("bass") ? ["bass": StemSetting(muted: true)] : nil },
|
||||
]
|
||||
|
||||
static func custom(_ c: CustomPreset) -> MixPreset { MixPreset(name: c.name, custom: true) { _ in c.mix } }
|
||||
|
||||
/// Every preset, the user's and the built-ins, in the order set in the Stem Editor (new ones go to the end).
|
||||
static func ordered(_ s: Settings) -> [MixPreset] {
|
||||
let pool = CustomPreset.load(s).map(custom) + all
|
||||
let order = s.presetOrder.split(separator: "\n").map(String.init)
|
||||
let rank = Dictionary(order.enumerated().map { ($1, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
return pool.enumerated().sorted { a, b in
|
||||
(rank[a.element.id] ?? order.count + a.offset, a.offset) < (rank[b.element.id] ?? order.count + b.offset, b.offset)
|
||||
}.map(\.element)
|
||||
}
|
||||
|
||||
/// Set by Appearance. Read straight from UserDefaults so labels work off the main thread.
|
||||
static var acapella: Bool { UserDefaults.standard.bool(forKey: "acapella") }
|
||||
|
||||
/// Built-ins deleted in the Stem Editor. Custom ids from older builds, which could hide those too, don't count.
|
||||
static func hidden(_ s: Settings) -> Set<String> {
|
||||
Set(s.hiddenPresets.split(separator: "\n").map(String.init).filter { $0.hasPrefix("b:") })
|
||||
}
|
||||
|
||||
/// The chips in the mixer bar, and the rotation for every Mix button outside the app.
|
||||
static func bar(_ s: Settings) -> [MixPreset] {
|
||||
let h = hidden(s)
|
||||
return ordered(s).filter { !h.contains($0.id) }
|
||||
}
|
||||
|
||||
static func restoreDefaults(_ s: Settings) { s.hiddenPresets = "" }
|
||||
|
||||
static func setOrder(_ s: Settings, _ list: [MixPreset]) { s.presetOrder = list.map(\.id).joined(separator: "\n") }
|
||||
|
||||
static func setHidden(_ s: Settings, _ id: String, _ on: Bool) {
|
||||
var h = hidden(s)
|
||||
if on { h.insert(id) } else { h.remove(id) }
|
||||
s.hiddenPresets = h.sorted().joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// The preset every song starts with, whatever Keep Stem Mix says. Nil when unlocked.
|
||||
static func locked(_ s: Settings) -> MixPreset? {
|
||||
s.lockedPreset.isEmpty ? nil : ordered(s).first { $0.id == s.lockedPreset }
|
||||
}
|
||||
|
||||
/// Drops stems left at their defaults, so mixes compare by what they actually change.
|
||||
static func normalized(_ m: [String: StemSetting]) -> [String: StemSetting] { m.filter { $0.value != StemSetting() } }
|
||||
|
||||
@@ -33,10 +79,19 @@ enum MixPresets {
|
||||
return all.first { p in p.build(names).map { normalized($0) == m } ?? false }
|
||||
}
|
||||
|
||||
/// The preset after the current one (a custom mix goes back to Full), skipping ones these stems can't do.
|
||||
static func next(_ names: [String], _ mix: [String: StemSetting]) -> MixPreset {
|
||||
let usable = all.filter { $0.build(names) != nil }
|
||||
guard let cur = current(names, mix), let i = usable.firstIndex(of: cur) else { return full }
|
||||
/// Like [current], but also matches the user's own presets, so a saved mix is named wherever it shows.
|
||||
static func current(_ s: Settings, _ names: [String], _ mix: [String: StemSetting]) -> MixPreset? {
|
||||
let m = normalized(mix)
|
||||
return ordered(s).first { p in p.build(names).map { normalized($0) == m } ?? false }
|
||||
}
|
||||
|
||||
/// The mix after the current one in the Stem Editor's list, skipping ones these stems can't do.
|
||||
/// A hand-made mix, or one deleted from the editor, goes to the start of the list.
|
||||
static func next(_ s: Settings, _ names: [String], _ mix: [String: StemSetting]) -> MixPreset {
|
||||
let usable = bar(s).filter { $0.build(names) != nil }
|
||||
guard !usable.isEmpty else { return full }
|
||||
let m = normalized(mix)
|
||||
guard let i = usable.firstIndex(where: { p in p.build(names).map { normalized($0) == m } ?? false }) else { return usable[0] }
|
||||
return usable[(i + 1) % usable.count]
|
||||
}
|
||||
|
||||
@@ -48,3 +103,41 @@ enum MixPresets {
|
||||
return v.muted || anySolo && !v.solo || v.level <= 0
|
||||
}
|
||||
}
|
||||
|
||||
/// A named, app-wide mix the user saved from the mixer. Stored as JSON in [Settings.presets].
|
||||
struct CustomPreset: Codable, Identifiable, Equatable {
|
||||
var name: String
|
||||
var mix: [String: StemSetting]
|
||||
var id: String { name }
|
||||
|
||||
@MainActor static func load() -> [CustomPreset] { load(AppGraph.shared.settings) }
|
||||
|
||||
static func load(_ s: Settings) -> [CustomPreset] {
|
||||
guard let d = s.presets.data(using: .utf8) else { return [] }
|
||||
return (try? JSONDecoder().decode([CustomPreset].self, from: d)) ?? []
|
||||
}
|
||||
|
||||
@MainActor static func save(_ list: [CustomPreset]) {
|
||||
guard let d = try? JSONEncoder().encode(list), let s = String(data: d, encoding: .utf8) else { return }
|
||||
AppGraph.shared.settings.presets = s
|
||||
}
|
||||
|
||||
/// Adds or replaces the preset with this name.
|
||||
@MainActor static func add(_ name: String, _ mix: [String: StemSetting]) {
|
||||
var l = load().filter { $0.name != name }
|
||||
l.append(CustomPreset(name: name, mix: MixPresets.normalized(mix)))
|
||||
save(l)
|
||||
}
|
||||
|
||||
@MainActor static func remove(_ name: String) {
|
||||
let s = AppGraph.shared.settings
|
||||
if s.lockedPreset == "c:" + name { s.lockedPreset = "" }
|
||||
save(load().filter { $0.name != name })
|
||||
}
|
||||
|
||||
@MainActor static func removeAll() {
|
||||
let s = AppGraph.shared.settings
|
||||
if s.lockedPreset.hasPrefix("c:") { s.lockedPreset = "" }
|
||||
save([])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user