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:
@@ -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,
|
||||
|
||||
+2
@@ -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),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user