diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/ShuffleSource.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/ShuffleSource.kt new file mode 100644 index 00000000..f23d1ec9 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/ShuffleSource.kt @@ -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 = + 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 { + 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): List { + if (orderedIds.isEmpty()) return emptyList() + val byId = trackDao.getByIds(orderedIds).associateBy { it.id } + return orderedIds.mapNotNull { byId[it]?.toDomain() } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt index 01eb267d..3991f50a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt @@ -24,6 +24,14 @@ interface AudioCacheIndexDao { @Query("SELECT trackId FROM audio_cache_index") suspend fun allCachedTrackIds(): List + /** + * 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 + /** Eviction-candidate ordering — oldest lastPlayedAt first within a source. */ @Query( "SELECT * FROM audio_cache_index " + 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 3c7cb02c..cc86cafc 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 @@ -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 = systemStatusInternal.asStateFlow() + /** True when the device has no usable internet — gates the offline-pool cards. */ + val offline: StateFlow = connectivity.online + .map { !it } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = false, + ) + + private val poolMessages = Channel(Channel.BUFFERED) + + /** Transient snackbar messages from offline-pool taps. */ + val transientMessages: Flow = 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, 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, 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, status: SystemPlaylistsStatus, + offline: Boolean, ): List { val out = mutableListOf() + 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)) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt index d54832e1..1e5087ed 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt @@ -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 = 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 = + likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).first().toSet() + // ── Writes (optimistic local → best-effort REST → enqueue on fail) ── /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/OfflinePoolCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/OfflinePoolCard.kt new file mode 100644 index 00000000..37476989 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/OfflinePoolCard.kt @@ -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, + ) + } +}