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 7c82b5ce..0b24e86f 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 @@ -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>`. 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>>`: 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> = - homeIndexDao.observeBySection(SECTION_RECENTLY_ADDED_ALBUMS).map { hydrateAlbums(it) } + fun observeRecentlyAddedAlbums(): Flow>> = + observeAlbumSection(SECTION_RECENTLY_ADDED_ALBUMS) - fun observeRediscoverAlbums(): Flow> = - homeIndexDao.observeBySection(SECTION_REDISCOVER_ALBUMS).map { hydrateAlbums(it) } + fun observeRediscoverAlbums(): Flow>> = + observeAlbumSection(SECTION_REDISCOVER_ALBUMS) - fun observeRediscoverArtists(): Flow> = - homeIndexDao.observeBySection(SECTION_REDISCOVER_ARTISTS).map { hydrateArtists(it) } + fun observeRediscoverArtists(): Flow>> = + observeArtistSection(SECTION_REDISCOVER_ARTISTS) - fun observeMostPlayedTracks(): Flow> = - homeIndexDao.observeBySection(SECTION_MOST_PLAYED_TRACKS).map { hydrateTracks(it) } + fun observeMostPlayedTracks(): Flow>> = + observeTrackSection(SECTION_MOST_PLAYED_TRACKS) - fun observeLastPlayedArtists(): Flow> = - homeIndexDao.observeBySection(SECTION_LAST_PLAYED_ARTISTS).map { hydrateArtists(it) } + fun observeLastPlayedArtists(): Flow>> = + 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) { @@ -107,25 +106,40 @@ class HomeRepository @Inject constructor( ) } - private suspend fun hydrateAlbums(rows: List): List = - 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>> = + 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): List = - 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>> = + 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): List = - 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>> = + 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 { 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 cc86cafc..6be6524a 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 @@ -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 = emptyList(), - val recentlyAddedAlbums: List = emptyList(), - val rediscoverAlbums: List = emptyList(), - val rediscoverArtists: List = emptyList(), - val mostPlayedTracks: List = emptyList(), - val lastPlayedArtists: List = emptyList(), + val recentlyAddedAlbums: List> = emptyList(), + val rediscoverAlbums: List> = emptyList(), + val rediscoverArtists: List> = emptyList(), + val mostPlayedTracks: List> = emptyList(), + val lastPlayedArtists: List> = emptyList(), ) { val isAllEmpty: Boolean get() = playlists.isEmpty() && @@ -386,7 +387,7 @@ private fun HomeSuccessContent( } private fun LazyListScope.recentlyAddedSection( - albums: List, + albums: List>, onAlbumClick: (String) -> Unit, ) { if (albums.isEmpty()) { @@ -415,8 +416,8 @@ private fun LazyListScope.recentlyAddedSection( } private fun LazyListScope.rediscoverSection( - albums: List, - artists: List, + albums: List>, + artists: List>, onAlbumClick: (String) -> Unit, onArtistClick: (String) -> Unit, ) { @@ -433,7 +434,7 @@ private fun LazyListScope.rediscoverSection( } private fun LazyListScope.mostPlayedSection( - tracks: List, + tracks: List>, onMostPlayedTap: (List, Int) -> Unit, ) { item { @@ -449,7 +450,7 @@ private fun LazyListScope.mostPlayedSection( } private fun LazyListScope.lastPlayedSection( - artists: List, + artists: List>, onArtistClick: (String) -> Unit, ) { item { @@ -492,8 +493,8 @@ private fun EmptySection(title: String, body: String) { */ @Composable private fun RediscoverBlock( - albums: List, - artists: List, + albums: List>, + artists: List>, onAlbumClick: (String) -> Unit, onArtistClick: (String) -> Unit, ) { @@ -511,12 +512,17 @@ private fun RediscoverBlock( @Composable private fun AlbumsRow( title: String, - albums: List, + albums: List>, 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, + artists: List>, 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, + tracks: List>, onTap: (List, 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, diff --git a/android/app/src/test/java/com/fabledsword/minstrel/home/data/HomeRepositoryTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/home/data/HomeRepositoryTest.kt new file mode 100644 index 00000000..161cb8b6 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/home/data/HomeRepositoryTest.kt @@ -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(relaxed = true) + private val metadataProvider = mockk(relaxed = true) + private val prewarmer = mockk(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(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() + } + } +}