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

SettingsStore.kt

package dev.stave.data

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Properties

/** ReplayGain operating mode. */
enum class ReplayGainMode { OFF, TRACK, ALBUM }

/**
 * User preferences persisted to disk.
 *
 * Upstream Android uses Jetpack DataStore, which isn't a jvmCommon dependency. Desktop
 * rolls its own tiny properties-file store at `$XDG_CONFIG_HOME/earhorn/config.properties`
 * (falls back to `~/.config/earhorn/config.properties`).
 *
 * Expands cleanly if we later want more settings — each key gets a [MutableStateFlow]
 * backed by a setter that writes the whole config file.
 *
 * Properties rather than JSON because the JDK ships it, it escapes unicode / special
 * chars automatically, and it reads as a plain `key=value` text file the user can
 * edit or grep when debugging.
 *
 * Writes serialize the whole config per call — file is tiny, atomicity matters more
 * than performance. Writes happen on [Dispatchers.IO] and are serialized by a lock
 * (see [saveToDisk]); reads are cheap in-memory once the store is constructed.
 */
class SettingsStore(
    configDir: File = StavePaths.configDir,
) {
    private val configFile: File = File(configDir, "config.properties").also {
        StavePaths.ensurePrivateDir(configDir)
    }

    // ---- Preferred server UDN ------------------------------------------------

    private val _preferredServerUdn = MutableStateFlow<String?>(null)

    /** UDN of the last server the user selected. Null if they haven't picked one yet
     *  or if they cleared it via Rescan. Auto-select logic in StaveApp reads this
     *  against the live discovery list. */
    val preferredServerUdn: StateFlow<String?> = _preferredServerUdn.asStateFlow()

    // ---- ReplayGain ----------------------------------------------------------

    private val _replayGainMode = MutableStateFlow(ReplayGainMode.OFF)

    /** RG mode. OFF = no volume correction; TRACK = per-track; ALBUM = per-album
     *  (preserves internal album dynamics). */
    val replayGainMode: StateFlow<ReplayGainMode> = _replayGainMode.asStateFlow()

    private val _replayGainPreAmpDb = MutableStateFlow(3f)

    /** Pre-amp in dB, added to every track's RG-corrected volume. Default +3dB —
     *  conventional because RG's -6dBFS target leaves headroom most listeners want
     *  to reclaim. Clamped to [-15, +15] in setter. */
    val replayGainPreAmpDb: StateFlow<Float> = _replayGainPreAmpDb.asStateFlow()

    // ---- Random-picker excluded genres --------------------------------------

    private val _randomExcludedGenres = MutableStateFlow<Set<String>>(emptySet())

    /** Genre names excluded from random-track / random-album picks. Case-insensitive
     *  comparison at filter time. Typical: "Podcast", "Comedy", "Audiobook" — so
     *  party-shuffle doesn't pull spoken-word items. */
    val randomExcludedGenres: StateFlow<Set<String>> = _randomExcludedGenres.asStateFlow()

    // ---- Volume scroll step --------------------------------------------------

    private val _volumeScrollStep = MutableStateFlow(5)

    /** Percentage points adjusted per scroll-wheel notch on volume controls. Applied
     *  both to the transport-bar scroll gesture and the volume widget's scroll and
     *  keyboard nudges. Default 5 → 20 notches across the full range. Valid range
     *  1..100; values are clamped in the setter. */
    val volumeScrollStep: StateFlow<Int> = _volumeScrollStep.asStateFlow()

    // ---- Last volume setting -------------------------------------------------

    private val _volumePercent = MutableStateFlow(100)

    /** Last-set playback volume (0..100). Restored on app startup so users don't
     *  get blasted at full volume every launch. Clamped in setter. */
    val volumePercent: StateFlow<Int> = _volumePercent.asStateFlow()

    // ---- Lyrics timing offset ------------------------------------------------

    private val _lyricsOffsetMs = MutableStateFlow(0)

    /** Milliseconds to shift lyric sync. Positive = lyrics advance LATER (pull
     *  them back). Negative = lyrics advance EARLIER (push them forward). Applied
     *  in [LyricsViewModel.indexFor] as `effectivePosition = actualPosition - offset`.
     *
     *  Useful for compensating:
     *  - Audio buffering (libvlc position runs slightly ahead of what the user
     *    hears; typically 20-80ms on PulseAudio, more on some systems).
     *  - Reading-ahead preference (some users like lyrics highlighted before the
     *    vocal so they can anticipate).
     *  - Systematically mis-timed LRCLIB submissions.
     *
     *  Range -1000..+1000ms; clamped in setter. Default 0 — no adjustment. */
    val lyricsOffsetMs: StateFlow<Int> = _lyricsOffsetMs.asStateFlow()

    // ---- Privacy: external service calls -----------------------------------

    private val _allowExternalCalls = MutableStateFlow(true)

    /** Master privacy switch. When FALSE, Earhorn will not contact any third-party
     *  service (LRCLIB for lyrics, etc.). The lyrics overlay shows a
     *  "disabled in privacy settings" state instead of pinging the network.
     *
     *  Default TRUE — opt-out, matching the Android app's behavior. Users who
     *  prefer a no-external-traffic posture can toggle this off; everything else
     *  in Earhorn (mDNS for cast, SSDP for UPnP, the local renderer, the local
     *  library scan) stays untouched because none of those is "external" — they
     *  all stay on the user's LAN. */
    val allowExternalCalls: StateFlow<Boolean> = _allowExternalCalls.asStateFlow()

    // ---- Renderer broadcast (act as UPnP MediaRenderer on the LAN) ----------

    private val _rendererBroadcastEnabled = MutableStateFlow(false)

    /** When true, this Earhorn instance advertises itself on the LAN as a UPnP
     *  MediaRenderer. Other Earhorn instances (and any UPnP control point —
     *  BubbleUPnP, Kazoo, JRiver Media Center) can then see it in their
     *  renderer picker and direct playback to it.
     *
     *  Off by default. The user opts in per-instance from the Settings screen.
     *  Each Earhorn in the household decides independently whether to be
     *  visible as a renderer — your laptop on the a room counter probably
     *  yes, your work laptop in the office probably no. */
    val rendererBroadcastEnabled: StateFlow<Boolean> = _rendererBroadcastEnabled.asStateFlow()

    private val _rendererFriendlyName = MutableStateFlow<String?>(null)

    /** Display name shown in other devices' renderer pickers. Null means
     *  "compute a default at start time" (typically "Earhorn on <hostname>")
     *  — set explicitly when the user wants a hand-chosen label like
     *  "Living Room Workstation". Empty / blank strings normalize to null. */
    val rendererFriendlyName: StateFlow<String?> = _rendererFriendlyName.asStateFlow()

    private val _rendererUdn = MutableStateFlow<String?>(null)

    /** UPnP UDN (Unique Device Name) for THIS instance's broadcast renderer.
     *  Generated as a random uuid: URI on first enable and persisted, so
     *  remote control points (other Earhorn instances, BubbleUPnP, etc)
     *  can keep their stored bindings across our app restarts.
     *
     *  Null until the first time the user enables broadcast. The renderer
     *  service generates and writes one then. */
    val rendererUdn: StateFlow<String?> = _rendererUdn.asStateFlow()

    // ---- Active renderer selection ------------------------------------------

    private val _activeRendererUdn = MutableStateFlow<String?>(null)

    /** UDN of the renderer this Earhorn is currently driving. Null means
     *  "use the local renderer" (libvlc through the machine's own speakers).
     *  Non-null means a remote UPnP MediaRenderer on the LAN — another Earhorn,
     *  BubbleUPnP receiver, Linn streamer, whatever the user picked — or a Cast
     *  device via the `cast:` prefix.
     *
     *  Observed by [dev.stave.upnp.renderer.RendererCoordinator] which attaches
     *  / detaches remote subscriptions and routes Renderer interface calls to
     *  the right delegate as this value changes.
     *
     *  Persistence semantics differ by renderer kind:
     *   - UPnP UDNs survive restart. At startup the picked renderer may not be
     *     discovered yet, so the coordinator also watches the discovery list
     *     and switches when the renderer appears.
     *   - `cast:` (sender-mode) selections are SESSION-ONLY. [loadFromDisk]
     *     deliberately drops them, so a launch never auto-attaches the sender
     *     stack to a Cast device. Attaching grabs the group's transport and
     *     volume the moment discovery finds it — on a device something else is
     *     mid-stream on, that's a hijack the user never asked for. Cast attach
     *     therefore only ever happens from an explicit in-session pick. (The
     *     passive counterpart, Follow, DOES resume across launches — see
     *     [followedCastId].) */
    val activeRendererUdn: StateFlow<String?> = _activeRendererUdn.asStateFlow()

    // ---- Followed cast device (observer mode) ---------------------------------

    private val _followedCastId = MutableStateFlow<String?>(null)

    /** Device id (the mDNS service name, i.e. `CastDevice.id`) of the Cast
     *  device the user is following in observer mode, or null when not
     *  following. Written by `RawCastObserver.observe()` on every follow,
     *  cleared by its `disconnect()` — which is only ever user-initiated
     *  ("stop following" / eyeball toggle) — and consumed by the observer's
     *  startup watcher to resume the follow once the device is discovered
     *  again.
     *
     *  Unlike `cast:` sender selections (see [activeRendererUdn]), a follow
     *  IS restored across restarts: Follow is a passive overlay that reads
     *  the group's session without touching its transport, so resuming it at
     *  launch can't hijack anything. */
    val followedCastId: StateFlow<String?> = _followedCastId.asStateFlow()

    // ---- Last-known Cast hosts (follow rescue) ----------------------------------

    private val _lastKnownHosts = MutableStateFlow<Map<String, LastKnownCastHost>>(emptyMap())

    /** Device id (`CastDevice.id`) -> where it last attached, for each Cast
     *  device a follow succeeded on (Android's `last_known_group_hosts`). The
     *  startup follow restore dials it when discovery hasn't found the device:
     *  a freshly rebooted router can drop multicast for minutes, exactly when a
     *  cold start needs mDNS. Persisted as `lastKnownHosts=` with one
     *  `id<TAB>host:port<TAB>name` line per device; most recent last, at most
     *  [MAX_LAST_KNOWN_HOSTS]. */
    val lastKnownHosts: StateFlow<Map<String, LastKnownCastHost>> = _lastKnownHosts.asStateFlow()

    // ---- Typography preferences ---------------------------------------------

    private val _displayFontFamily = MutableStateFlow(DEFAULT_DISPLAY_FONT_FAMILY)

    /**
     * Which font family drives display/headline/title text styles.
     *   - "fraunces" (default) — the Fraunces serif for those roles; Inter
     *     continues to handle body/label. The "mixed" look the app shipped with.
     *   - "inter" — Inter for every role, no Fraunces anywhere. The escape hatch
     *     for readers who don't enjoy a high-contrast serif on screen.
     *
     * Stored as a string rather than an enum to keep [SettingsStore] purely
     * commonMain; the UI's enum mapping lives next to the picker that writes it.
     */
    val displayFontFamily: StateFlow<String> = _displayFontFamily.asStateFlow()

    /** musicnow's /status address; the desktop reads a station's song from it
     *  (design decision, 2026-09-04). Blank turns the overlay off. */
    private val _musicnowStatusUrl = MutableStateFlow(DEFAULT_MUSICNOW_STATUS_URL)
    val musicnowStatusUrl: StateFlow<String> = _musicnowStatusUrl.asStateFlow()

    private val _fontScalePercent = MutableStateFlow(DEFAULT_FONT_SCALE_PERCENT)

    /**
     * Global typography multiplier expressed as a whole-number percent. 100
     * means typography renders at its declared sizes; 120 means 1.2× everything.
     *
     * Range expected 70..150 (clamped on write). Users adjust this from
     * Settings → Appearance → Font size with +/- buttons.
     */
    val fontScalePercent: StateFlow<Int> = _fontScalePercent.asStateFlow()

    // ---- Metadata fields shown on Now Playing -------------------------------

    private val _enabledMetadataFields = MutableStateFlow(dev.stave.domain.model.MetadataField.defaults)

    /** Which [dev.stave.domain.model.MetadataField]s the Now Playing metadata
     *  block shows (Android's `enabled_metadata_fields`). Persisted as
     *  `enabledMetadataFields=key1;key2`. A missing key means
     *  [dev.stave.domain.model.MetadataField.defaults]; a present but empty
     *  value means "show none", so the user can switch everything off.
     *  Unknown keys in the file are ignored. */
    val enabledMetadataFields: StateFlow<Set<dev.stave.domain.model.MetadataField>> =
        _enabledMetadataFields.asStateFlow()

    init {
        loadFromDisk()
    }

    // ---- Setters -------------------------------------------------------------

    suspend fun setPreferredServer(udn: String?): Unit = withContext(Dispatchers.IO) {
        _preferredServerUdn.value = udn?.takeIf { it.isNotBlank() }
        saveToDisk()
    }

    suspend fun setReplayGainMode(mode: ReplayGainMode): Unit = withContext(Dispatchers.IO) {
        _replayGainMode.value = mode
        saveToDisk()
    }

    suspend fun setReplayGainPreAmpDb(db: Float): Unit = withContext(Dispatchers.IO) {
        _replayGainPreAmpDb.value = db.coerceIn(-15f, 15f)
        saveToDisk()
    }

    suspend fun addExcludedGenre(genre: String): Unit = withContext(Dispatchers.IO) {
        val trimmed = genre.trim()
        if (trimmed.isEmpty()) return@withContext
        _randomExcludedGenres.value = _randomExcludedGenres.value + trimmed
        saveToDisk()
    }

    suspend fun removeExcludedGenre(genre: String): Unit = withContext(Dispatchers.IO) {
        _randomExcludedGenres.value = _randomExcludedGenres.value - genre
        saveToDisk()
    }

    suspend fun setVolumeScrollStep(step: Int): Unit = withContext(Dispatchers.IO) {
        _volumeScrollStep.value = step.coerceIn(1, 100)
        saveToDisk()
    }

    suspend fun setVolumePercent(percent: Int): Unit = withContext(Dispatchers.IO) {
        _volumePercent.value = percent.coerceIn(0, 100)
        saveToDisk()
    }

    suspend fun setLyricsOffsetMs(ms: Int): Unit = withContext(Dispatchers.IO) {
        _lyricsOffsetMs.value = ms.coerceIn(-1000, 1000)
        saveToDisk()
    }

    suspend fun setAllowExternalCalls(enabled: Boolean): Unit = withContext(Dispatchers.IO) {
        _allowExternalCalls.value = enabled
        saveToDisk()
    }

    suspend fun setRendererBroadcastEnabled(enabled: Boolean): Unit = withContext(Dispatchers.IO) {
        _rendererBroadcastEnabled.value = enabled
        saveToDisk()
    }

    suspend fun setRendererFriendlyName(name: String?): Unit = withContext(Dispatchers.IO) {
        _rendererFriendlyName.value = name?.trim()?.takeIf { it.isNotBlank() }
        saveToDisk()
    }

    /** Sets the persisted UDN. Used once by EarhornRendererService the first
     *  time the user enables broadcast, then never again. Idempotent: passing
     *  the same value or null when one is already set is a no-op. */
    suspend fun setRendererUdn(udn: String): Unit = withContext(Dispatchers.IO) {
        val trimmed = udn.trim()
        if (trimmed.isEmpty() || _rendererUdn.value == trimmed) return@withContext
        _rendererUdn.value = trimmed
        saveToDisk()
    }

    suspend fun setActiveRendererUdn(udn: String?): Unit = withContext(Dispatchers.IO) {
        _activeRendererUdn.value = udn?.trim()?.takeIf { it.isNotBlank() }
        saveToDisk()
    }

    suspend fun setFollowedCastId(id: String?): Unit = withContext(Dispatchers.IO) {
        _followedCastId.value = id?.trim()?.takeIf { it.isNotBlank() }
        saveToDisk()
    }

    /** Remember where Cast device [id] just attached. Android's writer, after its
     *  2026-09-02 fix: a real template per entry (the old one wrote the template
     *  text itself for every entry, so no host was ever stored). Unlike Android, the
     *  entry moves to the end and the OLDEST ones drop past the cap — Android's
     *  `take(24)` kept the oldest and never stored a 25th device. No write when
     *  nothing changed (every attach calls this). */
    suspend fun setLastKnownHost(id: String, friendlyName: String, host: String, port: Int): Unit =
        withContext(Dispatchers.IO) {
            val key = id.trim()
            if (key.isEmpty() || host.isBlank() || '\t' in key || '\n' in key) return@withContext
            val entry = LastKnownCastHost(
                friendlyName = friendlyName.replace('\t', ' ').replace('\n', ' '),
                host = host.trim(),
                port = port,
            )
            var changed = false
            _lastKnownHosts.update { cur ->
                if (cur.keys.lastOrNull() == key && cur[key] == entry) return@update cur
                changed = true
                val next = LinkedHashMap(cur)
                next.remove(key)
                next[key] = entry
                next.entries.toList().takeLast(MAX_LAST_KNOWN_HOSTS).associate { it.key to it.value }
            }
            if (changed) saveToDisk()
        }

    /** Set the display font family. Any value other than "inter" is treated as
     *  the default "fraunces" so a malformed persisted value still produces a
     *  legible UI. */
    suspend fun setDisplayFontFamily(family: String): Unit = withContext(Dispatchers.IO) {
        _displayFontFamily.value = if (family == FONT_FAMILY_INTER) FONT_FAMILY_INTER
            else DEFAULT_DISPLAY_FONT_FAMILY
        saveToDisk()
    }

    /** Set the global typography multiplier as whole-number percent. Clamped
     *  to [FONT_SCALE_MIN_PERCENT]..[FONT_SCALE_MAX_PERCENT]. */
    suspend fun setMusicnowStatusUrl(url: String): Unit = withContext(Dispatchers.IO) {
        _musicnowStatusUrl.value = url.trim()
        saveToDisk()
    }

    suspend fun setFontScalePercent(percent: Int): Unit = withContext(Dispatchers.IO) {
        _fontScalePercent.value = percent.coerceIn(FONT_SCALE_MIN_PERCENT, FONT_SCALE_MAX_PERCENT)
        saveToDisk()
    }

    suspend fun setMetadataFieldEnabled(
        field: dev.stave.domain.model.MetadataField,
        enabled: Boolean,
    ): Unit = withContext(Dispatchers.IO) {
        // Atomic read-modify-write: two quick checkbox clicks run as two IO
        // coroutines, and a plain get-then-set could drop one of them.
        _enabledMetadataFields.update { if (enabled) it + field else it - field }
        saveToDisk()
    }

    suspend fun setEnabledMetadataFields(
        fields: Set<dev.stave.domain.model.MetadataField>,
    ): Unit = withContext(Dispatchers.IO) {
        _enabledMetadataFields.value = fields
        saveToDisk()
    }

    // ---- Persistence ---------------------------------------------------------

    private fun loadFromDisk() {
        // A crash mid-save can leave a temp file behind; it is never read, only removed.
        configFile.parentFile?.let { StavePaths.deleteStaleTemps(it, TMP_PREFIX) }
        if (!configFile.exists()) return
        StavePaths.restrictToOwner(configFile)
        runCatching {
            val props = Properties()
            configFile.inputStream().use { props.load(it) }

            _preferredServerUdn.value = props.getProperty(KEY_PREFERRED_SERVER)
                ?.takeIf { it.isNotBlank() }

            props.getProperty(KEY_RG_MODE)?.toIntOrNull()?.let { ordinal ->
                _replayGainMode.value = ReplayGainMode.entries.getOrElse(ordinal) { ReplayGainMode.OFF }
            }

            props.getProperty(KEY_RG_PREAMP)?.toFloatOrNull()?.let { db ->
                _replayGainPreAmpDb.value = db.coerceIn(-15f, 15f)
            }

            props.getProperty(KEY_RANDOM_EXCLUDED_GENRES)?.let { packed ->
                // Semicolon-delimited set, since commas appear in genre tags.
                _randomExcludedGenres.value = packed.split(';')
                    .map { it.trim() }
                    .filter { it.isNotEmpty() }
                    .toSet()
            }

            props.getProperty(KEY_VOLUME_SCROLL_STEP)?.toIntOrNull()?.let { step ->
                _volumeScrollStep.value = step.coerceIn(1, 100)
            }

            props.getProperty(KEY_VOLUME_PERCENT)?.toIntOrNull()?.let { pct ->
                _volumePercent.value = pct.coerceIn(0, 100)
            }

            props.getProperty(KEY_LYRICS_OFFSET_MS)?.toIntOrNull()?.let { ms ->
                _lyricsOffsetMs.value = ms.coerceIn(-1000, 1000)
            }

            props.getProperty(KEY_ALLOW_EXTERNAL_CALLS)?.let { v ->
                _allowExternalCalls.value = v.equals("true", ignoreCase = true)
            }

            props.getProperty(KEY_RENDERER_BROADCAST)?.let { v ->
                _rendererBroadcastEnabled.value = v.equals("true", ignoreCase = true)
            }

            props.getProperty(KEY_RENDERER_FRIENDLY_NAME)
                ?.trim()
                ?.takeIf { it.isNotBlank() }
                ?.let { _rendererFriendlyName.value = it }

            props.getProperty(KEY_RENDERER_UDN)
                ?.trim()
                ?.takeIf { it.isNotBlank() }
                ?.let { _rendererUdn.value = it }

            props.getProperty(KEY_ACTIVE_RENDERER_UDN)
                ?.trim()
                ?.takeIf { it.isNotBlank() }
                // Cast sender selections are session-only: a persisted
                // `cast:<id>` is deliberately NOT restored, so a launch never
                // auto-attaches the sender stack to a Cast device (see the
                // [activeRendererUdn] KDoc for the why). The stale line stays
                // in the file until the next saveToDisk() omits it — harmless,
                // since every load drops it right here. setActiveRendererUdn
                // still persists `cast:` values normally during a session;
                // only the *restore* is suppressed.
                ?.takeIf {
                    !it.startsWith(dev.stave.upnp.renderer.RendererCoordinator.CAST_UDN_PREFIX)
                }
                ?.let { _activeRendererUdn.value = it }

            props.getProperty(KEY_FOLLOWED_CAST_ID)
                ?.trim()
                ?.takeIf { it.isNotBlank() }
                ?.let { _followedCastId.value = it }

            props.getProperty(KEY_LAST_KNOWN_HOSTS)?.let { packed ->
                _lastKnownHosts.value = packed.split('\n')
                    .mapNotNull { line ->
                        val parts = line.split('\t', limit = 3)
                        if (parts.size < 2 || parts[0].isBlank()) return@mapNotNull null
                        val hostPort = parts[1].trim()
                        val host = hostPort.substringBeforeLast(':').takeIf { it.isNotBlank() }
                            ?: return@mapNotNull null
                        val port = hostPort.substringAfterLast(':', "").toIntOrNull() ?: CAST_PORT
                        parts[0] to LastKnownCastHost(parts.getOrNull(2).orEmpty(), host, port)
                    }
                    .takeLast(MAX_LAST_KNOWN_HOSTS)
                    .toMap()
            }

            props.getProperty(KEY_DISPLAY_FONT_FAMILY)
                ?.trim()
                ?.takeIf { it.isNotBlank() }
                ?.let {
                    _displayFontFamily.value = if (it == FONT_FAMILY_INTER) FONT_FAMILY_INTER
                        else DEFAULT_DISPLAY_FONT_FAMILY
                }

            props.getProperty(KEY_MUSICNOW_STATUS_URL)?.let { _musicnowStatusUrl.value = it.trim() }
            props.getProperty(KEY_FONT_SCALE_PERCENT)?.toIntOrNull()?.let { pct ->
                _fontScalePercent.value = pct.coerceIn(FONT_SCALE_MIN_PERCENT, FONT_SCALE_MAX_PERCENT)
            }
            // Absent → keep defaults; present-but-empty → no fields.
            props.getProperty(KEY_ENABLED_METADATA_FIELDS)?.let { packed ->
                _enabledMetadataFields.value = packed.split(';')
                    .map { it.trim() }
                    .filter { it.isNotEmpty() }
                    .mapNotNull { dev.stave.domain.model.MetadataField.fromKey(it) }
                    .toSet()
            }
        }
    }

    /** Serializes [saveToDisk]: the snapshot below and the file replace happen under one
     *  lock, so the last save to run always carries every value set before it, and two
     *  setters can never interleave writes or renames (volume drags save dozens of times
     *  a second while a follow / renderer pick may be saving too). */
    private val saveLock = Any()

    private fun saveToDisk(): Unit = synchronized(saveLock) {
        runCatching {
            val props = Properties()
            _preferredServerUdn.value?.let { props.setProperty(KEY_PREFERRED_SERVER, it) }
            props.setProperty(KEY_RG_MODE, _replayGainMode.value.ordinal.toString())
            props.setProperty(KEY_RG_PREAMP, _replayGainPreAmpDb.value.toString())
            val excluded = _randomExcludedGenres.value
            if (excluded.isNotEmpty()) {
                props.setProperty(KEY_RANDOM_EXCLUDED_GENRES, excluded.joinToString(";"))
            }
            props.setProperty(KEY_VOLUME_SCROLL_STEP, _volumeScrollStep.value.toString())
            props.setProperty(KEY_VOLUME_PERCENT, _volumePercent.value.toString())
            props.setProperty(KEY_LYRICS_OFFSET_MS, _lyricsOffsetMs.value.toString())
            props.setProperty(KEY_ALLOW_EXTERNAL_CALLS, _allowExternalCalls.value.toString())
            props.setProperty(KEY_RENDERER_BROADCAST, _rendererBroadcastEnabled.value.toString())
            _rendererFriendlyName.value?.let { props.setProperty(KEY_RENDERER_FRIENDLY_NAME, it) }
            _rendererUdn.value?.let { props.setProperty(KEY_RENDERER_UDN, it) }
            _activeRendererUdn.value?.let { props.setProperty(KEY_ACTIVE_RENDERER_UDN, it) }
            _followedCastId.value?.let { props.setProperty(KEY_FOLLOWED_CAST_ID, it) }
            _lastKnownHosts.value.takeIf { it.isNotEmpty() }?.let { hosts ->
                props.setProperty(
                    KEY_LAST_KNOWN_HOSTS,
                    hosts.entries.joinToString("\n") { (id, h) -> "$id\t${h.host}:${h.port}\t${h.friendlyName}" },
                )
            }
            props.setProperty(KEY_DISPLAY_FONT_FAMILY, _displayFontFamily.value)
            props.setProperty(KEY_FONT_SCALE_PERCENT, _fontScalePercent.value.toString())
            props.setProperty(KEY_MUSICNOW_STATUS_URL, _musicnowStatusUrl.value)
            // Only written once the user departs from the defaults, so a later
            // change to the default set still reaches users who never touched it.
            _enabledMetadataFields.value
                .takeIf { it != dev.stave.domain.model.MetadataField.defaults }
                ?.let { set ->
                    props.setProperty(
                        KEY_ENABLED_METADATA_FIELDS,
                        dev.stave.domain.model.MetadataField.entries
                            .filter { it in set }
                            .joinToString(";") { it.key },
                    )
                }
            writeAtomically(props)
        }.onFailure { System.err.println("[Earhorn/Settings] save failed: ${it.message}") }
    }

    /** Fresh unique temp file + fsync + atomic rename; see [StavePaths.writeAtomically]. */
    private fun writeAtomically(props: Properties) {
        StavePaths.writeAtomically(configFile, TMP_PREFIX) { out -> props.store(out, "Earhorn preferences") }
    }

    companion object {
        private const val TMP_PREFIX = "config.properties."
        private const val KEY_PREFERRED_SERVER = "preferredServerUdn"
        private const val KEY_RG_MODE = "replayGainMode"
        private const val KEY_RG_PREAMP = "replayGainPreAmpDb"
        private const val KEY_RANDOM_EXCLUDED_GENRES = "randomExcludedGenres"
        private const val KEY_VOLUME_SCROLL_STEP = "volumeScrollStep"
        private const val KEY_VOLUME_PERCENT = "volumePercent"
        private const val KEY_LYRICS_OFFSET_MS = "lyricsOffsetMs"
        private const val KEY_ALLOW_EXTERNAL_CALLS = "allowExternalCalls"
        private const val KEY_RENDERER_BROADCAST = "rendererBroadcastEnabled"
        private const val KEY_RENDERER_FRIENDLY_NAME = "rendererFriendlyName"
        private const val KEY_RENDERER_UDN = "rendererUdn"
        private const val KEY_ACTIVE_RENDERER_UDN = "activeRendererUdn"
        private const val KEY_FOLLOWED_CAST_ID = "followedCastId"
        private const val KEY_LAST_KNOWN_HOSTS = "lastKnownHosts"
        /** Cap on [lastKnownHosts], as on Android. */
        const val MAX_LAST_KNOWN_HOSTS = 24
        private const val CAST_PORT = 8009
        private const val KEY_DISPLAY_FONT_FAMILY = "displayFontFamily"
        private const val KEY_FONT_SCALE_PERCENT = "fontScalePercent"
        private const val KEY_MUSICNOW_STATUS_URL = "musicnowStatusUrl"
        private const val KEY_ENABLED_METADATA_FIELDS = "enabledMetadataFields"
        /** Off until the user enters their own musicnow address. An address
         *  already saved in config.properties is loaded as before. */
        const val DEFAULT_MUSICNOW_STATUS_URL = ""

        /** Display-font-family constants. Held here so the UI and the store
         *  agree on the exact strings without having to share an enum across
         *  modules. */
        const val FONT_FAMILY_FRAUNCES = "fraunces"
        const val FONT_FAMILY_INTER = "inter"
        const val DEFAULT_DISPLAY_FONT_FAMILY = FONT_FAMILY_FRAUNCES

        /** Allowed range and default for the font-scale percent. 70..150 gives
         *  the user enough headroom in both directions without descending into
         *  illegible or absurdly large territory. */
        const val FONT_SCALE_MIN_PERCENT = 70
        const val FONT_SCALE_MAX_PERCENT = 150
        const val DEFAULT_FONT_SCALE_PERCENT = 100
    }
}

/** Where a Cast device last attached; see [SettingsStore.lastKnownHosts]. */
data class LastKnownCastHost(val friendlyName: String, val host: String, val port: Int)