import Foundation import Observation enum ThemeMode: String, CaseIterable, Identifiable { case Black, Dark, White var id: String { rawValue } var label: String { rawValue } } /// iOS system tints, light then dark variant. enum Accent: String, CaseIterable, Identifiable { case Music, Pink, Orange, Yellow, Green, Teal, Blue, Indigo, Purple var id: String { rawValue } var label: String { rawValue } var light: UInt32 { switch self { case .Music: 0xFA2D48 case .Pink: 0xFF2D55 case .Orange: 0xFF9500 case .Yellow: 0xFFCC00 case .Green: 0x34C759 case .Teal: 0x30B0C7 case .Blue: 0x007AFF case .Indigo: 0x5856D6 case .Purple: 0xAF52DE } } var dark: UInt32 { switch self { case .Music: 0xFC3C44 case .Pink: 0xFF375F case .Orange: 0xFF9F0A case .Yellow: 0xFFD60A case .Green: 0x30D158 case .Teal: 0x40C8E0 case .Blue: 0x0A84FF case .Indigo: 0x5E5CE6 case .Purple: 0xBF5AF2 } } } /// How stems travel from stemserve; Opus is transcoded (and cached) server-side. enum StemQuality: String, CaseIterable, Identifiable { case Lossless, High, Standard, Saver var id: String { rawValue } var label: String { switch self { case .Lossless: "Lossless" case .High: "High" case .Standard: "Standard" case .Saver: "Data Saver" } } var detail: String { switch self { case .Lossless: "Original 24-bit FLAC, ~30 MB per stem-minute" case .High: "Opus 256 kbps per stem" case .Standard: "Opus 160 kbps per stem" case .Saver: "Opus 96 kbps per stem" } } var fmt: String { self == .Lossless ? "flac" : "opus" } var kbps: Int { switch self { case .Lossless: 0 case .High: 256 case .Standard: 160 case .Saver: 96 } } } /// UserDefaults-backed settings. Safe to read from any thread (the engine and network code do); /// SwiftUI views observe them, and [onChange] lets the rest of the app react to writes. @Observable final class Settings: @unchecked Sendable { @ObservationIgnored private let defaults: UserDefaults @ObservationIgnored private let lock = NSLock() @ObservationIgnored private var store: [String: Any] = [:] @ObservationIgnored private var listeners: [String: [() -> Void]] = [:] init(defaults: UserDefaults = .standard) { self.defaults = defaults for key in Self.keys { if let v = defaults.object(forKey: key) { store[key] = v } } if let id = defaults.string(forKey: "clientId") { clientId = id } else { clientId = UUID().uuidString.lowercased() defaults.set(clientId, forKey: "clientId") } } private static let keys = [ "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle", "stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate", "stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout", "karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", ] private func read(_ kp: KeyPath, _ key: String, _ def: T) -> T { access(keyPath: kp) lock.lock() defer { lock.unlock() } return store[key] as? T ?? def } private func write(_ kp: KeyPath, _ key: String, _ v: T, _ def: T) { lock.lock() let old = store[key] as? T ?? def lock.unlock() if old == v { return } withMutation(keyPath: kp) { lock.lock() store[key] = v lock.unlock() } defaults.set(v, forKey: key) notify(key) } private func readEnum(_ kp: KeyPath, _ key: String, _ def: E) -> E where E.RawValue == String { access(keyPath: kp) lock.lock() defer { lock.unlock() } return (store[key] as? String).flatMap(E.init(rawValue:)) ?? def } private func writeEnum(_ kp: KeyPath, _ key: String, _ v: E, _ def: E) where E.RawValue == String { lock.lock() let old = (store[key] as? String).flatMap(E.init(rawValue:)) ?? def lock.unlock() if old == v { return } withMutation(keyPath: kp) { lock.lock() store[key] = v.rawValue lock.unlock() } defaults.set(v.rawValue, forKey: key) notify(key) } private func notify(_ key: String) { let run = { self.lock.lock() let fs = self.listeners[key] ?? [] self.lock.unlock() fs.forEach { $0() } } if Thread.isMainThread { run() } else { DispatchQueue.main.async(execute: run) } } /// Calls [f] on the main thread after any of [keys] changes. func onChange(_ keys: String..., f: @escaping () -> Void) { lock.lock() for k in keys { listeners[k, default: []].append(f) } lock.unlock() } var plexUrl: String { get { read(\.plexUrl, "plexUrl", "http://192.168.86.2:32400") } set { write(\.plexUrl, "plexUrl", newValue, "http://192.168.86.2:32400") } } var plexToken: String { get { read(\.plexToken, "plexToken", "") } set { write(\.plexToken, "plexToken", newValue, "") } } var plexUser: String { get { read(\.plexUser, "plexUser", "") } set { write(\.plexUser, "plexUser", newValue, "") } } 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, "") } } 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. var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } } 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) } } 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) } } var stemQuality: StemQuality { get { readEnum(\.stemQuality, "stemQuality", .High) } set { writeEnum(\.stemQuality, "stemQuality", newValue, .High) } } var cacheLimitGb: Int { get { read(\.cacheLimitGb, "cacheLimitGb", 10) } set { write(\.cacheLimitGb, "cacheLimitGb", newValue, 10) } } var downloadsWifiOnly: Bool { get { read(\.downloadsWifiOnly, "downloadsWifiOnly", true) } set { write(\.downloadsWifiOnly, "downloadsWifiOnly", newValue, true) } } /// Downloads page order: 0 download order, 1 artist, 2 release date. var downloadSort: Int { get { read(\.downloadSort, "downloadSort", 0) } set { write(\.downloadSort, "downloadSort", newValue, 0) } } var karaokeVocals: Int { get { read(\.karaokeVocals, "karaokeVocals", KaraokeVocals.Off.rawValue) } set { write(\.karaokeVocals, "karaokeVocals", newValue, KaraokeVocals.Off.rawValue) } } /// 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 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 } set { write(\.playbackSpeed, "playbackSpeed", newValue, 1.0) } } var masterVolume: Float { get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 } set { write(\.masterVolume, "masterVolume", newValue, 0.85) } } @ObservationIgnored let clientId: String }