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)