feat(android): offline-pool Home cards (audit v3 §4.3) — closes #28

Faithful port of Flutter's _OfflinePoolCard + shuffle_source.dart.
I was wrong earlier that Android lacked the source — the
audio_cache_index table already carries lastPlayedAt, exactly what
Flutter reads.

* ShuffleSource @Singleton over the local cache: recentlyPlayed()
  = audio_cache_index ordered by lastPlayedAt desc, materialized
  from cached_tracks; liked() = same ∩ the liked track-id set.
  Both are unions over the cache regardless of storage bucket,
  matching Flutter's note that the bucket split is eviction-only.
* OfflinePoolCard widget (sized to match PlaylistCard).
* AudioCacheIndexDao.trackIdsByRecency(); LikesRepository.likedTrackIds().
* HomeViewModel: offline StateFlow from ConnectivityObserver,
  playPool(kind) shuffles + plays (snackbar "No cached X tracks yet"
  when empty), transientMessages channel.
* buildPlaylistsRow gains an `offline` flag; when offline the two
  pool cards (Recently played, Liked) LEAD the Playlists row, before
  the system slots — the rest of the row (placeholders + user
  playlists) renders regardless, exactly as Flutter does.
* HomeScreen gains a SnackbarHost for the empty-pool message.

Together with edbc8053 (placeholders) this closes audit v3 #28.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 07:52:55 -04:00
parent edbc805333
commit 2a87a50b81
5 changed files with 226 additions and 6 deletions
@@ -0,0 +1,48 @@
package com.fabledsword.minstrel.cache
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.TrackRef
import javax.inject.Inject
import javax.inject.Singleton
private const val POOL_LIMIT = 100
/**
* Offline play sources over the local audio-cache index. Mirrors
* `flutter_client/lib/cache/shuffle_source.dart`.
*
* Both pools are UNIONs over the cache regardless of storage bucket
* (liked AND recently-played both included). The two-bucket split is
* storage/eviction-only and never filters playback — exactly what
* this relies on.
*
* Surfaced on Home's Playlists row only when offline; tap shuffles +
* plays the pool from the cache.
*/
@Singleton
class ShuffleSource @Inject constructor(
private val cacheIndexDao: AudioCacheIndexDao,
private val trackDao: CachedTrackDao,
private val likes: LikesRepository,
) {
/** Cached tracks, most-recently-played first (liked included). */
suspend fun recentlyPlayed(limit: Int = POOL_LIMIT): List<TrackRef> =
materialize(cacheIndexDao.trackIdsByRecency()).take(limit)
/** Cached tracks that are in the user's liked set, recency-ordered. */
suspend fun liked(limit: Int = POOL_LIMIT): List<TrackRef> {
val likedSet = likes.likedTrackIds()
val ids = cacheIndexDao.trackIdsByRecency().filter { it in likedSet }
return materialize(ids).take(limit)
}
/** Materialize ordered ids into TrackRefs from cached metadata, preserving order. */
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
if (orderedIds.isEmpty()) return emptyList()
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
return orderedIds.mapNotNull { byId[it]?.toDomain() }
}
}
@@ -24,6 +24,14 @@ interface AudioCacheIndexDao {
@Query("SELECT trackId FROM audio_cache_index") @Query("SELECT trackId FROM audio_cache_index")
suspend fun allCachedTrackIds(): List<String> suspend fun allCachedTrackIds(): List<String>
/**
* Cached track IDs most-recently-played first (nulls last so
* never-played downloads sort after real plays). Backs the
* offline-pool shuffle sources.
*/
@Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST")
suspend fun trackIdsByRecency(): List<String>
/** Eviction-candidate ordering — oldest lastPlayedAt first within a source. */ /** Eviction-candidate ordering — oldest lastPlayedAt first within a source. */
@Query( @Query(
"SELECT * FROM audio_cache_index " + "SELECT * FROM audio_cache_index " +
@@ -25,10 +25,14 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -41,6 +45,8 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.History
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music import com.composables.icons.lucide.Music
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
@@ -57,6 +63,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Home import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
@@ -68,12 +75,16 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -115,6 +126,8 @@ class HomeViewModel @Inject constructor(
private val homeRepository: HomeRepository, private val homeRepository: HomeRepository,
private val playlistsRepository: PlaylistsRepository, private val playlistsRepository: PlaylistsRepository,
private val player: com.fabledsword.minstrel.player.PlayerController, private val player: com.fabledsword.minstrel.player.PlayerController,
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
) : ViewModel() { ) : ViewModel() {
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus()) private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
@@ -122,10 +135,42 @@ class HomeViewModel @Inject constructor(
/** System-playlist build status for the Home placeholder cards. */ /** System-playlist build status for the Home placeholder cards. */
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow() val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
/** True when the device has no usable internet — gates the offline-pool cards. */
val offline: StateFlow<Boolean> = connectivity.online
.map { !it }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = false,
)
private val poolMessages = Channel<String>(Channel.BUFFERED)
/** Transient snackbar messages from offline-pool taps. */
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
init { init {
refresh() refresh()
} }
/**
* Tap an offline pool: shuffle + play its cached tracks. Empty
* pool surfaces a snackbar rather than starting silent playback.
*/
fun playPool(kind: OfflinePoolKind) {
viewModelScope.launch {
val tracks = when (kind) {
OfflinePoolKind.RECENTLY_PLAYED -> shuffleSource.recentlyPlayed()
OfflinePoolKind.LIKED -> shuffleSource.liked()
}.shuffled()
if (tracks.isEmpty()) {
poolMessages.trySend("No cached ${kind.label} tracks yet")
} else {
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
}
}
}
/** /**
* Tap on a Most-played tile: replace the queue with the section's * Tap on a Most-played tile: replace the queue with the section's
* tracks and start at the tapped index. Mirrors Flutter's * tracks and start at the tapped index. Mirrors Flutter's
@@ -202,6 +247,10 @@ fun HomeScreen(
navController: NavHostController, navController: NavHostController,
viewModel: HomeViewModel = hiltViewModel(), viewModel: HomeViewModel = hiltViewModel(),
) { ) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { snackbarHostState.showSnackbar(it) }
}
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
@@ -215,9 +264,11 @@ fun HomeScreen(
}, },
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner -> ) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle() val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
PullToRefreshScaffold( PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() }, onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner), modifier = Modifier.fillMaxSize().padding(inner),
@@ -238,10 +289,12 @@ fun HomeScreen(
is HomeUiState.Success -> HomeSuccessContent( is HomeUiState.Success -> HomeSuccessContent(
sections = s.sections, sections = s.sections,
systemStatus = systemStatus, systemStatus = systemStatus,
offline = offline,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) }, onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) }, onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onMostPlayedTap = viewModel::playMostPlayed, onMostPlayedTap = viewModel::playMostPlayed,
onPlayPool = viewModel::playPool,
) )
} }
} }
@@ -297,10 +350,12 @@ private fun SkeletonArtistRow() {
private fun HomeSuccessContent( private fun HomeSuccessContent(
sections: HomeSections, sections: HomeSections,
systemStatus: SystemPlaylistsStatus, systemStatus: SystemPlaylistsStatus,
offline: Boolean,
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit, onArtistClick: (String) -> Unit,
onPlaylistClick: (String) -> Unit, onPlaylistClick: (String) -> Unit,
onMostPlayedTap: (List<TrackRef>, Int) -> Unit, onMostPlayedTap: (List<TrackRef>, Int) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit,
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -309,8 +364,13 @@ private fun HomeSuccessContent(
item { item {
// Always rendered: real system/user playlists, with // Always rendered: real system/user playlists, with
// placeholder cards filling the For You / Discover / // placeholder cards filling the For You / Discover /
// 3× Songs-like slots that haven't generated yet. // 3× Songs-like slots that haven't generated yet. When
PlaylistsRow(buildPlaylistsRow(sections.playlists, systemStatus), onPlaylistClick) // offline, the cache-backed pool cards lead the row.
PlaylistsRow(
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
onPlaylistClick = onPlaylistClick,
onPlayPool = onPlayPool,
)
} }
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick) recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick)
rediscoverSection( rediscoverSection(
@@ -465,10 +525,16 @@ private fun AlbumsRow(
private fun PlaylistsRow( private fun PlaylistsRow(
rowItems: List<PlaylistRowItem>, rowItems: List<PlaylistRowItem>,
onPlaylistClick: (String) -> Unit, onPlaylistClick: (String) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit,
) { ) {
HorizontalScrollRow(title = "Playlists") { HorizontalScrollRow(title = "Playlists") {
itemsIndexed(items = rowItems) { _, item -> itemsIndexed(items = rowItems) { _, item ->
when (item) { when (item) {
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
label = item.kind.label,
icon = iconForPool(item.kind),
onClick = { onPlayPool(item.kind) },
)
is PlaylistRowItem.Real -> PlaylistCard( is PlaylistRowItem.Real -> PlaylistCard(
playlist = item.playlist, playlist = item.playlist,
onClick = { onPlaylistClick(item.playlist.id) }, onClick = { onPlaylistClick(item.playlist.id) },
@@ -482,22 +548,41 @@ private fun PlaylistsRow(
} }
} }
/** Either a real playlist tile or a not-yet-generated placeholder slot. */ private fun iconForPool(kind: OfflinePoolKind) = when (kind) {
OfflinePoolKind.RECENTLY_PLAYED -> Lucide.History
OfflinePoolKind.LIKED -> Lucide.Heart
}
/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */
private sealed interface PlaylistRowItem { private sealed interface PlaylistRowItem {
data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
data class Real(val playlist: PlaylistRef) : PlaylistRowItem data class Real(val playlist: PlaylistRef) : PlaylistRowItem
data class Placeholder(val label: String, val variant: String) : PlaylistRowItem data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
} }
/** The two offline cache pools surfaced on Home when offline. */
enum class OfflinePoolKind(val label: String) {
RECENTLY_PLAYED("Recently played"),
LIKED("Liked"),
}
/** /**
* Builds the Home Playlists row: For You + Discover + 3× Songs-like * Builds the Home Playlists row. When [offline], the two cache-backed
* fixed slots (real card when generated, placeholder otherwise), * pool cards (Recently played, Liked) lead the row — mirrors Flutter's
* then any user-owned playlists. Mirrors Flutter's `_buildPlaylistsRow`. * `_buildPlaylistsRow`. Then For You + Discover + 3× Songs-like fixed
* slots (real card when generated, placeholder otherwise), then any
* user-owned playlists.
*/ */
private fun buildPlaylistsRow( private fun buildPlaylistsRow(
owned: List<PlaylistRef>, owned: List<PlaylistRef>,
status: SystemPlaylistsStatus, status: SystemPlaylistsStatus,
offline: Boolean,
): List<PlaylistRowItem> { ): List<PlaylistRowItem> {
val out = mutableListOf<PlaylistRowItem>() val out = mutableListOf<PlaylistRowItem>()
if (offline) {
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED)
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED)
}
out += owned.firstOrNull { it.systemVariant == "for_you" } out += owned.firstOrNull { it.systemVariant == "for_you" }
?.let { PlaylistRowItem.Real(it) } ?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status)) ?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
@@ -12,6 +12,7 @@ import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.create import retrofit2.create
@@ -71,6 +72,10 @@ class LikesRepository @Inject constructor(
fun observeIsLiked(entityType: String, entityId: String): Flow<Boolean> = fun observeIsLiked(entityType: String, entityId: String): Flow<Boolean> =
likeDao.observeIsLiked(LOCAL_USER_ID, entityType, entityId) likeDao.observeIsLiked(LOCAL_USER_ID, entityType, entityId)
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
suspend fun likedTrackIds(): Set<String> =
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).first().toSet()
// ── Writes (optimistic local → best-effort REST → enqueue on fail) ── // ── Writes (optimistic local → best-effort REST → enqueue on fail) ──
/** /**
@@ -0,0 +1,74 @@
package com.fabledsword.minstrel.playlists.widgets
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
/**
* Home tile for an offline cache-backed pool ("Recently played" /
* "Liked"). Tapping shuffles + plays that pool from the local cache.
* Sized to match [PlaylistCard] so the Playlists row stays visually
* consistent. Mirrors Flutter's `_OfflinePoolCard`.
*/
@Composable
fun OfflinePoolCard(
label: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.width(176.dp)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp),
) {
Box(
modifier = Modifier
.size(144.dp)
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
}
Spacer(Modifier.height(8.dp))
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "Offline · shuffle",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}