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.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||||
|
import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
|
||||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -29,23 +31,29 @@ import javax.inject.Inject
|
|||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@Inject lateinit var seedCache: DetailSeedCache
|
@Inject lateinit var seedCache: DetailSeedCache
|
||||||
|
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent { App(seedCache = seedCache) }
|
setContent { App(seedCache = seedCache, cachedTrackIds = cachedTrackIds) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun App(
|
private fun App(
|
||||||
seedCache: DetailSeedCache,
|
seedCache: DetailSeedCache,
|
||||||
|
cachedTrackIds: CachedTrackIds,
|
||||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||||
gate: AuthGateViewModel = hiltViewModel(),
|
gate: AuthGateViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||||
|
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
||||||
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
||||||
CompositionLocalProvider(LocalDetailSeedCache provides seedCache) {
|
CompositionLocalProvider(
|
||||||
|
LocalDetailSeedCache provides seedCache,
|
||||||
|
LocalCachedTrackIds provides cached,
|
||||||
|
) {
|
||||||
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
|
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
|
||||||
val resolved = startDestination
|
val resolved = startDestination
|
||||||
if (resolved == null) {
|
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.history.data.HistoryRepository
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
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.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -200,6 +201,7 @@ private fun HistoryRow(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
CachedDot(entry.track.id)
|
||||||
Text(
|
Text(
|
||||||
text = relativeTime(entry.playedAt),
|
text = relativeTime(entry.playedAt),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.models.AlbumRef
|
|||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
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.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -349,6 +350,7 @@ private fun TrackRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
CachedDot(track.id)
|
||||||
Text(
|
Text(
|
||||||
text = formatDuration(track.durationSec),
|
text = formatDuration(track.durationSec),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
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.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
@@ -288,6 +289,7 @@ private fun LikedTrackRow(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
CachedDot(track.id)
|
||||||
LikeButton(liked = true, onToggle = onUnlike)
|
LikeButton(liked = true, onToggle = onUnlike)
|
||||||
TrackActionsButton(
|
TrackActionsButton(
|
||||||
track = track,
|
track = track,
|
||||||
|
|||||||
+2
@@ -72,6 +72,7 @@ import com.fabledsword.minstrel.player.PlayerController
|
|||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
|
import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
|
||||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
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.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
||||||
@@ -528,6 +529,7 @@ private fun TrackRow(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
row.trackId?.let { CachedDot(it) }
|
||||||
Text(
|
Text(
|
||||||
text = formatDuration(row.durationSec),
|
text = formatDuration(row.durationSec),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -311,6 +312,7 @@ private fun TrackResultRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
CachedDot(track.id)
|
||||||
TrackActionsButton(
|
TrackActionsButton(
|
||||||
track = track,
|
track = track,
|
||||||
onNavigateToAlbum = onNavigateToAlbum,
|
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