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
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user