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
2 changed files with 70 additions and 0 deletions
Showing only changes of commit e6bf99f580 - Show all commits
@@ -11,6 +11,7 @@ import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.ResumeController
import dagger.hilt.android.HiltAndroidApp
@@ -70,6 +71,14 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var playEventsReporter: PlayEventsReporter
/**
* Same construct-the-singleton trick — CoverPrefetcher's init
* block subscribes to PlayerController.uiState and warms Coil's
* cache with the next track's cover so track changes don't flash
* a loading state.
*/
@Suppress("unused") @Inject lateinit var coverPrefetcher: CoverPrefetcher
/**
* Same construct-the-singleton trick — EventsStream's init block
* subscribes to AuthStore.sessionCookie and opens / closes the
@@ -0,0 +1,61 @@
package com.fabledsword.minstrel.player
import android.content.Context
import coil3.SingletonImageLoader
import coil3.request.ImageRequest
import com.fabledsword.minstrel.di.ApplicationScope
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Warms Coil's memory + disk cache with the *next* queued track's
* cover so when the player advances, the on-screen AsyncImage and
* the NowPlaying dominant-color extraction both hit a cache instead
* of waiting on the network.
*
* Pulls the next cover URL out of [PlayerController.uiState] every
* time the queue or current-index changes, deduplicates, and fires
* a Coil enqueue. Fire-and-forget — the request's own Disposable is
* discarded; Coil's cache write happens on the loader's background
* thread regardless of subscription.
*
* Constructed at app launch via the construct-the-singleton trick
* in [com.fabledsword.minstrel.MinstrelApplication].
*/
@Singleton
class CoverPrefetcher @Inject constructor(
@ApplicationContext private val context: Context,
private val playerController: PlayerController,
@ApplicationScope private val scope: CoroutineScope,
) {
init {
scope.launch {
playerController.uiState
.mapNotNull { it.peekNextCoverUrl() }
.filter { it.isNotEmpty() }
.distinctUntilChanged()
.collect(::prefetch)
}
}
private fun prefetch(url: String) {
val request = ImageRequest.Builder(context)
.data(url)
.build()
SingletonImageLoader.get(context).enqueue(request)
}
}
/**
* Cover URL for `queueIndex + 1`, or null when the player is at the
* end of the queue / has no queue. Empty string is preserved so the
* caller can filter it; we don't want to prefetch a placeholder URL.
*/
private fun PlayerUiState.peekNextCoverUrl(): String? =
queue.getOrNull(queueIndex + 1)?.coverUrl