259 lines
14 KiB
Swift
259 lines
14 KiB
Swift
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")
|
|
}
|
|
// 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 = [
|
|
"servers", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
|
|
"stemUrl", "stemToken", "plexAccountToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
|
|
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout",
|
|
"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 {
|
|
access(keyPath: kp)
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return store[key] as? T ?? def
|
|
}
|
|
|
|
private func write<T: Equatable>(_ kp: KeyPath<Settings, T>, _ 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<E: RawRepresentable>(_ kp: KeyPath<Settings, E>, _ 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<E: RawRepresentable & Equatable>(_ kp: KeyPath<Settings, E>, _ 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, "") } }
|
|
/// 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, "") } }
|
|
var plexAccountToken: String { get { read(\.plexAccountToken, "plexAccountToken", "") } set { write(\.plexAccountToken, "plexAccountToken", 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) } }
|
|
/// 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) } }
|
|
|
|
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.
|
|
/// Library screen row order, titles joined by "|". Unlisted rows keep their default place after the listed ones.
|
|
var libraryOrder: String { get { read(\.libraryOrder, "libraryOrder", "") } set { write(\.libraryOrder, "libraryOrder", newValue, "") } }
|
|
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); 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 }
|
|
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
|
|
}
|