SettingsStore.kt
410 lines · 20.1 KB
Changed in: 0.5.0
| 1 | package dev.stave.data |
| 2 | |
| 3 | import android.content.Context |
| 4 | import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler |
| 5 | import androidx.datastore.preferences.core.booleanPreferencesKey |
| 6 | import androidx.datastore.preferences.core.edit |
| 7 | import androidx.datastore.preferences.core.emptyPreferences |
| 8 | import androidx.datastore.preferences.core.floatPreferencesKey |
| 9 | import androidx.datastore.preferences.core.intPreferencesKey |
| 10 | import androidx.datastore.preferences.core.stringPreferencesKey |
| 11 | import androidx.datastore.preferences.core.stringSetPreferencesKey |
| 12 | import androidx.datastore.preferences.preferencesDataStore |
| 13 | import dagger.hilt.android.qualifiers.ApplicationContext |
| 14 | import dev.stave.domain.model.MetadataField |
| 15 | import kotlinx.coroutines.flow.Flow |
| 16 | import kotlinx.coroutines.flow.map |
| 17 | import javax.inject.Inject |
| 18 | import javax.inject.Singleton |
| 19 | |
| 20 | /** Replay Gain operating mode. */ |
| 21 | enum class ReplayGainMode { OFF, TRACK, ALBUM } |
| 22 | |
| 23 | /** Preset color choices for the current-lyric highlight. AMBER matches the Earhorn |
| 24 | * accent; WHITE is high-contrast/no-tint; SAGE is the secondary accent for folks |
| 25 | * who want a less-warm look. Adding more is cheap — enum + a one-line entry in the |
| 26 | * settings radio row. */ |
| 27 | enum class LyricsHighlightColor { AMBER, WHITE, SAGE } |
| 28 | |
| 29 | /** Legacy preset — retained so existing settings blobs still deserialize. New code |
| 30 | * should read [SettingsStore.lyricsHighlightSize] / Bold / Color directly. */ |
| 31 | @Deprecated("Use the three independent settings (size, bold, color) instead.") |
| 32 | enum class LyricsHighlightStyle { SIZE_ONLY, COLOR_ONLY, SIZE_AND_COLOR, BOLD } |
| 33 | |
| 34 | // Maintainer, 2026-10-29: a corrupt settings file crashed the app at launch; |
| 35 | // replace it with empty (defaults) instead. |
| 36 | private val Context.dataStore by preferencesDataStore( |
| 37 | name = "stave_settings", |
| 38 | corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, |
| 39 | ) |
| 40 | |
| 41 | @Singleton |
| 42 | class SettingsStore @Inject constructor( |
| 43 | @ApplicationContext private val context: Context, |
| 44 | ) { |
| 45 | val enabledFields: Flow<Set<MetadataField>> = context.dataStore.data.map { prefs -> |
| 46 | val stored = prefs[KEY_FIELDS] |
| 47 | if (stored == null) MetadataField.defaults |
| 48 | else stored.mapNotNull(MetadataField::fromKey).toSet() |
| 49 | } |
| 50 | |
| 51 | /** UDN of the last server the user selected. Empty string = none chosen. Auto-navigate |
| 52 | * logic in EarhornNavHost reads this against the live discovery list. Cleared when the |
| 53 | * user presses Rescan. */ |
| 54 | val preferredServerUdn: Flow<String?> = context.dataStore.data.map { prefs -> |
| 55 | prefs[KEY_PREFERRED_SERVER]?.takeIf { it.isNotBlank() } |
| 56 | } |
| 57 | |
| 58 | /** Whether long titles/artists should marquee-scroll when ellipsized. Default off — |
| 59 | * marquee is stylish on the Now Playing screen but visually noisy in dense list |
| 60 | * views, so the user opts in. */ |
| 61 | val marqueeEnabled: Flow<Boolean> = context.dataStore.data.map { prefs -> |
| 62 | prefs[KEY_MARQUEE] ?: false |
| 63 | } |
| 64 | |
| 65 | /** Whether Fraunces (serif) renders app-wide. Default true — Fraunces carries |
| 66 | * the brand identity and is the design intent. Disabling falls through to Inter |
| 67 | * (sans-serif) for everything that would otherwise render in Fraunces; users who |
| 68 | * find serif type harder to read can opt out without losing the rest of the |
| 69 | * type-system structure (sizes, weights, letter-spacing all stay the same). */ |
| 70 | val useFraunces: Flow<Boolean> = context.dataStore.data.map { prefs -> |
| 71 | prefs[KEY_USE_FRAUNCES] ?: true |
| 72 | } |
| 73 | |
| 74 | /** ReplayGain mode. OFF = no volume correction; TRACK = per-track normalization; |
| 75 | * ALBUM = per-album normalization (preserves the album's internal dynamics). */ |
| 76 | val replayGainMode: Flow<ReplayGainMode> = context.dataStore.data.map { prefs -> |
| 77 | val ordinal = prefs[KEY_RG_MODE] ?: ReplayGainMode.OFF.ordinal |
| 78 | ReplayGainMode.entries.getOrElse(ordinal) { ReplayGainMode.OFF } |
| 79 | } |
| 80 | |
| 81 | /** ReplayGain pre-amp in dB. Added to every track's RG-corrected volume. Default +3dB |
| 82 | * — conventional because RG's -6dBFS target leaves headroom that most listeners want |
| 83 | * to reclaim. Range is clamped to [-15, +15] in setter. */ |
| 84 | val replayGainPreAmpDb: Flow<Float> = context.dataStore.data.map { prefs -> |
| 85 | prefs[KEY_RG_PREAMP] ?: 3f |
| 86 | } |
| 87 | |
| 88 | /** Genre names to exclude from random-track and random-album picks. Case-insensitive |
| 89 | * comparison at filter time. Users typically add "Podcast", "Comedy", "Audiobook" |
| 90 | * here so their party-shuffle doesn't pull spoken-word items. */ |
| 91 | val randomExcludedGenres: Flow<Set<String>> = context.dataStore.data.map { prefs -> |
| 92 | prefs[KEY_RANDOM_EXCLUDED_GENRES] ?: emptySet() |
| 93 | } |
| 94 | |
| 95 | /** Size multiplier applied to the current lyric line. 1.0 = same as others; |
| 96 | * 1.5 = 50% larger; 2.0 = double. Clamped [1.0, 2.5] by the setter. Default 1.5 |
| 97 | * matches the prior SIZE_AND_COLOR preset's visual feel. */ |
| 98 | val lyricsHighlightSize: Flow<Float> = context.dataStore.data.map { prefs -> |
| 99 | prefs[KEY_LYRICS_HIGHLIGHT_SIZE] ?: 1.5f |
| 100 | } |
| 101 | |
| 102 | /** Whether the current lyric line renders bold. */ |
| 103 | val lyricsHighlightBold: Flow<Boolean> = context.dataStore.data.map { prefs -> |
| 104 | prefs[KEY_LYRICS_HIGHLIGHT_BOLD] ?: false |
| 105 | } |
| 106 | |
| 107 | /** Accent color for the current lyric line. */ |
| 108 | val lyricsHighlightColor: Flow<LyricsHighlightColor> = context.dataStore.data.map { prefs -> |
| 109 | val ordinal = prefs[KEY_LYRICS_HIGHLIGHT_COLOR] ?: LyricsHighlightColor.AMBER.ordinal |
| 110 | LyricsHighlightColor.entries.getOrElse(ordinal) { LyricsHighlightColor.AMBER } |
| 111 | } |
| 112 | |
| 113 | /** Whether the lyrics screen shows a metadata chip grid (artist, album, format |
| 114 | * etc.) in the upper-right corner. Default on; the user can toggle it off if |
| 115 | * they prefer a clean reading view. */ |
| 116 | val lyricsMetadataEnabled: Flow<Boolean> = context.dataStore.data.map { prefs -> |
| 117 | prefs[KEY_LYRICS_METADATA] ?: true |
| 118 | } |
| 119 | |
| 120 | /** Master privacy switch. When FALSE, Earhorn will not contact any third-party service |
| 121 | * (lyrics lookup etc.). The lyrics screen shows a "disabled in privacy settings" |
| 122 | * state instead of pinging LRCLIB. Default TRUE — opt-out, matching typical app |
| 123 | * defaults; users who care about it can toggle off. */ |
| 124 | val allowExternalCalls: Flow<Boolean> = context.dataStore.data.map { prefs -> |
| 125 | prefs[KEY_ALLOW_EXTERNAL_CALLS] ?: true |
| 126 | } |
| 127 | |
| 128 | // ── Renderer mesh ──────────────────────────────────────────────── |
| 129 | // Settings backing the UPnP MediaRenderer broadcast feature and the |
| 130 | // user's currently-selected playback target. See [EarhornRendererService] |
| 131 | // for the broadcast side, [RendererCoordinator] for the routing side. |
| 132 | |
| 133 | /** Master switch for advertising this device as a UPnP MediaRenderer on the LAN. |
| 134 | * Default FALSE — opt-in, since broadcasting holds the multicast lock for |
| 135 | * longer and answers SSDP M-SEARCH probes from anything on the network. |
| 136 | * When TRUE, [EarhornRendererService] generates a UDN on first enable |
| 137 | * (stored in [rendererUdn]) and registers a LocalDevice via the holder. */ |
| 138 | val rendererBroadcastEnabled: Flow<Boolean> = context.dataStore.data.map { prefs -> |
| 139 | prefs[KEY_RENDERER_BROADCAST_ENABLED] ?: false |
| 140 | } |
| 141 | |
| 142 | /** User-supplied friendly name for the broadcast renderer. Null = autocompute |
| 143 | * ("Earhorn on $deviceName"). Empty strings normalize to null on write so the |
| 144 | * picker downstream doesn't have to special-case blank labels. */ |
| 145 | val rendererFriendlyName: Flow<String?> = context.dataStore.data.map { prefs -> |
| 146 | prefs[KEY_RENDERER_FRIENDLY_NAME]?.takeIf { it.isNotBlank() } |
| 147 | } |
| 148 | |
| 149 | /** Stable UDN this device advertises when broadcasting. Generated once on first |
| 150 | * broadcast-enable ([EarhornRendererService] writes it via [setRendererUdn]) and |
| 151 | * never changed afterward — re-using the same UDN across sessions lets other |
| 152 | * control points re-attach to the renderer they remember. Format is |
| 153 | * "uuid:" + standard UUID, matching jUPnP's UDN.identifierString. */ |
| 154 | val rendererUdn: Flow<String?> = context.dataStore.data.map { prefs -> |
| 155 | prefs[KEY_RENDERER_UDN]?.takeIf { it.isNotBlank() } |
| 156 | } |
| 157 | |
| 158 | /** UDN of the renderer the user wants to drive. Null = this device's local |
| 159 | * playback (Media3 / Cast as the existing flow decides). Non-null = a remote |
| 160 | * UPnP MediaRenderer discovered via [UpnpServiceHolder.renderers]. The |
| 161 | * RendererCoordinator combines this with the discovery list and switches the |
| 162 | * active backend whenever either side changes. */ |
| 163 | val leftyMode: Flow<Boolean> = context.dataStore.data.map { prefs -> |
| 164 | prefs[KEY_LEFTY_MODE] ?: false |
| 165 | } |
| 166 | |
| 167 | val activeRendererUdn: Flow<String?> = context.dataStore.data.map { prefs -> |
| 168 | prefs[KEY_ACTIVE_RENDERER_UDN]?.takeIf { it.isNotBlank() } |
| 169 | } |
| 170 | |
| 171 | /** Serialized descriptor of the Cast device the user was last OBSERVING |
| 172 | * (raw-cast follow mode), or null when not observing / after an explicit |
| 173 | * disconnect. Written by RawCastObserver on successful connect, cleared |
| 174 | * on user-initiated disconnect. The value is an opaque string owned by |
| 175 | * RawCastObserver (it serializes/parses its own CastDevice) — the store |
| 176 | * deliberately doesn't know the format, to avoid a data→cast dependency. |
| 177 | * |
| 178 | * Purpose: survive process death. Observing on a phone dies with the |
| 179 | * process (the TLS socket can't outlive it); persisting the target lets |
| 180 | * the PlaybackService auto-reattach on next launch if the CCA session |
| 181 | * is still active, instead of silently dumping the user into local mode |
| 182 | * with a stale queue on screen. */ |
| 183 | val observedCastDevice: Flow<String?> = context.dataStore.data.map { prefs -> |
| 184 | prefs[KEY_OBSERVED_CAST_DEVICE]?.takeIf { it.isNotBlank() } |
| 185 | } |
| 186 | |
| 187 | suspend fun setObservedCastDevice(serialized: String?) { |
| 188 | context.dataStore.edit { prefs -> |
| 189 | if (serialized.isNullOrBlank()) prefs.remove(KEY_OBSERVED_CAST_DEVICE) |
| 190 | else prefs[KEY_OBSERVED_CAST_DEVICE] = serialized |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | /** Kiosk hand-back target: package name of the app to bring forward when |
| 195 | * an auto-observed CCA session genuinely ends (voice-cast pipeline — |
| 196 | * FaveatHaus on other screens). Null = disarmed, which is the state |
| 197 | * on every normal install: it is ONLY ever set by an OBSERVE launch that |
| 198 | * carries a `returnTo` extra, and it is cleared the moment a hand-back |
| 199 | * fires, so a user who opens Earhorn by hand is never yanked away. |
| 200 | * Persisted (not in-memory) so process death mid-observation doesn't |
| 201 | * strand a kiosk screen in Earhorn after the music ends. */ |
| 202 | val kioskReturnPackage: Flow<String?> = context.dataStore.data.map { prefs -> |
| 203 | prefs[KEY_KIOSK_RETURN_PACKAGE]?.takeIf { it.isNotBlank() } |
| 204 | } |
| 205 | |
| 206 | /** group name -> "host:port" of the last successful attach. Cold |
| 207 | * starts use it when mDNS is dead (fresh-rebooted routers often drop |
| 208 | * multicast for minutes — 2026-09-01, the one Android 14 screen). */ |
| 209 | val lastKnownHosts: Flow<Map<String, String>> = context.dataStore.data.map { prefs -> |
| 210 | (prefs[KEY_LAST_HOSTS] ?: "").split('\n').filter { it.contains('\t') } |
| 211 | .associate { line -> line.substringBefore('\t') to line.substringAfter('\t') } |
| 212 | } |
| 213 | |
| 214 | suspend fun setLastKnownHost(group: String, hostPort: String) { |
| 215 | context.dataStore.edit { prefs -> |
| 216 | val cur = (prefs[KEY_LAST_HOSTS] ?: "").split('\n') |
| 217 | .filter { it.contains('\t') } |
| 218 | .associate { it.substringBefore('\t') to it.substringAfter('\t') } |
| 219 | .toMutableMap() |
| 220 | cur[group] = hostPort |
| 221 | // Audit 2026-09-02: this wrote the LITERAL text "${it.key}\t${it.value}" |
| 222 | // for every entry (a ${'$'} escape that was never a template), so the |
| 223 | // map on disk never held a real host and the cold-start rescue in |
| 224 | // 0.4.15 never dialled anything. Now a real template. |
| 225 | prefs[KEY_LAST_HOSTS] = cur.entries.take(24) |
| 226 | .joinToString("\n") { "${it.key}\t${it.value}" } |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | /** Whether OBSERVE events flip this device to Now Playing and arm the |
| 231 | * kiosk hand-back. ON for other screens (the pipeline's purpose); OFF |
| 232 | * turns this install into a manual instrument — OBSERVE is ignored |
| 233 | * entirely, so browsing is never hijacked (decision, 2026-08-30: |
| 234 | * mandatory on phones). Per-device. */ |
| 235 | val kioskFlipEnabled: Flow<Boolean> = context.dataStore.data.map { prefs -> |
| 236 | prefs[KEY_KIOSK_FLIP_ENABLED] ?: true |
| 237 | } |
| 238 | |
| 239 | suspend fun setKioskFlipEnabled(enabled: Boolean) { |
| 240 | context.dataStore.edit { prefs -> prefs[KEY_KIOSK_FLIP_ENABLED] = enabled } |
| 241 | } |
| 242 | |
| 243 | /** URL of the Dom-Etha PWA (house CCA control panel). Null = feature |
| 244 | * off. Not hardcoded — other installs point it wherever theirs lives. */ |
| 245 | val domEthaUrl: Flow<String?> = context.dataStore.data.map { prefs -> |
| 246 | prefs[KEY_DOM_ETHA_URL]?.takeIf { it.isNotBlank() } |
| 247 | } |
| 248 | |
| 249 | suspend fun setDomEthaUrl(url: String?) { |
| 250 | context.dataStore.edit { prefs -> |
| 251 | if (url.isNullOrBlank()) prefs.remove(KEY_DOM_ETHA_URL) |
| 252 | else prefs[KEY_DOM_ETHA_URL] = url.trim() |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | /** Which button occupies the Now Playing corner slot: false = sleep |
| 257 | * timer (default), true = Dom-Etha panel (only honored when a URL is |
| 258 | * set — a Dom-Etha choice with no URL falls back to the timer). */ |
| 259 | val cornerButtonDomEtha: Flow<Boolean> = context.dataStore.data.map { prefs -> |
| 260 | prefs[KEY_CORNER_BUTTON_DOM_ETHA] ?: false |
| 261 | } |
| 262 | |
| 263 | suspend fun setCornerButtonDomEtha(enabled: Boolean) { |
| 264 | context.dataStore.edit { prefs -> prefs[KEY_CORNER_BUTTON_DOM_ETHA] = enabled } |
| 265 | } |
| 266 | |
| 267 | suspend fun setKioskReturnPackage(pkg: String?) { |
| 268 | context.dataStore.edit { prefs -> |
| 269 | if (pkg.isNullOrBlank()) prefs.remove(KEY_KIOSK_RETURN_PACKAGE) |
| 270 | else prefs[KEY_KIOSK_RETURN_PACKAGE] = pkg |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | suspend fun setPreferredServer(udn: String?) { |
| 275 | context.dataStore.edit { prefs -> |
| 276 | if (udn.isNullOrBlank()) prefs.remove(KEY_PREFERRED_SERVER) |
| 277 | else prefs[KEY_PREFERRED_SERVER] = udn |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | suspend fun setMarqueeEnabled(enabled: Boolean) { |
| 282 | context.dataStore.edit { prefs -> prefs[KEY_MARQUEE] = enabled } |
| 283 | } |
| 284 | |
| 285 | suspend fun setUseFraunces(enabled: Boolean) { |
| 286 | context.dataStore.edit { prefs -> prefs[KEY_USE_FRAUNCES] = enabled } |
| 287 | } |
| 288 | |
| 289 | suspend fun setLyricsHighlightSize(multiplier: Float) { |
| 290 | context.dataStore.edit { prefs -> |
| 291 | prefs[KEY_LYRICS_HIGHLIGHT_SIZE] = multiplier.coerceIn(1.0f, 2.5f) |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | suspend fun setLyricsHighlightBold(bold: Boolean) { |
| 296 | context.dataStore.edit { prefs -> prefs[KEY_LYRICS_HIGHLIGHT_BOLD] = bold } |
| 297 | } |
| 298 | |
| 299 | suspend fun setLyricsHighlightColor(color: LyricsHighlightColor) { |
| 300 | context.dataStore.edit { prefs -> prefs[KEY_LYRICS_HIGHLIGHT_COLOR] = color.ordinal } |
| 301 | } |
| 302 | |
| 303 | suspend fun setAllowExternalCalls(allow: Boolean) { |
| 304 | context.dataStore.edit { prefs -> prefs[KEY_ALLOW_EXTERNAL_CALLS] = allow } |
| 305 | } |
| 306 | |
| 307 | suspend fun setReplayGainMode(mode: ReplayGainMode) { |
| 308 | context.dataStore.edit { prefs -> prefs[KEY_RG_MODE] = mode.ordinal } |
| 309 | } |
| 310 | |
| 311 | suspend fun setReplayGainPreAmpDb(db: Float) { |
| 312 | context.dataStore.edit { prefs -> |
| 313 | prefs[KEY_RG_PREAMP] = db.coerceIn(-15f, 15f) |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | suspend fun addExcludedGenre(genre: String) { |
| 318 | val trimmed = genre.trim() |
| 319 | if (trimmed.isEmpty()) return |
| 320 | context.dataStore.edit { prefs -> |
| 321 | val current = prefs[KEY_RANDOM_EXCLUDED_GENRES] ?: emptySet() |
| 322 | prefs[KEY_RANDOM_EXCLUDED_GENRES] = current + trimmed |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | suspend fun removeExcludedGenre(genre: String) { |
| 327 | context.dataStore.edit { prefs -> |
| 328 | val current = prefs[KEY_RANDOM_EXCLUDED_GENRES] ?: emptySet() |
| 329 | prefs[KEY_RANDOM_EXCLUDED_GENRES] = current - genre |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | suspend fun setLyricsMetadataEnabled(enabled: Boolean) { |
| 334 | context.dataStore.edit { prefs -> prefs[KEY_LYRICS_METADATA] = enabled } |
| 335 | } |
| 336 | |
| 337 | suspend fun setEnabled(fields: Set<MetadataField>) { |
| 338 | context.dataStore.edit { prefs -> |
| 339 | prefs[KEY_FIELDS] = fields.map { it.key }.toSet() |
| 340 | } |
| 341 | } |
| 342 | |
| 343 | suspend fun toggle(field: MetadataField) { |
| 344 | context.dataStore.edit { prefs -> |
| 345 | val current = (prefs[KEY_FIELDS] ?: MetadataField.defaults.map { it.key }.toSet()) |
| 346 | .toMutableSet() |
| 347 | if (current.contains(field.key)) current.remove(field.key) else current.add(field.key) |
| 348 | prefs[KEY_FIELDS] = current |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | // ── Renderer mesh setters ──────────────────────────────────────── |
| 353 | |
| 354 | suspend fun setRendererBroadcastEnabled(enabled: Boolean) { |
| 355 | context.dataStore.edit { prefs -> prefs[KEY_RENDERER_BROADCAST_ENABLED] = enabled } |
| 356 | } |
| 357 | |
| 358 | suspend fun setRendererFriendlyName(name: String?) { |
| 359 | context.dataStore.edit { prefs -> |
| 360 | if (name.isNullOrBlank()) prefs.remove(KEY_RENDERER_FRIENDLY_NAME) |
| 361 | else prefs[KEY_RENDERER_FRIENDLY_NAME] = name |
| 362 | } |
| 363 | } |
| 364 | |
| 365 | suspend fun setRendererUdn(udn: String?) { |
| 366 | context.dataStore.edit { prefs -> |
| 367 | if (udn.isNullOrBlank()) prefs.remove(KEY_RENDERER_UDN) |
| 368 | else prefs[KEY_RENDERER_UDN] = udn |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | suspend fun setLeftyMode(enabled: Boolean) { |
| 373 | context.dataStore.edit { it[KEY_LEFTY_MODE] = enabled } |
| 374 | } |
| 375 | |
| 376 | suspend fun setActiveRendererUdn(udn: String?) { |
| 377 | context.dataStore.edit { prefs -> |
| 378 | if (udn.isNullOrBlank()) prefs.remove(KEY_ACTIVE_RENDERER_UDN) |
| 379 | else prefs[KEY_ACTIVE_RENDERER_UDN] = udn |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | companion object { |
| 384 | private val KEY_FIELDS = stringSetPreferencesKey("enabled_metadata_fields") |
| 385 | private val KEY_PREFERRED_SERVER = stringPreferencesKey("preferred_server_udn") |
| 386 | private val KEY_OBSERVED_CAST_DEVICE = stringPreferencesKey("observed_cast_device") |
| 387 | private val KEY_KIOSK_RETURN_PACKAGE = stringPreferencesKey("kiosk_return_package") |
| 388 | private val KEY_KIOSK_FLIP_ENABLED = booleanPreferencesKey("kiosk_flip_enabled") |
| 389 | private val KEY_LAST_HOSTS = stringPreferencesKey("last_known_group_hosts") |
| 390 | private val KEY_DOM_ETHA_URL = stringPreferencesKey("dom_etha_url") |
| 391 | private val KEY_CORNER_BUTTON_DOM_ETHA = booleanPreferencesKey("corner_button_dom_etha") |
| 392 | private val KEY_MARQUEE = booleanPreferencesKey("marquee_enabled") |
| 393 | private val KEY_USE_FRAUNCES = booleanPreferencesKey("use_fraunces") |
| 394 | private val KEY_RG_MODE = intPreferencesKey("replay_gain_mode") |
| 395 | private val KEY_RG_PREAMP = floatPreferencesKey("replay_gain_preamp_db") |
| 396 | private val KEY_RANDOM_EXCLUDED_GENRES = stringSetPreferencesKey("random_excluded_genres") |
| 397 | private val KEY_LYRICS_HIGHLIGHT_SIZE = floatPreferencesKey("lyrics_highlight_size") |
| 398 | private val KEY_LYRICS_HIGHLIGHT_BOLD = booleanPreferencesKey("lyrics_highlight_bold") |
| 399 | private val KEY_LYRICS_HIGHLIGHT_COLOR = intPreferencesKey("lyrics_highlight_color") |
| 400 | private val KEY_ALLOW_EXTERNAL_CALLS = booleanPreferencesKey("allow_external_calls") |
| 401 | private val KEY_LYRICS_METADATA = booleanPreferencesKey("lyrics_metadata_enabled") |
| 402 | private val KEY_RENDERER_BROADCAST_ENABLED = |
| 403 | booleanPreferencesKey("renderer_broadcast_enabled") |
| 404 | private val KEY_RENDERER_FRIENDLY_NAME = |
| 405 | stringPreferencesKey("renderer_friendly_name") |
| 406 | private val KEY_RENDERER_UDN = stringPreferencesKey("renderer_udn") |
| 407 | private val KEY_ACTIVE_RENDERER_UDN = stringPreferencesKey("active_renderer_udn") |
| 408 | private val KEY_LEFTY_MODE = booleanPreferencesKey("lefty_mode") |
| 409 | } |
| 410 | } |


