QueuePersistenceStore.kt
114 lines · 4.5 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.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 | } |


