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,
@@ -0,0 +1,93 @@
package com.fabledsword.minstrel.home.data
import app.cash.turbine.test
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import com.fabledsword.minstrel.metadata.HomeArtistPrewarmer
import com.fabledsword.minstrel.metadata.MetadataProvider
import com.fabledsword.minstrel.models.AlbumRef
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import retrofit2.Retrofit
import kotlin.test.assertEquals
import kotlin.test.assertNull
class HomeRepositoryTest {
private lateinit var server: MockWebServer
private lateinit var retrofit: Retrofit
private val homeIndexDao = mockk<CachedHomeIndexDao>(relaxed = true)
private val metadataProvider = mockk<MetadataProvider>(relaxed = true)
private val prewarmer = mockk<HomeArtistPrewarmer>(relaxed = true)
@BeforeEach
fun setup() {
server = MockWebServer().apply { start() }
retrofit = Retrofit.Builder()
.baseUrl(server.url("/"))
.client(OkHttpClient.Builder().build())
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
}
@AfterEach
fun teardown() {
server.shutdown()
}
private companion object {
private val json = Json { ignoreUnknownKeys = true }
}
private fun repo() = HomeRepository(homeIndexDao, metadataProvider, prewarmer, retrofit)
@Test
fun `rediscover albums emit skeleton tiles then reveal as rows land`() = runTest {
val indexRow = CachedHomeIndexEntity(
section = HomeRepository.SECTION_REDISCOVER_ALBUMS,
position = 0,
entityType = "album",
entityId = "al1",
)
every {
homeIndexDao.observeBySection(HomeRepository.SECTION_REDISCOVER_ALBUMS)
} returns flowOf(listOf(indexRow))
val albumFlow = MutableStateFlow<AlbumRef?>(null)
every { metadataProvider.observeAlbum("al1") } returns albumFlow
repo().observeRediscoverAlbums().test {
val skeleton = awaitItem()
assertEquals(1, skeleton.size)
assertEquals("al1", skeleton[0].id)
assertNull(skeleton[0].value) // skeleton in position
albumFlow.value = AlbumRef(id = "al1", title = "Geogaddi", artistId = "a1")
val revealed = awaitItem()
assertEquals("al1", revealed[0].id)
assertEquals("Geogaddi", revealed[0].value?.title) // tile reveals
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `empty section emits empty list`() = runTest {
every {
homeIndexDao.observeBySection(HomeRepository.SECTION_REDISCOVER_ALBUMS)
} returns flowOf(emptyList())
repo().observeRediscoverAlbums().test {
assertEquals(0, awaitItem().size)
awaitComplete()
}
}
}