EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

SettingsStore.kt

628 lines · 30.8 KB

Changed in: 0.5.0

1package dev.stave.data
2
3import kotlinx.coroutines.Dispatchers
4import kotlinx.coroutines.flow.MutableStateFlow
5import kotlinx.coroutines.flow.StateFlow
6import kotlinx.coroutines.flow.asStateFlow
7import kotlinx.coroutines.flow.update
8import kotlinx.coroutines.withContext
9import java.io.File
10import java.util.Properties
11
12/** ReplayGain operating mode. */
13enum 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 */
33class 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]. */
628data class LastKnownCastHost(val friendlyName: String, val host: String, val port: Int)