feat(android): per-tile skeleton reveal on Home via reactive section flows

This commit is contained in:
2026-05-28 19:42:09 -04:00
parent 4eb225914c
commit 01b05f37c3
3 changed files with 206 additions and 70 deletions
@@ -2,48 +2,47 @@ 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
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.metadata.HomeArtistPrewarmer
import com.fabledsword.minstrel.metadata.MetadataProvider
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.HomeTile
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Cache-first reads for the Home screen. Per-section ID lists live
* in `cached_home_index`; entity rows live in `cached_albums` /
* Cache-first reads for the Home screen. Per-section ID lists live in
* `cached_home_index`; entity rows live in `cached_albums` /
* `cached_artists` / `cached_tracks`.
*
* Sections are exposed as `Flow<List<DomainRef>>`. Each emission
* joins the ID list against the per-entity DAO. IDs that aren't yet
* cached are silently dropped from the emitted list AND fire a
* dedup'd background fetch via [MetadataProvider]. The fetch upserts
* into Room, the Flow re-emits, the tile populates.
* Each section is a `Flow<List<HomeTile<Ref>>>`: the index Flow drives
* the ordered id list, and each id maps to a per-entity
* [MetadataProvider] Flow that emits the cached row (or null while it
* hydrates) and fires a dedup'd, concurrency-gated on-miss fetch. A
* null [HomeTile.value] renders as a skeleton in position; the tile
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
* per-item tile providers.
*
* `refreshIndex()` pulls `GET /api/home/index` and replaces each
* section in-place (delete-then-insert), so the Flow emissions
* propagate automatically. Errors surface to the caller. No more
* eager fan-out at refresh time — the on-miss path handles it
* declaratively as each tile materialises.
* `refreshIndex()` pulls `GET /api/home/index`, replaces each section
* in-place (delete-then-insert, so the section Flows re-fire), and
* pre-warms the top artists via [HomeArtistPrewarmer].
*/
@Singleton
class HomeRepository @Inject constructor(
private val homeIndexDao: CachedHomeIndexDao,
private val albumDao: CachedAlbumDao,
private val artistDao: CachedArtistDao,
private val trackDao: CachedTrackDao,
private val metadataProvider: MetadataProvider,
private val prewarmer: HomeArtistPrewarmer,
retrofit: Retrofit,
) {
private val api: HomeApi = retrofit.create()
@@ -62,26 +61,25 @@ class HomeRepository @Inject constructor(
)
}
fun observeRecentlyAddedAlbums(): Flow<List<AlbumRef>> =
homeIndexDao.observeBySection(SECTION_RECENTLY_ADDED_ALBUMS).map { hydrateAlbums(it) }
fun observeRecentlyAddedAlbums(): Flow<List<HomeTile<AlbumRef>>> =
observeAlbumSection(SECTION_RECENTLY_ADDED_ALBUMS)
fun observeRediscoverAlbums(): Flow<List<AlbumRef>> =
homeIndexDao.observeBySection(SECTION_REDISCOVER_ALBUMS).map { hydrateAlbums(it) }
fun observeRediscoverAlbums(): Flow<List<HomeTile<AlbumRef>>> =
observeAlbumSection(SECTION_REDISCOVER_ALBUMS)
fun observeRediscoverArtists(): Flow<List<ArtistRef>> =
homeIndexDao.observeBySection(SECTION_REDISCOVER_ARTISTS).map { hydrateArtists(it) }
fun observeRediscoverArtists(): Flow<List<HomeTile<ArtistRef>>> =
observeArtistSection(SECTION_REDISCOVER_ARTISTS)
fun observeMostPlayedTracks(): Flow<List<TrackRef>> =
homeIndexDao.observeBySection(SECTION_MOST_PLAYED_TRACKS).map { hydrateTracks(it) }
fun observeMostPlayedTracks(): Flow<List<HomeTile<TrackRef>>> =
observeTrackSection(SECTION_MOST_PLAYED_TRACKS)
fun observeLastPlayedArtists(): Flow<List<ArtistRef>> =
homeIndexDao.observeBySection(SECTION_LAST_PLAYED_ARTISTS).map { hydrateArtists(it) }
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
/**
* Pulls /api/home/index and replaces each section in
* cached_home_index. The Flow emissions re-fire automatically,
* and any missing entity rows fire on-miss fetches via the
* hydrate helpers below.
* Pulls /api/home/index, replaces each cached_home_index section,
* and pre-warms the top artists. The section Flows re-fire on the
* index change; missing entity rows hydrate via the on-miss path.
*/
suspend fun refreshIndex() {
val wire = api.getHomeIndex()
@@ -90,6 +88,7 @@ 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)
}
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
@@ -107,25 +106,40 @@ class HomeRepository @Inject constructor(
)
}
private suspend fun hydrateAlbums(rows: List<CachedHomeIndexEntity>): List<AlbumRef> =
rows.mapNotNull { row ->
val cached = albumDao.getById(row.entityId)
if (cached == null) metadataProvider.refreshAlbum(row.entityId)
cached?.toDomain()
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
if (rows.isEmpty()) {
flowOf(emptyList())
} else {
combine(rows.map { metadataProvider.observeAlbum(it.entityId) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
}
}
}
private suspend fun hydrateArtists(rows: List<CachedHomeIndexEntity>): List<ArtistRef> =
rows.mapNotNull { row ->
val cached = artistDao.getById(row.entityId)
if (cached == null) metadataProvider.refreshArtist(row.entityId)
cached?.toDomain()
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
if (rows.isEmpty()) {
flowOf(emptyList())
} else {
combine(rows.map { metadataProvider.observeArtist(it.entityId) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
}
}
}
private suspend fun hydrateTracks(rows: List<CachedHomeIndexEntity>): List<TrackRef> =
rows.mapNotNull { row ->
val cached = trackDao.getById(row.entityId)
if (cached == null) metadataProvider.refreshTrack(row.entityId)
cached?.toDomain()
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
if (rows.isEmpty()) {
flowOf(emptyList())
} else {
combine(rows.map { metadataProvider.observeTrack(it.entityId) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
}
}
}
companion object {
@@ -55,6 +55,7 @@ import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.HomeTile
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
import com.fabledsword.minstrel.models.TrackRef
@@ -97,11 +98,11 @@ private const val RECENTLY_ADDED_CHUNK = 25
data class HomeSections(
val playlists: List<PlaylistRef> = emptyList(),
val recentlyAddedAlbums: List<AlbumRef> = emptyList(),
val rediscoverAlbums: List<AlbumRef> = emptyList(),
val rediscoverArtists: List<ArtistRef> = emptyList(),
val mostPlayedTracks: List<TrackRef> = emptyList(),
val lastPlayedArtists: List<ArtistRef> = emptyList(),
val recentlyAddedAlbums: List<HomeTile<AlbumRef>> = emptyList(),
val rediscoverAlbums: List<HomeTile<AlbumRef>> = emptyList(),
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
) {
val isAllEmpty: Boolean
get() = playlists.isEmpty() &&
@@ -386,7 +387,7 @@ private fun HomeSuccessContent(
}
private fun LazyListScope.recentlyAddedSection(
albums: List<AlbumRef>,
albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit,
) {
if (albums.isEmpty()) {
@@ -415,8 +416,8 @@ private fun LazyListScope.recentlyAddedSection(
}
private fun LazyListScope.rediscoverSection(
albums: List<AlbumRef>,
artists: List<ArtistRef>,
albums: List<HomeTile<AlbumRef>>,
artists: List<HomeTile<ArtistRef>>,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
) {
@@ -433,7 +434,7 @@ private fun LazyListScope.rediscoverSection(
}
private fun LazyListScope.mostPlayedSection(
tracks: List<TrackRef>,
tracks: List<HomeTile<TrackRef>>,
onMostPlayedTap: (List<TrackRef>, Int) -> Unit,
) {
item {
@@ -449,7 +450,7 @@ private fun LazyListScope.mostPlayedSection(
}
private fun LazyListScope.lastPlayedSection(
artists: List<ArtistRef>,
artists: List<HomeTile<ArtistRef>>,
onArtistClick: (String) -> Unit,
) {
item {
@@ -492,8 +493,8 @@ private fun EmptySection(title: String, body: String) {
*/
@Composable
private fun RediscoverBlock(
albums: List<AlbumRef>,
artists: List<ArtistRef>,
albums: List<HomeTile<AlbumRef>>,
artists: List<HomeTile<ArtistRef>>,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
) {
@@ -511,12 +512,17 @@ private fun RediscoverBlock(
@Composable
private fun AlbumsRow(
title: String,
albums: List<AlbumRef>,
albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit,
) {
HorizontalScrollRow(title = title) {
items(items = albums, key = { it.id }) { album ->
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
items(items = albums, key = { it.id }) { tile ->
val album = tile.value
if (album == null) {
SkeletonAlbumTile()
} else {
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
}
}
}
}
@@ -611,12 +617,17 @@ private const val SONGS_LIKE_SLOTS = 3
@Composable
private fun ArtistsRow(
title: String,
artists: List<ArtistRef>,
artists: List<HomeTile<ArtistRef>>,
onArtistClick: (String) -> Unit,
) {
HorizontalScrollRow(title = title) {
items(items = artists, key = { it.id }) { artist ->
ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) })
items(items = artists, key = { it.id }) { tile ->
val artist = tile.value
if (artist == null) {
SkeletonArtistTile()
} else {
ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) })
}
}
}
}
@@ -629,16 +640,34 @@ private fun ArtistsRow(
*/
@Composable
private fun MostPlayedRow(
tracks: List<TrackRef>,
tracks: List<HomeTile<TrackRef>>,
onTap: (List<TrackRef>, Int) -> Unit,
) {
HorizontalScrollRow(title = "Most played") {
itemsIndexed(items = tracks, key = { _, t -> t.id }) { index, track ->
CompactTrackTile(track = track, onClick = { onTap(tracks, index) })
items(items = tracks, key = { it.id }) { tile ->
val track = tile.value
if (track == null) {
SkeletonCompactTrackTile()
} else {
CompactTrackTile(
track = track,
onClick = {
val playable = tracks.mapNotNull { it.value }
onTap(playable, playable.indexOfFirst { it.id == track.id })
},
)
}
}
}
}
@Composable
private fun SkeletonCompactTrackTile() {
Column(modifier = Modifier.width(140.dp)) {
SkeletonAlbumTile()
}
}
@Composable
private fun CompactTrackTile(
track: TrackRef,