From 09102dc6158cd0d8285a874621371d4f15c38670 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 2 Jul 2026 16:02:14 -0400 Subject: [PATCH] fix(android/resilience): auto-recover failed loads on reconnect (#1245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failed loads used to stay failed forever: no screen ViewModel listened to server-health recovery, and the cache-first screens swallowed refresh errors so a cold load against a down server looked like an empty account. - connectivity/Recovery.kt: recoveries() — per-collector flow of down→Healthy transitions; the screen-level half of the idiom SyncController/MutationReplayer/DiagnosticsUploader already use. - Every screen VM now re-runs its load on recovery (cache-first screens unconditionally; direct-load screens when sitting in Error). - Cache-first error surfacing: Home / Playlists / Liked track refresh failure; Library reads SyncController.lastSyncError (new) — empty cache + failed refresh now renders Error-with-Retry, not welcome copy. - Requests: 12s poll also retries from Error (was structurally unable to escape it — the in-flight predicate required a Success state). - Search: retry() bypasses the distinctUntilChanged query pipeline so a same-text resubmit after a transient failure actually re-runs. - ArtistDetail: secondary sections (similar artists / top tracks) re-fetch on recovery instead of staying silently absent. - ErrorRetry: LazyColumn wrapper (pull-to-refresh works on error states, same rationale as EmptyState) + optional title; adopted on every error branch; PlaylistDetail's one-shot ErrorBlock removed in its favor. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 --- .../admin/ui/AdminInvitesViewModel.kt | 10 +++ .../admin/ui/AdminQuarantineScreen.kt | 6 +- .../admin/ui/AdminQuarantineViewModel.kt | 10 +++ .../minstrel/admin/ui/AdminRequestsScreen.kt | 6 +- .../admin/ui/AdminRequestsViewModel.kt | 10 +++ .../minstrel/admin/ui/AdminUsersViewModel.kt | 10 +++ .../minstrel/cache/sync/SyncController.kt | 19 ++++- .../minstrel/connectivity/Recovery.kt | 27 +++++++ .../minstrel/discover/ui/DiscoverScreen.kt | 15 +++- .../minstrel/discover/ui/DiscoverViewModel.kt | 12 +++ .../minstrel/history/ui/HistoryTab.kt | 14 +++- .../minstrel/home/ui/HomeScreen.kt | 42 ++++++++-- .../minstrel/library/ui/AlbumDetailScreen.kt | 7 +- .../library/ui/AlbumDetailViewModel.kt | 11 +++ .../minstrel/library/ui/ArtistDetailScreen.kt | 7 +- .../library/ui/ArtistDetailViewModel.kt | 16 ++++ .../minstrel/library/ui/LibraryViewModel.kt | 16 ++-- .../fabledsword/minstrel/likes/ui/LikedTab.kt | 32 +++++++- .../playlists/ui/PlaylistDetailScreen.kt | 47 ++++------- .../playlists/ui/PlaylistsListScreen.kt | 35 ++++++--- .../minstrel/quarantine/ui/HiddenTab.kt | 6 +- .../quarantine/ui/HiddenTabViewModel.kt | 8 ++ .../minstrel/requests/ui/RequestsScreen.kt | 6 +- .../minstrel/requests/ui/RequestsViewModel.kt | 17 +++- .../minstrel/search/ui/SearchScreen.kt | 9 ++- .../minstrel/search/ui/SearchViewModel.kt | 23 ++++++ .../minstrel/shared/widgets/ErrorRetry.kt | 78 ++++++++++++------- 27 files changed, 396 insertions(+), 103 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/connectivity/Recovery.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminInvitesViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminInvitesViewModel.kt index 5a70ae3a..e1f6a956 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminInvitesViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminInvitesViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.admin.data.AdminInvitesRepository import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.models.Invite import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel @@ -25,6 +27,7 @@ data class AdminInvitesUiState( @HiltViewModel class AdminInvitesViewModel @Inject constructor( private val repository: AdminInvitesRepository, + networkStatus: NetworkStatusController, ) : ViewModel() { private val internal = MutableStateFlow(AdminInvitesUiState()) @@ -36,6 +39,13 @@ class AdminInvitesViewModel @Inject constructor( init { refresh() + // Screen-level auto-recovery (issue #1245): reload a failed list + // when server health returns instead of waiting for a manual pull. + viewModelScope.launch { + networkStatus.recoveries().collect { + if (internal.value.message != null) refresh() + } + } } fun refresh() { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt index db4f9c43..5822ad19 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt @@ -29,6 +29,7 @@ import androidx.navigation.NavHostController import com.fabledsword.minstrel.models.AdminQuarantineItemRef import com.fabledsword.minstrel.nav.AdminQuarantine import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold @@ -62,9 +63,10 @@ fun AdminQuarantineScreen( body = "When users flag tracks as bad rips, wrong tags, or " + "duplicates, their reports get aggregated and surfaced here.", ) - is AdminQuarantineUiState.Error -> EmptyState( + is AdminQuarantineUiState.Error -> ErrorRetry( title = "Couldn't load queue", - body = s.message, + message = s.message, + onRetry = { viewModel.refresh() }, ) is AdminQuarantineUiState.Success -> QueueList( rows = s.rows, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt index f894aed8..3e05b469 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.models.AdminQuarantineItemRef import dagger.hilt.android.lifecycle.HiltViewModel @@ -26,6 +28,7 @@ sealed interface AdminQuarantineUiState { class AdminQuarantineViewModel @Inject constructor( private val repository: AdminQuarantineRepository, private val eventsStream: EventsStream, + networkStatus: NetworkStatusController, ) : ViewModel() { private val internal = MutableStateFlow(AdminQuarantineUiState.Loading) @@ -38,6 +41,13 @@ class AdminQuarantineViewModel @Inject constructor( .filter { it.kind.startsWith("quarantine.") } .collect { refresh() } } + // Screen-level auto-recovery (issue #1245): reload a failed list + // when server health returns instead of waiting for a manual pull. + viewModelScope.launch { + networkStatus.recoveries().collect { + if (internal.value is AdminQuarantineUiState.Error) refresh() + } + } } fun refresh(): Job = viewModelScope.launch { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt index ff166749..bad71271 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt @@ -28,6 +28,7 @@ import androidx.navigation.NavHostController import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.nav.AdminRequests import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold @@ -61,9 +62,10 @@ fun AdminRequestsScreen( body = "When users ask Lidarr for new music, their pending " + "requests show up here for approval.", ) - is AdminRequestsUiState.Error -> EmptyState( + is AdminRequestsUiState.Error -> ErrorRetry( title = "Couldn't load requests", - body = s.message, + message = s.message, + onRetry = { viewModel.refresh() }, ) is AdminRequestsUiState.Success -> RequestList( rows = s.rows, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt index 6ac37758..95a5af32 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.admin.data.AdminRequestsRepository import com.fabledsword.minstrel.admin.data.AdminUsersRepository import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.models.RequestRef import dagger.hilt.android.lifecycle.HiltViewModel @@ -35,6 +37,7 @@ class AdminRequestsViewModel @Inject constructor( private val repository: AdminRequestsRepository, private val usersRepository: AdminUsersRepository, private val eventsStream: EventsStream, + networkStatus: NetworkStatusController, ) : ViewModel() { private val internal = MutableStateFlow(AdminRequestsUiState.Loading) @@ -47,6 +50,13 @@ class AdminRequestsViewModel @Inject constructor( .filter { it.kind in RELEVANT_EVENT_KINDS } .collect { refresh() } } + // Screen-level auto-recovery (issue #1245): reload a failed list + // when server health returns instead of waiting for a manual pull. + viewModelScope.launch { + networkStatus.recoveries().collect { + if (internal.value is AdminRequestsUiState.Error) refresh() + } + } } fun refresh(): Job = viewModelScope.launch { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersViewModel.kt index bf17fab8..029c0a34 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.admin.data.AdminUsersRepository import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.models.AdminUserRef import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -23,6 +25,7 @@ sealed interface AdminUsersUiState { @HiltViewModel class AdminUsersViewModel @Inject constructor( private val repository: AdminUsersRepository, + networkStatus: NetworkStatusController, ) : ViewModel() { private val internal = MutableStateFlow(AdminUsersUiState.Loading) @@ -30,6 +33,13 @@ class AdminUsersViewModel @Inject constructor( init { refresh() + // Screen-level auto-recovery (issue #1245): reload a failed list + // when server health returns instead of waiting for a manual pull. + viewModelScope.launch { + networkStatus.recoveries().collect { + if (internal.value is AdminUsersUiState.Error) refresh() + } + } } fun refresh(): Job = viewModelScope.launch { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt index 635f7290..106c3827 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt @@ -1,5 +1,6 @@ package com.fabledsword.minstrel.cache.sync +import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.endpoints.SyncApi import com.fabledsword.minstrel.auth.AuthStore import com.fabledsword.minstrel.connectivity.NetworkStatusController @@ -17,6 +18,9 @@ import com.fabledsword.minstrel.models.wire.SyncAlbumWire import com.fabledsword.minstrel.models.wire.SyncArtistWire import com.fabledsword.minstrel.models.wire.SyncTrackWire import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull @@ -64,6 +68,17 @@ class SyncController @Inject constructor( // pull-to-refresh) can fire concurrently; coalesce them into one pass. private val mutex = Mutex() + private val lastSyncErrorInternal = MutableStateFlow(null) + + /** + * Human copy for the most recent sync failure; null after any clean + * pass. Lets the Library screen distinguish "empty because the first + * sync failed" (show error + retry) from "genuinely empty library" + * (show welcome copy) — a failed sync over a populated cache stays + * silent, since the cached content is still the better surface. + */ + val lastSyncError: StateFlow = lastSyncErrorInternal.asStateFlow() + init { scope.launch { authStore.sessionCookie @@ -82,11 +97,13 @@ class SyncController @Inject constructor( } } - /** Public entry-point for "Sync now" affordances. Swallows errors. */ + /** Public entry-point for "Sync now" affordances. Swallows errors into [lastSyncError]. */ suspend fun syncSafe() { if (!mutex.tryLock()) return try { runCatching { sync() } + .onSuccess { lastSyncErrorInternal.value = null } + .onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) } } finally { mutex.unlock() } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/Recovery.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/Recovery.kt new file mode 100644 index 00000000..b29681ff --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/Recovery.kt @@ -0,0 +1,27 @@ +package com.fabledsword.minstrel.connectivity + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map + +/** + * Emits once each time server health RETURNS to [ServerHealth.Healthy] + * after the collector subscribed — the StateFlow's replayed current value + * is dropped, so only genuine down→up transitions fire (a screen that + * subscribes while already Healthy doesn't double-load). + * + * This is the screen-level half of the recovery idiom the app-lifetime + * singletons (SyncController / MutationReplayer / DiagnosticsUploader) + * already use: a ViewModel collects this in its viewModelScope and re-runs + * its load, so a surface that failed while the server was unreachable + * heals itself the moment connectivity returns instead of sitting in the + * failed state until a manual pull-to-refresh. + */ +fun NetworkStatusController.recoveries(): Flow = state + .map { it == ServerHealth.Healthy } + .distinctUntilChanged() + .drop(1) + .filter { it } + .map { } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt index ce0d7648..56630ac7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt @@ -41,6 +41,7 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef import com.fabledsword.minstrel.models.LidarrRequestKind import com.fabledsword.minstrel.models.LidarrSearchResultRef import com.fabledsword.minstrel.nav.Discover +import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold @@ -109,6 +110,7 @@ private fun DiscoverBody( snackbar.showSnackbar(snackbarFor(outcome, s.name)) } }, + onRetry = { viewModel.loadSuggestions() }, ) ResultsState.Loading -> LoadingCentered() is ResultsState.Loaded -> ResultsList( @@ -121,7 +123,11 @@ private fun DiscoverBody( } }, ) - is ResultsState.Error -> CenteredMessage("Search failed: ${r.message}") + is ResultsState.Error -> ErrorRetry( + title = "Search failed", + message = r.message, + onRetry = viewModel::runSearch, + ) } } } @@ -177,10 +183,15 @@ private fun SuggestionsPane( state: SuggestionState, locallyRequestedMbids: Set, onRequest: (ArtistSuggestionRef) -> Unit, + onRetry: () -> Unit, ) { when (state) { SuggestionState.Loading -> LoadingCentered() - is SuggestionState.Error -> CenteredMessage("Couldn't load suggestions.") + is SuggestionState.Error -> ErrorRetry( + title = "Couldn't load suggestions", + message = state.message, + onRetry = onRetry, + ) is SuggestionState.Loaded -> SuggestionsList( items = state.items.filter { it.mbid !in locallyRequestedMbids }, onRequest = onRequest, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt index 3ec241db..ab0c7997 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt @@ -3,6 +3,8 @@ package com.fabledsword.minstrel.discover.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.discover.data.DiscoverRepository import com.fabledsword.minstrel.discover.data.RequestOutcome import com.fabledsword.minstrel.models.ArtistSuggestionRef @@ -46,6 +48,7 @@ sealed interface ResultsState { @HiltViewModel class DiscoverViewModel @Inject constructor( private val repository: DiscoverRepository, + networkStatus: NetworkStatusController, ) : ViewModel() { private val internal = MutableStateFlow(DiscoverState()) @@ -53,6 +56,15 @@ class DiscoverViewModel @Inject constructor( init { loadSuggestions() + // Screen-level auto-recovery (issue #1245): re-run whichever pane + // failed while the server was unreachable once health returns. + viewModelScope.launch { + networkStatus.recoveries().collect { + val s = internal.value + if (s.suggestions is SuggestionState.Error) loadSuggestions() + if (s.results is ResultsState.Error) runSearch() + } + } } fun setQuery(value: String) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt index 08fc9a8d..290dc0a6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt @@ -19,6 +19,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.history.data.HistoryEntry import com.fabledsword.minstrel.history.data.HistoryRepository import com.fabledsword.minstrel.models.TrackRef @@ -26,6 +28,7 @@ import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb @@ -56,6 +59,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L class HistoryTabViewModel @Inject constructor( private val repository: HistoryRepository, private val player: PlayerController, + networkStatus: NetworkStatusController, ) : ViewModel() { private val refreshError = MutableStateFlow(null) @@ -82,6 +86,11 @@ class HistoryTabViewModel @Inject constructor( init { refresh() + // Screen-level auto-recovery (issue #1245): re-pull when server + // health returns, so a load that failed offline heals unprompted. + viewModelScope.launch { + networkStatus.recoveries().collect { refresh() } + } } fun refresh(): Job = viewModelScope.launch { @@ -116,9 +125,10 @@ fun HistoryTab( title = "No listening history yet", body = "Play something — your recent plays will show up here.", ) - is UiState.Error -> EmptyState( + is UiState.Error -> ErrorRetry( title = "Couldn't load history", - body = s.message, + message = s.message, + onRetry = { viewModel.refresh() }, ) is UiState.Success -> HistoryList( entries = s.data, 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 598b88c1..c7d7e6da 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 @@ -55,6 +55,7 @@ import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Music import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.connectivity.ServerHealth +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.home.data.HomeRepository import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.widgets.AlbumCard @@ -77,6 +78,7 @@ import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.asCacheFirstStateFlow import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold @@ -167,8 +169,22 @@ class HomeViewModel @Inject constructor( /** Transient snackbar messages from offline-pool taps. */ val transientMessages: Flow = poolMessages.receiveAsFlow() + /** + * Copy for the most recent /home/index refresh failure; null once a + * refresh succeeds. Folded into [uiState] so an empty cache + failed + * refresh renders as Error (with retry) instead of masquerading as + * the "Welcome to Minstrel" empty state. + */ + private val refreshError = MutableStateFlow(null) + 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. + viewModelScope.launch { + networkStatus.recoveries().collect { refresh() } + } // #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 @@ -289,7 +305,14 @@ class HomeViewModel @Inject constructor( * actual completion before hiding the indicator. */ fun refresh(): Job = viewModelScope.launch { - val home = launch { runCatching { homeRepository.refreshIndex() } } + refreshError.value = null + val home = launch { + // /home/index is the load-bearing pull: its failure drives the + // empty-cache Error state. A failure over a populated cache + // stays silent — cached sections beat a full-screen error. + runCatching { homeRepository.refreshIndex() } + .onFailure { refreshError.value = ErrorCopy.fromThrowable(it) } + } val lists = launch { runCatching { playlistsRepository.refreshList() } } val status = launch { runCatching { homeRepository.getSystemPlaylistsStatus() } @@ -336,9 +359,17 @@ class HomeViewModel @Inject constructor( } private fun combineHomeFlows() = - observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists -> + combine( + observeHomeSections(), + playlistsRepository.observeAll(), + refreshError, + ) { sections, playlists, err -> val merged = sections.copy(playlists = playlists) - if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged) + when { + !merged.isAllEmpty -> UiState.Success(merged) + err != null -> UiState.Error(err) + else -> UiState.Empty + } } } @@ -389,9 +420,10 @@ fun HomeScreen( "settings, then come back here for system playlists " + "and recommendations.", ) - is UiState.Error -> EmptyState( + is UiState.Error -> ErrorRetry( title = "Couldn't load home", - body = s.message, + message = s.message, + onRetry = { viewModel.refresh() }, ) is UiState.Success -> HomeSuccessContent( sections = s.data, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt index 79776ba0..ecb398d0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt @@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.widgets.TrackRow -import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.ServerImage @@ -116,9 +116,10 @@ private fun AlbumDetailStateContent( when (val s = state) { is AlbumDetailUiState.Loading -> if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList() - is AlbumDetailUiState.Error -> EmptyState( + is AlbumDetailUiState.Error -> ErrorRetry( title = "Couldn't load album", - body = s.message, + message = s.message, + onRetry = { viewModel.refresh() }, ) is AlbumDetailUiState.Success -> { val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt index dfb36fa5..abdba847 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.models.AlbumDetailRef @@ -36,6 +38,7 @@ class AlbumDetailViewModel @Inject constructor( private val likes: LikesRepository, private val player: PlayerController, private val seedCache: DetailSeedCache, + networkStatus: NetworkStatusController, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -64,6 +67,14 @@ class AlbumDetailViewModel @Inject constructor( init { refresh() + // Screen-level auto-recovery (issue #1245): a detail that failed to + // load while the server was unreachable re-fetches when health + // returns, so coming back to the screen shows content, not an error. + viewModelScope.launch { + networkStatus.recoveries().collect { + if (internal.value is AlbumDetailUiState.Error) refresh() + } + } } fun refresh(): Job = viewModelScope.launch { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt index e048eec0..f1fa4359 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt @@ -56,7 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.shared.formatDuration -import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold @@ -107,9 +107,10 @@ fun ArtistDetailScreen( } else { SkeletonArtistAlbumsGrid() } - is ArtistDetailUiState.Error -> EmptyState( + is ArtistDetailUiState.Error -> ErrorRetry( title = "Couldn't load artist", - body = s.message, + message = s.message, + onRetry = { viewModel.refresh() }, ) is ArtistDetailUiState.Success -> ArtistSuccessBody(s, viewModel, playerViewModel, navController) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt index 8bce5b37..bf33fcc9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.models.ArtistDetailRef @@ -45,6 +47,7 @@ class ArtistDetailViewModel @Inject constructor( private val likes: LikesRepository, private val player: PlayerController, private val seedCache: DetailSeedCache, + networkStatus: NetworkStatusController, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -69,6 +72,19 @@ class ArtistDetailViewModel @Inject constructor( init { refresh() + // Screen-level auto-recovery (issue #1245). From Error, redo the + // whole load; from Success, re-pull just the secondary sections — + // their fetch failures are swallowed to emptyList, so a dead zone + // during the first load leaves them permanently absent otherwise. + viewModelScope.launch { + networkStatus.recoveries().collect { + when (internal.value) { + is ArtistDetailUiState.Error -> refresh() + is ArtistDetailUiState.Success -> loadSecondarySections() + is ArtistDetailUiState.Loading -> Unit + } + } + } } fun toggleLikeArtist() { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt index e2af8795..beded2e3 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt @@ -24,11 +24,17 @@ class LibraryViewModel @Inject constructor( combine( repository.observeArtists(), repository.observeAlbums(), - ) { artists, albums -> - if (artists.isEmpty() && albums.isEmpty()) { - UiState.Empty - } else { - UiState.Success(LibraryData(artists, albums)) + syncController.lastSyncError, + ) { artists, albums, syncError -> + when { + artists.isNotEmpty() || albums.isNotEmpty() -> + UiState.Success(LibraryData(artists, albums)) + // Empty cache + failed sync is a load failure, not an empty + // library — surface it so the user gets a Retry instead of + // the welcome copy. Heals automatically: SyncController + // re-syncs on health recovery, clearing lastSyncError. + syncError != null -> UiState.Error(syncError) + else -> UiState.Empty } } .asCacheFirstStateFlow(viewModelScope) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt index 9587e7f2..393a4b82 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt @@ -25,6 +25,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.ArtistCard @@ -38,6 +41,7 @@ import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.asCacheFirstStateFlow import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LoadingCentered @@ -46,6 +50,7 @@ import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch @@ -68,14 +73,25 @@ data class LikedSections( class LikedTabViewModel @Inject constructor( private val repository: LikesRepository, private val player: PlayerController, + networkStatus: NetworkStatusController, ) : ViewModel() { + /** Copy for the most recent refresh failure; null once one succeeds. */ + private val refreshError = MutableStateFlow(null) + init { refresh() + // Screen-level auto-recovery (issue #1245): re-pull when server + // health returns, so a load that failed offline heals unprompted. + viewModelScope.launch { + networkStatus.recoveries().collect { refresh() } + } } fun refresh(): Job = viewModelScope.launch { + refreshError.value = null runCatching { repository.refreshIds() } + .onFailure { refreshError.value = ErrorCopy.fromThrowable(it) } } /** @@ -92,9 +108,16 @@ class LikedTabViewModel @Inject constructor( repository.observeLikedArtists(), repository.observeLikedAlbums(), repository.observeLikedTracks(), - ) { artists, albums, tracks -> + refreshError, + ) { artists, albums, tracks, err -> val sections = LikedSections(artists, albums, tracks) - if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections) + when { + !sections.isAllEmpty -> UiState.Success(sections) + // Empty cache + failed refresh is a load failure, not "no + // likes yet" — surface Error so the user gets a Retry. + err != null -> UiState.Error(err) + else -> UiState.Empty + } }.asCacheFirstStateFlow(viewModelScope) /** @@ -130,9 +153,10 @@ fun LikedTab( body = "Tap the heart on an artist, album, or track to start " + "building your liked collection.", ) - is UiState.Error -> EmptyState( + is UiState.Error -> ErrorRetry( title = "Couldn't load likes", - body = s.message, + message = s.message, + onRetry = { viewModel.refresh() }, ) is UiState.Success -> LikedContent( sections = s.data, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt index 1aa2ddbe..a63bed9a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -38,9 +38,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -60,6 +58,8 @@ import com.composables.icons.lucide.Play import com.composables.icons.lucide.RefreshCw import com.composables.icons.lucide.Shuffle import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.LiveEvent import com.fabledsword.minstrel.models.PlaylistRef @@ -76,6 +76,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.widgets.TrackRow +import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow @@ -117,6 +118,7 @@ class PlaylistDetailViewModel @Inject constructor( private val player: PlayerController, private val eventsStream: EventsStream, private val seedCache: DetailSeedCache, + networkStatus: NetworkStatusController, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -167,6 +169,14 @@ class PlaylistDetailViewModel @Inject constructor( .filter { it.kind.startsWith("playlist.") } .collect { handlePlaylistEvent(it) } } + // Screen-level auto-recovery (issue #1245): a detail that failed to + // load while the server was unreachable re-fetches when health + // returns, instead of waiting for the Retry tap. + viewModelScope.launch { + networkStatus.recoveries().collect { + if (internal.value is PlaylistDetailUiState.Error) refresh() + } + } } /** @@ -369,7 +379,11 @@ private fun PlaylistDetailContent( Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) { is PlaylistDetailUiState.Loading -> s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList() - is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh) + is PlaylistDetailUiState.Error -> ErrorRetry( + title = "Couldn't load playlist", + message = s.message, + onRetry = { viewModel.refresh() }, + ) is PlaylistDetailUiState.Success -> { val likedTrackIds by viewModel.likedTrackIds.collectAsState() val playerState by playerViewModel.uiState.collectAsState() @@ -653,33 +667,6 @@ private fun SeededPlaylistLoading(seed: PlaylistRef) { } } -@Composable -private fun ErrorBlock(message: String, onRetry: () -> Unit) { - var retried by remember { mutableStateOf(false) } - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyLarge, - ) - Spacer(Modifier.height(12.dp)) - OutlinedButton( - onClick = { - if (!retried) { - retried = true - onRetry() - } - }, - ) { Text("Retry") } - } -} - // ─── Helpers ───────────────────────────────────────────────────────── private const val UNAVAILABLE_ALPHA = 0.4f diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt index 14caa57b..f038550e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt @@ -27,8 +27,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController +import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.connectivity.NetworkStatusController import com.fabledsword.minstrel.connectivity.ServerHealth +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.nav.PlaylistDetail @@ -40,6 +42,7 @@ import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.asCacheFirstStateFlow import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold @@ -47,8 +50,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow @@ -84,6 +89,9 @@ class PlaylistsListViewModel @Inject constructor( initialValue = false, ) + /** Copy for the most recent list-refresh failure; null once one succeeds. */ + private val refreshError = MutableStateFlow(null) + init { refresh() // Live updates: a playlist created/updated/deleted from another @@ -93,11 +101,18 @@ class PlaylistsListViewModel @Inject constructor( .filter { it.kind.startsWith("playlist.") } .collect { refresh() } } + // Screen-level auto-recovery (issue #1245): re-pull when server + // health returns, so a load that failed offline heals unprompted. + viewModelScope.launch { + networkStatus.recoveries().collect { refresh() } + } } /** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */ fun refresh(): Job = viewModelScope.launch { + refreshError.value = null runCatching { repository.refreshList() } + .onFailure { refreshError.value = ErrorCopy.fromThrowable(it) } } /** Tile play button: shuffle the playlist's tracks and start at index 0. */ @@ -110,14 +125,15 @@ class PlaylistsListViewModel @Inject constructor( } val uiState: StateFlow>> = - repository.observeAll() - .map { list -> - if (list.isEmpty()) { - UiState.Empty - } else { - UiState.Success(list) - } + combine(repository.observeAll(), refreshError) { list, err -> + when { + list.isNotEmpty() -> UiState.Success(list) + // Empty cache + failed refresh is a load failure, not "no + // playlists yet" — surface Error so the user gets a Retry. + err != null -> UiState.Error(err) + else -> UiState.Empty } + } .asCacheFirstStateFlow(viewModelScope) } @@ -157,9 +173,10 @@ fun PlaylistsListScreen( "appear once your library has enough plays. Create your " + "own playlist from any album or track in the meantime.", ) - is UiState.Error -> EmptyState( + is UiState.Error -> ErrorRetry( title = "Couldn't load playlists", - body = s.message, + message = s.message, + onRetry = { viewModel.refresh() }, ) is UiState.Success -> PlaylistsGrid( playlists = s.data, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt index bc30cc9f..91897bf0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt @@ -27,6 +27,7 @@ import com.composables.icons.lucide.Lucide import com.fabledsword.minstrel.models.QuarantineRef import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb @@ -43,9 +44,10 @@ fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) { "and it'll show up here. The track stays out of system " + "playlists until you unhide it.", ) - is UiState.Error -> EmptyState( + is UiState.Error -> ErrorRetry( title = "Couldn't load hidden tracks", - body = s.message, + message = s.message, + onRetry = { viewModel.refresh() }, ) is UiState.Success -> HiddenList( rows = s.data, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt index c7831433..9d05fbce 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt @@ -3,6 +3,8 @@ package com.fabledsword.minstrel.quarantine.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.models.QuarantineRef import com.fabledsword.minstrel.shared.UiState @@ -24,6 +26,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L class HiddenTabViewModel @Inject constructor( private val repository: QuarantineRepository, private val eventsStream: EventsStream, + networkStatus: NetworkStatusController, ) : ViewModel() { private val refreshError = MutableStateFlow(null) @@ -61,6 +64,11 @@ class HiddenTabViewModel @Inject constructor( .filter { it.kind.startsWith("quarantine.") } .collect { refresh() } } + // Screen-level auto-recovery (issue #1245): re-pull when server + // health returns, so a load that failed offline heals unprompted. + viewModelScope.launch { + networkStatus.recoveries().collect { refresh() } + } } fun refresh(): Job = viewModelScope.launch { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt index 2059290d..2856a704 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt @@ -46,6 +46,7 @@ import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.Requests import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold @@ -78,9 +79,10 @@ fun RequestsScreen( body = "Use Discover to ask Lidarr for new music; your " + "requests show up here.", ) - is UiState.Error -> EmptyState( + is UiState.Error -> ErrorRetry( title = "Couldn't load requests", - body = s.message, + message = s.message, + onRetry = { viewModel.refresh() }, ) is UiState.Success -> RequestList( rows = s.data, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt index 9f2ee4b9..d95597d6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt @@ -3,6 +3,8 @@ package com.fabledsword.minstrel.requests.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestStatus @@ -30,6 +32,7 @@ private const val POLL_INTERVAL_MS = 12_000L class RequestsViewModel @Inject constructor( private val repository: RequestsRepository, private val eventsStream: EventsStream, + networkStatus: NetworkStatusController, ) : ViewModel() { private val internal = MutableStateFlow>>(UiState.Loading) @@ -45,6 +48,14 @@ class RequestsViewModel @Inject constructor( .collect { silentReload() } } viewModelScope.launch { pollWhileInFlight() } + // Screen-level auto-recovery (issue #1245): a load that failed while + // the server was unreachable heals the moment health returns, without + // waiting for the next poll tick. + viewModelScope.launch { + networkStatus.recoveries().collect { + if (internal.value is UiState.Error) silentReload() + } + } } fun refresh(): Job = viewModelScope.launch { @@ -71,7 +82,11 @@ class RequestsViewModel @Inject constructor( */ private suspend fun pollWhileInFlight() { while (true) { - if (hasInFlightRequest()) { + // Also retry from a failed load (issue #1245): from UiState.Error + // the in-flight predicate can never become true, so without this + // the poll never self-heals the screen — e.g. after a transient + // 500 that health monitoring (transport-level) never saw. + if (hasInFlightRequest() || internal.value is UiState.Error) { silentReload() } delay(POLL_INTERVAL_MS) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt index 6e3050c9..2766ba43 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt @@ -56,6 +56,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.Search as SearchRoute import com.fabledsword.minstrel.shared.widgets.TrackRow +import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb @@ -113,6 +114,7 @@ fun SearchScreen( onTrackPlay = viewModel::playTrack, onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) }, onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) }, + onRetry = viewModel::retry, ) } } @@ -151,11 +153,16 @@ private fun ResultsPane( onTrackPlay: (TrackRef) -> Unit, onNavigateToAlbum: (String) -> Unit, onNavigateToArtist: (String) -> Unit, + onRetry: () -> Unit, ) { when (state) { SearchResultsState.Idle -> CenteredHint("Type to search your library.") SearchResultsState.Loading -> LoadingCentered() - is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}") + is SearchResultsState.Error -> ErrorRetry( + title = "Search failed", + message = state.message, + onRetry = onRetry, + ) is SearchResultsState.Loaded -> { if (state.response.isEmpty) { CenteredHint( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt index 830fb501..b0d265eb 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt @@ -3,6 +3,8 @@ package com.fabledsword.minstrel.search.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.models.SearchResponseRef import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.player.PlayerController @@ -43,6 +45,7 @@ data class SearchState( class SearchViewModel @Inject constructor( private val repository: SearchRepository, private val player: PlayerController, + networkStatus: NetworkStatusController, ) : ViewModel() { private val queryFlow = MutableStateFlow("") @@ -69,6 +72,14 @@ class SearchViewModel @Inject constructor( runSearch(q) } } + // Screen-level auto-recovery (issue #1245): a search that failed + // while the server was unreachable re-runs when health returns — + // the queryFlow pipeline can't, since the text hasn't changed. + viewModelScope.launch { + networkStatus.recoveries().collect { + if (internal.value.results is SearchResultsState.Error) retry() + } + } } fun setQuery(value: String) { @@ -80,6 +91,18 @@ class SearchViewModel @Inject constructor( setQuery("") } + /** + * Re-run the current query after a failure. Bypasses the queryFlow + * pipeline, whose distinctUntilChanged drops a same-text resubmit — + * without this, a transient error pins "Search failed" until the + * user edits the query. + */ + fun retry() { + val q = internal.value.query.trim() + if (q.isEmpty()) return + viewModelScope.launch { runSearch(q) } + } + /** * Tapping a search result builds a queue from the full visible * track-results list starting at the tapped entry, matching Flutter. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ErrorRetry.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ErrorRetry.kt index a77f3aeb..3768296e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ErrorRetry.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ErrorRetry.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon @@ -23,44 +24,63 @@ import com.fabledsword.minstrel.theme.LocalActionColors * Shared error-with-retry widget. Surfaces a brief message and a * primary action button. Per the design system, the button uses * `LocalActionColors.primary` (Moss) — NEVER the accent. + * + * Renders inside a single-item LazyColumn (same rationale as + * [EmptyState]) so the widget participates in nested-scroll dispatch + * under PullToRefreshBox — both the Retry button and a pull gesture + * can recover from the error state. */ @Composable fun ErrorRetry( message: String, onRetry: () -> Unit, modifier: Modifier = Modifier, + title: String? = null, retryLabel: String = "Retry", ) { val actionColors = LocalActionColors.current - Column( - modifier = modifier - .fillMaxSize() - .padding(32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - imageVector = Lucide.TriangleAlert, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.error, - ) - Text( - text = message, - modifier = Modifier.padding(top = 16.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - Button( - onClick = onRetry, - modifier = Modifier.padding(top = 16.dp), - colors = ButtonDefaults.buttonColors( - containerColor = actionColors.primary, - contentColor = actionColors.onAction, - ), - ) { - Text(retryLabel) + LazyColumn(modifier = modifier.fillMaxSize()) { + item { + Column( + modifier = Modifier + .fillParentMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Lucide.TriangleAlert, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error, + ) + if (title != null) { + Text( + text = title, + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + Text( + text = message, + modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Button( + onClick = onRetry, + modifier = Modifier.padding(top = 16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = actionColors.primary, + contentColor = actionColors.onAction, + ), + ) { + Text(retryLabel) + } + } } } }