From 4713793900ece7e8479ba29f41577ffc62cf22d2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 13:03:03 -0400 Subject: [PATCH] feat(android): cached-indicator dot on track rows (#38 slice 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports Flutter's CachedIndicator (circle_check_big, size 14, accent, 4px left pad) to all 5 track-row screens (Album, History, Liked, Playlist, Search). CachedTrackIds exposes a reactive Set of trackIds with bytes in Media3's SimpleCache — true on-disk residency (keyed per track via the custom cache key from slice 1), so an evicted track loses its dot. Read through a LocalCachedTrackIds CompositionLocal provided at the app root, mirroring the existing LocalDetailSeedCache pattern, so leaf rows need no per-screen plumbing. The persisted cache is read eagerly, so dots paint for previously-cached tracks even when the library is opened offline. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/fabledsword/minstrel/MainActivity.kt | 12 +++- .../minstrel/cache/CachedTrackIds.kt | 60 +++++++++++++++++++ .../minstrel/history/ui/HistoryTab.kt | 2 + .../minstrel/library/ui/AlbumDetailScreen.kt | 2 + .../fabledsword/minstrel/likes/ui/LikedTab.kt | 2 + .../playlists/ui/PlaylistDetailScreen.kt | 2 + .../minstrel/search/ui/SearchScreen.kt | 2 + .../minstrel/shared/widgets/CachedDot.kt | 40 +++++++++++++ 8 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/CachedTrackIds.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CachedDot.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt index a84bc1d8..cda6d5dc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt @@ -18,9 +18,11 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.rememberNavController import com.fabledsword.minstrel.auth.ui.AuthGateViewModel +import com.fabledsword.minstrel.cache.CachedTrackIds import com.fabledsword.minstrel.nav.DetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.MinstrelNavGraph +import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds import com.fabledsword.minstrel.theme.MinstrelTheme import com.fabledsword.minstrel.theme.ThemePreferenceViewModel import dagger.hilt.android.AndroidEntryPoint @@ -29,23 +31,29 @@ import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { @Inject lateinit var seedCache: DetailSeedCache + @Inject lateinit var cachedTrackIds: CachedTrackIds override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContent { App(seedCache = seedCache) } + setContent { App(seedCache = seedCache, cachedTrackIds = cachedTrackIds) } } } @Composable private fun App( seedCache: DetailSeedCache, + cachedTrackIds: CachedTrackIds, themeVm: ThemePreferenceViewModel = hiltViewModel(), gate: AuthGateViewModel = hiltViewModel(), ) { val theme by themeVm.themeMode.collectAsStateWithLifecycle() + val cached by cachedTrackIds.ids.collectAsStateWithLifecycle() MinstrelTheme(darkOverride = theme.toDarkOverride()) { - CompositionLocalProvider(LocalDetailSeedCache provides seedCache) { + CompositionLocalProvider( + LocalDetailSeedCache provides seedCache, + LocalCachedTrackIds provides cached, + ) { val startDestination by gate.startDestination.collectAsStateWithLifecycle() val resolved = startDestination if (resolved == null) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/CachedTrackIds.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/CachedTrackIds.kt new file mode 100644 index 00000000..d5940efc --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/CachedTrackIds.kt @@ -0,0 +1,60 @@ +package com.fabledsword.minstrel.cache + +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.player.PlayerFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Reactive set of track ids that have audio bytes in the Media3 + * [SimpleCache][PlayerFactory.simpleCache], used to drive the cached + * indicator dot on track rows. + * + * The SimpleCache (keyed per track via the MediaItem custom cache key) + * is the source of truth for true on-disk residency — querying it + * directly, rather than trusting an index row, means an evicted track + * loses its dot. The key set is read on the IO dispatcher (it scans + * cache metadata) and re-read on every track change, since playback is + * what grows the incidental cache. + * + * The persisted SimpleCache survives process death, so the eager + * initial read paints dots for previously-cached tracks even when the + * library is opened offline with no active playback. + */ +@Singleton +class CachedTrackIds @Inject constructor( + playerController: PlayerController, + private val playerFactory: PlayerFactory, + @ApplicationScope scope: CoroutineScope, +) { + private val refresh = MutableStateFlow(0L) + + val ids: StateFlow> = + refresh + .map { readKeys() } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + init { + scope.launch { + playerController.uiState + .map { it.currentTrack?.id } + .distinctUntilChanged() + .collect { refresh.value += 1 } + } + } + + private fun readKeys(): Set = + runCatching { playerFactory.simpleCache.keys.toSet() }.getOrDefault(emptySet()) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt index 38eef426..aa584fdb 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt @@ -27,6 +27,7 @@ import com.fabledsword.minstrel.history.data.HistoryEntry import com.fabledsword.minstrel.history.data.HistoryRepository import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.shared.widgets.CachedDot import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold @@ -200,6 +201,7 @@ private fun HistoryRow( overflow = TextOverflow.Ellipsis, ) } + CachedDot(entry.track.id) Text( text = relativeTime(entry.playedAt), style = MaterialTheme.typography.bodySmall, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt index f1c43d2b..dfff7df6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt @@ -56,6 +56,7 @@ import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.shared.widgets.CachedDot import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold @@ -349,6 +350,7 @@ private fun TrackRow( ) } } + CachedDot(track.id) Text( text = formatDuration(track.durationSec), style = MaterialTheme.typography.bodySmall, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt index f7175541..48e06895 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt @@ -37,6 +37,7 @@ import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.shared.widgets.CachedDot import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.LikeButton @@ -288,6 +289,7 @@ private fun LikedTrackRow( overflow = TextOverflow.Ellipsis, ) } + CachedDot(track.id) LikeButton(liked = true, onToggle = onUnlike) TrackActionsButton( track = track, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt index 148b0539..d9033345 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -72,6 +72,7 @@ import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef import com.fabledsword.minstrel.playlists.data.PlaylistsRepository +import com.fabledsword.minstrel.shared.widgets.CachedDot import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow @@ -528,6 +529,7 @@ private fun TrackRow( overflow = TextOverflow.Ellipsis, ) } + row.trackId?.let { CachedDot(it) } Text( text = formatDuration(row.durationSec), style = MaterialTheme.typography.bodySmall, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt index ece6b84b..2c8d9086 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt @@ -59,6 +59,7 @@ import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.Search as SearchRoute +import com.fabledsword.minstrel.shared.widgets.CachedDot import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb @@ -311,6 +312,7 @@ private fun TrackResultRow( ) } } + CachedDot(track.id) TrackActionsButton( track = track, onNavigateToAlbum = onNavigateToAlbum, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CachedDot.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CachedDot.kt new file mode 100644 index 00000000..c6fdc2a2 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CachedDot.kt @@ -0,0 +1,40 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.Circle_check_big +import com.composables.icons.lucide.Lucide +import com.fabledsword.minstrel.theme.LocalFabledSwordTheme + +/** + * Track ids with audio cached locally, provided once at the app root + * from `CachedTrackIds`. Defaults to empty so any unwrapped preview / + * test simply shows no dots rather than crashing. + */ +val LocalCachedTrackIds = staticCompositionLocalOf { emptySet() } + +/** + * Small accent download glyph shown next to a track row when the track + * is cached locally. Ports Flutter's `CachedIndicator` + * (`circle_check_big`, size 14, accent, 4px left pad) — renders nothing + * when the track isn't cached. Reads the reactive set from + * [LocalCachedTrackIds], so the dot appears/disappears as the cache + * fills and evicts without per-row queries. + */ +@Composable +fun CachedDot(trackId: String, modifier: Modifier = Modifier) { + if (trackId !in LocalCachedTrackIds.current) return + Icon( + imageVector = Lucide.Circle_check_big, + contentDescription = "Downloaded", + tint = LocalFabledSwordTheme.current.accent, + modifier = modifier + .padding(start = 4.dp) + .size(14.dp), + ) +}