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.ListenBrainzStatusWire
import com.fabledsword.minstrel.models.wire.MyProfileWire import com.fabledsword.minstrel.models.wire.MyProfileWire
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import retrofit2.http.Body import retrofit2.http.Body
@@ -43,6 +44,14 @@ interface MeApi {
@GET("api/me/listenbrainz") @GET("api/me/listenbrainz")
suspend fun getListenBrainz(): ListenBrainzStatusWire 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 * PUT against the same `/api/me/listenbrainz` endpoint with one
* of two shapes — `{token: ...}` stashes a new token, * of two shapes — `{token: ...}` stashes a new token,
@@ -1,6 +1,7 @@
package com.fabledsword.minstrel.home.data package com.fabledsword.minstrel.home.data
import com.fabledsword.minstrel.api.endpoints.HomeApi 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.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao 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.metadata.MetadataProvider
import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -45,6 +47,20 @@ class HomeRepository @Inject constructor(
retrofit: Retrofit, retrofit: Retrofit,
) { ) {
private val api: HomeApi = retrofit.create() 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>> = fun observeRecentlyAddedAlbums(): Flow<List<AlbumRef>> =
homeIndexDao.observeBySection(SECTION_RECENTLY_ADDED_ALBUMS).map { hydrateAlbums(it) } 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.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail 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.nav.PlaylistDetail
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard 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.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions 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 com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -113,6 +117,11 @@ class HomeViewModel @Inject constructor(
private val player: com.fabledsword.minstrel.player.PlayerController, private val player: com.fabledsword.minstrel.player.PlayerController,
) : ViewModel() { ) : ViewModel() {
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
/** System-playlist build status for the Home placeholder cards. */
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
init { init {
refresh() refresh()
} }
@@ -140,8 +149,13 @@ class HomeViewModel @Inject constructor(
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
val home = launch { runCatching { homeRepository.refreshIndex() } } val home = launch { runCatching { homeRepository.refreshIndex() } }
val lists = launch { runCatching { playlistsRepository.refreshList() } } val lists = launch { runCatching { playlistsRepository.refreshList() } }
val status = launch {
runCatching { homeRepository.getSystemPlaylistsStatus() }
.onSuccess { systemStatusInternal.value = it }
}
home.join() home.join()
lists.join() lists.join()
status.join()
} }
val uiState: StateFlow<HomeUiState> = combineHomeFlows().stateIn( val uiState: StateFlow<HomeUiState> = combineHomeFlows().stateIn(
@@ -203,6 +217,7 @@ fun HomeScreen(
}, },
) { inner -> ) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
PullToRefreshScaffold( PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() }, onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner), modifier = Modifier.fillMaxSize().padding(inner),
@@ -222,6 +237,7 @@ fun HomeScreen(
) )
is HomeUiState.Success -> HomeSuccessContent( is HomeUiState.Success -> HomeSuccessContent(
sections = s.sections, sections = s.sections,
systemStatus = systemStatus,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) }, onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) }, onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
@@ -280,6 +296,7 @@ private fun SkeletonArtistRow() {
@Composable @Composable
private fun HomeSuccessContent( private fun HomeSuccessContent(
sections: HomeSections, sections: HomeSections,
systemStatus: SystemPlaylistsStatus,
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit, onArtistClick: (String) -> Unit,
onPlaylistClick: (String) -> Unit, onPlaylistClick: (String) -> Unit,
@@ -289,8 +306,11 @@ private fun HomeSuccessContent(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
) { ) {
if (sections.playlists.isNotEmpty()) { item {
item { PlaylistsRow(sections.playlists, onPlaylistClick) } // 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) recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick)
rediscoverSection( rediscoverSection(
@@ -443,16 +463,66 @@ private fun AlbumsRow(
@Composable @Composable
private fun PlaylistsRow( private fun PlaylistsRow(
playlists: List<PlaylistRef>, rowItems: List<PlaylistRowItem>,
onPlaylistClick: (String) -> Unit, onPlaylistClick: (String) -> Unit,
) { ) {
HorizontalScrollRow(title = "Playlists") { HorizontalScrollRow(title = "Playlists") {
items(items = playlists, key = { it.id }) { playlist -> itemsIndexed(items = rowItems) { _, item ->
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) }) 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 @Composable
private fun ArtistsRow( private fun ArtistsRow(
title: String, 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"
}