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 199 additions and 3 deletions
Showing only changes of commit ae26d66987 - Show all commits
@@ -14,6 +14,7 @@ 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 com.fabledsword.minstrel.update.data.VersionCheckController
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -79,6 +80,14 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var coverPrefetcher: CoverPrefetcher
/**
* Same construct-the-singleton trick — VersionCheckController's
* init block starts a 5-min poll loop against /healthz so the
* shell-level VersionTooOldBanner can surface min_client_version
* mismatches without waiting for the next user-driven request.
*/
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
/**
* Same construct-the-singleton trick — EventsStream's init block
* subscribes to AuthStore.sessionCookie and opens / closes the
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.player.ui.MiniPlayer
import com.fabledsword.minstrel.update.ui.VersionTooOldBanner
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
/**
@@ -53,9 +54,11 @@ fun ShellScaffold(
}
}
Column(modifier = modifier.fillMaxSize()) {
// Banner slot. ConnectionErrorBanner auto-shows when the device
// loses usable internet. VersionTooOld / UpdateBanner will join
// this slot once those mechanisms exist.
// Banner slot. VersionTooOldBanner appears when the server's
// min_client_version exceeds our build; ConnectionErrorBanner
// when the device loses usable internet. UpdateBanner will
// join this slot once #25 ships.
VersionTooOldBanner()
ConnectionErrorBanner()
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
content()
@@ -0,0 +1,28 @@
package com.fabledsword.minstrel.update.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.GET
/**
* Retrofit interface for `GET /healthz` — unauthenticated health probe
* returning the server's running version + the minimum client version
* it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController]
* to surface the VersionTooOld banner.
*/
interface HealthzApi {
@GET("healthz")
suspend fun check(): HealthzResponse
}
/**
* Wire shape for /healthz. `minClientVersion` may be empty on older
* servers; the caller treats empty as "skip" rather than "fail" so a
* partial deploy doesn't gate the UI.
*/
@Serializable
data class HealthzResponse(
val status: String = "",
val version: String = "",
@SerialName("min_client_version") val minClientVersion: String = "",
)
@@ -0,0 +1,67 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.update.api.HealthzApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import javax.inject.Inject
import javax.inject.Singleton
private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
/**
* Result of the most recent /healthz version-compatibility check.
* `Skipped` means the server didn't include `min_client_version`
* (partial deploy or older server) and the UI should not gate.
*/
enum class VersionResult { OK, TOO_OLD, SKIPPED }
/**
* Polls /healthz periodically and exposes the current
* [VersionResult] for the shell's VersionTooOld banner. Soft-fails
* on network errors — keeps the last-known result rather than
* showing a misleading "too old" on a connectivity blip.
*
* Constructed at app launch via the construct-the-singleton trick
* in [com.fabledsword.minstrel.MinstrelApplication].
*/
@Singleton
class VersionCheckController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
retrofit: Retrofit,
) {
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
private val internal = MutableStateFlow(VersionResult.SKIPPED)
val result: StateFlow<VersionResult> = internal.asStateFlow()
init {
scope.launch {
while (true) {
runOnce()
delay(POLL_INTERVAL_MS)
}
}
}
/** One-shot recheck, bypassing the poll cadence. Used by the banner's "Check now" button. */
fun recheck() {
scope.launch { runOnce() }
}
private suspend fun runOnce() {
val response = runCatching { api.check() }.getOrNull() ?: return
val min = response.minClientVersion
internal.value = when {
min.isEmpty() -> VersionResult.SKIPPED
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
else -> VersionResult.OK
}
}
}
@@ -0,0 +1,89 @@
package com.fabledsword.minstrel.update.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.TriangleAlert
import com.fabledsword.minstrel.update.data.VersionCheckController
import com.fabledsword.minstrel.update.data.VersionResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
/**
* Tiny VM that exposes the [VersionCheckController]'s current
* [VersionResult] plus its recheck trigger for the banner button.
*/
@HiltViewModel
class VersionTooOldViewModel @Inject constructor(
private val controller: VersionCheckController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() {
val result: StateFlow<VersionResult> = controller.result
fun recheck() = controller.recheck()
}
/**
* Shell-level banner shown when the server reports `min_client_version`
* newer than our build. Non-blocking — locally cached content keeps
* working; the banner is the nudge to install the next APK. "Check
* now" forces a fresh /healthz probe.
*/
@Composable
fun VersionTooOldBanner(viewModel: VersionTooOldViewModel = hiltViewModel()) {
val result by viewModel.result.collectAsStateWithLifecycle()
AnimatedVisibility(
visible = result == VersionResult.TOO_OLD,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Lucide.TriangleAlert,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = "This app is older than the server requires. " +
"Cached content still works; install an update when ready.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { viewModel.recheck() }) {
Text(
text = "Check now",
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
}