Merge pull request 'fix(android/resilience): auto-recover failed loads on reconnect' (#106) from dev into main
This commit was merged in pull request #106.
This commit is contained in:
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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 com.fabledsword.minstrel.models.Invite
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
@@ -25,6 +27,7 @@ data class AdminInvitesUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminInvitesViewModel @Inject constructor(
|
class AdminInvitesViewModel @Inject constructor(
|
||||||
private val repository: AdminInvitesRepository,
|
private val repository: AdminInvitesRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(AdminInvitesUiState())
|
private val internal = MutableStateFlow(AdminInvitesUiState())
|
||||||
@@ -36,6 +39,13 @@ class AdminInvitesViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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() {
|
fun refresh() {
|
||||||
|
|||||||
+4
-2
@@ -29,6 +29,7 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -62,9 +63,10 @@ fun AdminQuarantineScreen(
|
|||||||
body = "When users flag tracks as bad rips, wrong tags, or " +
|
body = "When users flag tracks as bad rips, wrong tags, or " +
|
||||||
"duplicates, their reports get aggregated and surfaced here.",
|
"duplicates, their reports get aggregated and surfaced here.",
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Error -> EmptyState(
|
is AdminQuarantineUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load queue",
|
title = "Couldn't load queue",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Success -> QueueList(
|
is AdminQuarantineUiState.Success -> QueueList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
+10
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.EventsStream
|
||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -26,6 +28,7 @@ sealed interface AdminQuarantineUiState {
|
|||||||
class AdminQuarantineViewModel @Inject constructor(
|
class AdminQuarantineViewModel @Inject constructor(
|
||||||
private val repository: AdminQuarantineRepository,
|
private val repository: AdminQuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||||
@@ -38,6 +41,13 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.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 {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -61,9 +62,10 @@ fun AdminRequestsScreen(
|
|||||||
body = "When users ask Lidarr for new music, their pending " +
|
body = "When users ask Lidarr for new music, their pending " +
|
||||||
"requests show up here for approval.",
|
"requests show up here for approval.",
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Error -> EmptyState(
|
is AdminRequestsUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Success -> RequestList(
|
is AdminRequestsUiState.Success -> RequestList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -35,6 +37,7 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
private val repository: AdminRequestsRepository,
|
private val repository: AdminRequestsRepository,
|
||||||
private val usersRepository: AdminUsersRepository,
|
private val usersRepository: AdminUsersRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
||||||
@@ -47,6 +50,13 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||||
.collect { refresh() }
|
.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 {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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 com.fabledsword.minstrel.models.AdminUserRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -23,6 +25,7 @@ sealed interface AdminUsersUiState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminUsersViewModel @Inject constructor(
|
class AdminUsersViewModel @Inject constructor(
|
||||||
private val repository: AdminUsersRepository,
|
private val repository: AdminUsersRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
||||||
@@ -30,6 +33,13 @@ class AdminUsersViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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 {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
+18
-1
@@ -1,5 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.cache.sync
|
package com.fabledsword.minstrel.cache.sync
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
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.SyncArtistWire
|
||||||
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
||||||
import kotlinx.coroutines.CoroutineScope
|
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.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
@@ -64,6 +68,17 @@ class SyncController @Inject constructor(
|
|||||||
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private val lastSyncErrorInternal = MutableStateFlow<String?>(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<String?> = lastSyncErrorInternal.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
authStore.sessionCookie
|
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() {
|
suspend fun syncSafe() {
|
||||||
if (!mutex.tryLock()) return
|
if (!mutex.tryLock()) return
|
||||||
try {
|
try {
|
||||||
runCatching { sync() }
|
runCatching { sync() }
|
||||||
|
.onSuccess { lastSyncErrorInternal.value = null }
|
||||||
|
.onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) }
|
||||||
} finally {
|
} finally {
|
||||||
mutex.unlock()
|
mutex.unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Unit> = state
|
||||||
|
.map { it == ServerHealth.Healthy }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.drop(1)
|
||||||
|
.filter { it }
|
||||||
|
.map { }
|
||||||
@@ -41,6 +41,7 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
|||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.nav.Discover
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -109,6 +110,7 @@ private fun DiscoverBody(
|
|||||||
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onRetry = { viewModel.loadSuggestions() },
|
||||||
)
|
)
|
||||||
ResultsState.Loading -> LoadingCentered()
|
ResultsState.Loading -> LoadingCentered()
|
||||||
is ResultsState.Loaded -> ResultsList(
|
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,
|
state: SuggestionState,
|
||||||
locallyRequestedMbids: Set<String>,
|
locallyRequestedMbids: Set<String>,
|
||||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SuggestionState.Loading -> LoadingCentered()
|
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(
|
is SuggestionState.Loaded -> SuggestionsList(
|
||||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||||
onRequest = onRequest,
|
onRequest = onRequest,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.discover.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.DiscoverRepository
|
||||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
@@ -46,6 +48,7 @@ sealed interface ResultsState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DiscoverViewModel @Inject constructor(
|
class DiscoverViewModel @Inject constructor(
|
||||||
private val repository: DiscoverRepository,
|
private val repository: DiscoverRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(DiscoverState())
|
private val internal = MutableStateFlow(DiscoverState())
|
||||||
@@ -53,6 +56,15 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadSuggestions()
|
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) {
|
fun setQuery(value: String) {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.HistoryEntry
|
||||||
import com.fabledsword.minstrel.history.data.HistoryRepository
|
import com.fabledsword.minstrel.history.data.HistoryRepository
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
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.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -56,6 +59,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HistoryTabViewModel @Inject constructor(
|
class HistoryTabViewModel @Inject constructor(
|
||||||
private val repository: HistoryRepository,
|
private val repository: HistoryRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -82,6 +86,11 @@ class HistoryTabViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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 {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
@@ -116,9 +125,10 @@ fun HistoryTab(
|
|||||||
title = "No listening history yet",
|
title = "No listening history yet",
|
||||||
body = "Play something — your recent plays will show up here.",
|
body = "Play something — your recent plays will show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load history",
|
title = "Couldn't load history",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HistoryList(
|
is UiState.Success -> HistoryList(
|
||||||
entries = s.data,
|
entries = s.data,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.composables.icons.lucide.Music
|
import com.composables.icons.lucide.Music
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
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.UiState
|
||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -167,8 +169,22 @@ class HomeViewModel @Inject constructor(
|
|||||||
/** Transient snackbar messages from offline-pool taps. */
|
/** Transient snackbar messages from offline-pool taps. */
|
||||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
val transientMessages: Flow<String> = 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<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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
|
// #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
|
||||||
@@ -289,7 +305,14 @@ class HomeViewModel @Inject constructor(
|
|||||||
* actual completion before hiding the indicator.
|
* actual completion before hiding the indicator.
|
||||||
*/
|
*/
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
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 lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||||
val status = launch {
|
val status = launch {
|
||||||
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
||||||
@@ -336,9 +359,17 @@ class HomeViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun combineHomeFlows() =
|
private fun combineHomeFlows() =
|
||||||
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
combine(
|
||||||
|
observeHomeSections(),
|
||||||
|
playlistsRepository.observeAll(),
|
||||||
|
refreshError,
|
||||||
|
) { sections, playlists, err ->
|
||||||
val merged = sections.copy(playlists = playlists)
|
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 " +
|
"settings, then come back here for system playlists " +
|
||||||
"and recommendations.",
|
"and recommendations.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load home",
|
title = "Couldn't load home",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HomeSuccessContent(
|
is UiState.Success -> HomeSuccessContent(
|
||||||
sections = s.data,
|
sections = s.data,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
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.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
@@ -116,9 +116,10 @@ private fun AlbumDetailStateContent(
|
|||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is AlbumDetailUiState.Loading ->
|
is AlbumDetailUiState.Loading ->
|
||||||
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
||||||
is AlbumDetailUiState.Error -> EmptyState(
|
is AlbumDetailUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load album",
|
title = "Couldn't load album",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AlbumDetailUiState.Success -> {
|
is AlbumDetailUiState.Success -> {
|
||||||
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.AlbumDetailRef
|
import com.fabledsword.minstrel.models.AlbumDetailRef
|
||||||
@@ -36,6 +38,7 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -64,6 +67,14 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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 {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath
|
|||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
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.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -107,9 +107,10 @@ fun ArtistDetailScreen(
|
|||||||
} else {
|
} else {
|
||||||
SkeletonArtistAlbumsGrid()
|
SkeletonArtistAlbumsGrid()
|
||||||
}
|
}
|
||||||
is ArtistDetailUiState.Error -> EmptyState(
|
is ArtistDetailUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load artist",
|
title = "Couldn't load artist",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is ArtistDetailUiState.Success ->
|
is ArtistDetailUiState.Success ->
|
||||||
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||||
|
|||||||
+16
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||||
@@ -45,6 +47,7 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -69,6 +72,19 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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() {
|
fun toggleLikeArtist() {
|
||||||
|
|||||||
@@ -24,11 +24,17 @@ class LibraryViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.observeArtists(),
|
repository.observeArtists(),
|
||||||
repository.observeAlbums(),
|
repository.observeAlbums(),
|
||||||
) { artists, albums ->
|
syncController.lastSyncError,
|
||||||
if (artists.isEmpty() && albums.isEmpty()) {
|
) { artists, albums, syncError ->
|
||||||
UiState.Empty
|
when {
|
||||||
} else {
|
artists.isNotEmpty() || albums.isNotEmpty() ->
|
||||||
UiState.Success(LibraryData(artists, albums))
|
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)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
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.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
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.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
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 com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -68,14 +73,25 @@ data class LikedSections(
|
|||||||
class LikedTabViewModel @Inject constructor(
|
class LikedTabViewModel @Inject constructor(
|
||||||
private val repository: LikesRepository,
|
private val repository: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Copy for the most recent refresh failure; null once one succeeds. */
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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 {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
refreshError.value = null
|
||||||
runCatching { repository.refreshIds() }
|
runCatching { repository.refreshIds() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,9 +108,16 @@ class LikedTabViewModel @Inject constructor(
|
|||||||
repository.observeLikedArtists(),
|
repository.observeLikedArtists(),
|
||||||
repository.observeLikedAlbums(),
|
repository.observeLikedAlbums(),
|
||||||
repository.observeLikedTracks(),
|
repository.observeLikedTracks(),
|
||||||
) { artists, albums, tracks ->
|
refreshError,
|
||||||
|
) { artists, albums, tracks, err ->
|
||||||
val sections = LikedSections(artists, albums, tracks)
|
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)
|
}.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,9 +153,10 @@ fun LikedTab(
|
|||||||
body = "Tap the heart on an artist, album, or track to start " +
|
body = "Tap the heart on an artist, album, or track to start " +
|
||||||
"building your liked collection.",
|
"building your liked collection.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load likes",
|
title = "Couldn't load likes",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> LikedContent(
|
is UiState.Success -> LikedContent(
|
||||||
sections = s.data,
|
sections = s.data,
|
||||||
|
|||||||
+17
-30
@@ -38,9 +38,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.RefreshCw
|
||||||
import com.composables.icons.lucide.Shuffle
|
import com.composables.icons.lucide.Shuffle
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEvent
|
import com.fabledsword.minstrel.events.LiveEvent
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
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.playlists.data.toPlayableTrackRefs
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
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.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
||||||
@@ -117,6 +118,7 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -167,6 +169,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { handlePlaylistEvent(it) }
|
.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) {
|
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
||||||
is PlaylistDetailUiState.Loading ->
|
is PlaylistDetailUiState.Loading ->
|
||||||
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
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 -> {
|
is PlaylistDetailUiState.Success -> {
|
||||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||||
val playerState by playerViewModel.uiState.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 ─────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private const val UNAVAILABLE_ALPHA = 0.4f
|
private const val UNAVAILABLE_ALPHA = 0.4f
|
||||||
|
|||||||
+26
-9
@@ -27,8 +27,10 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
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.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -47,8 +50,10 @@ 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.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
@@ -84,6 +89,9 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Copy for the most recent list-refresh failure; null once one succeeds. */
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Live updates: a playlist created/updated/deleted from another
|
// Live updates: a playlist created/updated/deleted from another
|
||||||
@@ -93,11 +101,18 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { refresh() }
|
.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. */
|
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
refreshError.value = null
|
||||||
runCatching { repository.refreshList() }
|
runCatching { repository.refreshList() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
||||||
@@ -110,14 +125,15 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
||||||
repository.observeAll()
|
combine(repository.observeAll(), refreshError) { list, err ->
|
||||||
.map { list ->
|
when {
|
||||||
if (list.isEmpty()) {
|
list.isNotEmpty() -> UiState.Success(list)
|
||||||
UiState.Empty
|
// Empty cache + failed refresh is a load failure, not "no
|
||||||
} else {
|
// playlists yet" — surface Error so the user gets a Retry.
|
||||||
UiState.Success(list)
|
err != null -> UiState.Error(err)
|
||||||
}
|
else -> UiState.Empty
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.asCacheFirstStateFlow(viewModelScope)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,9 +173,10 @@ fun PlaylistsListScreen(
|
|||||||
"appear once your library has enough plays. Create your " +
|
"appear once your library has enough plays. Create your " +
|
||||||
"own playlist from any album or track in the meantime.",
|
"own playlist from any album or track in the meantime.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load playlists",
|
title = "Couldn't load playlists",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> PlaylistsGrid(
|
is UiState.Success -> PlaylistsGrid(
|
||||||
playlists = s.data,
|
playlists = s.data,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
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 " +
|
"and it'll show up here. The track stays out of system " +
|
||||||
"playlists until you unhide it.",
|
"playlists until you unhide it.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load hidden tracks",
|
title = "Couldn't load hidden tracks",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HiddenList(
|
is UiState.Success -> HiddenList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.quarantine.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.EventsStream
|
||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
@@ -24,6 +26,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HiddenTabViewModel @Inject constructor(
|
class HiddenTabViewModel @Inject constructor(
|
||||||
private val repository: QuarantineRepository,
|
private val repository: QuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -61,6 +64,11 @@ class HiddenTabViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.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 {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Requests
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -78,9 +79,10 @@ fun RequestsScreen(
|
|||||||
body = "Use Discover to ask Lidarr for new music; your " +
|
body = "Use Discover to ask Lidarr for new music; your " +
|
||||||
"requests show up here.",
|
"requests show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> RequestList(
|
is UiState.Success -> RequestList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
+16
-1
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.requests.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.models.RequestStatus
|
import com.fabledsword.minstrel.models.RequestStatus
|
||||||
@@ -30,6 +32,7 @@ private const val POLL_INTERVAL_MS = 12_000L
|
|||||||
class RequestsViewModel @Inject constructor(
|
class RequestsViewModel @Inject constructor(
|
||||||
private val repository: RequestsRepository,
|
private val repository: RequestsRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
||||||
@@ -45,6 +48,14 @@ class RequestsViewModel @Inject constructor(
|
|||||||
.collect { silentReload() }
|
.collect { silentReload() }
|
||||||
}
|
}
|
||||||
viewModelScope.launch { pollWhileInFlight() }
|
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 {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
@@ -71,7 +82,11 @@ class RequestsViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private suspend fun pollWhileInFlight() {
|
private suspend fun pollWhileInFlight() {
|
||||||
while (true) {
|
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()
|
silentReload()
|
||||||
}
|
}
|
||||||
delay(POLL_INTERVAL_MS)
|
delay(POLL_INTERVAL_MS)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -113,6 +114,7 @@ fun SearchScreen(
|
|||||||
onTrackPlay = viewModel::playTrack,
|
onTrackPlay = viewModel::playTrack,
|
||||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
onRetry = viewModel::retry,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,11 +153,16 @@ private fun ResultsPane(
|
|||||||
onTrackPlay: (TrackRef) -> Unit,
|
onTrackPlay: (TrackRef) -> Unit,
|
||||||
onNavigateToAlbum: (String) -> Unit,
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
onNavigateToArtist: (String) -> Unit,
|
onNavigateToArtist: (String) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
||||||
SearchResultsState.Loading -> LoadingCentered()
|
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 -> {
|
is SearchResultsState.Loaded -> {
|
||||||
if (state.response.isEmpty) {
|
if (state.response.isEmpty) {
|
||||||
CenteredHint(
|
CenteredHint(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.search.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.SearchResponseRef
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
@@ -43,6 +45,7 @@ data class SearchState(
|
|||||||
class SearchViewModel @Inject constructor(
|
class SearchViewModel @Inject constructor(
|
||||||
private val repository: SearchRepository,
|
private val repository: SearchRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val queryFlow = MutableStateFlow("")
|
private val queryFlow = MutableStateFlow("")
|
||||||
@@ -69,6 +72,14 @@ class SearchViewModel @Inject constructor(
|
|||||||
runSearch(q)
|
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) {
|
fun setQuery(value: String) {
|
||||||
@@ -80,6 +91,18 @@ class SearchViewModel @Inject constructor(
|
|||||||
setQuery("")
|
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
|
* Tapping a search result builds a queue from the full visible
|
||||||
* track-results list starting at the tapped entry, matching Flutter.
|
* track-results list starting at the tapped entry, matching Flutter.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
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
|
* Shared error-with-retry widget. Surfaces a brief message and a
|
||||||
* primary action button. Per the design system, the button uses
|
* primary action button. Per the design system, the button uses
|
||||||
* `LocalActionColors.primary` (Moss) — NEVER the accent.
|
* `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
|
@Composable
|
||||||
fun ErrorRetry(
|
fun ErrorRetry(
|
||||||
message: String,
|
message: String,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
title: String? = null,
|
||||||
retryLabel: String = "Retry",
|
retryLabel: String = "Retry",
|
||||||
) {
|
) {
|
||||||
val actionColors = LocalActionColors.current
|
val actionColors = LocalActionColors.current
|
||||||
Column(
|
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||||
modifier = modifier
|
item {
|
||||||
.fillMaxSize()
|
Column(
|
||||||
.padding(32.dp),
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.Center,
|
.fillParentMaxSize()
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.padding(32.dp),
|
||||||
) {
|
verticalArrangement = Arrangement.Center,
|
||||||
Icon(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
imageVector = Lucide.TriangleAlert,
|
) {
|
||||||
contentDescription = null,
|
Icon(
|
||||||
modifier = Modifier.size(48.dp),
|
imageVector = Lucide.TriangleAlert,
|
||||||
tint = MaterialTheme.colorScheme.error,
|
contentDescription = null,
|
||||||
)
|
modifier = Modifier.size(48.dp),
|
||||||
Text(
|
tint = MaterialTheme.colorScheme.error,
|
||||||
text = message,
|
)
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
if (title != null) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
text = title,
|
||||||
textAlign = TextAlign.Center,
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
)
|
style = MaterialTheme.typography.titleMedium,
|
||||||
Button(
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
onClick = onRetry,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
)
|
||||||
colors = ButtonDefaults.buttonColors(
|
}
|
||||||
containerColor = actionColors.primary,
|
Text(
|
||||||
contentColor = actionColors.onAction,
|
text = message,
|
||||||
),
|
modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp),
|
||||||
) {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
Text(retryLabel)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-4
@@ -11,6 +11,7 @@ import com.fabledsword.minstrel.testutil.MainDispatcherExtension
|
|||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -21,6 +22,13 @@ import kotlin.test.assertEquals
|
|||||||
@ExtendWith(MainDispatcherExtension::class)
|
@ExtendWith(MainDispatcherExtension::class)
|
||||||
class LibraryViewModelTest {
|
class LibraryViewModelTest {
|
||||||
|
|
||||||
|
// lastSyncError needs a real StateFlow: the VM combines it into uiState,
|
||||||
|
// and a relaxed-mock flow never emits, so the combine would never fire.
|
||||||
|
private fun syncControllerMock(): SyncController =
|
||||||
|
mockk<SyncController>(relaxed = true) {
|
||||||
|
every { lastSyncError } returns MutableStateFlow(null)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `initial state is Loading before any DAO emission`() = runTest {
|
fun `initial state is Loading before any DAO emission`() = runTest {
|
||||||
val repo = mockk<LibraryRepository>()
|
val repo = mockk<LibraryRepository>()
|
||||||
@@ -29,7 +37,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,7 +59,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,7 +79,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,7 +102,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user