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
6 changed files with 104 additions and 34 deletions
Showing only changes of commit e6e4f6dcf1 - Show all commits
@@ -53,7 +53,9 @@ import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
@@ -100,15 +102,19 @@ class HomeViewModel @Inject constructor(
) : ViewModel() {
init {
// Fire-and-forget initial pull; the screen renders cached sections
// immediately and refreshes as the IDs land. Errors map to the
// Error state via the combine() chain's catch below.
viewModelScope.launch {
runCatching { homeRepository.refreshIndex() }
}
viewModelScope.launch {
runCatching { playlistsRepository.refreshList() }
}
refresh()
}
/**
* Pulls both /home/index and the playlists list. Returns the Job
* for the combined refresh so a pull-to-refresh wrapper can await
* actual completion before hiding the indicator.
*/
fun refresh(): Job = viewModelScope.launch {
val home = launch { runCatching { homeRepository.refreshIndex() } }
val lists = launch { runCatching { playlistsRepository.refreshList() } }
home.join()
lists.join()
}
val uiState: StateFlow<HomeUiState> = combineHomeFlows().stateIn(
@@ -170,7 +176,10 @@ fun HomeScreen(
},
) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
HomeUiState.Loading -> LoadingCentered()
HomeUiState.Empty -> EmptyState(
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
@@ -88,7 +89,10 @@ fun AlbumDetailScreen(
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AlbumDetailUiState.Loading -> LoadingCentered()
is AlbumDetailUiState.Error -> EmptyState(
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.models.AlbumDetailRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.player.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -62,17 +63,15 @@ class AlbumDetailViewModel @Inject constructor(
refresh()
}
fun refresh() {
viewModelScope.launch {
internal.value = AlbumDetailUiState.Loading
try {
val detail = repository.refreshAlbumDetail(albumId)
internal.value = AlbumDetailUiState.Success(detail)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AlbumDetailUiState.Error(e.message ?: "Album load failed")
}
fun refresh(): Job = viewModelScope.launch {
internal.value = AlbumDetailUiState.Loading
try {
val detail = repository.refreshAlbumDetail(albumId)
internal.value = AlbumDetailUiState.Success(detail)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AlbumDetailUiState.Error(e.message ?: "Album load failed")
}
}
@@ -49,6 +49,7 @@ import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -70,7 +71,10 @@ fun ArtistDetailScreen(
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
ArtistDetailUiState.Loading -> LoadingCentered()
is ArtistDetailUiState.Error -> EmptyState(
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.player.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -59,17 +60,15 @@ class ArtistDetailViewModel @Inject constructor(
}
}
fun refresh() {
viewModelScope.launch {
internal.value = ArtistDetailUiState.Loading
try {
val detail = repository.refreshArtistDetail(artistId)
internal.value = ArtistDetailUiState.Success(detail)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = ArtistDetailUiState.Error(e.message ?: "Artist load failed")
}
fun refresh(): Job = viewModelScope.launch {
internal.value = ArtistDetailUiState.Loading
try {
val detail = repository.refreshArtistDetail(artistId)
internal.value = ArtistDetailUiState.Success(detail)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = ArtistDetailUiState.Error(e.message ?: "Artist load failed")
}
}
@@ -0,0 +1,55 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import kotlinx.coroutines.launch
/**
* Wraps [content] in a Material3 PullToRefreshBox so the user can
* swipe-down to trigger [onRefresh]. The wrapper manages the
* `isRefreshing` indicator while [onRefresh] is in flight.
*
* [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the
* indicator hides exactly when the underlying coroutine completes,
* not on a heuristic delay. ViewModels whose existing `refresh()`
* launched into viewModelScope just need to start returning the Job
* (`fun refresh(): Job = viewModelScope.launch { ... }`).
*
* Mirrors the Flutter `RefreshIndicator` wrapping that every list +
* grid has in the web/mobile client — Android v1 was missing this
* everywhere (audit #8).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PullToRefreshScaffold(
onRefresh: suspend () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
var isRefreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
scope.launch {
isRefreshing = true
try {
onRefresh()
} finally {
isRefreshing = false
}
}
},
modifier = modifier.fillMaxSize(),
) {
content()
}
}