diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index 3157c4ad..7c5bfe6f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt index 9110a894..19aa6269 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt @@ -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" } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt index 724e180c..866e0a23 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt @@ -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 + * 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, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ResumeController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ResumeController.kt new file mode 100644 index 00000000..18940a02 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ResumeController.kt @@ -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(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)), + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ResumePayload.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ResumePayload.kt new file mode 100644 index 00000000..5042dde3 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ResumePayload.kt @@ -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, + val queueIndex: Int, + val positionMs: Long, + val source: String? = null, +) { + companion object { + const val SCHEMA_VERSION: Int = 1 + } +}