diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index eb913061..e013a149 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -54,6 +54,7 @@ import com.composables.icons.lucide.History import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Music import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.home.data.HomeRepository import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.widgets.AlbumCard @@ -134,7 +135,7 @@ class HomeViewModel @Inject constructor( private val libraryRepository: LibraryRepository, private val player: com.fabledsword.minstrel.player.PlayerController, private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource, - connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver, + networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController, ) : ViewModel() { private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus()) @@ -142,9 +143,13 @@ class HomeViewModel @Inject constructor( /** System-playlist build status for the Home placeholder cards. */ val systemStatus: StateFlow = systemStatusInternal.asStateFlow() - /** True when the device has no usable internet — gates the offline-pool cards. */ - val offline: StateFlow = connectivity.online - .map { !it } + /** + * Cache-only when there's no link OR the server is unreachable. Reads the + * unified [NetworkStatusController] (not the raw device link) so Home reacts + * to ServerDown too; the transient Unstable state stays calm (not offline). + */ + val offline: StateFlow = networkStatus.state + .map { it == ServerHealth.Offline || it == ServerHealth.ServerDown } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), @@ -701,18 +706,19 @@ private fun PlaylistsRow( icon = iconForPool(item.kind), onClick = { onPlayPool(item.kind) }, ) - is PlaylistRowItem.Real -> PlaylistCard( - playlist = item.playlist, - onClick = { onPlaylistClick(item.playlist.id) }, - onPlay = { onPlayPlaylist(item.playlist) }, - // Match Flutter: refreshable system playlists need - // the live server endpoints, so disable their play - // overlay when offline (user can still tap into the - // detail and shuffle all from cache). User playlists - // play from cache + survive offline. - playEnabled = item.playlist.trackCount > 0 && - !(offline && item.playlist.refreshable), - ) + is PlaylistRowItem.Real -> { + // Greyed offline when the tile needs the live server or + // isn't fully cached — dimmed but still tappable into the + // detail to shuffle whatever subset is cached. + val greyed = offline && item.playlist.unavailableOffline + PlaylistCard( + playlist = item.playlist, + onClick = { onPlaylistClick(item.playlist.id) }, + onPlay = { onPlayPlaylist(item.playlist) }, + playEnabled = item.playlist.trackCount > 0 && !greyed, + greyed = greyed, + ) + } is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard( label = item.label, variant = item.variant, @@ -728,7 +734,7 @@ private fun iconForPool(kind: OfflinePoolKind) = when (kind) { } /** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */ -private sealed interface PlaylistRowItem { +internal sealed interface PlaylistRowItem { data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem data class Real(val playlist: PlaylistRef) : PlaylistRowItem data class Placeholder(val label: String, val variant: String) : PlaylistRowItem @@ -741,55 +747,81 @@ enum class OfflinePoolKind(val label: String) { } /** - * Builds the Home Playlists row. When [offline], the two cache-backed - * pool cards (Recently played, Liked) lead the row. Then For You + - * Discover + 3× Songs-like fixed slots (real card when generated, - * placeholder otherwise), then the secondary system kinds (deep cuts / - * rediscover / new for you / on this day / first listens) when they - * exist — no placeholders for these since they're conditional on - * library shape, not guaranteed singletons. Finally user-owned - * playlists. + * Builds the Home Playlists row. + * + * Online: For You + Discover + 3× Songs-like fixed slots (real card when + * generated, placeholder otherwise), then the secondary system kinds (deep cuts + * / rediscover / new for you / on this day / first listens) when they exist — + * no placeholders for these since they're conditional on library shape — then + * user-owned playlists. + * + * Offline: the two cache-backed pools (Recently played, Liked) lead, then the + * same real playlists in curated order but stably partitioned fully-cached + * first / greyed after, and the "building/pending" placeholders are dropped + * (they need the server to generate, so they're meaningless offline). * * Diverges from Flutter (`flutter_client/lib/library/home_screen.dart` * `_buildPlaylistsRow`) which only shows the 5 fixed slots and never * surfaces the secondary kinds on Home. Operator authorized the * divergence on 2026-06-01; web UI catch-up tracked as task #53. */ -private fun buildPlaylistsRow( +internal fun buildPlaylistsRow( owned: List, status: SystemPlaylistsStatus, offline: Boolean, +): List { + if (!offline) return buildOnlineRow(owned, status) + val out = mutableListOf( + PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED), + PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED), + ) + val (available, greyed) = orderedRealPlaylists(owned).partition { !it.unavailableOffline } + (available + greyed).forEach { out += PlaylistRowItem.Real(it) } + return out +} + +/** The online layout: fixed system slots (with placeholders), secondary, user. */ +private fun buildOnlineRow( + owned: List, + status: SystemPlaylistsStatus, ): List { val out = mutableListOf() - if (offline) { - out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED) - out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED) - } out += owned.firstOrNull { it.systemVariant == "for_you" } ?.let { PlaylistRowItem.Real(it) } ?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status)) out += owned.firstOrNull { it.systemVariant == "discover" } ?.let { PlaylistRowItem.Real(it) } ?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status)) - val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(3) + val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS) for (i in 0 until SONGS_LIKE_SLOTS) { out += songsLike.getOrNull(i) ?.let { PlaylistRowItem.Real(it) } ?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status)) } - // Secondary system kinds in server-registry order. Only included - // when actually generated — these depend on library shape (Deep - // cuts needs deep albums, On this day needs prior history, etc.) - // so a missing one means "not enough data" rather than "still - // building". for (variant in SECONDARY_SYSTEM_VARIANTS) { - owned.firstOrNull { it.systemVariant == variant } - ?.let { out += PlaylistRowItem.Real(it) } + owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) } } owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) } return out } +/** + * Curated real-playlist order (system primaries, then secondary, then user). + * Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and + * only differs by dropping placeholders + partitioning available-first. + */ +private fun orderedRealPlaylists(owned: List): List { + val out = mutableListOf() + owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it } + owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it } + out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS) + for (variant in SECONDARY_SYSTEM_VARIANTS) { + owned.firstOrNull { it.systemVariant == variant }?.let { out += it } + } + owned.filter { it.systemVariant == null }.forEach { out += it } + return out +} + private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when { s.inFlight -> "building" s.lastError != null -> "failed" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt index d02a7e0f..b690f911 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt @@ -32,6 +32,13 @@ data class PlaylistRef( * expose a refresh trigger; rebuilds are scheduler-driven). */ val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist" + + /** + * Can't be relied on while offline — either it needs the live server to + * (re)generate (a refreshable system mix) or not all its tracks are cached. + * Callers combine this with the current offline state to grey the tile. + */ + val unavailableOffline: Boolean get() = refreshable || !fullyCached } /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt index b5302718..14caa57b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt @@ -27,7 +27,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController -import com.fabledsword.minstrel.connectivity.ConnectivityObserver +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.nav.PlaylistDetail @@ -66,7 +67,7 @@ class PlaylistsListViewModel @Inject constructor( private val repository: PlaylistsRepository, private val player: PlayerController, private val eventsStream: EventsStream, - connectivity: ConnectivityObserver, + networkStatus: NetworkStatusController, ) : ViewModel() { private val poolMessages = Channel(Channel.BUFFERED) @@ -74,9 +75,9 @@ class PlaylistsListViewModel @Inject constructor( /** Transient snackbar messages from playlist tile play taps. */ val transientMessages: Flow = poolMessages.receiveAsFlow() - /** True when the device has no usable internet -- gates refreshable system tiles. */ - val offline: StateFlow = connectivity.online - .map { !it } + /** Cache-only: no link OR server unreachable (Unstable stays calm). Greys tiles. */ + val offline: StateFlow = networkStatus.state + .map { it == ServerHealth.Offline || it == ServerHealth.ServerDown } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), @@ -191,14 +192,15 @@ private fun PlaylistsGrid( SectionHeader("System playlists") } items(items = systemPlaylists, key = { it.id }) { playlist -> + // Greyed offline when it needs the live server or isn't fully + // cached — dimmed but still tappable into the detail. + val greyed = offline && playlist.unavailableOffline PlaylistCard( playlist = playlist, onClick = { onPlaylistClick(playlist.id) }, onPlay = { onPlay(playlist) }, - // Refreshable system tiles need server endpoints (systemShuffle); - // disable when offline. Empty mixes show a snackbar after tap. - playEnabled = playlist.trackCount > 0 && - !(offline && playlist.refreshable), + playEnabled = playlist.trackCount > 0 && !greyed, + greyed = greyed, ) } } @@ -207,11 +209,13 @@ private fun PlaylistsGrid( SectionHeader("Your playlists") } items(items = userPlaylists, key = { it.id }) { playlist -> + val greyed = offline && playlist.unavailableOffline PlaylistCard( playlist = playlist, onClick = { onPlaylistClick(playlist.id) }, onPlay = { onPlay(playlist) }, - playEnabled = playlist.trackCount > 0, + playEnabled = playlist.trackCount > 0 && !greyed, + greyed = greyed, ) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt index da12d8e4..55ef6cfb 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -40,6 +41,10 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens * [playEnabled] disables the overlay (50% alpha + ignore taps) — Home * uses it for system playlists in offline mode (their server-side * shuffle endpoint is unreachable) and for empty playlists. + * + * [greyed] dims the whole tile (it can't be reliably played offline) while + * keeping it tappable — the detail screen is the escape hatch for shuffling + * whatever subset is cached. Greyed always disables the play overlay too. */ @Composable fun PlaylistCard( @@ -48,11 +53,13 @@ fun PlaylistCard( modifier: Modifier = Modifier, onPlay: (suspend () -> Unit)? = null, playEnabled: Boolean = true, + greyed: Boolean = false, ) { val seedCache = LocalDetailSeedCache.current Surface( modifier = modifier .width(176.dp) + .alpha(if (greyed) GREYED_ALPHA else 1f) // dimmed, still clickable .clickable { seedCache.stashPlaylist(playlist) onClick() @@ -63,7 +70,7 @@ fun PlaylistCard( PlaylistCardCover( playlist = playlist, onPlay = onPlay, - playEnabled = playEnabled, + playEnabled = playEnabled && !greyed, ) Spacer(Modifier.height(8.dp)) Text( @@ -148,6 +155,7 @@ private fun VariantPill(label: String, modifier: Modifier = Modifier) { } private const val PILL_BG_ALPHA = 0.85f +private const val GREYED_ALPHA = 0.45f private fun subtitleFor(playlist: PlaylistRef): String = when { playlist.trackCount > 0 -> "${playlist.trackCount} tracks" diff --git a/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt new file mode 100644 index 00000000..ed09e992 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt @@ -0,0 +1,59 @@ +package com.fabledsword.minstrel.home.ui + +import com.fabledsword.minstrel.models.PlaylistRef +import com.fabledsword.minstrel.models.SystemPlaylistsStatus +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BuildPlaylistsRowTest { + + private fun user(id: String, cached: Boolean) = + PlaylistRef(id = id, userId = "u", name = id, trackCount = 1, fullyCached = cached) + + @Test + fun `offline row leads with pools then available before greyed`() { + val owned = listOf(user("partial", cached = false), user("full", cached = true)) + val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = true) + + assertTrue(row[0] is PlaylistRowItem.OfflinePool) + assertTrue(row[1] is PlaylistRowItem.OfflinePool) + // Fully-cached "full" comes before the greyed "partial". + val reals = row.filterIsInstance().map { it.playlist.id } + assertEquals(listOf("full", "partial"), reals) + } + + @Test + fun `offline greys a refreshable system playlist even when fully cached`() { + val forYou = PlaylistRef( + id = "fy", + userId = "u", + name = "For You", + systemVariant = "for_you", + trackCount = 1, + fullyCached = true, + ) + val row = buildPlaylistsRow( + listOf(forYou, user("u1", cached = true)), + SystemPlaylistsStatus(), + offline = true, + ) + // The fully-cached user playlist is available; the refreshable system + // mix needs the server, so it greys out and sorts after. + val reals = row.filterIsInstance().map { it.playlist.id } + assertEquals(listOf("u1", "fy"), reals) + } + + @Test + fun `offline row drops building-pending placeholders`() { + val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = true) + assertTrue(row.none { it is PlaylistRowItem.Placeholder }) + } + + @Test + fun `online row has no offline pools and keeps system-slot placeholders`() { + val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = false) + assertTrue(row.none { it is PlaylistRowItem.OfflinePool }) + assertTrue(row.any { it is PlaylistRowItem.Placeholder }) + } +}