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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user