Compare commits

..

8 Commits

Author SHA1 Message Date
bvandeusen d145fee35d Merge pull request 'fix(android/resilience): auto-recover failed loads on reconnect' (#106) from dev into main
android / Build + lint + test (push) Successful in 4m44s
release / Build signed APK (tag releases only) (push) Successful in 4m31s
release / Build + push container image (push) Successful in 16s
2026-07-02 16:45:13 -04:00
bvandeusen 7628330f72 test(android/library): stub SyncController.lastSyncError with a real StateFlow
android / Build + lint + test (push) Successful in 3m45s
The VM now combines lastSyncError into uiState; a relaxed-mock flow
never emits, so the combine never fired and the state sat at Loading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 16:10:45 -04:00
bvandeusen 09102dc615 fix(android/resilience): auto-recover failed loads on reconnect (#1245)
android / Build + lint + test (push) Failing after 3m3s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 16:02:14 -04:00
bvandeusen 611715154b Merge pull request 'M9 diagnostics follow-ups: playback relabel, sort, connected fix, per-skip + track-identity' (#105) from dev into main
test-go / test (push) Successful in 38s
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m18s
test-go / integration (push) Successful in 4m39s
release / Build signed APK (tag releases only) (push) Successful in 3m46s
release / Build + push container image (push) Successful in 16s
2026-06-30 19:19:15 -04:00
bvandeusen 392454b249 feat(android/diagnostics): track-identity enrichment + zero stale Sonos state
android / Build + lint + test (push) Successful in 3m27s
Numeric indices wobble across re-casts (offset +1↔0 seen during output
toggling), making "same track?" ambiguous. Enrich both the track_change
event and the heartbeat with local_track_id (TrackRef.id) and sonos_uri
(RemotePlayerState.currentTrackUri — the URL the speaker is actually
streaming), so a desync is unambiguous.

Also fixes the cast→phone stale-state pollution (#1211): sonos_* is now
zeroed unless a remote route is active, via a shared putSonos() helper —
so a just-ended cast's RemotePlayerState can't masquerade as live Sonos
data in the diagnostics.

Refs Scribe M9 (#119), tasks #1210 #1211.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-30 19:15:12 -04:00
bvandeusen cdfc79e6ab feat(android/diagnostics): per-skip track-change event
android / Build + lint + test (push) Successful in 3m35s
Heartbeats are 45s apart and missed a rapid skip burst (local_index
16→22 in one gap). Add a 'playback' track_change event emitted on each
queue-index / current-track change, snapshotting local vs Sonos
index+position + server_health + upnp_loading + route — so a transient
skip-induced desync is captured at the instant it happens. (uiState is a
conflated StateFlow, so a very rapid burst may coalesce intermediate
indices; we still get the boundaries + the snapshot.)

Refs Scribe M9 (#119), task #1210.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-30 18:48:35 -04:00
bvandeusen 79f2d79a2e feat(diagnostics): 'playback' kind, newest-first sort, fix active-route subtitle
test-go / test (push) Successful in 32s
test-web / test (push) Successful in 41s
android / Build + lint + test (push) Successful in 3m40s
test-go / integration (push) Successful in 4m30s
Relabel (#1204): route + player_state events fire for every output route,
not just UPnP — split them into a new 'playback' kind; 'upnp_sync' now
means genuinely UPnP/Sonos signal (drops, resync). Migration 0037 adds
'playback' to the kind CHECK; server whitelist, Android reporter labels,
and the web kind filter updated.

Web sort: the diagnostics list gains a Newest/Oldest-first sort (default
newest at top); export follows the displayed order.

Fix (#1205): OutputRoute.isConnected was derived from RouteInfo.connectionState,
which stays DISCONNECTED for local SYSTEM routes even when active — so a
connected Bluetooth device showed "Available" and reported connected:false.
The picker subtitle now uses isSelected (route == selected route); the dead
isConnected field is removed and the misleading `connected` field dropped
from the diagnostics route event (it only ever logs the active route).

Refs Scribe M9 (#119), tasks #1204 #1205.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-30 16:33:01 -04:00
bvandeusen 96b15b75e6 feat(web/diagnostics): default to recent 500, move time window to Advanced
test-web / test (push) Successful in 39s
The diagnostics view already defaulted to the most recent 500 events (no
window); make that the obvious path. Device/Kind stay primary; the
start/end window + row cap move into a collapsed "Advanced filters"
disclosure (auto-opens when a window is active) with a "Reset to recent
500" action. Caption now states whether you're seeing the recent default
or a windowed slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-30 12:42:25 -04:00
35 changed files with 641 additions and 204 deletions
@@ -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() {
@@ -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,
@@ -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>(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 {
@@ -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,
@@ -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>(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 {
@@ -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>(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 {
@@ -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<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 {
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()
}
@@ -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 { }
@@ -102,6 +102,7 @@ class DiagnosticsReporter @Inject constructor(
launch { collectServerHealth() }
launch { collectUpnpDrops() }
launch { collectPlayerState() }
launch { collectTrackChanges() }
launch { collectRoutes() }
launch { heartbeatLoop() }
}
@@ -147,11 +148,13 @@ class DiagnosticsReporter @Inject constructor(
}
private suspend fun collectPlayerState() {
// 'playback', not 'upnp_sync' — player state applies to every output
// route (phone speaker, Bluetooth, UPnP), not just casting.
playerController.uiState
.map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) }
.distinctUntilChanged()
.collect { (source, upnpLoading, err) ->
record("upnp_sync", buildJsonObject {
record("playback", buildJsonObject {
put("event", "player_state")
put("source", source ?: "")
put("upnp_loading", upnpLoading)
@@ -160,15 +163,44 @@ class DiagnosticsReporter @Inject constructor(
}
}
// Emit a snapshot at each track/queue-index change — captures a
// skip-induced local↔Sonos desync at the INSTANT it happens, which the
// 45s heartbeat misses. Note: uiState is a conflated StateFlow, so a
// very rapid skip burst may coalesce intermediate indices (we still get
// the boundaries + the local-vs-Sonos snapshot).
private suspend fun collectTrackChanges() {
playerController.uiState
.map { it.queueIndex to it.currentTrack?.id }
.distinctUntilChanged()
.collect { (index, _) ->
val ui = playerController.uiState.value
val casting = outputPicker.routesState.value.current.protocol !=
OutputRoute.Protocol.SYSTEM
record("playback", buildJsonObject {
put("event", "track_change")
put("local_index", index)
// Track IDENTITY, not just index — indices wobble across
// re-casts, so the id + Sonos URI make a desync unambiguous.
put("local_track_id", ui.currentTrack?.id ?: "")
put("local_pos_ms", ui.positionMs)
putSonos(this, casting)
put("upnp_loading", ui.isUpnpLoading)
put("server_health", networkStatus.state.value.name)
put("route", outputPicker.routesState.value.current.name)
})
}
}
private suspend fun collectRoutes() {
// 'playback' — route changes happen for all outputs. This only ever
// logs the ACTIVE route (routesState.current), so no "connected" flag.
outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r ->
record("upnp_sync", buildJsonObject {
record("playback", buildJsonObject {
put("event", "route")
put("id", r.id)
put("name", r.name)
put("kind", r.kind.name)
put("protocol", r.protocol.name)
put("connected", r.isConnected)
})
}
}
@@ -186,13 +218,11 @@ class DiagnosticsReporter @Inject constructor(
put("is_playing", ui.isPlaying)
put("source", ui.currentSource ?: "")
put("local_index", ui.queueIndex)
put("local_track_id", ui.currentTrack?.id ?: "")
put("local_pos_ms", ui.positionMs)
put("route", route.name)
put("route_protocol", route.protocol.name)
// UPnP/Sonos remote-vs-local — the desync signal.
put("sonos_track", remoteState.trackNumber)
put("sonos_pos_ms", remoteState.positionMs)
put("sonos_playing", remoteState.isPlaying)
putSonos(this, routeActive)
addPowerFields(this)
})
}
@@ -257,6 +287,18 @@ class DiagnosticsReporter @Inject constructor(
addPowerFields(this)
}
// Sonos/UPnP remote-vs-local desync fields. Only meaningful while a
// remote route is active; zeroed otherwise so a stale RemotePlayerState
// from a just-ended cast can't masquerade as live Sonos data (the
// cast→phone handoff artifact). currentTrackUri is the desync ground
// truth — it carries the track the speaker is actually streaming.
private fun putSonos(builder: kotlinx.serialization.json.JsonObjectBuilder, casting: Boolean) {
builder.put("sonos_track", if (casting) remoteState.trackNumber else 0)
builder.put("sonos_pos_ms", if (casting) remoteState.positionMs else 0)
builder.put("sonos_playing", casting && remoteState.isPlaying)
if (casting) builder.put("sonos_uri", remoteState.currentTrackUri)
}
private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
builder.put("doze", pm.isDeviceIdleMode)
@@ -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<String>,
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,
@@ -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) {
@@ -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<String?>(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,
@@ -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<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 {
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,
@@ -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()
@@ -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 {
@@ -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)
@@ -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() {
@@ -24,11 +24,17 @@ class LibraryViewModel @Inject constructor(
combine(
repository.observeArtists(),
repository.observeAlbums(),
) { artists, albums ->
if (artists.isEmpty() && albums.isEmpty()) {
UiState.Empty
} else {
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)
@@ -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<String?>(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,
@@ -133,7 +133,7 @@ private fun RouteRow(
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val subtitle = route.description ?: defaultSubtitle(route)
val subtitle = route.description ?: defaultSubtitle(route, isSelected)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
@@ -198,10 +198,13 @@ private fun MulticastHintRow() {
}
}
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
// isSelected (route == the active/selected route) drives the Bluetooth
// "Connected" subtitle. RouteInfo.connectionState can't — it stays
// DISCONNECTED for local SYSTEM routes even when they're in use.
private fun defaultSubtitle(route: OutputRoute, isSelected: Boolean): String = when (route.kind) {
OutputRoute.Kind.BuiltIn -> "Phone speaker"
OutputRoute.Kind.Wired -> "Wired"
OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available"
OutputRoute.Kind.Bluetooth -> if (isSelected) "Connected" else "Available"
OutputRoute.Kind.Cast -> "Cast"
OutputRoute.Kind.Other -> "Available"
}
@@ -21,7 +21,6 @@ data class OutputRoute(
val description: String?,
val kind: Kind,
val protocol: Protocol,
val isConnected: Boolean,
) {
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
@@ -43,9 +42,11 @@ data class OutputRoute(
/**
* Lift a MediaRouter [route] into the domain model. Kind is
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
* device types fall through to [Kind.Other]. The
* `connectionState` proxy is good enough for the chip's
* "Connected"/"Available" subtitle.
* device types fall through to [Kind.Other]. "Active" is NOT an
* intrinsic of the route — the caller derives it by comparing to
* the selected route (RouteSnapshot.current), because
* RouteInfo.connectionState only reflects remote-route handshakes
* and stays DISCONNECTED for local SYSTEM routes even when in use.
*/
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
val kind = when (route.deviceType) {
@@ -58,15 +59,12 @@ data class OutputRoute(
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
else -> Kind.Other
}
val connected =
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
return OutputRoute(
id = route.id,
name = route.name,
description = route.description,
kind = kind,
protocol = Protocol.SYSTEM,
isConnected = connected,
)
}
@@ -76,10 +74,10 @@ data class OutputRoute(
* network speakers into the same `OutputPickerController`
* routes stream the system routes come through.
*
* `isConnected = false` because UPnP devices have no
* MediaRouter connection-state concept — they're always
* "available" on the LAN, and the picker's selected-route
* rendering handles the "currently playing" indicator.
* UPnP devices have no MediaRouter connection-state concept —
* they're always "available" on the LAN, and the picker's
* selected-route rendering handles the "currently playing"
* indicator.
*
* Subtitle is `manufacturer modelName` joined by a single
* space, falling back to "Network speaker" when both fields
@@ -96,7 +94,6 @@ data class OutputRoute(
description = description,
kind = Kind.Other,
protocol = Protocol.UPNP,
isConnected = false,
)
}
}
@@ -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
@@ -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<String?>(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,12 +125,13 @@ class PlaylistsListViewModel @Inject constructor(
}
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
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,
@@ -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,
@@ -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<String?>(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 {
@@ -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,
@@ -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<List<RequestRef>>>(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)
@@ -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(
@@ -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.
@@ -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,18 +24,26 @@ 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
LazyColumn(modifier = modifier.fillMaxSize()) {
item {
Column(
modifier = modifier
.fillMaxSize()
modifier = Modifier
.fillParentMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
@@ -45,9 +54,18 @@ fun ErrorRetry(
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 = 16.dp),
modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
@@ -64,3 +82,5 @@ fun ErrorRetry(
}
}
}
}
}
@@ -11,6 +11,7 @@ import com.fabledsword.minstrel.testutil.MainDispatcherExtension
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
@@ -21,6 +22,13 @@ import kotlin.test.assertEquals
@ExtendWith(MainDispatcherExtension::class)
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
fun `initial state is Loading before any DAO emission`() = runTest {
val repo = mockk<LibraryRepository>()
@@ -29,7 +37,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel(
repo,
mockk<SyncController>(relaxed = true),
syncControllerMock(),
mockk<PlayerController>(relaxed = true),
)
@@ -51,7 +59,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel(
repo,
mockk<SyncController>(relaxed = true),
syncControllerMock(),
mockk<PlayerController>(relaxed = true),
)
@@ -71,7 +79,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel(
repo,
mockk<SyncController>(relaxed = true),
syncControllerMock(),
mockk<PlayerController>(relaxed = true),
)
@@ -94,7 +102,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel(
repo,
mockk<SyncController>(relaxed = true),
syncControllerMock(),
mockk<PlayerController>(relaxed = true),
)
+2 -1
View File
@@ -21,7 +21,8 @@ import (
// variants do NOT require a new category here.
var validDiagnosticKinds = map[string]struct{}{
"connectivity": {},
"upnp_sync": {},
"upnp_sync": {}, // genuinely UPnP/Sonos-specific (drops, resync)
"playback": {}, // route + player-state, any output (phone/BT/UPnP)
"power": {},
"lifecycle": {},
"heartbeat": {},
@@ -0,0 +1,9 @@
-- Drop rows using the new value first so they don't violate the restored
-- (narrower) constraint, then revert the whitelist.
DELETE FROM diagnostic_events WHERE kind = 'playback';
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
CHECK (kind IN (
'connectivity', 'upnp_sync', 'power', 'lifecycle',
'heartbeat', 'http'
));
@@ -0,0 +1,19 @@
-- Add 'playback' to the diagnostic_events kind whitelist (M9 follow-up).
--
-- `route` and `player_state` events fire for EVERY output route (phone
-- speaker, Bluetooth, UPnP/Sonos), so bucketing them under 'upnp_sync'
-- was misleading — an operator on Bluetooth earbuds saw "upnp_sync"
-- rows. 'upnp_sync' now means genuinely UPnP/Sonos-specific signal
-- (drops, and future resync/desync); general playback + route telemetry
-- moves to the new 'playback' kind.
--
-- Per the enum-CHECK-whitelist rule, the new value lands by dropping and
-- re-adding the constraint in the same change. Existing rows tagged
-- 'upnp_sync' stay as-is (debug telemetry on a 30-day retention).
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
CHECK (kind IN (
'connectivity', 'upnp_sync', 'power', 'lifecycle',
'heartbeat', 'http', 'playback'
));
+86 -21
View File
@@ -22,11 +22,20 @@
const client = useQueryClient();
const KINDS = ['connectivity', 'upnp_sync', 'power', 'lifecycle', 'heartbeat', 'http'] as const;
const KINDS = [
'connectivity',
'playback',
'upnp_sync',
'power',
'lifecycle',
'heartbeat',
'http'
] as const;
function kindLabel(k: string): string {
switch (k) {
case 'connectivity': return 'Connectivity';
case 'playback': return 'Playback';
case 'upnp_sync': return 'UPnP sync';
case 'power': return 'Power';
case 'lifecycle': return 'Lifecycle';
@@ -36,13 +45,26 @@
}
}
// Default view = the most recent N events, no time window. The
// start/end window is an Advanced affordance.
const DEFAULT_LIMIT = 500;
// Filter state.
let accountId = $state('');
let clientId = $state('');
let kind = $state('');
let fromLocal = $state('');
let toLocal = $state('');
let limit = $state(500);
let limit = $state(DEFAULT_LIMIT);
// True when an explicit time window is set (drives the caption wording).
const hasWindow = $derived(Boolean(fromLocal || toLocal));
function resetWindow() {
fromLocal = '';
toLocal = '';
limit = DEFAULT_LIMIT;
}
// datetime-local (browser-local, no tz) → RFC3339 UTC the API accepts.
function toRfc(v: string): string | undefined {
@@ -69,10 +91,17 @@
const devicesQuery = $derived($devicesStore);
const devices = $derived(devicesQuery.data ?? []);
// Display sort. The API returns newest-first; default the view to that
// (most recent at the top), with an Oldest-first option for reading a
// timeline top-to-bottom. Export follows whatever's displayed.
let sortOrder = $state<'newest' | 'oldest'>('newest');
const diagStore = $derived(createAdminDiagnosticsQuery(filter));
const diagQuery = $derived($diagStore);
// API returns newest-first; reverse to chronological for a readable timeline.
const rows = $derived([...((diagQuery.data ?? []) as AdminDiagnostic[])].reverse());
const rows = $derived.by(() => {
const data = [...((diagQuery.data ?? []) as AdminDiagnostic[])]; // newest-first
return sortOrder === 'oldest' ? data.reverse() : data;
});
function fmtTime(iso: string): string {
const d = new Date(iso);
@@ -191,8 +220,10 @@
</div>
</section>
<!-- Filters -->
<section class="flex flex-wrap items-end gap-3">
<!-- Filters. Default view = most recent {DEFAULT_LIMIT} events, no time
window; the start/end window lives under Advanced. -->
<section class="space-y-3">
<div class="flex flex-wrap items-end gap-3">
<label class="block text-xs text-text-secondary">
Device
<select
@@ -217,6 +248,44 @@
{/each}
</select>
</label>
<label class="block text-xs text-text-secondary">
Sort
<select
bind:value={sortOrder}
class="mt-1 block w-36 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</label>
<div class="ml-auto flex gap-2">
<button
type="button"
onclick={onCopyJson}
disabled={rows.length === 0}
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
>
<Copy size={15} /> Copy JSON
</button>
<button
type="button"
onclick={onDownloadNdjson}
disabled={rows.length === 0}
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
>
<Download size={15} /> Download
</button>
</div>
</div>
<!-- Advanced: explicit start/end window + row cap. Collapsed by
default so the common case (recent N) needs no interaction. -->
<details class="rounded-md border border-border" open={hasWindow}>
<summary class="cursor-pointer px-3 py-2 text-xs text-text-secondary">
Advanced filters{hasWindow ? ' · window active' : ''}
</summary>
<div class="flex flex-wrap items-end gap-3 border-t border-border px-3 py-3">
<label class="block text-xs text-text-secondary">
From
<input
@@ -243,25 +312,15 @@
class="mt-1 block w-24 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
/>
</label>
<div class="ml-auto flex gap-2">
<button
type="button"
onclick={onCopyJson}
disabled={rows.length === 0}
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
onclick={resetWindow}
class="rounded border border-border px-3 py-2 text-sm text-text-secondary hover:bg-surface-hover"
>
<Copy size={15} /> Copy JSON
</button>
<button
type="button"
onclick={onDownloadNdjson}
disabled={rows.length === 0}
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
>
<Download size={15} /> Download
Reset to recent {DEFAULT_LIMIT}
</button>
</div>
</details>
</section>
<!-- Timeline -->
@@ -276,7 +335,13 @@
</p>
{:else}
<div class="flex items-center justify-between">
<span class="text-xs text-text-muted">{rows.length} events (oldest first)</span>
<span class="text-xs text-text-muted">
{#if hasWindow}
{rows.length} events in window ({sortOrder === 'newest' ? 'newest' : 'oldest'} first)
{:else}
Most recent {rows.length} events ({sortOrder === 'newest' ? 'newest' : 'oldest'} first) · set a time window under Advanced
{/if}
</span>
</div>
<ul class="divide-y divide-border rounded-md border border-border font-mono text-xs">
{#each rows as r (r.id)}