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.ImageLoader
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.ResumeController
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -26,9 +30,20 @@ class MinstrelApplication :
*/ */
@Inject lateinit var okHttpClient: OkHttpClient @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() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
appScope.launch { resumeController.restore() }
} }
override val workManagerConfiguration: Configuration 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.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao 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 com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -46,5 +47,10 @@ object DatabaseModule {
@Singleton @Singleton
fun provideCachedTrackDao(db: AppDatabase): CachedTrackDao = db.cachedTrackDao() fun provideCachedTrackDao(db: AppDatabase): CachedTrackDao = db.cachedTrackDao()
@Provides
@Singleton
fun provideCachedResumeStateDao(db: AppDatabase): CachedResumeStateDao =
db.cachedResumeStateDao()
private const val DATABASE_NAME = "minstrel.db" private const val DATABASE_NAME = "minstrel.db"
} }
@@ -1,5 +1,7 @@
package com.fabledsword.minstrel.models package com.fabledsword.minstrel.models
import kotlinx.serialization.Serializable
/** /**
* Lightweight reference to one track. Mirrors * Lightweight reference to one track. Mirrors
* `flutter_client/lib/models/track.dart`'s `TrackRef`. * `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 * Constructed from `TrackWire` (server JSON) via `LibraryMappers`, and
* from `CachedTrackEntity` (Room cache) via the same mappers. Either * from `CachedTrackEntity` (Room cache) via the same mappers. Either
* source produces an equivalent TrackRef. * 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( data class TrackRef(
val id: String, val id: String,
val title: 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
}
}