feat(android): "You might like" Home rows (#790 client slice)
android / Build + lint + test (push) Failing after 1m17s
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:
Vendored
+5
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user