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

+52 -3
View File
@@ -97,13 +97,19 @@ final class Settings: @unchecked Sendable {
clientId = UUID().uuidString.lowercased()
defaults.set(clientId, forKey: "clientId")
}
// Single-Plex settings from before multi-server become the first server, with bare keys.
if store["servers"] == nil, let t = store["plexToken"] as? String, !t.isEmpty {
let sections = (store["musicSection"] as? String ?? "").split(separator: ",").map(String.init)
servers = [MediaServer(id: "", kind: .plex, name: "Plex", url: plexUrl, token: t, user: plexUser,
machineId: plexMachineId, sections: sections, sectionTitles: musicSectionTitle)]
}
}
private static let keys = [
"plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
"servers", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
"stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout",
"karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly",
"karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", "noStems", "precacheCount", "hiddenNowPlaying", "presets", "presetOrder", "hiddenPresets", "lockedPreset", "webStations", "lockScreenMix", "acapella",
]
private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T {
@@ -171,6 +177,34 @@ final class Settings: @unchecked Sendable {
var plexMachineId: String { get { read(\.plexMachineId, "plexMachineId", "") } set { write(\.plexMachineId, "plexMachineId", newValue, "") } }
var musicSection: String { get { read(\.musicSection, "musicSection", "") } set { write(\.musicSection, "musicSection", newValue, "") } }
var musicSectionTitle: String { get { read(\.musicSectionTitle, "musicSectionTitle", "") } set { write(\.musicSectionTitle, "musicSectionTitle", newValue, "") } }
/// Music servers as JSON; use [servers].
/// Web radio stations as JSON ([WebStation]).
var stationsJson: String { get { read(\.stationsJson, "webStations", "") } set { write(\.stationsJson, "webStations", newValue, "") } }
var stations: [WebStation] {
get { (try? JSONDecoder().decode([WebStation].self, from: Data(stationsJson.utf8))) ?? [] }
set { stationsJson = (try? JSONEncoder().encode(newValue)).map { String(decoding: $0, as: UTF8.self) } ?? "" }
}
var serversJson: String { get { read(\.serversJson, "servers", "") } set { write(\.serversJson, "servers", newValue, "") } }
var servers: [MediaServer] {
get { (try? JSONDecoder().decode([MediaServer].self, from: Data(serversJson.utf8))) ?? [] }
set { serversJson = (try? JSONEncoder().encode(newValue)).map { String(decoding: $0, as: UTF8.self) } ?? "" }
}
/// Changes whenever the libraries being read change; screens reload on it.
var libraryKey: String { servers.filter(\.ready).map { "\($0.id)@\($0.url)/\($0.sections.joined(separator: ","))" }.joined(separator: ";") }
var libraryReady: Bool { servers.contains(where: \.ready) }
/// Adds or replaces a server by id.
func save(_ server: MediaServer) {
var list = servers
if let i = list.firstIndex(where: { $0.id == server.id }) { list[i] = server } else { list.append(server) }
servers = list
}
func removeServer(_ id: String) { servers = servers.filter { $0.id != id } }
/// The id a new server gets: empty (bare keys) when no server holds it yet.
func nextServerId() -> String { servers.contains { $0.id.isEmpty } ? MediaServer.newId() : "" }
var stemUrl: String { get { read(\.stemUrl, "stemUrl", "http://192.168.86.2:8765") } set { write(\.stemUrl, "stemUrl", newValue, "http://192.168.86.2:8765") } }
var stemToken: String { get { read(\.stemToken, "stemToken", "") } set { write(\.stemToken, "stemToken", newValue, "") } }
/// Plex path prefixes that matched the stems index, newline-separated.
@@ -179,6 +213,9 @@ final class Settings: @unchecked Sendable {
var tabLayout: String { get { read(\.tabLayout, "tabLayout", Tab.encode(Tab.defaultLayout)) } set { write(\.tabLayout, "tabLayout", newValue, Tab.encode(Tab.defaultLayout)) } }
var theme: ThemeMode { get { readEnum(\.theme, "theme", .Black) } set { writeEnum(\.theme, "theme", newValue, .Black) } }
var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } }
/// Spells the A Cappella mix "Acapella". MixPresets reads the key directly.
var acapella: Bool { get { read(\.acapella, "acapella", false) } set { write(\.acapella, "acapella", newValue, false) } }
var lockScreenMix: Bool { get { read(\.lockScreenMix, "lockScreenMix", false) } set { write(\.lockScreenMix, "lockScreenMix", newValue, false) } }
var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } }
var highRefreshRate: Bool { get { read(\.highRefreshRate, "highRefreshRate", true) } set { write(\.highRefreshRate, "highRefreshRate", newValue, true) } }
@@ -191,7 +228,19 @@ final class Settings: @unchecked Sendable {
/// How far down Soft karaoke puts the vocals, in dB.
var karaokeSoftDb: Int { get { read(\.karaokeSoftDb, "karaokeSoftDb", -12) } set { write(\.karaokeSoftDb, "karaokeSoftDb", newValue, -12) } }
/// Library lists show only what has stems.
var stemsOnly: Bool { get { read(\.stemsOnly, "stemsOnly", false) } set { write(\.stemsOnly, "stemsOnly", newValue, false) } }
var stemsOnly: Bool { get { read(\.stemsOnly, "stemsOnly", false) } set { write(\.stemsOnly, "stemsOnly", newValue, false); if newValue { noStems = false } } }
var noStems: Bool { get { read(\.noStems, "noStems", false) } set { write(\.noStems, "noStems", newValue, false); if newValue { stemsOnly = false } } }
/// How many songs after the current one to cache ahead (0 to 50).
var precacheCount: Int { get { read(\.precacheCount, "precacheCount", 5) } set { write(\.precacheCount, "precacheCount", min(50, max(0, newValue)), 5) } }
/// Now-playing buttons the user hid, comma-separated.
var hiddenNowPlaying: String { get { read(\.hiddenNowPlaying, "hiddenNowPlaying", "") } set { write(\.hiddenNowPlaying, "hiddenNowPlaying", newValue, "") } }
/// App-wide saved stem mixes as JSON ([name: mix]).
var presets: String { get { read(\.presets, "presets", "") } set { write(\.presets, "presets", newValue, "") } }
/// Mixer bar order and hidden built-ins, newline-separated preset ids ("b:Karaoke", "c:My Mix").
var presetOrder: String { get { read(\.presetOrder, "presetOrder", "") } set { write(\.presetOrder, "presetOrder", newValue, "") } }
var hiddenPresets: String { get { read(\.hiddenPresets, "hiddenPresets", "") } set { write(\.hiddenPresets, "hiddenPresets", newValue, "") } }
/// The id of the preset every song starts with, or empty.
var lockedPreset: String { get { read(\.lockedPreset, "lockedPreset", "") } set { write(\.lockedPreset, "lockedPreset", newValue, "") } }
var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
var playbackSpeed: Double {
get { access(keyPath: \.playbackSpeed); lock.lock(); defer { lock.unlock() }; return (store["playbackSpeed"] as? NSNumber)?.doubleValue ?? 1 }