feat(android): "You might like" Home rows (#790 client slice)
android / Build + lint + test (push) Failing after 1m17s

Surface the server's you_might_like_albums / you_might_like_artists sections
(daily-built, cold-start gated, taste-aware) on the Home screen, mirroring the
Rediscover block.

- HomeIndexWire: two new slices, defaulted to emptyList() so decode is safe
  against older servers that don't emit them (Class-B discipline).
- HomeRepository: two section constants + observeYouMightLikeAlbums/Artists
  (reusing the existing album/artist hydration helpers) + refreshIndex now
  replaces both sections and pre-warms their artists. No Room schema change —
  cached_home_index stores the section string verbatim.
- HomeScreen: HomeSections gains the two fields (+ isAllEmpty); the ViewModel
  combine is split (core 5 → +2 you-might-like → +playlists) to stay within the
  coroutines 5-arity limit; a YouMightLikeBlock + youMightLikeSection render an
  albums-then-artists block below Rediscover, with a "still learning your taste"
  empty state for the cold-start/gated case.

Server side already shipped in v2026.06.11; this makes it visible on device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 22:16:32 -04:00
parent aff346c731
commit abc225e12a
4 changed files with 113 additions and 14 deletions
@@ -14,6 +14,11 @@ import kotlinx.datetime.Instant
* - "rediscover_artists"
* - "most_played_tracks"
* - "last_played_artists"
* - "you_might_like_albums"
* - "you_might_like_artists"
*
* New `section` values need no schema change — the column stores the
* string verbatim and the DAO keys on it.
*
* `entityType` is "album" | "artist" | "track" — dispatches hydration
* to the right per-entity endpoint when a tile is rendered.
@@ -76,6 +76,12 @@ class HomeRepository @Inject constructor(
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
fun observeYouMightLikeAlbums(): Flow<List<HomeTile<AlbumRef>>> =
observeAlbumSection(SECTION_YOU_MIGHT_LIKE_ALBUMS)
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
/**
* Pulls /api/home/index, replaces each cached_home_index section,
* and pre-warms the top artists. The section Flows re-fire on the
@@ -88,7 +94,11 @@ class HomeRepository @Inject constructor(
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
prewarmer.warm(wire.rediscoverArtists + wire.lastPlayedArtists)
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
prewarmer.warm(
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
)
}
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
@@ -148,5 +158,7 @@ class HomeRepository @Inject constructor(
const val SECTION_REDISCOVER_ARTISTS = "rediscover_artists"
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
}
}
@@ -116,6 +116,8 @@ data class HomeSections(
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
val youMightLikeAlbums: List<HomeTile<AlbumRef>> = emptyList(),
val youMightLikeArtists: List<HomeTile<ArtistRef>> = emptyList(),
) {
val isAllEmpty: Boolean
get() = playlists.isEmpty() &&
@@ -123,7 +125,9 @@ data class HomeSections(
rediscoverAlbums.isEmpty() &&
rediscoverArtists.isEmpty() &&
mostPlayedTracks.isEmpty() &&
lastPlayedArtists.isEmpty()
lastPlayedArtists.isEmpty() &&
youMightLikeAlbums.isEmpty() &&
youMightLikeArtists.isEmpty()
}
// ─── ViewModel ───────────────────────────────────────────────────────
@@ -289,13 +293,14 @@ class HomeViewModel @Inject constructor(
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
/**
* Five home-section flows merged into the sections struct without
* playlists; then merged with the playlists flow in [combineHomeFlows].
* Splitting it like this keeps each combine call inside the
* coroutines built-in arity limit (5) and avoids the typed-vararg
* acrobatics needed for a 6-flow combine across heterogeneous types.
* The core five section flows merged into the sections struct. Split
* out so each combine call stays inside the coroutines built-in arity
* limit (5) and avoids the typed-vararg acrobatics a wider combine
* across heterogeneous types would need. [observeHomeSections] then
* folds in the two you-might-like flows, and [combineHomeFlows] the
* playlists flow.
*/
private fun observeHomeSections() = combine(
private fun observeCoreSections() = combine(
homeRepository.observeRecentlyAddedAlbums(),
homeRepository.observeRediscoverAlbums(),
homeRepository.observeRediscoverArtists(),
@@ -311,6 +316,14 @@ class HomeViewModel @Inject constructor(
)
}
private fun observeHomeSections() = combine(
observeCoreSections(),
homeRepository.observeYouMightLikeAlbums(),
homeRepository.observeYouMightLikeArtists(),
) { core, ymlAlbums, ymlArtists ->
core.copy(youMightLikeAlbums = ymlAlbums, youMightLikeArtists = ymlArtists)
}
private fun combineHomeFlows() =
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
val merged = sections.copy(playlists = playlists)
@@ -472,6 +485,14 @@ private fun HomeSuccessContent(
onPlayAlbum = onPlayAlbum,
onPlayArtist = onPlayArtist,
)
youMightLikeSection(
albums = sections.youMightLikeAlbums,
artists = sections.youMightLikeArtists,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlayAlbum = onPlayAlbum,
onPlayArtist = onPlayArtist,
)
mostPlayedSection(sections.mostPlayedTracks, onMostPlayedTap)
lastPlayedSection(sections.lastPlayedArtists, onArtistClick, onPlayArtist)
item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) }
@@ -667,6 +688,61 @@ private fun RediscoverBlock(
}
}
/**
* "You might like" — in-library albums/artists predicted from the user's
* taste that they don't actively spin (server section, daily-built, cold-
* start gated). Same albums-then-artists block shape as Rediscover; the
* artist sub-row drops its title when an album sub-row precedes it so the
* two read as one section.
*/
private fun LazyListScope.youMightLikeSection(
albums: List<HomeTile<AlbumRef>>,
artists: List<HomeTile<ArtistRef>>,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlayAlbum: suspend (String) -> Unit,
onPlayArtist: suspend (String) -> Unit,
) {
item {
if (albums.isNotEmpty() || artists.isNotEmpty()) {
YouMightLikeBlock(
albums = albums,
artists = artists,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlayAlbum = onPlayAlbum,
onPlayArtist = onPlayArtist,
)
} else {
EmptySection(
title = "You might like",
body = "We're still learning your taste — keep listening and " +
"picks you might like will show up here.",
)
}
}
}
@Composable
private fun YouMightLikeBlock(
albums: List<HomeTile<AlbumRef>>,
artists: List<HomeTile<ArtistRef>>,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlayAlbum: suspend (String) -> Unit,
onPlayArtist: suspend (String) -> Unit,
) {
Column {
if (albums.isNotEmpty()) {
AlbumsRow("You might like", albums, onAlbumClick, onPlayAlbum)
}
if (artists.isNotEmpty()) {
val title = if (albums.isEmpty()) "You might like" else ""
ArtistsRow(title, artists, onArtistClick, onPlayArtist)
}
}
}
@Composable
private fun AlbumsRow(
title: String,
@@ -10,13 +10,17 @@ import kotlinx.serialization.Serializable
* `internal/api/types.go HomeIndexPayload`).
*
* Section name implies entity type; no per-entry type tag is needed:
* - recentlyAddedAlbums → album
* - rediscoverAlbums → album
* - rediscoverArtists → artist
* - mostPlayedTracks → track
* - lastPlayedArtists → artist
* - recentlyAddedAlbums → album
* - rediscoverAlbums → album
* - rediscoverArtists → artist
* - mostPlayedTracks → track
* - lastPlayedArtists → artist
* - youMightLikeAlbums → album
* - youMightLikeArtists → artist
*
* All slices default to empty so missing keys don't throw on decode.
* All slices default to empty so missing keys don't throw on decode
* which also keeps the client safe against older servers that don't
* emit the you-might-like sections yet.
*/
@Serializable
data class HomeIndexWire(
@@ -25,4 +29,6 @@ data class HomeIndexWire(
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
@SerialName("last_played_artists") val lastPlayedArtists: List<String> = emptyList(),
@SerialName("you_might_like_albums") val youMightLikeAlbums: List<String> = emptyList(),
@SerialName("you_might_like_artists") val youMightLikeArtists: List<String> = emptyList(),
)