package dev.stave.data
import android.content.Context
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.stave.domain.model.MetadataField
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/** Replay Gain operating mode. */
enum class ReplayGainMode { OFF, TRACK, ALBUM }
/** Preset color choices for the current-lyric highlight. AMBER matches the Earhorn
* accent; WHITE is high-contrast/no-tint; SAGE is the secondary accent for folks
* who want a less-warm look. Adding more is cheap — enum + a one-line entry in the
* settings radio row. */
enum class LyricsHighlightColor { AMBER, WHITE, SAGE }
/** Legacy preset — retained so existing settings blobs still deserialize. New code
* should read [SettingsStore.lyricsHighlightSize] / Bold / Color directly. */
@Deprecated("Use the three independent settings (size, bold, color) instead.")
enum class LyricsHighlightStyle { SIZE_ONLY, COLOR_ONLY, SIZE_AND_COLOR, BOLD }
// Maintainer, 2026-10-29: a corrupt settings file crashed the app at launch;
// replace it with empty (defaults) instead.
private val Context.dataStore by preferencesDataStore(
name = "stave_settings",
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
)
@Singleton
class SettingsStore @Inject constructor(
@ApplicationContext private val context: Context,
) {
val enabledFields: Flow<Set<MetadataField>> = context.dataStore.data.map { prefs ->
val stored = prefs[KEY_FIELDS]
if (stored == null) MetadataField.defaults
else stored.mapNotNull(MetadataField::fromKey).toSet()
}
/** UDN of the last server the user selected. Empty string = none chosen. Auto-navigate
* logic in EarhornNavHost reads this against the live discovery list. Cleared when the
* user presses Rescan. */
val preferredServerUdn: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_PREFERRED_SERVER]?.takeIf { it.isNotBlank() }
}
/** Whether long titles/artists should marquee-scroll when ellipsized. Default off —
* marquee is stylish on the Now Playing screen but visually noisy in dense list
* views, so the user opts in. */
val marqueeEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_MARQUEE] ?: false
}
/** Whether Fraunces (serif) renders app-wide. Default true — Fraunces carries
* the brand identity and is the design intent. Disabling falls through to Inter
* (sans-serif) for everything that would otherwise render in Fraunces; users who
* find serif type harder to read can opt out without losing the rest of the
* type-system structure (sizes, weights, letter-spacing all stay the same). */
val useFraunces: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_USE_FRAUNCES] ?: true
}
/** ReplayGain mode. OFF = no volume correction; TRACK = per-track normalization;
* ALBUM = per-album normalization (preserves the album's internal dynamics). */
val replayGainMode: Flow<ReplayGainMode> = context.dataStore.data.map { prefs ->
val ordinal = prefs[KEY_RG_MODE] ?: ReplayGainMode.OFF.ordinal
ReplayGainMode.entries.getOrElse(ordinal) { ReplayGainMode.OFF }
}
/** ReplayGain pre-amp in dB. Added to every track's RG-corrected volume. Default +3dB
* — conventional because RG's -6dBFS target leaves headroom that most listeners want
* to reclaim. Range is clamped to [-15, +15] in setter. */
val replayGainPreAmpDb: Flow<Float> = context.dataStore.data.map { prefs ->
prefs[KEY_RG_PREAMP] ?: 3f
}
/** Genre names to exclude from random-track and random-album picks. Case-insensitive
* comparison at filter time. Users typically add "Podcast", "Comedy", "Audiobook"
* here so their party-shuffle doesn't pull spoken-word items. */
val randomExcludedGenres: Flow<Set<String>> = context.dataStore.data.map { prefs ->
prefs[KEY_RANDOM_EXCLUDED_GENRES] ?: emptySet()
}
/** Size multiplier applied to the current lyric line. 1.0 = same as others;
* 1.5 = 50% larger; 2.0 = double. Clamped [1.0, 2.5] by the setter. Default 1.5
* matches the prior SIZE_AND_COLOR preset's visual feel. */
val lyricsHighlightSize: Flow<Float> = context.dataStore.data.map { prefs ->
prefs[KEY_LYRICS_HIGHLIGHT_SIZE] ?: 1.5f
}
/** Whether the current lyric line renders bold. */
val lyricsHighlightBold: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_LYRICS_HIGHLIGHT_BOLD] ?: false
}
/** Accent color for the current lyric line. */
val lyricsHighlightColor: Flow<LyricsHighlightColor> = context.dataStore.data.map { prefs ->
val ordinal = prefs[KEY_LYRICS_HIGHLIGHT_COLOR] ?: LyricsHighlightColor.AMBER.ordinal
LyricsHighlightColor.entries.getOrElse(ordinal) { LyricsHighlightColor.AMBER }
}
/** Whether the lyrics screen shows a metadata chip grid (artist, album, format
* etc.) in the upper-right corner. Default on; the user can toggle it off if
* they prefer a clean reading view. */
val lyricsMetadataEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_LYRICS_METADATA] ?: true
}
/** Master privacy switch. When FALSE, Earhorn will not contact any third-party service
* (lyrics lookup etc.). The lyrics screen shows a "disabled in privacy settings"
* state instead of pinging LRCLIB. Default TRUE — opt-out, matching typical app
* defaults; users who care about it can toggle off. */
val allowExternalCalls: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_ALLOW_EXTERNAL_CALLS] ?: true
}
// ── Renderer mesh ────────────────────────────────────────────────
// Settings backing the UPnP MediaRenderer broadcast feature and the
// user's currently-selected playback target. See [EarhornRendererService]
// for the broadcast side, [RendererCoordinator] for the routing side.
/** Master switch for advertising this device as a UPnP MediaRenderer on the LAN.
* Default FALSE — opt-in, since broadcasting holds the multicast lock for
* longer and answers SSDP M-SEARCH probes from anything on the network.
* When TRUE, [EarhornRendererService] generates a UDN on first enable
* (stored in [rendererUdn]) and registers a LocalDevice via the holder. */
val rendererBroadcastEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_RENDERER_BROADCAST_ENABLED] ?: false
}
/** User-supplied friendly name for the broadcast renderer. Null = autocompute
* ("Earhorn on $deviceName"). Empty strings normalize to null on write so the
* picker downstream doesn't have to special-case blank labels. */
val rendererFriendlyName: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_RENDERER_FRIENDLY_NAME]?.takeIf { it.isNotBlank() }
}
/** Stable UDN this device advertises when broadcasting. Generated once on first
* broadcast-enable ([EarhornRendererService] writes it via [setRendererUdn]) and
* never changed afterward — re-using the same UDN across sessions lets other
* control points re-attach to the renderer they remember. Format is
* "uuid:" + standard UUID, matching jUPnP's UDN.identifierString. */
val rendererUdn: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_RENDERER_UDN]?.takeIf { it.isNotBlank() }
}
/** UDN of the renderer the user wants to drive. Null = this device's local
* playback (Media3 / Cast as the existing flow decides). Non-null = a remote
* UPnP MediaRenderer discovered via [UpnpServiceHolder.renderers]. The
* RendererCoordinator combines this with the discovery list and switches the
* active backend whenever either side changes. */
val leftyMode: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_LEFTY_MODE] ?: false
}
val activeRendererUdn: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_ACTIVE_RENDERER_UDN]?.takeIf { it.isNotBlank() }
}
/** Serialized descriptor of the Cast device the user was last OBSERVING
* (raw-cast follow mode), or null when not observing / after an explicit
* disconnect. Written by RawCastObserver on successful connect, cleared
* on user-initiated disconnect. The value is an opaque string owned by
* RawCastObserver (it serializes/parses its own CastDevice) — the store
* deliberately doesn't know the format, to avoid a data→cast dependency.
*
* Purpose: survive process death. Observing on a phone dies with the
* process (the TLS socket can't outlive it); persisting the target lets
* the PlaybackService auto-reattach on next launch if the CCA session
* is still active, instead of silently dumping the user into local mode
* with a stale queue on screen. */
val observedCastDevice: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_OBSERVED_CAST_DEVICE]?.takeIf { it.isNotBlank() }
}
suspend fun setObservedCastDevice(serialized: String?) {
context.dataStore.edit { prefs ->
if (serialized.isNullOrBlank()) prefs.remove(KEY_OBSERVED_CAST_DEVICE)
else prefs[KEY_OBSERVED_CAST_DEVICE] = serialized
}
}
/** Kiosk hand-back target: package name of the app to bring forward when
* an auto-observed CCA session genuinely ends (voice-cast pipeline —
* FaveatHaus on other screens). Null = disarmed, which is the state
* on every normal install: it is ONLY ever set by an OBSERVE launch that
* carries a `returnTo` extra, and it is cleared the moment a hand-back
* fires, so a user who opens Earhorn by hand is never yanked away.
* Persisted (not in-memory) so process death mid-observation doesn't
* strand a kiosk screen in Earhorn after the music ends. */
val kioskReturnPackage: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_KIOSK_RETURN_PACKAGE]?.takeIf { it.isNotBlank() }
}
/** group name -> "host:port" of the last successful attach. Cold
* starts use it when mDNS is dead (fresh-rebooted routers often drop
* multicast for minutes — 2026-09-01, the one Android 14 screen). */
val lastKnownHosts: Flow<Map<String, String>> = context.dataStore.data.map { prefs ->
(prefs[KEY_LAST_HOSTS] ?: "").split('\n').filter { it.contains('\t') }
.associate { line -> line.substringBefore('\t') to line.substringAfter('\t') }
}
suspend fun setLastKnownHost(group: String, hostPort: String) {
context.dataStore.edit { prefs ->
val cur = (prefs[KEY_LAST_HOSTS] ?: "").split('\n')
.filter { it.contains('\t') }
.associate { it.substringBefore('\t') to it.substringAfter('\t') }
.toMutableMap()
cur[group] = hostPort
// Audit 2026-09-02: this wrote the LITERAL text "${it.key}\t${it.value}"
// for every entry (a ${'$'} escape that was never a template), so the
// map on disk never held a real host and the cold-start rescue in
// 0.4.15 never dialled anything. Now a real template.
prefs[KEY_LAST_HOSTS] = cur.entries.take(24)
.joinToString("\n") { "${it.key}\t${it.value}" }
}
}
/** Whether OBSERVE events flip this device to Now Playing and arm the
* kiosk hand-back. ON for other screens (the pipeline's purpose); OFF
* turns this install into a manual instrument — OBSERVE is ignored
* entirely, so browsing is never hijacked (decision, 2026-08-30:
* mandatory on phones). Per-device. */
val kioskFlipEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_KIOSK_FLIP_ENABLED] ?: true
}
suspend fun setKioskFlipEnabled(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[KEY_KIOSK_FLIP_ENABLED] = enabled }
}
/** URL of the Dom-Etha PWA (house CCA control panel). Null = feature
* off. Not hardcoded — other installs point it wherever theirs lives. */
val domEthaUrl: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_DOM_ETHA_URL]?.takeIf { it.isNotBlank() }
}
suspend fun setDomEthaUrl(url: String?) {
context.dataStore.edit { prefs ->
if (url.isNullOrBlank()) prefs.remove(KEY_DOM_ETHA_URL)
else prefs[KEY_DOM_ETHA_URL] = url.trim()
}
}
/** Which button occupies the Now Playing corner slot: false = sleep
* timer (default), true = Dom-Etha panel (only honored when a URL is
* set — a Dom-Etha choice with no URL falls back to the timer). */
val cornerButtonDomEtha: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_CORNER_BUTTON_DOM_ETHA] ?: false
}
suspend fun setCornerButtonDomEtha(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[KEY_CORNER_BUTTON_DOM_ETHA] = enabled }
}
suspend fun setKioskReturnPackage(pkg: String?) {
context.dataStore.edit { prefs ->
if (pkg.isNullOrBlank()) prefs.remove(KEY_KIOSK_RETURN_PACKAGE)
else prefs[KEY_KIOSK_RETURN_PACKAGE] = pkg
}
}
suspend fun setPreferredServer(udn: String?) {
context.dataStore.edit { prefs ->
if (udn.isNullOrBlank()) prefs.remove(KEY_PREFERRED_SERVER)
else prefs[KEY_PREFERRED_SERVER] = udn
}
}
suspend fun setMarqueeEnabled(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[KEY_MARQUEE] = enabled }
}
suspend fun setUseFraunces(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[KEY_USE_FRAUNCES] = enabled }
}
suspend fun setLyricsHighlightSize(multiplier: Float) {
context.dataStore.edit { prefs ->
prefs[KEY_LYRICS_HIGHLIGHT_SIZE] = multiplier.coerceIn(1.0f, 2.5f)
}
}
suspend fun setLyricsHighlightBold(bold: Boolean) {
context.dataStore.edit { prefs -> prefs[KEY_LYRICS_HIGHLIGHT_BOLD] = bold }
}
suspend fun setLyricsHighlightColor(color: LyricsHighlightColor) {
context.dataStore.edit { prefs -> prefs[KEY_LYRICS_HIGHLIGHT_COLOR] = color.ordinal }
}
suspend fun setAllowExternalCalls(allow: Boolean) {
context.dataStore.edit { prefs -> prefs[KEY_ALLOW_EXTERNAL_CALLS] = allow }
}
suspend fun setReplayGainMode(mode: ReplayGainMode) {
context.dataStore.edit { prefs -> prefs[KEY_RG_MODE] = mode.ordinal }
}
suspend fun setReplayGainPreAmpDb(db: Float) {
context.dataStore.edit { prefs ->
prefs[KEY_RG_PREAMP] = db.coerceIn(-15f, 15f)
}
}
suspend fun addExcludedGenre(genre: String) {
val trimmed = genre.trim()
if (trimmed.isEmpty()) return
context.dataStore.edit { prefs ->
val current = prefs[KEY_RANDOM_EXCLUDED_GENRES] ?: emptySet()
prefs[KEY_RANDOM_EXCLUDED_GENRES] = current + trimmed
}
}
suspend fun removeExcludedGenre(genre: String) {
context.dataStore.edit { prefs ->
val current = prefs[KEY_RANDOM_EXCLUDED_GENRES] ?: emptySet()
prefs[KEY_RANDOM_EXCLUDED_GENRES] = current - genre
}
}
suspend fun setLyricsMetadataEnabled(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[KEY_LYRICS_METADATA] = enabled }
}
suspend fun setEnabled(fields: Set<MetadataField>) {
context.dataStore.edit { prefs ->
prefs[KEY_FIELDS] = fields.map { it.key }.toSet()
}
}
suspend fun toggle(field: MetadataField) {
context.dataStore.edit { prefs ->
val current = (prefs[KEY_FIELDS] ?: MetadataField.defaults.map { it.key }.toSet())
.toMutableSet()
if (current.contains(field.key)) current.remove(field.key) else current.add(field.key)
prefs[KEY_FIELDS] = current
}
}
// ── Renderer mesh setters ────────────────────────────────────────
suspend fun setRendererBroadcastEnabled(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[KEY_RENDERER_BROADCAST_ENABLED] = enabled }
}
suspend fun setRendererFriendlyName(name: String?) {
context.dataStore.edit { prefs ->
if (name.isNullOrBlank()) prefs.remove(KEY_RENDERER_FRIENDLY_NAME)
else prefs[KEY_RENDERER_FRIENDLY_NAME] = name
}
}
suspend fun setRendererUdn(udn: String?) {
context.dataStore.edit { prefs ->
if (udn.isNullOrBlank()) prefs.remove(KEY_RENDERER_UDN)
else prefs[KEY_RENDERER_UDN] = udn
}
}
suspend fun setLeftyMode(enabled: Boolean) {
context.dataStore.edit { it[KEY_LEFTY_MODE] = enabled }
}
suspend fun setActiveRendererUdn(udn: String?) {
context.dataStore.edit { prefs ->
if (udn.isNullOrBlank()) prefs.remove(KEY_ACTIVE_RENDERER_UDN)
else prefs[KEY_ACTIVE_RENDERER_UDN] = udn
}
}
companion object {
private val KEY_FIELDS = stringSetPreferencesKey("enabled_metadata_fields")
private val KEY_PREFERRED_SERVER = stringPreferencesKey("preferred_server_udn")
private val KEY_OBSERVED_CAST_DEVICE = stringPreferencesKey("observed_cast_device")
private val KEY_KIOSK_RETURN_PACKAGE = stringPreferencesKey("kiosk_return_package")
private val KEY_KIOSK_FLIP_ENABLED = booleanPreferencesKey("kiosk_flip_enabled")
private val KEY_LAST_HOSTS = stringPreferencesKey("last_known_group_hosts")
private val KEY_DOM_ETHA_URL = stringPreferencesKey("dom_etha_url")
private val KEY_CORNER_BUTTON_DOM_ETHA = booleanPreferencesKey("corner_button_dom_etha")
private val KEY_MARQUEE = booleanPreferencesKey("marquee_enabled")
private val KEY_USE_FRAUNCES = booleanPreferencesKey("use_fraunces")
private val KEY_RG_MODE = intPreferencesKey("replay_gain_mode")
private val KEY_RG_PREAMP = floatPreferencesKey("replay_gain_preamp_db")
private val KEY_RANDOM_EXCLUDED_GENRES = stringSetPreferencesKey("random_excluded_genres")
private val KEY_LYRICS_HIGHLIGHT_SIZE = floatPreferencesKey("lyrics_highlight_size")
private val KEY_LYRICS_HIGHLIGHT_BOLD = booleanPreferencesKey("lyrics_highlight_bold")
private val KEY_LYRICS_HIGHLIGHT_COLOR = intPreferencesKey("lyrics_highlight_color")
private val KEY_ALLOW_EXTERNAL_CALLS = booleanPreferencesKey("allow_external_calls")
private val KEY_LYRICS_METADATA = booleanPreferencesKey("lyrics_metadata_enabled")
private val KEY_RENDERER_BROADCAST_ENABLED =
booleanPreferencesKey("renderer_broadcast_enabled")
private val KEY_RENDERER_FRIENDLY_NAME =
stringPreferencesKey("renderer_friendly_name")
private val KEY_RENDERER_UDN = stringPreferencesKey("renderer_udn")
private val KEY_ACTIVE_RENDERER_UDN = stringPreferencesKey("active_renderer_udn")
private val KEY_LEFTY_MODE = booleanPreferencesKey("lefty_mode")
}
}