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

QueuePersistenceStore.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.data
2
3import android.content.Context
4import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
5import androidx.datastore.preferences.core.edit
6import androidx.datastore.preferences.core.emptyPreferences
7import androidx.datastore.preferences.core.longPreferencesKey
8import androidx.datastore.preferences.core.stringPreferencesKey
9import androidx.datastore.preferences.preferencesDataStore
10import dagger.hilt.android.qualifiers.ApplicationContext
11import kotlinx.coroutines.flow.first
12import kotlinx.serialization.Serializable
13import kotlinx.serialization.json.Json
14import javax.inject.Inject
15import javax.inject.Singleton
16
17/** Serializable mirror of DidlItem.Track for persistence. Duration held as millis
18 * because kotlin.time.Duration isn't @Serializable without a custom serializer. */
19@Serializable
20data class PersistedTrack(
21 val id: String,
22 val parentId: String,
23 val title: String,
24 val artist: String? = null,
25 val albumArtist: String? = null,
26 val album: String? = null,
27 val composer: String? = null,
28 val genre: String? = null,
29 val date: String? = null,
30 val trackNumber: Int? = null,
31 val discNumber: Int? = null,
32 val durationMs: Long? = null,
33 val albumArtUri: String? = null,
34 val resourceUrl: String,
35 val mimeType: String? = null,
36 val sampleFrequency: Int? = null,
37 val bitsPerSample: Int? = null,
38 val channels: Int? = null,
39 val bitrate: Int? = null,
40 val size: Long? = null,
41 val extras: Map<String, String> = emptyMap(),
42)
43
44/** Full queue snapshot: the list, the currently-playing index, and where the head
45 * was in that track. Position is stored so resume starts close to where the user
46 * left off rather than restarting the track. */
47@Serializable
48data class PersistedQueue(
49 val tracks: List<PersistedTrack>,
50 val currentIndex: Int,
51 val positionMs: Long,
52)
53
54// Maintainer, 2026-10-29: a corrupt queue file crashed the app at launch;
55// replace it with empty (no saved queue) instead.
56private val Context.queueDataStore by preferencesDataStore(
57 name = "stave_queue",
58 corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
59)
60
61@Singleton
62class QueuePersistenceStore @Inject constructor(
63 @ApplicationContext private val context: Context,
64) {
65 private val json = Json { ignoreUnknownKeys = true }
66
67 suspend fun save(snapshot: PersistedQueue) {
68 val encoded = runCatching {
69 json.encodeToString(PersistedQueue.serializer(), snapshot)
70 }.getOrNull() ?: return
71 context.queueDataStore.edit { prefs ->
72 prefs[KEY_QUEUE_JSON] = encoded
73 }
74 // Maintainer, 2026-10-29: synchronous "is there anything to resume" flag
75 // for the media-button receiver (see EarhornMediaButtonReceiver), which
76 // must decide inside onReceive and can't read DataStore.
77 setResumable(context, snapshot.tracks.any { it.resourceUrl.isNotBlank() })
78 }
79
80 /** Load the most recent snapshot, or null if nothing is saved or the blob is
81 * corrupt. Corruption returns null rather than throwing — we'd rather restore to
82 * empty queue than crash on startup because of a bad save. */
83 suspend fun load(): PersistedQueue? {
84 val prefs = context.queueDataStore.data.first()
85 val raw = prefs[KEY_QUEUE_JSON] ?: return null
86 if (raw.isBlank()) return null
87 return runCatching {
88 json.decodeFromString(PersistedQueue.serializer(), raw)
89 }.getOrNull()
90 }
91
92 suspend fun clear() {
93 context.queueDataStore.edit { it.clear() }
94 setResumable(context, false) // Maintainer, 2026-10-29: see save()
95 }
96
97 companion object {
98 private val KEY_QUEUE_JSON = stringPreferencesKey("queue_json")
99
100 private const val RESUME_PREFS = "earhorn_resume"
101 private const val KEY_RESUMABLE = "resumable"
102
103 private fun setResumable(context: Context, resumable: Boolean) {
104 context.getSharedPreferences(RESUME_PREFS, Context.MODE_PRIVATE)
105 .edit().putBoolean(KEY_RESUMABLE, resumable).apply()
106 }
107
108 /** Maintainer, 2026-10-29: true when the last saved queue has at least one
109 * playable track. Read synchronously by the media-button receiver. */
110 fun hasResumableQueue(context: Context): Boolean =
111 context.getSharedPreferences(RESUME_PREFS, Context.MODE_PRIVATE)
112 .getBoolean(KEY_RESUMABLE, false)
113 }
114}