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

SettingsStore.kt

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")
    }
}