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:
@@ -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() }
|
||||
}
|
||||
}
|
||||
+8
@@ -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) ──
|
||||
|
||||
/**
|
||||
|
||||
+74
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user