package dev.stave.data
import android.content.Context
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
/** Serializable mirror of DidlItem.Track for persistence. Duration held as millis
* because kotlin.time.Duration isn't @Serializable without a custom serializer. */
@Serializable
data class PersistedTrack(
val id: String,
val parentId: String,
val title: String,
val artist: String? = null,
val albumArtist: String? = null,
val album: String? = null,
val composer: String? = null,
val genre: String? = null,
val date: String? = null,
val trackNumber: Int? = null,
val discNumber: Int? = null,
val durationMs: Long? = null,
val albumArtUri: String? = null,
val resourceUrl: String,
val mimeType: String? = null,
val sampleFrequency: Int? = null,
val bitsPerSample: Int? = null,
val channels: Int? = null,
val bitrate: Int? = null,
val size: Long? = null,
val extras: Map<String, String> = emptyMap(),
)
/** Full queue snapshot: the list, the currently-playing index, and where the head
* was in that track. Position is stored so resume starts close to where the user
* left off rather than restarting the track. */
@Serializable
data class PersistedQueue(
val tracks: List<PersistedTrack>,
val currentIndex: Int,
val positionMs: Long,
)
// Maintainer, 2026-10-29: a corrupt queue file crashed the app at launch;
// replace it with empty (no saved queue) instead.
private val Context.queueDataStore by preferencesDataStore(
name = "stave_queue",
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
)
@Singleton
class QueuePersistenceStore @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun save(snapshot: PersistedQueue) {
val encoded = runCatching {
json.encodeToString(PersistedQueue.serializer(), snapshot)
}.getOrNull() ?: return
context.queueDataStore.edit { prefs ->
prefs[KEY_QUEUE_JSON] = encoded
}
// Maintainer, 2026-10-29: synchronous "is there anything to resume" flag
// for the media-button receiver (see EarhornMediaButtonReceiver), which
// must decide inside onReceive and can't read DataStore.
setResumable(context, snapshot.tracks.any { it.resourceUrl.isNotBlank() })
}
/** Load the most recent snapshot, or null if nothing is saved or the blob is
* corrupt. Corruption returns null rather than throwing — we'd rather restore to
* empty queue than crash on startup because of a bad save. */
suspend fun load(): PersistedQueue? {
val prefs = context.queueDataStore.data.first()
val raw = prefs[KEY_QUEUE_JSON] ?: return null
if (raw.isBlank()) return null
return runCatching {
json.decodeFromString(PersistedQueue.serializer(), raw)
}.getOrNull()
}
suspend fun clear() {
context.queueDataStore.edit { it.clear() }
setResumable(context, false) // Maintainer, 2026-10-29: see save()
}
companion object {
private val KEY_QUEUE_JSON = stringPreferencesKey("queue_json")
private const val RESUME_PREFS = "earhorn_resume"
private const val KEY_RESUMABLE = "resumable"
private fun setResumable(context: Context, resumable: Boolean) {
context.getSharedPreferences(RESUME_PREFS, Context.MODE_PRIVATE)
.edit().putBoolean(KEY_RESUMABLE, resumable).apply()
}
/** Maintainer, 2026-10-29: true when the last saved queue has at least one
* playable track. Read synchronously by the media-button receiver. */
fun hasResumableQueue(context: Context): Boolean =
context.getSharedPreferences(RESUME_PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_RESUMABLE, false)
}
}