diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index c7d7e6da..70046f7d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -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(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 = 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> = 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, + 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, + ) } } }