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, )