feat: M3 weighted shuffle v1 — real /api/radio with scoring #21

Merged
bvandeusen merged 262 commits from dev into main 2026-04-27 11:00:29 -04:00
5 changed files with 143 additions and 0 deletions
Showing only changes of commit 2a28d22a2a - Show all commits
@@ -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
}
}