feat(home): "Updating your mixes…" veil over daily-rebuild churn
android / Build + lint + test (push) Successful in 3m58s

The 03:00 system-playlist rebuild (playlist.system_rebuilt) re-pulls
Home, and refreshIndex() rewrites every section delete-then-insert — so
all 7 rows + the Playlists row visibly collapse to empty, refill with
skeletons, then pop in per-tile as metadata hydrates. Read as a lot of
busy on-screen movement.

Raise an "Updating your mixes…" veil for automatic refreshes only (daily
rebuild + reconnect re-pull): HomeViewModel.isUpdating, driven by a new
refreshBehindVeil() the event/recovery collectors call in place of
refresh(). It holds through the pull plus a short settle so hydration
lands behind the veil, then wipes off. Manual pull-to-refresh keeps its
PullToRefreshBox spinner; cold start keeps the skeleton.

The veil is a near-opaque, background-tinted overlay that wipes in from
the left and swallows taps while raised. Extracted HomeStateCrossfade so
HomeScreen stays under detekt's LongMethod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 17:52:42 -04:00
parent 29e1e7c64c
commit 772a52b23e
@@ -2,11 +2,19 @@
package com.fabledsword.minstrel.home.ui package com.fabledsword.minstrel.home.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -27,6 +35,7 @@ import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -88,6 +97,7 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -110,6 +120,16 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
private const val RECENTLY_ADDED_GRID_ROWS = 2 private const val RECENTLY_ADDED_GRID_ROWS = 2
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440 private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
// "Updating your mixes…" veil (automatic refresh). Held through the pull
// plus VEIL_SETTLE_MS so per-tile hydration lands behind it before it wipes
// off; near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
private const val VEIL_SETTLE_MS = 500L
private const val VEIL_WIPE_MS = 280
private const val VEIL_ALPHA = 0.96f
private const val VEIL_SPINNER_DP = 22
private const val VEIL_SPINNER_STROKE_DP = 2
private const val VEIL_LABEL_GAP_DP = 12
// ─── State ─────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────
data class HomeSections( data class HomeSections(
@@ -177,22 +197,36 @@ class HomeViewModel @Inject constructor(
*/ */
private val refreshError = MutableStateFlow<String?>(null) private val refreshError = MutableStateFlow<String?>(null)
private val updatingInternal = MutableStateFlow(false)
/**
* True while an automatic background refresh (the 03:00 daily rebuild
* or a reconnect re-pull) is repopulating Home. Drives the "Updating
* your mixes…" veil so the section churn — delete-then-insert in
* [HomeRepository.refreshIndex] plus per-tile hydration — happens
* hidden behind the veil instead of on screen. Manual pull-to-refresh
* and cold start are NOT veiled (they own the pull spinner / skeleton).
*/
val isUpdating: StateFlow<Boolean> = updatingInternal.asStateFlow()
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): a Home that failed to // Screen-level auto-recovery (issue #1245): a Home that failed to
// load while the server was unreachable re-pulls itself the moment // load while the server was unreachable re-pulls itself the moment
// health returns — same idiom as SyncController, one layer up. // health returns — same idiom as SyncController, one layer up.
// Veiled: content is already on screen and would otherwise churn.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { refresh() } networkStatus.recoveries().collect { refreshBehindVeil() }
} }
// #968: the daily 03:00 rebuild (and manual refresh) emit // #968: the daily 03:00 rebuild (and manual refresh) emit
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles // playlist.system_rebuilt; re-pull Home so the system-playlist tiles
// and You-might-like rows reflect the new snapshot without a manual // and You-might-like rows reflect the new snapshot without a manual
// reload. Mirrors the web SSE consumer. // reload. Mirrors the web SSE consumer. Veiled so the multi-section
// rebuild churn hides behind "Updating your mixes…".
viewModelScope.launch { viewModelScope.launch {
eventsStream.events eventsStream.events
.filter { it.kind == "playlist.system_rebuilt" } .filter { it.kind == "playlist.system_rebuilt" }
.collect { refresh() } .collect { refreshBehindVeil() }
} }
} }
@@ -323,6 +357,26 @@ class HomeViewModel @Inject constructor(
status.join() status.join()
} }
/**
* Automatic background refresh with the "Updating your mixes…" veil
* raised (see [isUpdating]). Used by the daily-rebuild + reconnect
* paths where Home is already on screen. Holds the veil through the
* pull plus a short settle so per-tile hydration lands behind it, then
* lets it wipe off. Overlapping automatic refreshes are rare enough
* (once-daily rebuild, reconnect) that a plain flag beats a counter.
*/
private fun refreshBehindVeil() {
viewModelScope.launch {
updatingInternal.value = true
try {
refresh().join()
delay(VEIL_SETTLE_MS)
} finally {
updatingInternal.value = false
}
}
}
val uiState: StateFlow<UiState<HomeSections>> = val uiState: StateFlow<UiState<HomeSections>> =
combineHomeFlows().asCacheFirstStateFlow(viewModelScope) combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
@@ -398,47 +452,108 @@ fun HomeScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle() val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle()
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
PullToRefreshScaffold( PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() }, onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner), modifier = Modifier.fillMaxSize().padding(inner),
) { ) {
// Key Crossfade on the state CLASS, not the instance. Each Box(Modifier.fillMaxSize()) {
// section emission produces a new UiState.Success(data); if HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
// we keyed on `state` directly, every per-section // Automatic-refresh veil: the daily rebuild / reconnect
// hydration tick would re-run the 300ms crossfade, and // churn hides behind an "Updating your mixes…" wipe. Manual
// first-sign-in (six sections cascading in) reads as // pull owns the PullToRefreshBox spinner instead.
// continuous flicker. Keying on the class restricts the UpdatingVeil(visible = updating)
// animation to Loading↔Success↔Empty↔Error transitions and }
// lets normal Success→Success recompositions update the }
// LazyColumn without a fade. }
Crossfade(targetState = state::class, label = "home-state") { _ -> }
when (val s = state) {
UiState.Loading -> HomeSkeletonContent() /**
UiState.Empty -> EmptyState( * The Loading/Empty/Error/Success switch for Home, crossfaded on state.
title = "Welcome to Minstrel", *
body = "Nothing to show yet — scan a folder in your server " + * Key the Crossfade on the state CLASS, not the instance. Each section
"settings, then come back here for system playlists " + * emission produces a new UiState.Success(data); if we keyed on `state`
"and recommendations.", * directly, every per-section hydration tick would re-run the 300ms
) * crossfade, and first-sign-in (six sections cascading in) reads as
is UiState.Error -> ErrorRetry( * continuous flicker. Keying on the class restricts the animation to
title = "Couldn't load home", * Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
message = s.message, * recompositions update the LazyColumn without a fade.
onRetry = { viewModel.refresh() }, */
) @Composable
is UiState.Success -> HomeSuccessContent( private fun HomeStateCrossfade(
sections = s.data, state: UiState<HomeSections>,
systemStatus = systemStatus, systemStatus: SystemPlaylistsStatus,
offline = offline, offline: Boolean,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, navController: NavHostController,
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) }, viewModel: HomeViewModel,
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) }, ) {
onMostPlayedTap = viewModel::playMostPlayed, Crossfade(targetState = state::class, label = "home-state") { _ ->
onPlayPool = viewModel::playPool, when (val s = state) {
onPlayAlbum = viewModel::playAlbum, UiState.Loading -> HomeSkeletonContent()
onPlayArtist = viewModel::playArtistShuffled, UiState.Empty -> EmptyState(
onPlayPlaylist = viewModel::playPlaylist, title = "Welcome to Minstrel",
) body = "Nothing to show yet — scan a folder in your server " +
} "settings, then come back here for system playlists " +
"and recommendations.",
)
is UiState.Error -> ErrorRetry(
title = "Couldn't load home",
message = s.message,
onRetry = { viewModel.refresh() },
)
is UiState.Success -> HomeSuccessContent(
sections = s.data,
systemStatus = systemStatus,
offline = offline,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onMostPlayedTap = viewModel::playMostPlayed,
onPlayPool = viewModel::playPool,
onPlayAlbum = viewModel::playAlbum,
onPlayArtist = viewModel::playArtistShuffled,
onPlayPlaylist = viewModel::playPlaylist,
)
}
}
}
/**
* Full-bleed "Updating your mixes…" veil that wipes in from the left,
* holds while an automatic refresh repopulates Home, then wipes off.
* Near-opaque so the section churn underneath never shows; swallows taps
* while raised so a mid-hydration tile can't be hit.
*/
@Composable
private fun BoxScope.UpdatingVeil(visible: Boolean) {
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally(tween(VEIL_WIPE_MS)) { -it } + fadeIn(tween(VEIL_WIPE_MS)),
exit = slideOutHorizontally(tween(VEIL_WIPE_MS)) { it } + fadeOut(tween(VEIL_WIPE_MS)),
modifier = Modifier.matchParentSize(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = VEIL_ALPHA))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {},
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(VEIL_SPINNER_DP.dp),
strokeWidth = VEIL_SPINNER_STROKE_DP.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(VEIL_LABEL_GAP_DP.dp))
Text(
text = "Updating your mixes…",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
} }
} }
} }