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

SettingsStore.kt

410 lines · 20.1 KB

Changed in: 0.5.0

1package dev.stave.data
2
3import android.content.Context
4import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
5import androidx.datastore.preferences.core.booleanPreferencesKey
6import androidx.datastore.preferences.core.edit
7import androidx.datastore.preferences.core.emptyPreferences
8import androidx.datastore.preferences.core.floatPreferencesKey
9import androidx.datastore.preferences.core.intPreferencesKey
10import androidx.datastore.preferences.core.stringPreferencesKey
11import androidx.datastore.preferences.core.stringSetPreferencesKey
12import androidx.datastore.preferences.preferencesDataStore
13import dagger.hilt.android.qualifiers.ApplicationContext
14import dev.stave.domain.model.MetadataField
15import kotlinx.coroutines.flow.Flow
16import kotlinx.coroutines.flow.map
17import javax.inject.Inject
18import javax.inject.Singleton
19
20/** Replay Gain operating mode. */
21enum 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. */
27enum 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.")
32enum 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.
36private val Context.dataStore by preferencesDataStore(
37 name = "stave_settings",
38 corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
39)
40
41@Singleton
42class 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}