feat(android): per-tile skeleton reveal on Home via reactive section flows
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user