feat(home): "Updating your mixes…" veil over daily-rebuild churn
android / Build + lint + test (push) Successful in 3m58s
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:
@@ -2,11 +2,19 @@
|
||||
|
||||
package com.fabledsword.minstrel.home.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -88,6 +97,7 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
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_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 ───────────────────────────────────────────────────────────
|
||||
|
||||
data class HomeSections(
|
||||
@@ -177,22 +197,36 @@ class HomeViewModel @Inject constructor(
|
||||
*/
|
||||
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 {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
||||
// load while the server was unreachable re-pulls itself the moment
|
||||
// health returns — same idiom as SyncController, one layer up.
|
||||
// Veiled: content is already on screen and would otherwise churn.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect { refresh() }
|
||||
networkStatus.recoveries().collect { refreshBehindVeil() }
|
||||
}
|
||||
// #968: the daily 03:00 rebuild (and manual refresh) emit
|
||||
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
|
||||
// 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 {
|
||||
eventsStream.events
|
||||
.filter { it.kind == "playlist.system_rebuilt" }
|
||||
.collect { refresh() }
|
||||
.collect { refreshBehindVeil() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +357,26 @@ class HomeViewModel @Inject constructor(
|
||||
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>> =
|
||||
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
||||
|
||||
@@ -398,47 +452,108 @@ fun HomeScreen(
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) {
|
||||
// Key Crossfade on the state CLASS, not the instance. Each
|
||||
// section emission produces a new UiState.Success(data); if
|
||||
// we keyed on `state` directly, every per-section
|
||||
// hydration tick would re-run the 300ms crossfade, and
|
||||
// first-sign-in (six sections cascading in) reads as
|
||||
// continuous flicker. Keying on the class restricts the
|
||||
// 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(
|
||||
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,
|
||||
)
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
||||
// Automatic-refresh veil: the daily rebuild / reconnect
|
||||
// churn hides behind an "Updating your mixes…" wipe. Manual
|
||||
// pull owns the PullToRefreshBox spinner instead.
|
||||
UpdatingVeil(visible = updating)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Loading/Empty/Error/Success switch for Home, crossfaded on state.
|
||||
*
|
||||
* Key the Crossfade on the state CLASS, not the instance. Each section
|
||||
* emission produces a new UiState.Success(data); if we keyed on `state`
|
||||
* directly, every per-section hydration tick would re-run the 300ms
|
||||
* crossfade, and first-sign-in (six sections cascading in) reads as
|
||||
* continuous flicker. Keying on the class restricts the animation to
|
||||
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
|
||||
* recompositions update the LazyColumn without a fade.
|
||||
*/
|
||||
@Composable
|
||||
private fun HomeStateCrossfade(
|
||||
state: UiState<HomeSections>,
|
||||
systemStatus: SystemPlaylistsStatus,
|
||||
offline: Boolean,
|
||||
navController: NavHostController,
|
||||
viewModel: HomeViewModel,
|
||||
) {
|
||||
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
||||
when (val s = state) {
|
||||
UiState.Loading -> HomeSkeletonContent()
|
||||
UiState.Empty -> EmptyState(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user