feat(android): cached-indicator dot on track rows (#38 slice 2)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 13:03:03 -04:00
parent 7024549e35
commit 4713793900
8 changed files with 120 additions and 2 deletions
@@ -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) {
@@ -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<Set<String>> =
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<String> =
runCatching { playerFactory.simpleCache.keys.toSet() }.getOrDefault(emptySet())
}
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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<String>() }
/**
* 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),
)
}