diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHomeIndexEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHomeIndexEntity.kt index 70d5e11c..7d439de7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHomeIndexEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHomeIndexEntity.kt @@ -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. 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 0b24e86f..26c54578 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 @@ -76,6 +76,12 @@ class HomeRepository @Inject constructor( fun observeLastPlayedArtists(): Flow>> = observeArtistSection(SECTION_LAST_PLAYED_ARTISTS) + fun observeYouMightLikeAlbums(): Flow>> = + observeAlbumSection(SECTION_YOU_MIGHT_LIKE_ALBUMS) + + fun observeYouMightLikeArtists(): Flow>> = + 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) { @@ -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" } } 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 e013a149..58841748 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 @@ -116,6 +116,8 @@ data class HomeSections( val rediscoverArtists: List> = emptyList(), val mostPlayedTracks: List> = emptyList(), val lastPlayedArtists: List> = emptyList(), + val youMightLikeAlbums: List> = emptyList(), + val youMightLikeArtists: List> = 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>, + artists: List>, + 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>, + artists: List>, + 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, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt index 8d0b4fa1..3c254e33 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt @@ -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 = emptyList(), @SerialName("most_played_tracks") val mostPlayedTracks: List = emptyList(), @SerialName("last_played_artists") val lastPlayedArtists: List = emptyList(), + @SerialName("you_might_like_albums") val youMightLikeAlbums: List = emptyList(), + @SerialName("you_might_like_artists") val youMightLikeArtists: List = emptyList(), )