feat(android): system-playlist placeholder cards on Home (audit v3 §4.4, part of #28)

The Home Playlists row now always renders the For You + Discover +
3× Songs-like slots: a real PlaylistCard when the system playlist
has generated, otherwise a PlaylistPlaceholderCard showing its
build state — building / failed / seed-needed / pending — derived
from GET /api/me/system-playlists-status. Mirrors Flutter's
_buildPlaylistsRow + PlaylistPlaceholderCard.

* SystemPlaylistsStatus domain + wire; MeApi.getSystemPlaylistsStatus;
  HomeRepository.getSystemPlaylistsStatus.
* HomeViewModel fetches the status in refresh() and exposes it as a
  StateFlow; HomeScreen collects it and threads it to the row builder.
* PlaylistPlaceholderCard widget (sized to match PlaylistCard) with
  a status chip + variant subtitle.
* buildPlaylistsRow / variantFor port the slot logic; user playlists
  follow the fixed system slots.

The offline-pool cards (§4.3) are NOT in this commit — Android has
no local recently-played cache (history is fetch-on-visit), so the
"Recently played" pool has no offline source. Deferring that half
until the history snapshot cache lands; see #28 notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 07:42:13 -04:00
parent 8f5635c489
commit edbc805333
6 changed files with 246 additions and 5 deletions
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
import com.fabledsword.minstrel.models.wire.MyProfileWire
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
@@ -43,6 +44,14 @@ interface MeApi {
@GET("api/me/listenbrainz")
suspend fun getListenBrainz(): ListenBrainzStatusWire
/**
* Caller's most recent system-playlist build state. Drives the
* Home placeholder cards (building / failed / pending / seed-needed)
* for system playlists that haven't generated yet.
*/
@GET("api/me/system-playlists-status")
suspend fun getSystemPlaylistsStatus(): SystemPlaylistsStatusWire
/**
* PUT against the same `/api/me/listenbrainz` endpoint with one
* of two shapes — `{token: ...}` stashes a new token,
@@ -1,6 +1,7 @@
package com.fabledsword.minstrel.home.data
import com.fabledsword.minstrel.api.endpoints.HomeApi
import com.fabledsword.minstrel.api.endpoints.MeApi
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
@@ -10,6 +11,7 @@ import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.metadata.MetadataProvider
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -45,6 +47,20 @@ class HomeRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: HomeApi = retrofit.create()
private val meApi: MeApi = retrofit.create()
/**
* Caller's system-playlist build state for the Home placeholder
* cards. One-shot GET; the caller refreshes alongside the index.
*/
suspend fun getSystemPlaylistsStatus(): SystemPlaylistsStatus =
meApi.getSystemPlaylistsStatus().let {
SystemPlaylistsStatus(
inFlight = it.inFlight,
lastRunAt = it.lastRunAt,
lastError = it.lastError,
)
}
fun observeRecentlyAddedAlbums(): Flow<List<AlbumRef>> =
homeIndexDao.observeBySection(SECTION_RECENTLY_ADDED_ALBUMS).map { hydrateAlbums(it) }
@@ -50,6 +50,7 @@ import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
@@ -57,6 +58,7 @@ import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
@@ -66,8 +68,10 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
@@ -113,6 +117,11 @@ class HomeViewModel @Inject constructor(
private val player: com.fabledsword.minstrel.player.PlayerController,
) : ViewModel() {
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
/** System-playlist build status for the Home placeholder cards. */
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
init {
refresh()
}
@@ -140,8 +149,13 @@ class HomeViewModel @Inject constructor(
fun refresh(): Job = viewModelScope.launch {
val home = launch { runCatching { homeRepository.refreshIndex() } }
val lists = launch { runCatching { playlistsRepository.refreshList() } }
val status = launch {
runCatching { homeRepository.getSystemPlaylistsStatus() }
.onSuccess { systemStatusInternal.value = it }
}
home.join()
lists.join()
status.join()
}
val uiState: StateFlow<HomeUiState> = combineHomeFlows().stateIn(
@@ -203,6 +217,7 @@ fun HomeScreen(
},
) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle()
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
@@ -222,6 +237,7 @@ fun HomeScreen(
)
is HomeUiState.Success -> HomeSuccessContent(
sections = s.sections,
systemStatus = systemStatus,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
@@ -280,6 +296,7 @@ private fun SkeletonArtistRow() {
@Composable
private fun HomeSuccessContent(
sections: HomeSections,
systemStatus: SystemPlaylistsStatus,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlaylistClick: (String) -> Unit,
@@ -289,8 +306,11 @@ private fun HomeSuccessContent(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
if (sections.playlists.isNotEmpty()) {
item { PlaylistsRow(sections.playlists, onPlaylistClick) }
item {
// Always rendered: real system/user playlists, with
// placeholder cards filling the For You / Discover /
// 3× Songs-like slots that haven't generated yet.
PlaylistsRow(buildPlaylistsRow(sections.playlists, systemStatus), onPlaylistClick)
}
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick)
rediscoverSection(
@@ -443,16 +463,66 @@ private fun AlbumsRow(
@Composable
private fun PlaylistsRow(
playlists: List<PlaylistRef>,
rowItems: List<PlaylistRowItem>,
onPlaylistClick: (String) -> Unit,
) {
HorizontalScrollRow(title = "Playlists") {
items(items = playlists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
itemsIndexed(items = rowItems) { _, item ->
when (item) {
is PlaylistRowItem.Real -> PlaylistCard(
playlist = item.playlist,
onClick = { onPlaylistClick(item.playlist.id) },
)
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
label = item.label,
variant = item.variant,
)
}
}
}
}
/** Either a real playlist tile or a not-yet-generated placeholder slot. */
private sealed interface PlaylistRowItem {
data class Real(val playlist: PlaylistRef) : PlaylistRowItem
data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
}
/**
* Builds the Home Playlists row: For You + Discover + 3× Songs-like
* fixed slots (real card when generated, placeholder otherwise),
* then any user-owned playlists. Mirrors Flutter's `_buildPlaylistsRow`.
*/
private fun buildPlaylistsRow(
owned: List<PlaylistRef>,
status: SystemPlaylistsStatus,
): List<PlaylistRowItem> {
val out = mutableListOf<PlaylistRowItem>()
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)
for (i in 0 until SONGS_LIKE_SLOTS) {
out += songsLike.getOrNull(i)
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
}
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
return out
}
private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
s.inFlight -> "building"
s.lastError != null -> "failed"
slot == "songs-like" && s.lastRunAt != null -> "seed-needed"
else -> "pending"
}
private const val SONGS_LIKE_SLOTS = 3
@Composable
private fun ArtistsRow(
title: String,
@@ -0,0 +1,17 @@
package com.fabledsword.minstrel.models
/**
* Caller's most recent system_playlist_runs state, driving the Home
* placeholder cards for not-yet-generated system playlists. Mirrors
* `flutter_client/lib/models/system_playlists_status.dart` and the
* server's `systemPlaylistsStatusResp`.
*
* Zero values (inFlight=false, both timestamps null) mean the user
* has never had a build attempted — the placeholders read as
* "pending".
*/
data class SystemPlaylistsStatus(
val inFlight: Boolean = false,
val lastRunAt: String? = null,
val lastError: String? = null,
)
@@ -0,0 +1,14 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape for `GET /api/me/system-playlists-status`.
*/
@Serializable
data class SystemPlaylistsStatusWire(
@SerialName("in_flight") val inFlight: Boolean = false,
@SerialName("last_run_at") val lastRunAt: String? = null,
@SerialName("last_error") val lastError: String? = null,
)
@@ -0,0 +1,115 @@
package com.fabledsword.minstrel.playlists.widgets
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.RefreshCw
import com.composables.icons.lucide.TriangleAlert
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
/**
* Placeholder tile for a system playlist that hasn't generated yet.
* Mirrors `flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart`.
* Sized to match [PlaylistCard] so the Home Playlists row stays
* visually consistent.
*
* [variant] is one of building / failed / pending / seed-needed,
* derived by the caller from the system-playlists build status:
* - building → a run is in flight
* - failed → the last run errored
* - seed-needed → songs-like slot with no seed artist liked yet
* - pending → never built / waiting for the next scheduled run
*/
@Composable
fun PlaylistPlaceholderCard(
label: String,
variant: String,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.width(176.dp).padding(horizontal = 8.dp)) {
Box(
modifier = Modifier
.size(144.dp)
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = iconFor(variant),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(40.dp),
)
Surface(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(6.dp),
shape = RoundedCornerShape(FabledSwordFlatTokens.radiusSm),
color = MaterialTheme.colorScheme.surface.copy(alpha = STATUS_BG_ALPHA),
) {
Text(
text = statusLabelFor(variant),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
Spacer(Modifier.height(8.dp))
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitleFor(variant),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
private const val STATUS_BG_ALPHA = 0.85f
private fun iconFor(variant: String) = when (variant) {
"failed" -> Lucide.TriangleAlert
"building" -> Lucide.RefreshCw
else -> Lucide.ListMusic
}
private fun statusLabelFor(variant: String): String = when (variant) {
"building" -> "Building"
"failed" -> "Failed"
"seed-needed" -> "Needs a seed"
else -> "Pending"
}
private fun subtitleFor(variant: String): String = when (variant) {
"building" -> "Generating now…"
"failed" -> "Last build failed"
"seed-needed" -> "Like an artist to seed this"
else -> "Coming soon"
}