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