QueuePersistenceStore.kt
Each line, with the release that last changed it.
| 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.edit | |
| 6 | import androidx.datastore.preferences.core.emptyPreferences | |
| 7 | import androidx.datastore.preferences.core.longPreferencesKey | |
| 8 | import androidx.datastore.preferences.core.stringPreferencesKey | |
| 9 | import androidx.datastore.preferences.preferencesDataStore | |
| 10 | import dagger.hilt.android.qualifiers.ApplicationContext | |
| 11 | import kotlinx.coroutines.flow.first | |
| 12 | import kotlinx.serialization.Serializable | |
| 13 | import kotlinx.serialization.json.Json | |
| 14 | import javax.inject.Inject | |
| 15 | import 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 | |
| 20 | data 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 | |
| 48 | data 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. | |
| 56 | private val Context.queueDataStore by preferencesDataStore( | |
| 57 | name = "stave_queue", | |
| 58 | corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, | |
| 59 | ) | |
| 60 | ||
| 61 | @Singleton | |
| 62 | class 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 | } |


