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")
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. */
@Query(
"SELECT * FROM audio_cache_index " +
@@ -25,10 +25,14 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -41,6 +45,8 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
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.Music
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.PlaylistDetail
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.PlaylistPlaceholderCard
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -115,6 +126,8 @@ class HomeViewModel @Inject constructor(
private val homeRepository: HomeRepository,
private val playlistsRepository: PlaylistsRepository,
private val player: com.fabledsword.minstrel.player.PlayerController,
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
) : ViewModel() {
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
@@ -122,10 +135,42 @@ class HomeViewModel @Inject constructor(
/** System-playlist build status for the Home placeholder cards. */
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 {
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
* tracks and start at the tapped index. Mirrors Flutter's
@@ -202,6 +247,10 @@ fun HomeScreen(
navController: NavHostController,
viewModel: HomeViewModel = hiltViewModel(),
) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { snackbarHostState.showSnackbar(it) }
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -215,9 +264,11 @@ fun HomeScreen(
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle()
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
@@ -238,10 +289,12 @@ fun HomeScreen(
is HomeUiState.Success -> HomeSuccessContent(
sections = s.sections,
systemStatus = systemStatus,
offline = offline,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onMostPlayedTap = viewModel::playMostPlayed,
onPlayPool = viewModel::playPool,
)
}
}
@@ -297,10 +350,12 @@ private fun SkeletonArtistRow() {
private fun HomeSuccessContent(
sections: HomeSections,
systemStatus: SystemPlaylistsStatus,
offline: Boolean,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlaylistClick: (String) -> Unit,
onMostPlayedTap: (List<TrackRef>, Int) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -309,8 +364,13 @@ private fun HomeSuccessContent(
item {
// Always rendered: real system/user playlists, with
// placeholder cards filling the For You / Discover /
// 3× Songs-like slots that haven't generated yet.
PlaylistsRow(buildPlaylistsRow(sections.playlists, systemStatus), onPlaylistClick)
// 3× Songs-like slots that haven't generated yet. When
// offline, the cache-backed pool cards lead the row.
PlaylistsRow(
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
onPlaylistClick = onPlaylistClick,
onPlayPool = onPlayPool,
)
}
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick)
rediscoverSection(
@@ -465,10 +525,16 @@ private fun AlbumsRow(
private fun PlaylistsRow(
rowItems: List<PlaylistRowItem>,
onPlaylistClick: (String) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit,
) {
HorizontalScrollRow(title = "Playlists") {
itemsIndexed(items = rowItems) { _, item ->
when (item) {
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
label = item.kind.label,
icon = iconForPool(item.kind),
onClick = { onPlayPool(item.kind) },
)
is PlaylistRowItem.Real -> PlaylistCard(
playlist = item.playlist,
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 {
data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
data class Real(val playlist: PlaylistRef) : 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
* fixed slots (real card when generated, placeholder otherwise),
* then any user-owned playlists. Mirrors Flutter's `_buildPlaylistsRow`.
* Builds the Home Playlists row. When [offline], the two cache-backed
* pool cards (Recently played, Liked) lead the row — mirrors Flutter's
* `_buildPlaylistsRow`. Then For You + Discover + 3× Songs-like fixed
* slots (real card when generated, placeholder otherwise), then any
* user-owned playlists.
*/
private fun buildPlaylistsRow(
owned: List<PlaylistRef>,
status: SystemPlaylistsStatus,
offline: Boolean,
): List<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" }
?.let { PlaylistRowItem.Real(it) }
?: 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.TrackRef
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit
import retrofit2.create
@@ -71,6 +72,10 @@ class LikesRepository @Inject constructor(
fun observeIsLiked(entityType: String, entityId: String): Flow<Boolean> =
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) ──
/**
@@ -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,
)
}
}