SettingsStore.kt
Each line, with the release that last changed it.
| 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) |


