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/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/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/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/connectivity/ServerHealthController.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt new file mode 100644 index 00000000..eb6c1a81 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt @@ -0,0 +1,63 @@ +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 +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, + ) +} + +/** + * 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/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/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/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/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() 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) 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 } } 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, ) { 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- 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, ) 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