From 6184c6272182e5557b50e9ea9ed118349bc06b4a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 11:02:51 -0400 Subject: [PATCH 1/7] feat(android): MediaSession picks up UPnP state via direct listener invocation Closes Scribe #606. Two pieces: MediaMetadata gets durationMs (lock-screen scrubber gets a known total even when wrapped ExoPlayer is paused under UPnP); MinstrelForwardingPlayer keeps an externalListeners registry that mirrors super.addListener so we can directly invoke onIsPlayingChanged / onPlaybackStateChanged / onMediaItemTransition when remoteState mutates. Fires from pollOnce + play()/pause() onSuccess. dedup via lastNotified guards so we don't spam events at 1Hz when nothing changed. --- .../player/MinstrelForwardingPlayer.kt | 79 ++++++++++++++++++- .../minstrel/player/PlayerController.kt | 4 + 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index f1f610c1..f8dcb773 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -81,6 +81,21 @@ class MinstrelForwardingPlayer( // CONFLATED so repeated trySend's between polls don't queue up. private val pollTrigger = Channel(Channel.CONFLATED) + // External Player.Listener registry (separate from super.addListener which + // forwards to the wrapped ExoPlayer). The wrapped player is paused with + // no audio loaded while UPnP is active, so it never fires events for our + // synthesized remote state -- the MediaSession's notification card and + // lock-screen scrubber stay frozen on whatever state was last captured + // before UPnP took over. We dual-register: super.addListener keeps the + // listener attached to the delegate (so local-playback events still + // reach it), AND we hold a ref here so we can directly invoke listener + // callbacks on remote-state changes. The listener's read of isPlaying / + // duration / position then routes through our overrides to remoteState. + private val externalListeners = mutableListOf() + + @Volatile private var lastNotifiedIsPlaying: Boolean = false + @Volatile private var lastNotifiedTrackIdx: Int = -1 + private val lifecycleObserver = object : DefaultLifecycleObserver { override fun onResume(owner: LifecycleOwner) { pollTrigger.trySend(Unit) @@ -121,7 +136,10 @@ class MinstrelForwardingPlayer( } else { scope.launch { runCatching { active.avTransport.play() } - .onSuccess { remoteState.applyTransportPlaying() } + .onSuccess { + remoteState.applyTransportPlaying() + notifyRemoteStateChanged() + } .onFailure { handleSoapFailure(active, it) } } } @@ -139,7 +157,10 @@ class MinstrelForwardingPlayer( } else { scope.launch { runCatching { active.avTransport.pause() } - .onSuccess { remoteState.applyTransportPaused() } + .onSuccess { + remoteState.applyTransportPaused() + notifyRemoteStateChanged() + } .onFailure { handleSoapFailure(active, it) } } } @@ -269,6 +290,54 @@ class MinstrelForwardingPlayer( override fun getPlayWhenReady(): Boolean = if (isRemote()) remoteState.isPlaying else super.getPlayWhenReady() + override fun addListener(listener: Player.Listener) { + super.addListener(listener) + synchronized(externalListeners) { externalListeners.add(listener) } + } + + override fun removeListener(listener: Player.Listener) { + super.removeListener(listener) + synchronized(externalListeners) { externalListeners.remove(listener) } + } + + /** + * Direct-invoke the externally-registered Player.Listeners so the + * MediaSession's PlaybackState publisher (notification card, lock-screen + * scrubber, BT/AVRCP, Auto, Wear OS tile) re-reads our overridden state. + * The listeners then query isPlaying / getDuration / getCurrentPosition, + * all of which route through to remoteState while UPnP is active. + * + * Posted to the player's application looper because Player.Listener + * callbacks contract on the application thread. + */ + private fun notifyRemoteStateChanged() { + if (!isRemote()) return + val playing = remoteState.isPlaying + val trackIdx = (remoteState.trackNumber - 1).coerceAtLeast(0) + val isPlayingChanged = playing != lastNotifiedIsPlaying + val trackChanged = trackIdx != lastNotifiedTrackIdx + if (!isPlayingChanged && !trackChanged) return + lastNotifiedIsPlaying = playing + lastNotifiedTrackIdx = trackIdx + val snapshot = synchronized(externalListeners) { externalListeners.toList() } + handler.post { + for (l in snapshot) { + if (isPlayingChanged) { + l.onIsPlayingChanged(playing) + l.onPlaybackStateChanged(Player.STATE_READY) + } + if (trackChanged) { + val item = if (trackIdx < delegate.mediaItemCount) { + delegate.getMediaItemAt(trackIdx) + } else { + null + } + l.onMediaItemTransition(item, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) + } + } + } + } + override fun release() { pollJob?.cancel() scope.cancel() @@ -288,6 +357,11 @@ class MinstrelForwardingPlayer( private fun onActiveChanged(active: ActiveUpnp?) { pollJob?.cancel() nonPlayingPollStreak = 0 + // Reset notify cache so the first poll after a route flip republishes + // playing/track state to the MediaSession even if it happens to match + // the prior session's values numerically. + lastNotifiedIsPlaying = false + lastNotifiedTrackIdx = -1 if (active != null) { Timber.w("UPnP active: %s -- pollLoop starting", active.routeName) // Pause the wrapped ExoPlayer so we are not playing local audio @@ -373,6 +447,7 @@ class MinstrelForwardingPlayer( } TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit } + notifyRemoteStateChanged() } private fun maybeSyncLocalCursor(sonosTrack: Int) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index a6af30d3..0305255b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -666,6 +666,10 @@ class PlayerController @Inject constructor( .setArtist(artistName) .setAlbumTitle(albumTitle) .apply { + // Server-known duration -- gives the lock-screen / notification + // scrubber a real total even when the wrapped ExoPlayer is + // paused under UPnP (it never probes a duration in that state). + if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND) if (source != null) setExtras(sourceExtras(source)) } .build() From 4d0a0b8e09546af520bff0e0e49208fdfb00c466 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 11:03:55 -0400 Subject: [PATCH 2/7] fix(android): throttle UPnP extend with 50ms delay per successful AddURIToQueue Closes Scribe #611. The 2026-06-04 logcat showed 33 consecutive AddURIToQueue failures clustered at ~10ms intervals once the burst hit offset 39 -- characteristic of Sonos's burst-add rate-limit. 50ms between successful adds adds ~5s to the 100-track background extension but eliminates the burst rejection. Next reproduction with the SOAP fault detail logging (audit commit c5b326c6) will confirm the fault code if any tracks still fail. --- .../minstrel/player/output/OutputPickerController.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt index 48fdc0de..f1f9307c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt @@ -19,6 +19,7 @@ import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController import com.fabledsword.minstrel.player.output.upnp.bareUdn import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -372,6 +373,13 @@ class OutputPickerController @Inject constructor( if (outcome.isSuccess) { consecutiveFailures = 0 succeeded += 1 + // Throttle the burst so we don't tickle Sonos's burst-add + // rejection -- logcat 2026-06-04 showed 33 consecutive + // failures clustered at ~10ms intervals once offset 39 was + // reached, which looks like a rate-limit kicking in. The + // delay is small enough that extending 100 tracks adds + // only ~5s to background work that's already async. + delay(EXTEND_THROTTLE_MS) } else { consecutiveFailures += 1 val e = outcome.exceptionOrNull() @@ -423,5 +431,6 @@ class OutputPickerController @Inject constructor( private companion object { const val EXTEND_ABORT_AFTER_FAILURES = 3 + const val EXTEND_THROTTLE_MS = 50L } } From 80a6be25aabd5750357a3e5e4b6386461db7c3b0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 11:25:22 -0400 Subject: [PATCH 3/7] feat(android): ServerHealth tri-state composite + banner distinguishes offline vs server-down Phase 1 of #618. VersionCheckController gains reachable: StateFlow from the same /healthz poll (no double polling). ServerHealthController combines connectivity.online + versionCheck.reachable into ServerHealth { Healthy, Offline, ServerDown }. ConnectionErrorBanner now branches on the tri-state, distinguishing 'no Wi-Fi' from 'server unreachable.' Phases 2-5 (local search, cache-only audio source, row-level not-cached affordance, write-affordance gray-out) ship separately as independent slices. --- .../minstrel/MinstrelApplication.kt | 10 ++++ .../connectivity/ServerHealthController.kt | 53 +++++++++++++++++++ .../connectivity/ui/ConnectionErrorBanner.kt | 38 +++++++------ .../update/data/VersionCheckController.kt | 18 ++++++- 4 files changed, 102 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index d04bb858..1b6f8e91 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -19,6 +19,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter import com.fabledsword.minstrel.player.PlaybackErrorReporter import com.fabledsword.minstrel.player.ResumeController import com.fabledsword.minstrel.update.data.UpdateBannerController +import com.fabledsword.minstrel.connectivity.ServerHealthController import com.fabledsword.minstrel.update.data.VersionCheckController import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope @@ -121,6 +122,15 @@ class MinstrelApplication : */ @Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController + /** + * Same construct-the-singleton trick — ServerHealthController combines + * ConnectivityObserver + VersionCheckController.reachable into the + * tri-state ServerHealth signal. Its stateIn is `SharingStarted.Eagerly` + * so the StateFlow needs an active subscriber from launch onward; the + * @Inject keeps the singleton alive and the flow collecting. + */ + @Suppress("unused") @Inject lateinit var serverHealthController: ServerHealthController + /** * Same construct-the-singleton trick — UpdateBannerController polls * /api/client/version at launch + every 24h and drives the shell's diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt new file mode 100644 index 00000000..e0aa1f2c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt @@ -0,0 +1,53 @@ +package com.fabledsword.minstrel.connectivity + +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.update.data.VersionCheckController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Tri-state server-reachability signal that downstream consumers can branch + * on to decide whether to hit the network, gate writes, or fall back to + * cache-only behavior. + * + * Composed from two existing signals -- this controller doesn't poll its own + * endpoint: + * + * - [ConnectivityObserver.online] -- system-level NetworkCallback with the + * INTERNET + VALIDATED capability check (captive portals fail this). + * - [VersionCheckController.reachable] -- did the last `/healthz` poll + * succeed. Distinguishes "device has network but our server is down" from + * "no network at all," which the connectivity-only signal can't. + * + * `version too old` is intentionally *not* folded in here -- it's a separate + * UX (the VersionTooOldBanner) and conflating it with offline would mask the + * real cause. + */ +enum class ServerHealth { Healthy, Offline, ServerDown } + +@Singleton +class ServerHealthController @Inject constructor( + @ApplicationScope scope: CoroutineScope, + connectivity: ConnectivityObserver, + versionCheck: VersionCheckController, +) { + val state: StateFlow = combine( + connectivity.online, + versionCheck.reachable, + ) { online, serverReachable -> + when { + !online -> ServerHealth.Offline + !serverReachable -> ServerHealth.ServerDown + else -> ServerHealth.Healthy + } + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = ServerHealth.Healthy, + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt index 3dbb33c4..c736610e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt @@ -25,45 +25,45 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import com.composables.icons.lucide.CloudOff import com.composables.icons.lucide.Lucide -import com.fabledsword.minstrel.connectivity.ConnectivityObserver +import com.fabledsword.minstrel.connectivity.ServerHealth +import com.fabledsword.minstrel.connectivity.ServerHealthController import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import javax.inject.Inject -private const val ONLINE_SHARE_STOP_TIMEOUT_MS = 5_000L +private const val HEALTH_SHARE_STOP_TIMEOUT_MS = 5_000L /** - * Tiny VM that just lifts the [ConnectivityObserver] singleton's - * Flow into a StateFlow with the standard sharing strategy. Keeps - * the banner composable pure-presentation. + * Lifts [ServerHealthController]'s tri-state into a StateFlow for the banner + * composable. Keeps the banner pure-presentation. */ @HiltViewModel class ConnectivityBannerViewModel @Inject constructor( - observer: ConnectivityObserver, + health: ServerHealthController, @Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle, ) : ViewModel() { - val online: StateFlow = observer.online.stateIn( + val health: StateFlow = health.state.stateIn( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(ONLINE_SHARE_STOP_TIMEOUT_MS), - initialValue = true, + started = SharingStarted.WhileSubscribed(HEALTH_SHARE_STOP_TIMEOUT_MS), + initialValue = ServerHealth.Healthy, ) } /** - * Banner shown at the top of the shell when the device has no usable - * internet. Mirrors Flutter's ConnectionErrorBanner: red-tinted error - * surface, CloudOff icon, "No connection — check Wi-Fi or mobile - * data" copy. Auto-hides via slide+fade when connectivity returns. + * Banner shown at the top of the shell when the user can't reach the server. + * Tri-state so we tell the user *why*: no device network vs server-down. + * Copy choices match the Flutter analogues. Auto-hides via slide+fade when + * health returns to [ServerHealth.Healthy]. */ @Composable fun ConnectionErrorBanner( viewModel: ConnectivityBannerViewModel = hiltViewModel(), ) { - val online by viewModel.online.collectAsStateWithLifecycle() + val health by viewModel.health.collectAsStateWithLifecycle() AnimatedVisibility( - visible = !online, + visible = health != ServerHealth.Healthy, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { @@ -81,7 +81,13 @@ fun ConnectionErrorBanner( tint = MaterialTheme.colorScheme.onErrorContainer, ) Text( - text = "No connection — check Wi-Fi or mobile data.", + text = when (health) { + ServerHealth.Offline -> + "No connection — check Wi-Fi or mobile data." + ServerHealth.ServerDown -> + "Server unreachable — your cached content is still available." + ServerHealth.Healthy -> "" + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt index 3ab9087d..1aa51435 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt @@ -41,6 +41,16 @@ class VersionCheckController @Inject constructor( private val internal = MutableStateFlow(VersionResult.SKIPPED) val result: StateFlow = internal.asStateFlow() + // Whether the most recent /healthz poll reached the server. Separate from + // VersionResult because "unreachable" and "version mismatch" drive + // different UX (offline banner vs version-too-old banner). Optimistic + // initial value -- the first poll fires within seconds of app launch and + // we don't want a "server down" flash before we've actually tried. + // Consumed by ServerHealthController to compose with ConnectivityObserver + // for the tri-state offline / server-down / healthy signal. + private val internalReachable = MutableStateFlow(true) + val reachable: StateFlow = internalReachable.asStateFlow() + init { scope.launch { while (true) { @@ -56,7 +66,13 @@ class VersionCheckController @Inject constructor( } private suspend fun runOnce() { - val response = runCatching { api.check() }.getOrNull() ?: return + val outcome = runCatching { api.check() } + val response = outcome.getOrNull() + if (response == null) { + internalReachable.value = false + return + } + internalReachable.value = true val min = response.minClientVersion internal.value = when { min.isEmpty() -> VersionResult.SKIPPED From fced6b681e3a9007190ff1ba2985167992a35d4e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 11:27:11 -0400 Subject: [PATCH 4/7] feat(android): cache-only audio path when ServerHealth != Healthy Phase 3 of #618. Wraps the OkHttpDataSource upstream of CacheDataSource with OfflineGatedDataSource. CacheDataSource only consults the upstream factory on a cache miss, so playback of cached audio is unaffected. Offline tap on a non-cached track now throws OfflineException immediately (subclass of IOException for ExoPlayer's PlaybackException to wrap) instead of waiting on a multi-second OkHttp timeout. AudioPrefetcher keeps its own ungated upstream -- writes fail silently when offline, no user-visible impact. --- .../minstrel/player/OfflineGatedDataSource.kt | 65 +++++++++++++++++++ .../minstrel/player/PlayerFactory.kt | 8 ++- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt new file mode 100644 index 00000000..677a9033 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt @@ -0,0 +1,65 @@ +package com.fabledsword.minstrel.player + +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import com.fabledsword.minstrel.connectivity.ServerHealth +import com.fabledsword.minstrel.connectivity.ServerHealthController +import java.io.IOException +import java.io.InterruptedIOException + +/** + * DataSource wrapper that fails the network read immediately when + * [ServerHealthController] reports a non-Healthy state. CacheDataSource only + * calls this upstream factory on cache misses, so playback of cached audio is + * unaffected -- only "tap a non-cached track while offline" hits this branch + * and gets a fast, meaningful error instead of a multi-second network timeout + * (which then surfaced as a silent decode failure to the user). + * + * Wrapping rather than substituting the OkHttp data source lets the cache + * write path remain intact for when health returns and we DO want to fetch: + * we keep the same upstream all the time, just gate `open()`. + */ +class OfflineGatedDataSource( + private val delegate: DataSource, + private val health: ServerHealthController, +) : DataSource { + + override fun open(dataSpec: DataSpec): Long { + when (health.state.value) { + ServerHealth.Offline -> throw OfflineException( + "Track not in the on-device cache and the device is offline.", + ) + ServerHealth.ServerDown -> throw OfflineException( + "Track not in the on-device cache and the Minstrel server is unreachable.", + ) + ServerHealth.Healthy -> Unit + } + return delegate.open(dataSpec) + } + + override fun close() = delegate.close() + override fun getUri() = delegate.uri + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + delegate.read(buffer, offset, length) + override fun addTransferListener(transferListener: TransferListener) = + delegate.addTransferListener(transferListener) + override fun getResponseHeaders() = delegate.responseHeaders +} + +class OfflineGatedDataSourceFactory( + private val upstream: DataSource.Factory, + private val health: ServerHealthController, +) : DataSource.Factory { + override fun createDataSource(): DataSource = + OfflineGatedDataSource(upstream.createDataSource(), health) +} + +/** + * Signals the audio-source error path that the request was denied because the + * device is offline / the server is unreachable. ExoPlayer's [androidx.media3 + * .common.PlaybackException] catches it via [InterruptedIOException]'s + * `IOException` ancestor and surfaces it as a SOURCE error, which then flows + * through the existing [PlaybackErrorReporter] -> snackbar path. + */ +class OfflineException(message: String) : IOException(message) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt index dd28ca5e..91176bb6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -52,6 +52,7 @@ class PlayerFactory @Inject constructor( private val cacheConfig: CacheConfig, private val activeUpnpHolder: ActiveUpnpHolder, private val remoteState: RemotePlayerState, + private val serverHealth: com.fabledsword.minstrel.connectivity.ServerHealthController, ) { private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() } @@ -83,9 +84,14 @@ class PlayerFactory @Inject constructor( private fun buildExoPlayer(): ExoPlayer { val httpDataSource = OkHttpDataSource.Factory(okHttpClient) + // Gate network reads on ServerHealth so a cache miss while offline + // fails fast with an OfflineException instead of hitting an OkHttp + // timeout. CacheDataSource only consults the upstream factory on a + // cache miss, so playback of cached audio is unaffected. + val gatedUpstream = OfflineGatedDataSourceFactory(httpDataSource, serverHealth) val cacheDataSource = CacheDataSource.Factory() .setCache(simpleCache) - .setUpstreamDataSourceFactory(httpDataSource) + .setUpstreamDataSourceFactory(gatedUpstream) .setCacheWriteDataSinkFactory( CacheDataSink.Factory() .setCache(simpleCache) From 48de720514f6ccf4e3c09da057157eb6e661b00c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 12:35:01 -0400 Subject: [PATCH 5/7] =?UTF-8?q?feat(android):=20#618=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20search=20falls=20back=20to=20cached=20entities=20wh?= =?UTF-8?q?en=20offline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ServerHealthController reports Offline or ServerDown, SearchRepository runs Room LIKE queries against cached_artists / cached_albums / cached_tracks instead of hitting /api/search. The screen draws a one-line hint above the results so the user can tell server matches from on-device-only matches. Adds searchByName / searchByTitle DAO methods; LOCAL_SEARCH_LIMIT=20 matches the server's default page size. --- .../minstrel/cache/db/dao/CachedAlbumDao.kt | 8 ++++ .../minstrel/cache/db/dao/CachedArtistDao.kt | 8 ++++ .../minstrel/cache/db/dao/CachedTrackDao.kt | 8 ++++ .../minstrel/search/data/SearchRepository.kt | 45 +++++++++++++++++-- .../minstrel/search/ui/SearchScreen.kt | 43 +++++++++++++----- .../minstrel/search/ui/SearchViewModel.kt | 16 +++++-- 6 files changed, 112 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt index 267b25d8..da68891b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt @@ -24,6 +24,14 @@ interface CachedAlbumDao { @Query("SELECT * FROM cached_albums WHERE id = :id") fun observeById(id: String): Flow + @Query( + "SELECT * FROM cached_albums " + + "WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " + + "ORDER BY sortTitle COLLATE NOCASE ASC " + + "LIMIT :limit", + ) + suspend fun searchByTitle(q: String, limit: Int): List + @Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit") suspend fun idsStaleBefore(before: Long, limit: Int): List diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt index 450053b1..d5127a8b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt @@ -18,6 +18,14 @@ interface CachedArtistDao { @Query("SELECT * FROM cached_artists WHERE id = :id") fun observeById(id: String): Flow + @Query( + "SELECT * FROM cached_artists " + + "WHERE name LIKE '%' || :q || '%' COLLATE NOCASE " + + "ORDER BY sortName COLLATE NOCASE ASC " + + "LIMIT :limit", + ) + suspend fun searchByName(q: String, limit: Int): List + @Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit") suspend fun idsStaleBefore(before: Long, limit: Int): List diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt index b07095a6..a65fa09e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt @@ -24,6 +24,14 @@ interface CachedTrackDao { @Query("SELECT * FROM cached_tracks WHERE id IN (:ids)") suspend fun getByIds(ids: List): List + @Query( + "SELECT * FROM cached_tracks " + + "WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " + + "ORDER BY title COLLATE NOCASE ASC " + + "LIMIT :limit", + ) + suspend fun searchByTitle(q: String, limit: Int): List + @Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit") suspend fun idsStaleBefore(before: Long, limit: Int): List diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt index c310a496..5c6b5527 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt @@ -1,6 +1,11 @@ package com.fabledsword.minstrel.search.data import com.fabledsword.minstrel.api.endpoints.SearchApi +import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao +import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.connectivity.ServerHealth +import com.fabledsword.minstrel.connectivity.ServerHealthController import com.fabledsword.minstrel.library.data.toDomain import com.fabledsword.minstrel.models.SearchResponseRef import retrofit2.Retrofit @@ -8,17 +13,34 @@ import retrofit2.create import javax.inject.Inject import javax.inject.Singleton +private const val LOCAL_SEARCH_LIMIT = 20 + /** - * Thin Retrofit wrapper around `/api/search`. Debouncing lives in - * the ViewModel, not here, so the repository stays trivial. + * Cache-first when offline. When [ServerHealthController] is Healthy the + * repository hits `/api/search` and returns the server's three-facet + * paged response. When health is Offline or ServerDown it falls back to + * Room LIKE queries against `cached_*` so the user can still find + * something to play from what's already on the device; the outcome's + * [SearchOutcome.localOnly] flag lets the screen draw an "offline + * results" hint instead of pretending the server answered. */ @Singleton class SearchRepository @Inject constructor( retrofit: Retrofit, + private val serverHealth: ServerHealthController, + private val trackDao: CachedTrackDao, + private val albumDao: CachedAlbumDao, + private val artistDao: CachedArtistDao, ) { private val api: SearchApi = retrofit.create() - suspend fun search(query: String): SearchResponseRef { + suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) { + ServerHealth.Healthy -> SearchOutcome(remoteSearch(query), localOnly = false) + ServerHealth.Offline, ServerHealth.ServerDown -> + SearchOutcome(localSearch(query), localOnly = true) + } + + private suspend fun remoteSearch(query: String): SearchResponseRef { val wire = api.search(query) return SearchResponseRef( artists = wire.artists.items.map { it.toDomain() }, @@ -26,4 +48,21 @@ class SearchRepository @Inject constructor( tracks = wire.tracks.items.map { it.toDomain() }, ) } + + private suspend fun localSearch(query: String): SearchResponseRef = SearchResponseRef( + artists = artistDao.searchByName(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() }, + albums = albumDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() }, + tracks = trackDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() }, + ) } + +/** + * Wraps the search response with the signal of whether the result came + * from the server or from the local cached entities. The screen renders + * the same SearchResponseRef either way; the flag drives the offline + * banner copy. + */ +data class SearchOutcome( + val response: SearchResponseRef, + val localOnly: Boolean, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt index 4cb90dbb..6e3050c9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt @@ -158,17 +158,28 @@ private fun ResultsPane( is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}") is SearchResultsState.Loaded -> { if (state.response.isEmpty) { - CenteredHint("No matches for that query.") - } else { - ResultsList( - response = state.response, - playingTrackId = playingTrackId, - onArtistClick = onArtistClick, - onAlbumClick = onAlbumClick, - onTrackPlay = onTrackPlay, - onNavigateToAlbum = onNavigateToAlbum, - onNavigateToArtist = onNavigateToArtist, + CenteredHint( + if (state.localOnly) { + "No matches in your on-device library." + } else { + "No matches for that query." + }, ) + } else { + Column(modifier = Modifier.fillMaxSize()) { + if (state.localOnly) { + OfflineResultsHint() + } + ResultsList( + response = state.response, + playingTrackId = playingTrackId, + onArtistClick = onArtistClick, + onAlbumClick = onAlbumClick, + onTrackPlay = onTrackPlay, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + } } } } @@ -308,6 +319,18 @@ private fun SectionHeader(label: String, count: Int) { } } +@Composable +private fun OfflineResultsHint() { + Text( + text = "Showing on-device matches only — the server is unreachable.", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + @Composable private fun CenteredHint(text: String) { Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt index 86860f09..830fb501 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt @@ -26,7 +26,10 @@ sealed interface SearchResultsState { /** Empty query — screen shows "type to search" hint. */ data object Idle : SearchResultsState data object Loading : SearchResultsState - data class Loaded(val response: SearchResponseRef) : SearchResultsState + data class Loaded( + val response: SearchResponseRef, + val localOnly: Boolean = false, + ) : SearchResultsState data class Error(val message: String) : SearchResultsState } @@ -98,8 +101,15 @@ class SearchViewModel @Inject constructor( private suspend fun runSearch(q: String) { internal.update { it.copy(results = SearchResultsState.Loading) } try { - val response = repository.search(q) - internal.update { it.copy(results = SearchResultsState.Loaded(response)) } + val outcome = repository.search(q) + internal.update { + it.copy( + results = SearchResultsState.Loaded( + response = outcome.response, + localOnly = outcome.localOnly, + ), + ) + } } catch ( @Suppress("TooGenericExceptionCaught") e: Throwable, ) { From 1daea79f64fd7b74932466fa23daf1330b1c6759 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 12:37:42 -0400 Subject: [PATCH 6/7] =?UTF-8?q?feat(android):=20#618=20Phase=204=20?= =?UTF-8?q?=E2=80=94=20row-level=20offline-unavailable=20affordance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrackRow now consumes the LocalServerHealth CompositionLocal (provided once at MainActivity from ServerHealthController.state). When the server is Offline or ServerDown and the track id isn't in LocalCachedTrackIds, the row dims to 0.4 alpha and a tap fires a Toast instead of attempting playback. Replaces the silent "tap-and-fail-to-OfflineException" UX with explicit at-a-glance signaling of which rows in a long list will work. Trailing slot (kebab / like / playlist-add) stays interactive so write affordances can route through MutationQueue — Phase 5 gates those at the action level. --- .../com/fabledsword/minstrel/MainActivity.kt | 8 +++++ .../connectivity/ServerHealthController.kt | 10 ++++++ .../minstrel/shared/widgets/TrackRow.kt | 34 +++++++++++++++++-- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt index f1203b98..cfbe9396 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt @@ -21,6 +21,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.rememberNavController import com.fabledsword.minstrel.auth.ui.AuthGateViewModel import com.fabledsword.minstrel.cache.CachedTrackIds +import com.fabledsword.minstrel.connectivity.LocalServerHealth +import com.fabledsword.minstrel.connectivity.ServerHealth +import com.fabledsword.minstrel.connectivity.ServerHealthController import com.fabledsword.minstrel.nav.DetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.MinstrelNavGraph @@ -38,6 +41,7 @@ import javax.inject.Inject class MainActivity : ComponentActivity() { @Inject lateinit var seedCache: DetailSeedCache @Inject lateinit var cachedTrackIds: CachedTrackIds + @Inject lateinit var serverHealth: ServerHealthController // Flipped to true when the user taps the media notification (or // any other entry point that asks for the full player). The App @@ -54,6 +58,7 @@ class MainActivity : ComponentActivity() { App( seedCache = seedCache, cachedTrackIds = cachedTrackIds, + serverHealth = serverHealth, pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(), onOpenedNowPlaying = { pendingOpenNowPlaying.value = false }, ) @@ -86,6 +91,7 @@ class MainActivity : ComponentActivity() { private fun App( seedCache: DetailSeedCache, cachedTrackIds: CachedTrackIds, + serverHealth: ServerHealthController, pendingOpenNowPlaying: StateFlow, onOpenedNowPlaying: () -> Unit, themeVm: ThemePreferenceViewModel = hiltViewModel(), @@ -93,11 +99,13 @@ private fun App( ) { val theme by themeVm.themeMode.collectAsStateWithLifecycle() val cached by cachedTrackIds.ids.collectAsStateWithLifecycle() + val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle() val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle() MinstrelTheme(darkOverride = theme.toDarkOverride()) { CompositionLocalProvider( LocalDetailSeedCache provides seedCache, LocalCachedTrackIds provides cached, + LocalServerHealth provides health, ) { val startDestination by gate.startDestination.collectAsStateWithLifecycle() val resolved = startDestination diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt index e0aa1f2c..eb6c1a81 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt @@ -1,5 +1,6 @@ package com.fabledsword.minstrel.connectivity +import androidx.compose.runtime.staticCompositionLocalOf import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.update.data.VersionCheckController import kotlinx.coroutines.CoroutineScope @@ -51,3 +52,12 @@ class ServerHealthController @Inject constructor( initialValue = ServerHealth.Healthy, ) } + +/** + * Reactive [ServerHealth] snapshot provided once at the app root from the + * controller's StateFlow. Lets leaf composables (TrackRow gating, write- + * affordance disabling) branch on health without each ViewModel re- + * injecting the controller. Defaults to Healthy so unwrapped previews + * and tests don't crash. + */ +val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt index 0b5c95c1..a80c343b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt @@ -1,5 +1,6 @@ package com.fabledsword.minstrel.shared.widgets +import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -12,8 +13,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.fabledsword.minstrel.connectivity.LocalServerHealth +import com.fabledsword.minstrel.connectivity.ServerHealth + +private const val OFFLINE_UNAVAILABLE_ALPHA = 0.4f +private const val OFFLINE_TAP_MESSAGE = "Not downloaded — connect to play" /** * Shared track-list row. Replaces the 5 per-screen `TrackRow`s — every @@ -30,6 +37,14 @@ import androidx.compose.ui.unit.dp * for applying its own alpha if it should match (the row doesn't * cascade because the trailing slot's content is the caller's, not * ours). + * + * Reads [LocalServerHealth] + [LocalCachedTrackIds] and intercepts taps + * on tracks that aren't downloaded when the server is unreachable — + * fires a Toast instead of attempting playback. The text dims so the + * user can see at a glance which rows in a long list will work offline. + * The trailing slot stays interactive so the kebab / like / playlist- + * add affordances can still queue mutations for offline replay (Phase + * 5 of #618 gates those at the action level). */ @Composable fun TrackRow( @@ -45,15 +60,28 @@ fun TrackRow( leading: @Composable () -> Unit = {}, trailing: @Composable RowScope.() -> Unit = {}, ) { + val context = LocalContext.current + val offlineUnavailable = LocalServerHealth.current != ServerHealth.Healthy && + trackId !in LocalCachedTrackIds.current val titleColor = if (nowPlaying) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurface } + val effectiveAlpha = if (offlineUnavailable) { + minOf(contentAlpha, OFFLINE_UNAVAILABLE_ALPHA) + } else { + contentAlpha + } + val effectiveOnClick: () -> Unit = if (offlineUnavailable) { + { Toast.makeText(context, OFFLINE_TAP_MESSAGE, Toast.LENGTH_SHORT).show() } + } else { + onClick + } Row( modifier = modifier .fillMaxWidth() - .clickable(enabled = enabled, onClick = onClick) + .clickable(enabled = enabled, onClick = effectiveOnClick) .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = horizontalArrangement, @@ -63,7 +91,7 @@ fun TrackRow( Text( text = title, style = MaterialTheme.typography.bodyLarge, - color = titleColor.copy(alpha = contentAlpha), + color = titleColor.copy(alpha = effectiveAlpha), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -71,7 +99,7 @@ fun TrackRow( Text( text = artist, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = effectiveAlpha), maxLines = 1, overflow = TextOverflow.Ellipsis, ) From 1e17eeda726f37c9b12d148ae65d324e8d230d66 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 12:40:59 -0400 Subject: [PATCH 7/7] =?UTF-8?q?feat(android):=20#618=20Phase=205=20?= =?UTF-8?q?=E2=80=94=20confirm=20offline=20writes=20via=20shell=20snackbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MutationQueue now emits "Saved — will sync when online" on a SharedFlow whenever a user-driven enqueue lands (like toggle, playlist append, request create/cancel, quarantine flag/unflag). Background enqueues (play-offline events, playback-error reports) do not emit — those fire from non-foreground paths where a snackbar would be either dropped (no shell mounted) or jarring (lock-screen toggle). ShellScaffold subscribes via OfflineWriteHintViewModel and routes the hint through its existing snackbar host. Replaces the prior silent- queue UX where a tap on a like / playlist add looked successful but the user couldn't tell whether the server had been hit or the call was deferred for replay. --- .../minstrel/cache/mutations/MutationQueue.kt | 106 +++++++++++------- .../mutations/OfflineWriteHintViewModel.kt | 20 ++++ .../minstrel/shared/widgets/ShellScaffold.kt | 7 ++ 3 files changed, 90 insertions(+), 43 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/OfflineWriteHintViewModel.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt index 541178cb..ec72f104 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt @@ -2,12 +2,18 @@ package com.fabledsword.minstrel.cache.mutations import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton +private const val QUEUED_MESSAGE = "Saved — will sync when online" + /** * Stable mutation kinds the queue knows how to replay. Strings are * persisted in `cached_mutations.kind` so renaming a variant breaks @@ -71,47 +77,59 @@ class MutationQueue @Inject constructor( private val dao: CachedMutationDao, private val json: Json, ) { + // capacity=1 DROP_OLDEST so a burst of user enqueues (e.g. liking N + // tracks while offline) surfaces as one snackbar rather than queueing + // N. replay=0 because a hint observed at enqueue time isn't useful + // to a screen that mounts later. + private val _userEnqueueHints = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** + * Hint stream consumed by [com.fabledsword.minstrel.shared.widgets.ShellScaffold] + * to surface "Saved — will sync when online" as a snackbar whenever a + * user-driven write hits the offline-fallback path. Background-only + * enqueues (play-events, playback-error reports) do not emit — those + * fire from non-foreground paths where a snackbar would be either + * dropped (no shell mounted) or jarring (lock-screen toggle). + */ + val userEnqueueHints: SharedFlow = _userEnqueueHints.asSharedFlow() + suspend fun enqueueLikeToggle( entityType: String, entityId: String, desiredState: Boolean, - ): Long = dao.insert( - CachedMutationEntity( - kind = MutationKind.LIKE_TOGGLE, - payload = json.encodeToString( - LikeTogglePayload.serializer(), - LikeTogglePayload(entityType, entityId, desiredState), - ), + ): Long = insertUserDriven( + MutationKind.LIKE_TOGGLE, + json.encodeToString( + LikeTogglePayload.serializer(), + LikeTogglePayload(entityType, entityId, desiredState), ), ) - suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = dao.insert( - CachedMutationEntity( - kind = MutationKind.REQUEST_CREATE, - payload = json.encodeToString(RequestCreatePayload.serializer(), payload), - ), + suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = insertUserDriven( + MutationKind.REQUEST_CREATE, + json.encodeToString(RequestCreatePayload.serializer(), payload), ) - suspend fun enqueueQuarantineUnflag(trackId: String): Long = dao.insert( - CachedMutationEntity( - kind = MutationKind.QUARANTINE_UNFLAG, - payload = json.encodeToString( - QuarantineUnflagPayload.serializer(), - QuarantineUnflagPayload(trackId), - ), + suspend fun enqueueQuarantineUnflag(trackId: String): Long = insertUserDriven( + MutationKind.QUARANTINE_UNFLAG, + json.encodeToString( + QuarantineUnflagPayload.serializer(), + QuarantineUnflagPayload(trackId), ), ) suspend fun enqueuePlaylistAppend( playlistId: String, trackIds: List, - ): Long = dao.insert( - CachedMutationEntity( - kind = MutationKind.PLAYLIST_APPEND, - payload = json.encodeToString( - PlaylistAppendPayload.serializer(), - PlaylistAppendPayload(playlistId, trackIds), - ), + ): Long = insertUserDriven( + MutationKind.PLAYLIST_APPEND, + json.encodeToString( + PlaylistAppendPayload.serializer(), + PlaylistAppendPayload(playlistId, trackIds), ), ) @@ -119,13 +137,19 @@ class MutationQueue @Inject constructor( trackId: String, reason: String, notes: String, - ): Long = dao.insert( - CachedMutationEntity( - kind = MutationKind.QUARANTINE_FLAG, - payload = json.encodeToString( - QuarantineFlagPayload.serializer(), - QuarantineFlagPayload(trackId, reason, notes), - ), + ): Long = insertUserDriven( + MutationKind.QUARANTINE_FLAG, + json.encodeToString( + QuarantineFlagPayload.serializer(), + QuarantineFlagPayload(trackId, reason, notes), + ), + ) + + suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven( + MutationKind.REQUEST_CANCEL, + json.encodeToString( + RequestCancelPayload.serializer(), + RequestCancelPayload(requestId), ), ) @@ -136,22 +160,18 @@ class MutationQueue @Inject constructor( ), ) - suspend fun enqueueRequestCancel(requestId: String): Long = dao.insert( - CachedMutationEntity( - kind = MutationKind.REQUEST_CANCEL, - payload = json.encodeToString( - RequestCancelPayload.serializer(), - RequestCancelPayload(requestId), - ), - ), - ) - suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert( CachedMutationEntity( kind = MutationKind.PLAYBACK_ERROR_REPORT, payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload), ), ) + + private suspend fun insertUserDriven(kind: String, payload: String): Long { + val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload)) + _userEnqueueHints.tryEmit(QUEUED_MESSAGE) + return id + } } /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/OfflineWriteHintViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/OfflineWriteHintViewModel.kt new file mode 100644 index 00000000..b1632ef3 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/OfflineWriteHintViewModel.kt @@ -0,0 +1,20 @@ +package com.fabledsword.minstrel.cache.mutations + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * Hilt-injectable wrapper exposing [MutationQueue.userEnqueueHints] to + * ShellScaffold. The queue itself is an app-scoped singleton; this VM + * just bridges its SharedFlow into a `hiltViewModel()`-resolvable + * surface so ShellScaffold can collect it without an EntryPoint + * accessor. Mirrors PlaybackErrorViewModel. + */ +@HiltViewModel +class OfflineWriteHintViewModel @Inject constructor( + mutationQueue: MutationQueue, +) : ViewModel() { + val messages: Flow = mutationQueue.userEnqueueHints +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt index aa711e78..d5e3310d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel +import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner import com.fabledsword.minstrel.player.ui.MiniPlayer import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel @@ -48,6 +49,7 @@ fun ShellScaffold( modifier: Modifier = Modifier, trackActionsViewModel: TrackActionsViewModel = hiltViewModel(), playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(), + offlineWriteHintViewModel: OfflineWriteHintViewModel = hiltViewModel(), content: @Composable () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } @@ -61,6 +63,11 @@ fun ShellScaffold( snackbarHostState.showSnackbar(msg) } } + LaunchedEffect(Unit) { + offlineWriteHintViewModel.messages.collect { msg -> + snackbarHostState.showSnackbar(msg) + } + } // Consume the status-bar inset once here so the banner stack sits // below the status bar (mirrors Flutter's SafeArea(bottom:false)). // statusBarsPadding consumes the inset for descendants, so the in-