feat(android): grey offline-unavailable playlists, lead with cached pools
android / Build + lint + test (push) Successful in 3m28s

Home + Playlists list derive offline from NetworkStatusController (Offline or
ServerDown, not the raw device link), fixing the consistency gap. PlaylistRef
gains unavailableOffline (refreshable || !fullyCached); PlaylistCard dims the
whole tile when greyed but stays tappable. buildPlaylistsRow offline: pools
lead, real playlists partitioned available-first, placeholders dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 12:40:30 -04:00
parent 69ccd7b25d
commit 31d8c30dfe
5 changed files with 159 additions and 49 deletions
@@ -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<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
/** True when the device has no usable internet — gates the offline-pool cards. */
val offline: StateFlow<Boolean> = 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<Boolean> = 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<PlaylistRef>,
status: SystemPlaylistsStatus,
offline: Boolean,
): List<PlaylistRowItem> {
if (!offline) return buildOnlineRow(owned, status)
val out = mutableListOf<PlaylistRowItem>(
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<PlaylistRef>,
status: SystemPlaylistsStatus,
): List<PlaylistRowItem> {
val out = mutableListOf<PlaylistRowItem>()
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<PlaylistRef>): List<PlaylistRef> {
val out = mutableListOf<PlaylistRef>()
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"
@@ -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
}
/**
@@ -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<String>(Channel.BUFFERED)
@@ -74,9 +75,9 @@ class PlaylistsListViewModel @Inject constructor(
/** Transient snackbar messages from playlist tile play taps. */
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
/** True when the device has no usable internet -- gates refreshable system tiles. */
val offline: StateFlow<Boolean> = connectivity.online
.map { !it }
/** Cache-only: no link OR server unreachable (Unstable stays calm). Greys tiles. */
val offline: StateFlow<Boolean> = 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,
)
}
}
@@ -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"
@@ -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<PlaylistRowItem.Real>().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<PlaylistRowItem.Real>().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 })
}
}