Android #618 offline-mode UX + Sonos polish + server DRY #81

Merged
bvandeusen merged 7 commits from dev into main 2026-06-04 12:53:59 -04:00
3 changed files with 49 additions and 3 deletions
Showing only changes of commit 1daea79f64 - Show all commits
@@ -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<Boolean>,
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
@@ -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 }
@@ -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,
)