From edbc8053333e5d97f350d5f616ae2fcc42c18130 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 07:42:13 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20system-playlist=20placeholder?= =?UTF-8?q?=20cards=20on=20Home=20(audit=20v3=20=C2=A74.4,=20part=20of=20#?= =?UTF-8?q?28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../minstrel/api/endpoints/MeApi.kt | 9 ++ .../minstrel/home/data/HomeRepository.kt | 16 +++ .../minstrel/home/ui/HomeScreen.kt | 80 +++++++++++- .../minstrel/models/SystemPlaylistsStatus.kt | 17 +++ .../models/wire/SystemPlaylistsStatusWire.kt | 14 +++ .../widgets/PlaylistPlaceholderCard.kt | 115 ++++++++++++++++++ 6 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/SystemPlaylistsStatus.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/wire/SystemPlaylistsStatusWire.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistPlaceholderCard.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt index d91c13cb..99dc9c7c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt @@ -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, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt index fb4a2279..7c82b5ce 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt @@ -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> = homeIndexDao.observeBySection(SECTION_RECENTLY_ADDED_ALBUMS).map { hydrateAlbums(it) } 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 5b323735..3c7cb02c 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 @@ -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 = 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 = 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, + rowItems: List, 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, + status: SystemPlaylistsStatus, +): List { + val out = mutableListOf() + 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, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/SystemPlaylistsStatus.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/SystemPlaylistsStatus.kt new file mode 100644 index 00000000..edee47ee --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/SystemPlaylistsStatus.kt @@ -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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SystemPlaylistsStatusWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SystemPlaylistsStatusWire.kt new file mode 100644 index 00000000..cae3e7a5 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SystemPlaylistsStatusWire.kt @@ -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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistPlaceholderCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistPlaceholderCard.kt new file mode 100644 index 00000000..272abda5 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistPlaceholderCard.kt @@ -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" +}