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.Lucide
import com.composables.icons.lucide.Music import com.composables.icons.lucide.Music
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.home.data.HomeRepository import com.fabledsword.minstrel.home.data.HomeRepository
import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.AlbumCard
@@ -134,7 +135,7 @@ class HomeViewModel @Inject constructor(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val player: com.fabledsword.minstrel.player.PlayerController, private val player: com.fabledsword.minstrel.player.PlayerController,
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource, private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver, networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus()) private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
@@ -142,9 +143,13 @@ class HomeViewModel @Inject constructor(
/** System-playlist build status for the Home placeholder cards. */ /** System-playlist build status for the Home placeholder cards. */
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow() 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 * Cache-only when there's no link OR the server is unreachable. Reads the
.map { !it } * 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( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
@@ -701,18 +706,19 @@ private fun PlaylistsRow(
icon = iconForPool(item.kind), icon = iconForPool(item.kind),
onClick = { onPlayPool(item.kind) }, onClick = { onPlayPool(item.kind) },
) )
is PlaylistRowItem.Real -> PlaylistCard( is PlaylistRowItem.Real -> {
playlist = item.playlist, // Greyed offline when the tile needs the live server or
onClick = { onPlaylistClick(item.playlist.id) }, // isn't fully cached — dimmed but still tappable into the
onPlay = { onPlayPlaylist(item.playlist) }, // detail to shuffle whatever subset is cached.
// Match Flutter: refreshable system playlists need val greyed = offline && item.playlist.unavailableOffline
// the live server endpoints, so disable their play PlaylistCard(
// overlay when offline (user can still tap into the playlist = item.playlist,
// detail and shuffle all from cache). User playlists onClick = { onPlaylistClick(item.playlist.id) },
// play from cache + survive offline. onPlay = { onPlayPlaylist(item.playlist) },
playEnabled = item.playlist.trackCount > 0 && playEnabled = item.playlist.trackCount > 0 && !greyed,
!(offline && item.playlist.refreshable), greyed = greyed,
) )
}
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard( is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
label = item.label, label = item.label,
variant = item.variant, 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. */ /** 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 OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
data class Real(val playlist: PlaylistRef) : PlaylistRowItem data class Real(val playlist: PlaylistRef) : PlaylistRowItem
data class Placeholder(val label: String, val variant: String) : 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 * Builds the Home Playlists row.
* pool cards (Recently played, Liked) lead the row. Then For You + *
* Discover + 3× Songs-like fixed slots (real card when generated, * Online: For You + Discover + 3× Songs-like fixed slots (real card when
* placeholder otherwise), then the secondary system kinds (deep cuts / * generated, placeholder otherwise), then the secondary system kinds (deep cuts
* rediscover / new for you / on this day / first listens) when they * / rediscover / new for you / on this day / first listens) when they exist —
* exist — no placeholders for these since they're conditional on * no placeholders for these since they're conditional on library shape — then
* library shape, not guaranteed singletons. Finally user-owned * user-owned playlists.
* 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` * Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never * `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
* surfaces the secondary kinds on Home. Operator authorized the * surfaces the secondary kinds on Home. Operator authorized the
* divergence on 2026-06-01; web UI catch-up tracked as task #53. * divergence on 2026-06-01; web UI catch-up tracked as task #53.
*/ */
private fun buildPlaylistsRow( internal fun buildPlaylistsRow(
owned: List<PlaylistRef>, owned: List<PlaylistRef>,
status: SystemPlaylistsStatus, status: SystemPlaylistsStatus,
offline: Boolean, 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> { ): List<PlaylistRowItem> {
val out = mutableListOf<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" } out += owned.firstOrNull { it.systemVariant == "for_you" }
?.let { PlaylistRowItem.Real(it) } ?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status)) ?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
out += owned.firstOrNull { it.systemVariant == "discover" } out += owned.firstOrNull { it.systemVariant == "discover" }
?.let { PlaylistRowItem.Real(it) } ?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status)) ?: 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) { for (i in 0 until SONGS_LIKE_SLOTS) {
out += songsLike.getOrNull(i) out += songsLike.getOrNull(i)
?.let { PlaylistRowItem.Real(it) } ?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status)) ?: 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) { for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant } owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
?.let { out += PlaylistRowItem.Real(it) }
} }
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) } owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
return out 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 { private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
s.inFlight -> "building" s.inFlight -> "building"
s.lastError != null -> "failed" s.lastError != null -> "failed"
@@ -32,6 +32,13 @@ data class PlaylistRef(
* expose a refresh trigger; rebuilds are scheduler-driven). * expose a refresh trigger; rebuilds are scheduler-driven).
*/ */
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist" 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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController 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.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.nav.PlaylistDetail
@@ -66,7 +67,7 @@ class PlaylistsListViewModel @Inject constructor(
private val repository: PlaylistsRepository, private val repository: PlaylistsRepository,
private val player: PlayerController, private val player: PlayerController,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
connectivity: ConnectivityObserver, networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val poolMessages = Channel<String>(Channel.BUFFERED) private val poolMessages = Channel<String>(Channel.BUFFERED)
@@ -74,9 +75,9 @@ class PlaylistsListViewModel @Inject constructor(
/** Transient snackbar messages from playlist tile play taps. */ /** Transient snackbar messages from playlist tile play taps. */
val transientMessages: Flow<String> = poolMessages.receiveAsFlow() val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
/** True when the device has no usable internet -- gates refreshable system tiles. */ /** Cache-only: no link OR server unreachable (Unstable stays calm). Greys tiles. */
val offline: StateFlow<Boolean> = connectivity.online val offline: StateFlow<Boolean> = networkStatus.state
.map { !it } .map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
@@ -191,14 +192,15 @@ private fun PlaylistsGrid(
SectionHeader("System playlists") SectionHeader("System playlists")
} }
items(items = systemPlaylists, key = { it.id }) { playlist -> 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( PlaylistCard(
playlist = playlist, playlist = playlist,
onClick = { onPlaylistClick(playlist.id) }, onClick = { onPlaylistClick(playlist.id) },
onPlay = { onPlay(playlist) }, onPlay = { onPlay(playlist) },
// Refreshable system tiles need server endpoints (systemShuffle); playEnabled = playlist.trackCount > 0 && !greyed,
// disable when offline. Empty mixes show a snackbar after tap. greyed = greyed,
playEnabled = playlist.trackCount > 0 &&
!(offline && playlist.refreshable),
) )
} }
} }
@@ -207,11 +209,13 @@ private fun PlaylistsGrid(
SectionHeader("Your playlists") SectionHeader("Your playlists")
} }
items(items = userPlaylists, key = { it.id }) { playlist -> items(items = userPlaylists, key = { it.id }) { playlist ->
val greyed = offline && playlist.unavailableOffline
PlaylistCard( PlaylistCard(
playlist = playlist, playlist = playlist,
onClick = { onPlaylistClick(playlist.id) }, onClick = { onPlaylistClick(playlist.id) },
onPlay = { onPlay(playlist) }, 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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 * [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
* uses it for system playlists in offline mode (their server-side * uses it for system playlists in offline mode (their server-side
* shuffle endpoint is unreachable) and for empty playlists. * 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 @Composable
fun PlaylistCard( fun PlaylistCard(
@@ -48,11 +53,13 @@ fun PlaylistCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onPlay: (suspend () -> Unit)? = null, onPlay: (suspend () -> Unit)? = null,
playEnabled: Boolean = true, playEnabled: Boolean = true,
greyed: Boolean = false,
) { ) {
val seedCache = LocalDetailSeedCache.current val seedCache = LocalDetailSeedCache.current
Surface( Surface(
modifier = modifier modifier = modifier
.width(176.dp) .width(176.dp)
.alpha(if (greyed) GREYED_ALPHA else 1f) // dimmed, still clickable
.clickable { .clickable {
seedCache.stashPlaylist(playlist) seedCache.stashPlaylist(playlist)
onClick() onClick()
@@ -63,7 +70,7 @@ fun PlaylistCard(
PlaylistCardCover( PlaylistCardCover(
playlist = playlist, playlist = playlist,
onPlay = onPlay, onPlay = onPlay,
playEnabled = playEnabled, playEnabled = playEnabled && !greyed,
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
@@ -148,6 +155,7 @@ private fun VariantPill(label: String, modifier: Modifier = Modifier) {
} }
private const val PILL_BG_ALPHA = 0.85f private const val PILL_BG_ALPHA = 0.85f
private const val GREYED_ALPHA = 0.45f
private fun subtitleFor(playlist: PlaylistRef): String = when { private fun subtitleFor(playlist: PlaylistRef): String = when {
playlist.trackCount > 0 -> "${playlist.trackCount} tracks" 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 })
}
}