SettingsStore.kt
628 lines · 30.8 KB
Changed in: 0.5.0
| 1 | package dev.stave.data |
| 2 | |
| 3 | import kotlinx.coroutines.Dispatchers |
| 4 | import kotlinx.coroutines.flow.MutableStateFlow |
| 5 | import kotlinx.coroutines.flow.StateFlow |
| 6 | import kotlinx.coroutines.flow.asStateFlow |
| 7 | import kotlinx.coroutines.flow.update |
| 8 | import kotlinx.coroutines.withContext |
| 9 | import java.io.File |
| 10 | import java.util.Properties |
| 11 | |
| 12 | /** ReplayGain operating mode. */ |
| 13 | enum class ReplayGainMode { OFF, TRACK, ALBUM } |
| 14 | |
| 15 | /** |
| 16 | * User preferences persisted to disk. |
| 17 | * |
| 18 | * Upstream Android uses Jetpack DataStore, which isn't a jvmCommon dependency. Desktop |
| 19 | * rolls its own tiny properties-file store at `$XDG_CONFIG_HOME/earhorn/config.properties` |
| 20 | * (falls back to `~/.config/earhorn/config.properties`). |
| 21 | * |
| 22 | * Expands cleanly if we later want more settings — each key gets a [MutableStateFlow] |
| 23 | * backed by a setter that writes the whole config file. |
| 24 | * |
| 25 | * Properties rather than JSON because the JDK ships it, it escapes unicode / special |
| 26 | * chars automatically, and it reads as a plain `key=value` text file the user can |
| 27 | * edit or grep when debugging. |
| 28 | * |
| 29 | * Writes serialize the whole config per call — file is tiny, atomicity matters more |
| 30 | * than performance. Writes happen on [Dispatchers.IO] and are serialized by a lock |
| 31 | * (see [saveToDisk]); reads are cheap in-memory once the store is constructed. |
| 32 | */ |
| 33 | class SettingsStore( |
| 34 | configDir: File = StavePaths.configDir, |
| 35 | ) { |
| 36 | private val configFile: File = File(configDir, "config.properties").also { |
| 37 | StavePaths.ensurePrivateDir(configDir) |
| 38 | } |
| 39 | |
| 40 | // ---- Preferred server UDN ------------------------------------------------ |
| 41 | |
| 42 | private val _preferredServerUdn = MutableStateFlow<String?>(null) |
| 43 | |
| 44 | /** UDN of the last server the user selected. Null if they haven't picked one yet |
| 45 | * or if they cleared it via Rescan. Auto-select logic in StaveApp reads this |
| 46 | * against the live discovery list. */ |
| 47 | val preferredServerUdn: StateFlow<String?> = _preferredServerUdn.asStateFlow() |
| 48 | |
| 49 | // ---- ReplayGain ---------------------------------------------------------- |
| 50 | |
| 51 | private val _replayGainMode = MutableStateFlow(ReplayGainMode.OFF) |
| 52 | |
| 53 | /** RG mode. OFF = no volume correction; TRACK = per-track; ALBUM = per-album |
| 54 | * (preserves internal album dynamics). */ |
| 55 | val replayGainMode: StateFlow<ReplayGainMode> = _replayGainMode.asStateFlow() |
| 56 | |
| 57 | private val _replayGainPreAmpDb = MutableStateFlow(3f) |
| 58 | |
| 59 | /** Pre-amp in dB, added to every track's RG-corrected volume. Default +3dB — |
| 60 | * conventional because RG's -6dBFS target leaves headroom most listeners want |
| 61 | * to reclaim. Clamped to [-15, +15] in setter. */ |
| 62 | val replayGainPreAmpDb: StateFlow<Float> = _replayGainPreAmpDb.asStateFlow() |
| 63 | |
| 64 | // ---- Random-picker excluded genres -------------------------------------- |
| 65 | |
| 66 | private val _randomExcludedGenres = MutableStateFlow<Set<String>>(emptySet()) |
| 67 | |
| 68 | /** Genre names excluded from random-track / random-album picks. Case-insensitive |
| 69 | * comparison at filter time. Typical: "Podcast", "Comedy", "Audiobook" — so |
| 70 | * party-shuffle doesn't pull spoken-word items. */ |
| 71 | val randomExcludedGenres: StateFlow<Set<String>> = _randomExcludedGenres.asStateFlow() |
| 72 | |
| 73 | // ---- Volume scroll step -------------------------------------------------- |
| 74 | |
| 75 | private val _volumeScrollStep = MutableStateFlow(5) |
| 76 | |
| 77 | /** Percentage points adjusted per scroll-wheel notch on volume controls. Applied |
| 78 | * both to the transport-bar scroll gesture and the volume widget's scroll and |
| 79 | * keyboard nudges. Default 5 → 20 notches across the full range. Valid range |
| 80 | * 1..100; values are clamped in the setter. */ |
| 81 | val volumeScrollStep: StateFlow<Int> = _volumeScrollStep.asStateFlow() |
| 82 | |
| 83 | // ---- Last volume setting ------------------------------------------------- |
| 84 | |
| 85 | private val _volumePercent = MutableStateFlow(100) |
| 86 | |
| 87 | /** Last-set playback volume (0..100). Restored on app startup so users don't |
| 88 | * get blasted at full volume every launch. Clamped in setter. */ |
| 89 | val volumePercent: StateFlow<Int> = _volumePercent.asStateFlow() |
| 90 | |
| 91 | // ---- Lyrics timing offset ------------------------------------------------ |
| 92 | |
| 93 | private val _lyricsOffsetMs = MutableStateFlow(0) |
| 94 | |
| 95 | /** Milliseconds to shift lyric sync. Positive = lyrics advance LATER (pull |
| 96 | * them back). Negative = lyrics advance EARLIER (push them forward). Applied |
| 97 | * in [LyricsViewModel.indexFor] as `effectivePosition = actualPosition - offset`. |
| 98 | * |
| 99 | * Useful for compensating: |
| 100 | * - Audio buffering (libvlc position runs slightly ahead of what the user |
| 101 | * hears; typically 20-80ms on PulseAudio, more on some systems). |
| 102 | * - Reading-ahead preference (some users like lyrics highlighted before the |
| 103 | * vocal so they can anticipate). |
| 104 | * - Systematically mis-timed LRCLIB submissions. |
| 105 | * |
| 106 | * Range -1000..+1000ms; clamped in setter. Default 0 — no adjustment. */ |
| 107 | val lyricsOffsetMs: StateFlow<Int> = _lyricsOffsetMs.asStateFlow() |
| 108 | |
| 109 | // ---- Privacy: external service calls ----------------------------------- |
| 110 | |
| 111 | private val _allowExternalCalls = MutableStateFlow(true) |
| 112 | |
| 113 | /** Master privacy switch. When FALSE, Earhorn will not contact any third-party |
| 114 | * service (LRCLIB for lyrics, etc.). The lyrics overlay shows a |
| 115 | * "disabled in privacy settings" state instead of pinging the network. |
| 116 | * |
| 117 | * Default TRUE — opt-out, matching the Android app's behavior. Users who |
| 118 | * prefer a no-external-traffic posture can toggle this off; everything else |
| 119 | * in Earhorn (mDNS for cast, SSDP for UPnP, the local renderer, the local |
| 120 | * library scan) stays untouched because none of those is "external" — they |
| 121 | * all stay on the user's LAN. */ |
| 122 | val allowExternalCalls: StateFlow<Boolean> = _allowExternalCalls.asStateFlow() |
| 123 | |
| 124 | // ---- Renderer broadcast (act as UPnP MediaRenderer on the LAN) ---------- |
| 125 | |
| 126 | private val _rendererBroadcastEnabled = MutableStateFlow(false) |
| 127 | |
| 128 | /** When true, this Earhorn instance advertises itself on the LAN as a UPnP |
| 129 | * MediaRenderer. Other Earhorn instances (and any UPnP control point — |
| 130 | * BubbleUPnP, Kazoo, JRiver Media Center) can then see it in their |
| 131 | * renderer picker and direct playback to it. |
| 132 | * |
| 133 | * Off by default. The user opts in per-instance from the Settings screen. |
| 134 | * Each Earhorn in the household decides independently whether to be |
| 135 | * visible as a renderer — your laptop on the a room counter probably |
| 136 | * yes, your work laptop in the office probably no. */ |
| 137 | val rendererBroadcastEnabled: StateFlow<Boolean> = _rendererBroadcastEnabled.asStateFlow() |
| 138 | |
| 139 | private val _rendererFriendlyName = MutableStateFlow<String?>(null) |
| 140 | |
| 141 | /** Display name shown in other devices' renderer pickers. Null means |
| 142 | * "compute a default at start time" (typically "Earhorn on <hostname>") |
| 143 | * — set explicitly when the user wants a hand-chosen label like |
| 144 | * "Living Room Workstation". Empty / blank strings normalize to null. */ |
| 145 | val rendererFriendlyName: StateFlow<String?> = _rendererFriendlyName.asStateFlow() |
| 146 | |
| 147 | private val _rendererUdn = MutableStateFlow<String?>(null) |
| 148 | |
| 149 | /** UPnP UDN (Unique Device Name) for THIS instance's broadcast renderer. |
| 150 | * Generated as a random uuid: URI on first enable and persisted, so |
| 151 | * remote control points (other Earhorn instances, BubbleUPnP, etc) |
| 152 | * can keep their stored bindings across our app restarts. |
| 153 | * |
| 154 | * Null until the first time the user enables broadcast. The renderer |
| 155 | * service generates and writes one then. */ |
| 156 | val rendererUdn: StateFlow<String?> = _rendererUdn.asStateFlow() |
| 157 | |
| 158 | // ---- Active renderer selection ------------------------------------------ |
| 159 | |
| 160 | private val _activeRendererUdn = MutableStateFlow<String?>(null) |
| 161 | |
| 162 | /** UDN of the renderer this Earhorn is currently driving. Null means |
| 163 | * "use the local renderer" (libvlc through the machine's own speakers). |
| 164 | * Non-null means a remote UPnP MediaRenderer on the LAN — another Earhorn, |
| 165 | * BubbleUPnP receiver, Linn streamer, whatever the user picked — or a Cast |
| 166 | * device via the `cast:` prefix. |
| 167 | * |
| 168 | * Observed by [dev.stave.upnp.renderer.RendererCoordinator] which attaches |
| 169 | * / detaches remote subscriptions and routes Renderer interface calls to |
| 170 | * the right delegate as this value changes. |
| 171 | * |
| 172 | * Persistence semantics differ by renderer kind: |
| 173 | * - UPnP UDNs survive restart. At startup the picked renderer may not be |
| 174 | * discovered yet, so the coordinator also watches the discovery list |
| 175 | * and switches when the renderer appears. |
| 176 | * - `cast:` (sender-mode) selections are SESSION-ONLY. [loadFromDisk] |
| 177 | * deliberately drops them, so a launch never auto-attaches the sender |
| 178 | * stack to a Cast device. Attaching grabs the group's transport and |
| 179 | * volume the moment discovery finds it — on a device something else is |
| 180 | * mid-stream on, that's a hijack the user never asked for. Cast attach |
| 181 | * therefore only ever happens from an explicit in-session pick. (The |
| 182 | * passive counterpart, Follow, DOES resume across launches — see |
| 183 | * [followedCastId].) */ |
| 184 | val activeRendererUdn: StateFlow<String?> = _activeRendererUdn.asStateFlow() |
| 185 | |
| 186 | // ---- Followed cast device (observer mode) --------------------------------- |
| 187 | |
| 188 | private val _followedCastId = MutableStateFlow<String?>(null) |
| 189 | |
| 190 | /** Device id (the mDNS service name, i.e. `CastDevice.id`) of the Cast |
| 191 | * device the user is following in observer mode, or null when not |
| 192 | * following. Written by `RawCastObserver.observe()` on every follow, |
| 193 | * cleared by its `disconnect()` — which is only ever user-initiated |
| 194 | * ("stop following" / eyeball toggle) — and consumed by the observer's |
| 195 | * startup watcher to resume the follow once the device is discovered |
| 196 | * again. |
| 197 | * |
| 198 | * Unlike `cast:` sender selections (see [activeRendererUdn]), a follow |
| 199 | * IS restored across restarts: Follow is a passive overlay that reads |
| 200 | * the group's session without touching its transport, so resuming it at |
| 201 | * launch can't hijack anything. */ |
| 202 | val followedCastId: StateFlow<String?> = _followedCastId.asStateFlow() |
| 203 | |
| 204 | // ---- Last-known Cast hosts (follow rescue) ---------------------------------- |
| 205 | |
| 206 | private val _lastKnownHosts = MutableStateFlow<Map<String, LastKnownCastHost>>(emptyMap()) |
| 207 | |
| 208 | /** Device id (`CastDevice.id`) -> where it last attached, for each Cast |
| 209 | * device a follow succeeded on (Android's `last_known_group_hosts`). The |
| 210 | * startup follow restore dials it when discovery hasn't found the device: |
| 211 | * a freshly rebooted router can drop multicast for minutes, exactly when a |
| 212 | * cold start needs mDNS. Persisted as `lastKnownHosts=` with one |
| 213 | * `id<TAB>host:port<TAB>name` line per device; most recent last, at most |
| 214 | * [MAX_LAST_KNOWN_HOSTS]. */ |
| 215 | val lastKnownHosts: StateFlow<Map<String, LastKnownCastHost>> = _lastKnownHosts.asStateFlow() |
| 216 | |
| 217 | // ---- Typography preferences --------------------------------------------- |
| 218 | |
| 219 | private val _displayFontFamily = MutableStateFlow(DEFAULT_DISPLAY_FONT_FAMILY) |
| 220 | |
| 221 | /** |
| 222 | * Which font family drives display/headline/title text styles. |
| 223 | * - "fraunces" (default) — the Fraunces serif for those roles; Inter |
| 224 | * continues to handle body/label. The "mixed" look the app shipped with. |
| 225 | * - "inter" — Inter for every role, no Fraunces anywhere. The escape hatch |
| 226 | * for readers who don't enjoy a high-contrast serif on screen. |
| 227 | * |
| 228 | * Stored as a string rather than an enum to keep [SettingsStore] purely |
| 229 | * commonMain; the UI's enum mapping lives next to the picker that writes it. |
| 230 | */ |
| 231 | val displayFontFamily: StateFlow<String> = _displayFontFamily.asStateFlow() |
| 232 | |
| 233 | /** musicnow's /status address; the desktop reads a station's song from it |
| 234 | * (design decision, 2026-09-04). Blank turns the overlay off. */ |
| 235 | private val _musicnowStatusUrl = MutableStateFlow(DEFAULT_MUSICNOW_STATUS_URL) |
| 236 | val musicnowStatusUrl: StateFlow<String> = _musicnowStatusUrl.asStateFlow() |
| 237 | |
| 238 | private val _fontScalePercent = MutableStateFlow(DEFAULT_FONT_SCALE_PERCENT) |
| 239 | |
| 240 | /** |
| 241 | * Global typography multiplier expressed as a whole-number percent. 100 |
| 242 | * means typography renders at its declared sizes; 120 means 1.2× everything. |
| 243 | * |
| 244 | * Range expected 70..150 (clamped on write). Users adjust this from |
| 245 | * Settings → Appearance → Font size with +/- buttons. |
| 246 | */ |
| 247 | val fontScalePercent: StateFlow<Int> = _fontScalePercent.asStateFlow() |
| 248 | |
| 249 | // ---- Metadata fields shown on Now Playing ------------------------------- |
| 250 | |
| 251 | private val _enabledMetadataFields = MutableStateFlow(dev.stave.domain.model.MetadataField.defaults) |
| 252 | |
| 253 | /** Which [dev.stave.domain.model.MetadataField]s the Now Playing metadata |
| 254 | * block shows (Android's `enabled_metadata_fields`). Persisted as |
| 255 | * `enabledMetadataFields=key1;key2`. A missing key means |
| 256 | * [dev.stave.domain.model.MetadataField.defaults]; a present but empty |
| 257 | * value means "show none", so the user can switch everything off. |
| 258 | * Unknown keys in the file are ignored. */ |
| 259 | val enabledMetadataFields: StateFlow<Set<dev.stave.domain.model.MetadataField>> = |
| 260 | _enabledMetadataFields.asStateFlow() |
| 261 | |
| 262 | init { |
| 263 | loadFromDisk() |
| 264 | } |
| 265 | |
| 266 | // ---- Setters ------------------------------------------------------------- |
| 267 | |
| 268 | suspend fun setPreferredServer(udn: String?): Unit = withContext(Dispatchers.IO) { |
| 269 | _preferredServerUdn.value = udn?.takeIf { it.isNotBlank() } |
| 270 | saveToDisk() |
| 271 | } |
| 272 | |
| 273 | suspend fun setReplayGainMode(mode: ReplayGainMode): Unit = withContext(Dispatchers.IO) { |
| 274 | _replayGainMode.value = mode |
| 275 | saveToDisk() |
| 276 | } |
| 277 | |
| 278 | suspend fun setReplayGainPreAmpDb(db: Float): Unit = withContext(Dispatchers.IO) { |
| 279 | _replayGainPreAmpDb.value = db.coerceIn(-15f, 15f) |
| 280 | saveToDisk() |
| 281 | } |
| 282 | |
| 283 | suspend fun addExcludedGenre(genre: String): Unit = withContext(Dispatchers.IO) { |
| 284 | val trimmed = genre.trim() |
| 285 | if (trimmed.isEmpty()) return@withContext |
| 286 | _randomExcludedGenres.value = _randomExcludedGenres.value + trimmed |
| 287 | saveToDisk() |
| 288 | } |
| 289 | |
| 290 | suspend fun removeExcludedGenre(genre: String): Unit = withContext(Dispatchers.IO) { |
| 291 | _randomExcludedGenres.value = _randomExcludedGenres.value - genre |
| 292 | saveToDisk() |
| 293 | } |
| 294 | |
| 295 | suspend fun setVolumeScrollStep(step: Int): Unit = withContext(Dispatchers.IO) { |
| 296 | _volumeScrollStep.value = step.coerceIn(1, 100) |
| 297 | saveToDisk() |
| 298 | } |
| 299 | |
| 300 | suspend fun setVolumePercent(percent: Int): Unit = withContext(Dispatchers.IO) { |
| 301 | _volumePercent.value = percent.coerceIn(0, 100) |
| 302 | saveToDisk() |
| 303 | } |
| 304 | |
| 305 | suspend fun setLyricsOffsetMs(ms: Int): Unit = withContext(Dispatchers.IO) { |
| 306 | _lyricsOffsetMs.value = ms.coerceIn(-1000, 1000) |
| 307 | saveToDisk() |
| 308 | } |
| 309 | |
| 310 | suspend fun setAllowExternalCalls(enabled: Boolean): Unit = withContext(Dispatchers.IO) { |
| 311 | _allowExternalCalls.value = enabled |
| 312 | saveToDisk() |
| 313 | } |
| 314 | |
| 315 | suspend fun setRendererBroadcastEnabled(enabled: Boolean): Unit = withContext(Dispatchers.IO) { |
| 316 | _rendererBroadcastEnabled.value = enabled |
| 317 | saveToDisk() |
| 318 | } |
| 319 | |
| 320 | suspend fun setRendererFriendlyName(name: String?): Unit = withContext(Dispatchers.IO) { |
| 321 | _rendererFriendlyName.value = name?.trim()?.takeIf { it.isNotBlank() } |
| 322 | saveToDisk() |
| 323 | } |
| 324 | |
| 325 | /** Sets the persisted UDN. Used once by EarhornRendererService the first |
| 326 | * time the user enables broadcast, then never again. Idempotent: passing |
| 327 | * the same value or null when one is already set is a no-op. */ |
| 328 | suspend fun setRendererUdn(udn: String): Unit = withContext(Dispatchers.IO) { |
| 329 | val trimmed = udn.trim() |
| 330 | if (trimmed.isEmpty() || _rendererUdn.value == trimmed) return@withContext |
| 331 | _rendererUdn.value = trimmed |
| 332 | saveToDisk() |
| 333 | } |
| 334 | |
| 335 | suspend fun setActiveRendererUdn(udn: String?): Unit = withContext(Dispatchers.IO) { |
| 336 | _activeRendererUdn.value = udn?.trim()?.takeIf { it.isNotBlank() } |
| 337 | saveToDisk() |
| 338 | } |
| 339 | |
| 340 | suspend fun setFollowedCastId(id: String?): Unit = withContext(Dispatchers.IO) { |
| 341 | _followedCastId.value = id?.trim()?.takeIf { it.isNotBlank() } |
| 342 | saveToDisk() |
| 343 | } |
| 344 | |
| 345 | /** Remember where Cast device [id] just attached. Android's writer, after its |
| 346 | * 2026-09-02 fix: a real template per entry (the old one wrote the template |
| 347 | * text itself for every entry, so no host was ever stored). Unlike Android, the |
| 348 | * entry moves to the end and the OLDEST ones drop past the cap — Android's |
| 349 | * `take(24)` kept the oldest and never stored a 25th device. No write when |
| 350 | * nothing changed (every attach calls this). */ |
| 351 | suspend fun setLastKnownHost(id: String, friendlyName: String, host: String, port: Int): Unit = |
| 352 | withContext(Dispatchers.IO) { |
| 353 | val key = id.trim() |
| 354 | if (key.isEmpty() || host.isBlank() || '\t' in key || '\n' in key) return@withContext |
| 355 | val entry = LastKnownCastHost( |
| 356 | friendlyName = friendlyName.replace('\t', ' ').replace('\n', ' '), |
| 357 | host = host.trim(), |
| 358 | port = port, |
| 359 | ) |
| 360 | var changed = false |
| 361 | _lastKnownHosts.update { cur -> |
| 362 | if (cur.keys.lastOrNull() == key && cur[key] == entry) return@update cur |
| 363 | changed = true |
| 364 | val next = LinkedHashMap(cur) |
| 365 | next.remove(key) |
| 366 | next[key] = entry |
| 367 | next.entries.toList().takeLast(MAX_LAST_KNOWN_HOSTS).associate { it.key to it.value } |
| 368 | } |
| 369 | if (changed) saveToDisk() |
| 370 | } |
| 371 | |
| 372 | /** Set the display font family. Any value other than "inter" is treated as |
| 373 | * the default "fraunces" so a malformed persisted value still produces a |
| 374 | * legible UI. */ |
| 375 | suspend fun setDisplayFontFamily(family: String): Unit = withContext(Dispatchers.IO) { |
| 376 | _displayFontFamily.value = if (family == FONT_FAMILY_INTER) FONT_FAMILY_INTER |
| 377 | else DEFAULT_DISPLAY_FONT_FAMILY |
| 378 | saveToDisk() |
| 379 | } |
| 380 | |
| 381 | /** Set the global typography multiplier as whole-number percent. Clamped |
| 382 | * to [FONT_SCALE_MIN_PERCENT]..[FONT_SCALE_MAX_PERCENT]. */ |
| 383 | suspend fun setMusicnowStatusUrl(url: String): Unit = withContext(Dispatchers.IO) { |
| 384 | _musicnowStatusUrl.value = url.trim() |
| 385 | saveToDisk() |
| 386 | } |
| 387 | |
| 388 | suspend fun setFontScalePercent(percent: Int): Unit = withContext(Dispatchers.IO) { |
| 389 | _fontScalePercent.value = percent.coerceIn(FONT_SCALE_MIN_PERCENT, FONT_SCALE_MAX_PERCENT) |
| 390 | saveToDisk() |
| 391 | } |
| 392 | |
| 393 | suspend fun setMetadataFieldEnabled( |
| 394 | field: dev.stave.domain.model.MetadataField, |
| 395 | enabled: Boolean, |
| 396 | ): Unit = withContext(Dispatchers.IO) { |
| 397 | // Atomic read-modify-write: two quick checkbox clicks run as two IO |
| 398 | // coroutines, and a plain get-then-set could drop one of them. |
| 399 | _enabledMetadataFields.update { if (enabled) it + field else it - field } |
| 400 | saveToDisk() |
| 401 | } |
| 402 | |
| 403 | suspend fun setEnabledMetadataFields( |
| 404 | fields: Set<dev.stave.domain.model.MetadataField>, |
| 405 | ): Unit = withContext(Dispatchers.IO) { |
| 406 | _enabledMetadataFields.value = fields |
| 407 | saveToDisk() |
| 408 | } |
| 409 | |
| 410 | // ---- Persistence --------------------------------------------------------- |
| 411 | |
| 412 | private fun loadFromDisk() { |
| 413 | // A crash mid-save can leave a temp file behind; it is never read, only removed. |
| 414 | configFile.parentFile?.let { StavePaths.deleteStaleTemps(it, TMP_PREFIX) } |
| 415 | if (!configFile.exists()) return |
| 416 | StavePaths.restrictToOwner(configFile) |
| 417 | runCatching { |
| 418 | val props = Properties() |
| 419 | configFile.inputStream().use { props.load(it) } |
| 420 | |
| 421 | _preferredServerUdn.value = props.getProperty(KEY_PREFERRED_SERVER) |
| 422 | ?.takeIf { it.isNotBlank() } |
| 423 | |
| 424 | props.getProperty(KEY_RG_MODE)?.toIntOrNull()?.let { ordinal -> |
| 425 | _replayGainMode.value = ReplayGainMode.entries.getOrElse(ordinal) { ReplayGainMode.OFF } |
| 426 | } |
| 427 | |
| 428 | props.getProperty(KEY_RG_PREAMP)?.toFloatOrNull()?.let { db -> |
| 429 | _replayGainPreAmpDb.value = db.coerceIn(-15f, 15f) |
| 430 | } |
| 431 | |
| 432 | props.getProperty(KEY_RANDOM_EXCLUDED_GENRES)?.let { packed -> |
| 433 | // Semicolon-delimited set, since commas appear in genre tags. |
| 434 | _randomExcludedGenres.value = packed.split(';') |
| 435 | .map { it.trim() } |
| 436 | .filter { it.isNotEmpty() } |
| 437 | .toSet() |
| 438 | } |
| 439 | |
| 440 | props.getProperty(KEY_VOLUME_SCROLL_STEP)?.toIntOrNull()?.let { step -> |
| 441 | _volumeScrollStep.value = step.coerceIn(1, 100) |
| 442 | } |
| 443 | |
| 444 | props.getProperty(KEY_VOLUME_PERCENT)?.toIntOrNull()?.let { pct -> |
| 445 | _volumePercent.value = pct.coerceIn(0, 100) |
| 446 | } |
| 447 | |
| 448 | props.getProperty(KEY_LYRICS_OFFSET_MS)?.toIntOrNull()?.let { ms -> |
| 449 | _lyricsOffsetMs.value = ms.coerceIn(-1000, 1000) |
| 450 | } |
| 451 | |
| 452 | props.getProperty(KEY_ALLOW_EXTERNAL_CALLS)?.let { v -> |
| 453 | _allowExternalCalls.value = v.equals("true", ignoreCase = true) |
| 454 | } |
| 455 | |
| 456 | props.getProperty(KEY_RENDERER_BROADCAST)?.let { v -> |
| 457 | _rendererBroadcastEnabled.value = v.equals("true", ignoreCase = true) |
| 458 | } |
| 459 | |
| 460 | props.getProperty(KEY_RENDERER_FRIENDLY_NAME) |
| 461 | ?.trim() |
| 462 | ?.takeIf { it.isNotBlank() } |
| 463 | ?.let { _rendererFriendlyName.value = it } |
| 464 | |
| 465 | props.getProperty(KEY_RENDERER_UDN) |
| 466 | ?.trim() |
| 467 | ?.takeIf { it.isNotBlank() } |
| 468 | ?.let { _rendererUdn.value = it } |
| 469 | |
| 470 | props.getProperty(KEY_ACTIVE_RENDERER_UDN) |
| 471 | ?.trim() |
| 472 | ?.takeIf { it.isNotBlank() } |
| 473 | // Cast sender selections are session-only: a persisted |
| 474 | // `cast:<id>` is deliberately NOT restored, so a launch never |
| 475 | // auto-attaches the sender stack to a Cast device (see the |
| 476 | // [activeRendererUdn] KDoc for the why). The stale line stays |
| 477 | // in the file until the next saveToDisk() omits it — harmless, |
| 478 | // since every load drops it right here. setActiveRendererUdn |
| 479 | // still persists `cast:` values normally during a session; |
| 480 | // only the *restore* is suppressed. |
| 481 | ?.takeIf { |
| 482 | !it.startsWith(dev.stave.upnp.renderer.RendererCoordinator.CAST_UDN_PREFIX) |
| 483 | } |
| 484 | ?.let { _activeRendererUdn.value = it } |
| 485 | |
| 486 | props.getProperty(KEY_FOLLOWED_CAST_ID) |
| 487 | ?.trim() |
| 488 | ?.takeIf { it.isNotBlank() } |
| 489 | ?.let { _followedCastId.value = it } |
| 490 | |
| 491 | props.getProperty(KEY_LAST_KNOWN_HOSTS)?.let { packed -> |
| 492 | _lastKnownHosts.value = packed.split('\n') |
| 493 | .mapNotNull { line -> |
| 494 | val parts = line.split('\t', limit = 3) |
| 495 | if (parts.size < 2 || parts[0].isBlank()) return@mapNotNull null |
| 496 | val hostPort = parts[1].trim() |
| 497 | val host = hostPort.substringBeforeLast(':').takeIf { it.isNotBlank() } |
| 498 | ?: return@mapNotNull null |
| 499 | val port = hostPort.substringAfterLast(':', "").toIntOrNull() ?: CAST_PORT |
| 500 | parts[0] to LastKnownCastHost(parts.getOrNull(2).orEmpty(), host, port) |
| 501 | } |
| 502 | .takeLast(MAX_LAST_KNOWN_HOSTS) |
| 503 | .toMap() |
| 504 | } |
| 505 | |
| 506 | props.getProperty(KEY_DISPLAY_FONT_FAMILY) |
| 507 | ?.trim() |
| 508 | ?.takeIf { it.isNotBlank() } |
| 509 | ?.let { |
| 510 | _displayFontFamily.value = if (it == FONT_FAMILY_INTER) FONT_FAMILY_INTER |
| 511 | else DEFAULT_DISPLAY_FONT_FAMILY |
| 512 | } |
| 513 | |
| 514 | props.getProperty(KEY_MUSICNOW_STATUS_URL)?.let { _musicnowStatusUrl.value = it.trim() } |
| 515 | props.getProperty(KEY_FONT_SCALE_PERCENT)?.toIntOrNull()?.let { pct -> |
| 516 | _fontScalePercent.value = pct.coerceIn(FONT_SCALE_MIN_PERCENT, FONT_SCALE_MAX_PERCENT) |
| 517 | } |
| 518 | // Absent → keep defaults; present-but-empty → no fields. |
| 519 | props.getProperty(KEY_ENABLED_METADATA_FIELDS)?.let { packed -> |
| 520 | _enabledMetadataFields.value = packed.split(';') |
| 521 | .map { it.trim() } |
| 522 | .filter { it.isNotEmpty() } |
| 523 | .mapNotNull { dev.stave.domain.model.MetadataField.fromKey(it) } |
| 524 | .toSet() |
| 525 | } |
| 526 | } |
| 527 | } |
| 528 | |
| 529 | /** Serializes [saveToDisk]: the snapshot below and the file replace happen under one |
| 530 | * lock, so the last save to run always carries every value set before it, and two |
| 531 | * setters can never interleave writes or renames (volume drags save dozens of times |
| 532 | * a second while a follow / renderer pick may be saving too). */ |
| 533 | private val saveLock = Any() |
| 534 | |
| 535 | private fun saveToDisk(): Unit = synchronized(saveLock) { |
| 536 | runCatching { |
| 537 | val props = Properties() |
| 538 | _preferredServerUdn.value?.let { props.setProperty(KEY_PREFERRED_SERVER, it) } |
| 539 | props.setProperty(KEY_RG_MODE, _replayGainMode.value.ordinal.toString()) |
| 540 | props.setProperty(KEY_RG_PREAMP, _replayGainPreAmpDb.value.toString()) |
| 541 | val excluded = _randomExcludedGenres.value |
| 542 | if (excluded.isNotEmpty()) { |
| 543 | props.setProperty(KEY_RANDOM_EXCLUDED_GENRES, excluded.joinToString(";")) |
| 544 | } |
| 545 | props.setProperty(KEY_VOLUME_SCROLL_STEP, _volumeScrollStep.value.toString()) |
| 546 | props.setProperty(KEY_VOLUME_PERCENT, _volumePercent.value.toString()) |
| 547 | props.setProperty(KEY_LYRICS_OFFSET_MS, _lyricsOffsetMs.value.toString()) |
| 548 | props.setProperty(KEY_ALLOW_EXTERNAL_CALLS, _allowExternalCalls.value.toString()) |
| 549 | props.setProperty(KEY_RENDERER_BROADCAST, _rendererBroadcastEnabled.value.toString()) |
| 550 | _rendererFriendlyName.value?.let { props.setProperty(KEY_RENDERER_FRIENDLY_NAME, it) } |
| 551 | _rendererUdn.value?.let { props.setProperty(KEY_RENDERER_UDN, it) } |
| 552 | _activeRendererUdn.value?.let { props.setProperty(KEY_ACTIVE_RENDERER_UDN, it) } |
| 553 | _followedCastId.value?.let { props.setProperty(KEY_FOLLOWED_CAST_ID, it) } |
| 554 | _lastKnownHosts.value.takeIf { it.isNotEmpty() }?.let { hosts -> |
| 555 | props.setProperty( |
| 556 | KEY_LAST_KNOWN_HOSTS, |
| 557 | hosts.entries.joinToString("\n") { (id, h) -> "$id\t${h.host}:${h.port}\t${h.friendlyName}" }, |
| 558 | ) |
| 559 | } |
| 560 | props.setProperty(KEY_DISPLAY_FONT_FAMILY, _displayFontFamily.value) |
| 561 | props.setProperty(KEY_FONT_SCALE_PERCENT, _fontScalePercent.value.toString()) |
| 562 | props.setProperty(KEY_MUSICNOW_STATUS_URL, _musicnowStatusUrl.value) |
| 563 | // Only written once the user departs from the defaults, so a later |
| 564 | // change to the default set still reaches users who never touched it. |
| 565 | _enabledMetadataFields.value |
| 566 | .takeIf { it != dev.stave.domain.model.MetadataField.defaults } |
| 567 | ?.let { set -> |
| 568 | props.setProperty( |
| 569 | KEY_ENABLED_METADATA_FIELDS, |
| 570 | dev.stave.domain.model.MetadataField.entries |
| 571 | .filter { it in set } |
| 572 | .joinToString(";") { it.key }, |
| 573 | ) |
| 574 | } |
| 575 | writeAtomically(props) |
| 576 | }.onFailure { System.err.println("[Earhorn/Settings] save failed: ${it.message}") } |
| 577 | } |
| 578 | |
| 579 | /** Fresh unique temp file + fsync + atomic rename; see [StavePaths.writeAtomically]. */ |
| 580 | private fun writeAtomically(props: Properties) { |
| 581 | StavePaths.writeAtomically(configFile, TMP_PREFIX) { out -> props.store(out, "Earhorn preferences") } |
| 582 | } |
| 583 | |
| 584 | companion object { |
| 585 | private const val TMP_PREFIX = "config.properties." |
| 586 | private const val KEY_PREFERRED_SERVER = "preferredServerUdn" |
| 587 | private const val KEY_RG_MODE = "replayGainMode" |
| 588 | private const val KEY_RG_PREAMP = "replayGainPreAmpDb" |
| 589 | private const val KEY_RANDOM_EXCLUDED_GENRES = "randomExcludedGenres" |
| 590 | private const val KEY_VOLUME_SCROLL_STEP = "volumeScrollStep" |
| 591 | private const val KEY_VOLUME_PERCENT = "volumePercent" |
| 592 | private const val KEY_LYRICS_OFFSET_MS = "lyricsOffsetMs" |
| 593 | private const val KEY_ALLOW_EXTERNAL_CALLS = "allowExternalCalls" |
| 594 | private const val KEY_RENDERER_BROADCAST = "rendererBroadcastEnabled" |
| 595 | private const val KEY_RENDERER_FRIENDLY_NAME = "rendererFriendlyName" |
| 596 | private const val KEY_RENDERER_UDN = "rendererUdn" |
| 597 | private const val KEY_ACTIVE_RENDERER_UDN = "activeRendererUdn" |
| 598 | private const val KEY_FOLLOWED_CAST_ID = "followedCastId" |
| 599 | private const val KEY_LAST_KNOWN_HOSTS = "lastKnownHosts" |
| 600 | /** Cap on [lastKnownHosts], as on Android. */ |
| 601 | const val MAX_LAST_KNOWN_HOSTS = 24 |
| 602 | private const val CAST_PORT = 8009 |
| 603 | private const val KEY_DISPLAY_FONT_FAMILY = "displayFontFamily" |
| 604 | private const val KEY_FONT_SCALE_PERCENT = "fontScalePercent" |
| 605 | private const val KEY_MUSICNOW_STATUS_URL = "musicnowStatusUrl" |
| 606 | private const val KEY_ENABLED_METADATA_FIELDS = "enabledMetadataFields" |
| 607 | /** Off until the user enters their own musicnow address. An address |
| 608 | * already saved in config.properties is loaded as before. */ |
| 609 | const val DEFAULT_MUSICNOW_STATUS_URL = "" |
| 610 | |
| 611 | /** Display-font-family constants. Held here so the UI and the store |
| 612 | * agree on the exact strings without having to share an enum across |
| 613 | * modules. */ |
| 614 | const val FONT_FAMILY_FRAUNCES = "fraunces" |
| 615 | const val FONT_FAMILY_INTER = "inter" |
| 616 | const val DEFAULT_DISPLAY_FONT_FAMILY = FONT_FAMILY_FRAUNCES |
| 617 | |
| 618 | /** Allowed range and default for the font-scale percent. 70..150 gives |
| 619 | * the user enough headroom in both directions without descending into |
| 620 | * illegible or absurdly large territory. */ |
| 621 | const val FONT_SCALE_MIN_PERCENT = 70 |
| 622 | const val FONT_SCALE_MAX_PERCENT = 150 |
| 623 | const val DEFAULT_FONT_SCALE_PERCENT = 100 |
| 624 | } |
| 625 | } |
| 626 | |
| 627 | /** Where a Cast device last attached; see [SettingsStore.lastKnownHosts]. */ |
| 628 | data class LastKnownCastHost(val friendlyName: String, val host: String, val port: Int) |


