feat(home): Songs-like → dedicated row + wider spread (#1491)
Promote the best-performing surface ("Songs like {artist}", ~8% skip /
~86% completion) out of the shared Playlists carousel into its own Home
row on both Android and web, and widen the daily build from 3 to 6 mixes
so the dedicated row shows a wider spread.
Server (internal/playlists):
- PickSeedArtists candidate pool 5 → 12; pickSeedArtistsForDay now takes
songsLikeSeedCount (6) instead of a hardcoded 3. Graceful degradation
and daily rotation preserved.
Android (HomeScreen.kt):
- New songsLikeSection + buildSongsLikeRow; PlaylistsRow takes a title so
it renders both the "Playlists" and "Songs like…" rows. buildOnlineRow
/ orderedRealPlaylists no longer reserve the 3 songs-like slots.
Offline shows cached mixes (available-first), hides the row when none.
Web (+page.svelte):
- Dedicated "Songs like…" row from songsLikeRow; dropped the 3-slot cap
and removed songs-like from the Playlists carousel.
Tests: seed_selection_test.go, BuildPlaylistsRowTest.kt, page.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -623,10 +623,12 @@ private fun HomeSuccessContent(
|
||||
) {
|
||||
item {
|
||||
// Always rendered: real system/user playlists, with
|
||||
// placeholder cards filling the For You / Discover /
|
||||
// 3× Songs-like slots that haven't generated yet. When
|
||||
// offline, the cache-backed pool cards lead the row.
|
||||
// placeholder cards filling the For You / Discover slots
|
||||
// that haven't generated yet. When offline, the cache-backed
|
||||
// pool cards lead the row. Songs-like now lives in its own
|
||||
// dedicated row below (#1491), no longer inside this carousel.
|
||||
PlaylistsRow(
|
||||
title = "Playlists",
|
||||
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
||||
offline = offline,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
@@ -634,6 +636,18 @@ private fun HomeSuccessContent(
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
}
|
||||
// Songs-like is the best-performing surface (#1491) — promoted out
|
||||
// of the Playlists carousel into its own row so it shows a wider
|
||||
// spread of "Songs like {artist}" mixes. Hidden when there's
|
||||
// nothing to show (offline with none cached).
|
||||
songsLikeSection(
|
||||
playlists = sections.playlists,
|
||||
status = systemStatus,
|
||||
offline = offline,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onPlayPool = onPlayPool,
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
youMightLikeSection(
|
||||
albums = sections.youMightLikeAlbums,
|
||||
artists = sections.youMightLikeArtists,
|
||||
@@ -657,6 +671,35 @@ private fun HomeSuccessContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
|
||||
* rendering with a distinct title; hidden entirely when there's nothing
|
||||
* to show (offline with no cached mixes). Online with none generated yet
|
||||
* still shows a few placeholders so the building / seed-needed state is
|
||||
* visible, matching the pre-promotion carousel behavior.
|
||||
*/
|
||||
private fun LazyListScope.songsLikeSection(
|
||||
playlists: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
offline: Boolean,
|
||||
onPlaylistClick: (String) -> Unit,
|
||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||
) {
|
||||
val rowItems = buildSongsLikeRow(playlists, status, offline)
|
||||
if (rowItems.isEmpty()) return
|
||||
item {
|
||||
PlaylistsRow(
|
||||
title = "Songs like…",
|
||||
rowItems = rowItems,
|
||||
offline = offline,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onPlayPool = onPlayPool,
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.recentlyAddedSection(
|
||||
albums: List<HomeTile<AlbumRef>>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
@@ -926,13 +969,14 @@ private fun AlbumsRow(
|
||||
|
||||
@Composable
|
||||
private fun PlaylistsRow(
|
||||
title: String,
|
||||
rowItems: List<PlaylistRowItem>,
|
||||
offline: Boolean,
|
||||
onPlaylistClick: (String) -> Unit,
|
||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||
) {
|
||||
HorizontalScrollRow(title = "Playlists") {
|
||||
HorizontalScrollRow(title = title) {
|
||||
itemsIndexed(items = rowItems) { _, item ->
|
||||
when (item) {
|
||||
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
||||
@@ -983,11 +1027,12 @@ enum class OfflinePoolKind(val label: String) {
|
||||
/**
|
||||
* 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 —
|
||||
* Online: For You + Discover 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.
|
||||
* user-owned playlists. Songs-like has its own dedicated row (#1491) via
|
||||
* [buildSongsLikeRow] and no longer appears in this carousel.
|
||||
*
|
||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
||||
* same real playlists in curated order but stably partitioned fully-cached
|
||||
@@ -1014,7 +1059,7 @@ internal fun buildPlaylistsRow(
|
||||
return out
|
||||
}
|
||||
|
||||
/** The online layout: fixed system slots (with placeholders), secondary, user. */
|
||||
/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
|
||||
private fun buildOnlineRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
@@ -1026,12 +1071,7 @@ private fun buildOnlineRow(
|
||||
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(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))
|
||||
}
|
||||
// Songs-like is no longer here — it has its own dedicated row (#1491).
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||
}
|
||||
@@ -1039,16 +1079,42 @@ private fun buildOnlineRow(
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
|
||||
* mixes the server generated, no longer capped to the 3 carousel slots.
|
||||
*
|
||||
* Online: every generated mix as a real card; when none exist yet, a few
|
||||
* placeholders so the building / seed-needed state stays visible.
|
||||
* Offline: the cached mixes only (fully-cached first, greyed after), and
|
||||
* an empty list — hiding the whole section — when nothing is cached.
|
||||
*/
|
||||
internal fun buildSongsLikeRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
offline: Boolean,
|
||||
): List<PlaylistRowItem> {
|
||||
val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
|
||||
if (offline) {
|
||||
val (available, greyed) = mixes.partition { !it.unavailableOffline }
|
||||
return (available + greyed).map { PlaylistRowItem.Real(it) }
|
||||
}
|
||||
if (mixes.isNotEmpty()) return mixes.map { PlaylistRowItem.Real(it) }
|
||||
return List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
|
||||
PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Songs-like is excluded here too — it renders in its own row via
|
||||
* [buildSongsLikeRow] in both online and offline modes (#1491).
|
||||
*/
|
||||
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 }
|
||||
}
|
||||
@@ -1063,7 +1129,10 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
||||
else -> "pending"
|
||||
}
|
||||
|
||||
private const val SONGS_LIKE_SLOTS = 3
|
||||
// How many "Songs like…" placeholder cards the dedicated row shows while
|
||||
// the mixes haven't generated yet (building / seed-needed). Real mixes,
|
||||
// once generated, are shown in full and no longer capped by this (#1491).
|
||||
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
|
||||
|
||||
/**
|
||||
* The 5 system playlist kinds the server generates that aren't pinned
|
||||
|
||||
@@ -56,4 +56,51 @@ class BuildPlaylistsRowTest {
|
||||
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
||||
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
||||
}
|
||||
|
||||
private fun songsLike(id: String, cached: Boolean) = PlaylistRef(
|
||||
id = id,
|
||||
userId = "u",
|
||||
name = "Songs like $id",
|
||||
systemVariant = "songs_like_artist",
|
||||
trackCount = 25,
|
||||
fullyCached = cached,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `songs-like no longer appears in the main playlists carousel`() {
|
||||
val owned = listOf(songsLike("a", cached = true), user("u1", cached = true))
|
||||
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
assertTrue("a" !in reals) { "songs-like mix leaked into the Playlists row: $reals" }
|
||||
assertTrue("u1" in reals)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `online songs-like row shows every generated mix uncapped`() {
|
||||
// Six generated mixes — the old carousel capped at 3; the dedicated row shows all.
|
||||
val owned = (1..6).map { songsLike("a$it", cached = true) }
|
||||
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
assertEquals((1..6).map { "a$it" }, reals)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `online songs-like row shows placeholders when none generated`() {
|
||||
val row = buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = false)
|
||||
assertTrue(row.isNotEmpty())
|
||||
assertTrue(row.all { it is PlaylistRowItem.Placeholder })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline songs-like row shows cached mixes available-first, none hides it`() {
|
||||
val owned = listOf(songsLike("partial", cached = false), songsLike("full", cached = true))
|
||||
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = true)
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
// Fully-cached songs-like mix (not refreshable) is available; the
|
||||
// un-cached one greys and sorts after. No placeholders offline.
|
||||
assertEquals(listOf("full", "partial"), reals)
|
||||
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
|
||||
|
||||
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user