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 129 additions and 0 deletions
Showing only changes of commit 57d01d70ad - Show all commits
@@ -14,6 +14,7 @@ import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.VersionCheckController
import dagger.hilt.android.HiltAndroidApp
@@ -73,6 +74,16 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var playEventsReporter: PlayEventsReporter
/**
* Same construct-the-singleton trick — PlaybackErrorReporter
* subscribes to PlayerController.playbackErrorEvents and emits
* coalesced "Couldn't play X" / "Skipped N tracks" messages on
* its own Flow, which ShellScaffold surfaces in the shared
* snackbar. Without this construct ExoPlayer skip-on-error is
* invisible to the user.
*/
@Suppress("unused") @Inject lateinit var playbackErrorReporter: PlaybackErrorReporter
/**
* Same construct-the-singleton trick — CoverPrefetcher's init
* block subscribes to PlayerController.uiState and warms Coil's
@@ -0,0 +1,68 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
private const val DEBOUNCE_MS = 2_000L
/**
* Surfaces ExoPlayer track-load failures (404 / decoder failure /
* premature EOS / network drop) as user-visible snackbar messages.
*
* Without this the player just silently skips the dead track — the
* worst kind of bug, because the user can't tell "this file is
* broken" from "the app is flaky".
*
* Pattern mirrors Flutter's `playback_error_reporter.dart`: collect
* per-error events from [PlayerController.playbackErrorEvents],
* debounce in a 2s window, and emit "Couldn't play 'X' — skipping"
* for a single error or "Skipped N unplayable tracks" when a burst
* lands inside the window. Stops the user from seeing a stack of N
* toasts on a network blip that fails N tracks in a row.
*
* ShellScaffold collects [messages] into its existing snackbar host
* so the reporter doesn't need its own UI surface. Constructed at
* app launch via the construct-the-singleton trick in
* [com.fabledsword.minstrel.MinstrelApplication].
*/
@Singleton
class PlaybackErrorReporter @Inject constructor(
private val playerController: PlayerController,
@ApplicationScope private val scope: CoroutineScope,
) {
private val outChannel = Channel<String>(Channel.BUFFERED)
/** Coalesced user-facing snackbar text. Subscribers should show in a SnackbarHost. */
val messages: Flow<String> = outChannel.receiveAsFlow()
init {
scope.launch {
val buffer = mutableListOf<String>()
var debounceJob: kotlinx.coroutines.Job? = null
playerController.playbackErrorEvents.collect { title ->
buffer.add(title)
debounceJob?.cancel()
debounceJob = scope.launch {
delay(DEBOUNCE_MS)
if (buffer.isEmpty()) return@launch
val n = buffer.size
val first = buffer.first()
buffer.clear()
val msg = if (n == 1) {
"Couldn't play \"$first\" — skipping"
} else {
"Skipped $n unplayable tracks"
}
outChannel.trySend(msg)
}
}
}
}
}
@@ -12,9 +12,12 @@ import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
@@ -59,6 +62,16 @@ class PlayerController @Inject constructor(
private val uiStateInternal = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = uiStateInternal.asStateFlow()
/**
* Per-error events for [com.fabledsword.minstrel.player.PlaybackErrorReporter].
* Emits the failing track's title (or `"Track"` when unknown) each
* time Media3's `onPlayerError` fires — not bound to the lifetime of
* any UI subscriber so a torn-down screen doesn't drop events.
* Conflated channel so backpressure can't stall the player loop.
*/
private val playbackErrorEventsChannel = Channel<String>(Channel.CONFLATED)
val playbackErrorEvents: Flow<String> = playbackErrorEventsChannel.receiveAsFlow()
/**
* Stable queue snapshot kept in sync with the player's MediaItems —
* the player's own getMediaItem(index) returns Media3 types; we keep
@@ -181,6 +194,15 @@ class PlayerController @Inject constructor(
mediaController = controller
controller.addListener(
object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
val title = queueRefs
.getOrNull(controller.currentMediaItemIndex)
?.title
?.takeIf { it.isNotEmpty() }
?: "Track"
playbackErrorEventsChannel.trySend(title)
}
override fun onEvents(player: Player, events: Player.Events) {
val idx = player.currentMediaItemIndex
val current = queueRefs.getOrNull(idx)
@@ -0,0 +1,21 @@
package com.fabledsword.minstrel.player.ui
import androidx.lifecycle.ViewModel
import com.fabledsword.minstrel.player.PlaybackErrorReporter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Hilt-injectable wrapper exposing [PlaybackErrorReporter.messages]
* to ShellScaffold. The reporter itself is an app-scoped singleton;
* this VM just bridges its Flow into a `hiltViewModel()`-resolvable
* surface so ShellScaffold can collect it without an EntryPoint
* accessor.
*/
@HiltViewModel
class PlaybackErrorViewModel @Inject constructor(
reporter: PlaybackErrorReporter,
) : ViewModel() {
val messages: Flow<String> = reporter.messages
}
@@ -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.player.ui.PlaybackErrorViewModel
import com.fabledsword.minstrel.update.ui.VersionTooOldBanner
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
@@ -45,6 +46,7 @@ fun ShellScaffold(
onExpandPlayer: () -> Unit,
modifier: Modifier = Modifier,
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(),
content: @Composable () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
@@ -53,6 +55,11 @@ fun ShellScaffold(
snackbarHostState.showSnackbar(msg)
}
}
LaunchedEffect(Unit) {
playbackErrorViewModel.messages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
Column(modifier = modifier.fillMaxSize()) {
// Banner slot. VersionTooOldBanner appears when the server's
// min_client_version exceeds our build; ConnectionErrorBanner