feat(android): ResumeController + persist last queue (M8 phase 6.5 — closes Phase 6)

Phase 6 closes. A torn-down player session now resumes the last queue
on next app launch — the equivalent of the Flutter ResumeController's
job, but plumbed via PlayerController's StateFlow rather than the
audio_service idle-stop dance.

Files:
  - models/TrackRef.kt: add @Serializable so List<TrackRef> can be
    JSON-encoded by the persistence path (mild leak of persistence
    concern into the domain type; alternative duplicate-DTO approach
    not worth the boilerplate yet).
  - player/ResumePayload.kt: @Serializable persisted shape
    (schema version + tracks + queueIndex + positionMs + source).
    `schema` field lets future schema drift drop unreadable rows
    gracefully rather than crash.
  - player/ResumeController.kt: collects PlayerController.uiState;
    persists when (currentTrack id, queueIndex, queue.size) changes —
    captures real session transitions without churning on the 1Hz
    position tick. restore() decodes the row and calls
    PlayerController.setQueue. Catches SerializationException +
    drops the row on schema drift.
  - cache/db/DatabaseModule.kt: @Provides CachedResumeStateDao bridge.
  - MinstrelApplication: @Inject ResumeController + ApplicationScope
    CoroutineScope; onCreate launches resumeController.restore().
    Injecting forces Hilt to construct the singleton so its
    observe-and-persist init block runs.

No circular DI — ResumeController depends on PlayerController, not
the other way around.

This closes Phase 6 of the M8 native rewrite. The player layer is
feature-complete enough to demo on a device once playback wiring
arrives (Phase 11 settings → server URL, Phase 12 sync controller →
library data, and a "Play this album" affordance — none of which
exist yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 23:22:59 -04:00
parent 5736bff174
commit 2a28d22a2a
5 changed files with 143 additions and 0 deletions
@@ -6,7 +6,11 @@ import androidx.work.Configuration
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.ResumeController
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
@@ -26,9 +30,20 @@ class MinstrelApplication :
*/
@Inject lateinit var okHttpClient: OkHttpClient
/**
* Injecting ResumeController forces Hilt to construct the singleton
* so its init block (observe + persist on track change) wires up at
* app launch. We also fire `restore()` from onCreate so a torn-down
* player resumes the last queue without requiring UI interaction.
*/
@Inject lateinit var resumeController: ResumeController
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
appScope.launch { resumeController.restore() }
}
override val workManagerConfiguration: Configuration
@@ -5,6 +5,7 @@ import androidx.room.Room
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import dagger.Module
import dagger.Provides
@@ -46,5 +47,10 @@ object DatabaseModule {
@Singleton
fun provideCachedTrackDao(db: AppDatabase): CachedTrackDao = db.cachedTrackDao()
@Provides
@Singleton
fun provideCachedResumeStateDao(db: AppDatabase): CachedResumeStateDao =
db.cachedResumeStateDao()
private const val DATABASE_NAME = "minstrel.db"
}
@@ -1,5 +1,7 @@
package com.fabledsword.minstrel.models
import kotlinx.serialization.Serializable
/**
* Lightweight reference to one track. Mirrors
* `flutter_client/lib/models/track.dart`'s `TrackRef`.
@@ -11,7 +13,13 @@ package com.fabledsword.minstrel.models
* Constructed from `TrackWire` (server JSON) via `LibraryMappers`, and
* from `CachedTrackEntity` (Room cache) via the same mappers. Either
* source produces an equivalent TrackRef.
*
* `@Serializable` because the ResumeController persists List<TrackRef>
* as JSON in `cached_resume_state.json`. (Mild leak of persistence
* concern into the domain type, but the alternative — a duplicate
* persisted DTO with manual mappers — wasn't worth the boilerplate.)
*/
@Serializable
data class TrackRef(
val id: String,
val title: String,
@@ -0,0 +1,91 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists the player's last queue + position to Room so a torn-down
* session can resume on next app launch. Mirrors
* `flutter_client/lib/cache/resume_controller.dart`.
*
* Subscribes to [PlayerController.uiState] in init; persists when the
* (track-id, queueIndex) changes — captures real session transitions
* (track started / skipped) without churning on every 1Hz position tick.
*
* Restore is one-shot at app start (called from `MinstrelApplication.
* onCreate`). Reads the row, decodes, calls
* [PlayerController.setQueue] — the player's transport buttons + any
* paired Wear/lock-screen surface now have something to act on even
* if the user never opened the in-app UI this run.
*/
@Singleton
class ResumeController @Inject constructor(
private val playerController: PlayerController,
private val dao: CachedResumeStateDao,
private val json: Json,
@ApplicationScope private val scope: CoroutineScope,
) {
init {
scope.launch { observeAndPersist() }
}
suspend fun restore() {
val row = dao.get() ?: return
val payload =
try {
json.decodeFromString<ResumePayload>(row.json)
} catch (e: SerializationException) {
// Schema drift or corruption — drop the row and start clean
// rather than crashing or leaving a poison pill.
Timber.w(e, "ResumeController: dropping unreadable resume state")
dao.clear()
return
}
if (payload.tracks.isEmpty()) return
playerController.setQueue(
tracks = payload.tracks,
initialIndex = payload.queueIndex.coerceAtLeast(0).coerceAtMost(payload.tracks.lastIndex),
source = payload.source,
)
// PositionMs not seeked yet — Player connection may still be
// establishing; the seek call would no-op. Acceptable to start
// from 0 on resume for now; #future-polish if it bothers anyone.
}
private suspend fun observeAndPersist() {
playerController.uiState
// Skip the initial "empty" snapshot before anything has been played.
.drop(1)
.map { Triple(it.currentTrack?.id, it.queueIndex, it.queue.size) }
.distinctUntilChanged()
.collect { persistCurrentSnapshot() }
}
private suspend fun persistCurrentSnapshot() {
val state = playerController.uiState.value
if (state.queue.isEmpty()) {
// Clear so an emptied queue doesn't auto-restore stale tracks.
dao.clear()
return
}
val payload = ResumePayload(
tracks = state.queue,
queueIndex = state.queueIndex.coerceAtLeast(0),
positionMs = state.positionMs,
)
dao.upsert(
CachedResumeStateEntity(json = json.encodeToString(payload)),
)
}
}
@@ -0,0 +1,23 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.serialization.Serializable
/**
* The JSON-serialized shape persisted in
* `cached_resume_state.json` by [ResumeController]. Versioned via the
* `schema` field so a future change can read old snapshots safely
* (decode → drop on mismatch rather than crash).
*/
@Serializable
data class ResumePayload(
val schema: Int = SCHEMA_VERSION,
val tracks: List<TrackRef>,
val queueIndex: Int,
val positionMs: Long,
val source: String? = null,
) {
companion object {
const val SCHEMA_VERSION: Int = 1
}
}