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

QueuePersistenceStore.kt

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)
    }
}