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 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() {
@@ -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,
@@ -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 {
@@ -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 { }
@@ -102,6 +102,7 @@ class DiagnosticsReporter @Inject constructor(
launch { collectServerHealth() } launch { collectServerHealth() }
launch { collectUpnpDrops() } launch { collectUpnpDrops() }
launch { collectPlayerState() } launch { collectPlayerState() }
launch { collectTrackChanges() }
launch { collectRoutes() } launch { collectRoutes() }
launch { heartbeatLoop() } launch { heartbeatLoop() }
} }
@@ -147,11 +148,13 @@ class DiagnosticsReporter @Inject constructor(
} }
private suspend fun collectPlayerState() { private suspend fun collectPlayerState() {
// 'playback', not 'upnp_sync' — player state applies to every output
// route (phone speaker, Bluetooth, UPnP), not just casting.
playerController.uiState playerController.uiState
.map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) } .map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) }
.distinctUntilChanged() .distinctUntilChanged()
.collect { (source, upnpLoading, err) -> .collect { (source, upnpLoading, err) ->
record("upnp_sync", buildJsonObject { record("playback", buildJsonObject {
put("event", "player_state") put("event", "player_state")
put("source", source ?: "") put("source", source ?: "")
put("upnp_loading", upnpLoading) 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() { 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 -> outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r ->
record("upnp_sync", buildJsonObject { record("playback", buildJsonObject {
put("event", "route") put("event", "route")
put("id", r.id) put("id", r.id)
put("name", r.name) put("name", r.name)
put("kind", r.kind.name) put("kind", r.kind.name)
put("protocol", r.protocol.name) put("protocol", r.protocol.name)
put("connected", r.isConnected)
}) })
} }
} }
@@ -186,13 +218,11 @@ class DiagnosticsReporter @Inject constructor(
put("is_playing", ui.isPlaying) put("is_playing", ui.isPlaying)
put("source", ui.currentSource ?: "") put("source", ui.currentSource ?: "")
put("local_index", ui.queueIndex) put("local_index", ui.queueIndex)
put("local_track_id", ui.currentTrack?.id ?: "")
put("local_pos_ms", ui.positionMs) put("local_pos_ms", ui.positionMs)
put("route", route.name) put("route", route.name)
put("route_protocol", route.protocol.name) put("route_protocol", route.protocol.name)
// UPnP/Sonos remote-vs-local — the desync signal. putSonos(this, routeActive)
put("sonos_track", remoteState.trackNumber)
put("sonos_pos_ms", remoteState.positionMs)
put("sonos_playing", remoteState.isPlaying)
addPowerFields(this) addPowerFields(this)
}) })
} }
@@ -257,6 +287,18 @@ class DiagnosticsReporter @Inject constructor(
addPowerFields(this) 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) { private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
builder.put("doze", pm.isDeviceIdleMode) 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.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)
@@ -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,
@@ -133,7 +133,7 @@ private fun RouteRow(
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
val subtitle = route.description ?: defaultSubtitle(route) val subtitle = route.description ?: defaultSubtitle(route, isSelected)
Text( Text(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodySmall, 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.BuiltIn -> "Phone speaker"
OutputRoute.Kind.Wired -> "Wired" 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.Cast -> "Cast"
OutputRoute.Kind.Other -> "Available" OutputRoute.Kind.Other -> "Available"
} }
@@ -21,7 +21,6 @@ data class OutputRoute(
val description: String?, val description: String?,
val kind: Kind, val kind: Kind,
val protocol: Protocol, val protocol: Protocol,
val isConnected: Boolean,
) { ) {
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other } 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 * Lift a MediaRouter [route] into the domain model. Kind is
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown * inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
* device types fall through to [Kind.Other]. The * device types fall through to [Kind.Other]. "Active" is NOT an
* `connectionState` proxy is good enough for the chip's * intrinsic of the route — the caller derives it by comparing to
* "Connected"/"Available" subtitle. * 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 { fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
val kind = when (route.deviceType) { val kind = when (route.deviceType) {
@@ -58,15 +59,12 @@ data class OutputRoute(
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
else -> Kind.Other else -> Kind.Other
} }
val connected =
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
return OutputRoute( return OutputRoute(
id = route.id, id = route.id,
name = route.name, name = route.name,
description = route.description, description = route.description,
kind = kind, kind = kind,
protocol = Protocol.SYSTEM, protocol = Protocol.SYSTEM,
isConnected = connected,
) )
} }
@@ -76,10 +74,10 @@ data class OutputRoute(
* network speakers into the same `OutputPickerController` * network speakers into the same `OutputPickerController`
* routes stream the system routes come through. * routes stream the system routes come through.
* *
* `isConnected = false` because UPnP devices have no * UPnP devices have no MediaRouter connection-state concept —
* MediaRouter connection-state concept — they're always * they're always "available" on the LAN, and the picker's
* "available" on the LAN, and the picker's selected-route * selected-route rendering handles the "currently playing"
* rendering handles the "currently playing" indicator. * indicator.
* *
* Subtitle is `manufacturer modelName` joined by a single * Subtitle is `manufacturer modelName` joined by a single
* space, falling back to "Network speaker" when both fields * space, falling back to "Network speaker" when both fields
@@ -96,7 +94,6 @@ data class OutputRoute(
description = description, description = description,
kind = Kind.Other, kind = Kind.Other,
protocol = Protocol.UPNP, protocol = Protocol.UPNP,
isConnected = false,
) )
} }
} }
@@ -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
@@ -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,12 +125,13 @@ 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,
@@ -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,18 +24,26 @@ 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
LazyColumn(modifier = modifier.fillMaxSize()) {
item {
Column( Column(
modifier = modifier modifier = Modifier
.fillMaxSize() .fillParentMaxSize()
.padding(32.dp), .padding(32.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -45,9 +54,18 @@ fun ErrorRetry(
modifier = Modifier.size(48.dp), modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error, 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(
text = message, text = message,
modifier = Modifier.padding(top = 16.dp), modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, 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.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),
) )
+2 -1
View File
@@ -21,7 +21,8 @@ import (
// variants do NOT require a new category here. // variants do NOT require a new category here.
var validDiagnosticKinds = map[string]struct{}{ var validDiagnosticKinds = map[string]struct{}{
"connectivity": {}, "connectivity": {},
"upnp_sync": {}, "upnp_sync": {}, // genuinely UPnP/Sonos-specific (drops, resync)
"playback": {}, // route + player-state, any output (phone/BT/UPnP)
"power": {}, "power": {},
"lifecycle": {}, "lifecycle": {},
"heartbeat": {}, "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 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 { function kindLabel(k: string): string {
switch (k) { switch (k) {
case 'connectivity': return 'Connectivity'; case 'connectivity': return 'Connectivity';
case 'playback': return 'Playback';
case 'upnp_sync': return 'UPnP sync'; case 'upnp_sync': return 'UPnP sync';
case 'power': return 'Power'; case 'power': return 'Power';
case 'lifecycle': return 'Lifecycle'; 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. // Filter state.
let accountId = $state(''); let accountId = $state('');
let clientId = $state(''); let clientId = $state('');
let kind = $state(''); let kind = $state('');
let fromLocal = $state(''); let fromLocal = $state('');
let toLocal = $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. // datetime-local (browser-local, no tz) → RFC3339 UTC the API accepts.
function toRfc(v: string): string | undefined { function toRfc(v: string): string | undefined {
@@ -69,10 +91,17 @@
const devicesQuery = $derived($devicesStore); const devicesQuery = $derived($devicesStore);
const devices = $derived(devicesQuery.data ?? []); 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 diagStore = $derived(createAdminDiagnosticsQuery(filter));
const diagQuery = $derived($diagStore); const diagQuery = $derived($diagStore);
// API returns newest-first; reverse to chronological for a readable timeline. const rows = $derived.by(() => {
const rows = $derived([...((diagQuery.data ?? []) as AdminDiagnostic[])].reverse()); const data = [...((diagQuery.data ?? []) as AdminDiagnostic[])]; // newest-first
return sortOrder === 'oldest' ? data.reverse() : data;
});
function fmtTime(iso: string): string { function fmtTime(iso: string): string {
const d = new Date(iso); const d = new Date(iso);
@@ -191,8 +220,10 @@
</div> </div>
</section> </section>
<!-- Filters --> <!-- Filters. Default view = most recent {DEFAULT_LIMIT} events, no time
<section class="flex flex-wrap items-end gap-3"> 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"> <label class="block text-xs text-text-secondary">
Device Device
<select <select
@@ -217,6 +248,44 @@
{/each} {/each}
</select> </select>
</label> </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"> <label class="block text-xs text-text-secondary">
From From
<input <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" class="mt-1 block w-24 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
/> />
</label> </label>
<div class="ml-auto flex gap-2">
<button <button
type="button" type="button"
onclick={onCopyJson} onclick={resetWindow}
disabled={rows.length === 0} class="rounded border border-border px-3 py-2 text-sm text-text-secondary hover:bg-surface-hover"
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 Reset to recent {DEFAULT_LIMIT}
</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> </button>
</div> </div>
</details>
</section> </section>
<!-- Timeline --> <!-- Timeline -->
@@ -276,7 +335,13 @@
</p> </p>
{:else} {:else}
<div class="flex items-center justify-between"> <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> </div>
<ul class="divide-y divide-border rounded-md border border-border font-mono text-xs"> <ul class="divide-y divide-border rounded-md border border-border font-mono text-xs">
{#each rows as r (r.id)} {#each rows as r (r.id)}