skip the startup reveal animation and seed the first colour

The mini player and tab bar no longer expand or animate their inset when playback is
restored at launch, and the first artwork colour lands without a 600ms fade from the
base scheme. The last active artwork seed is persisted and read back synchronously so
the mini player can paint with it on its first frame.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent c7bab22dc5
commit ea09f24844
8 changed files with 129 additions and 10 deletions
@@ -5,6 +5,7 @@ import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.ArtworkSeedStore
import com.subtracks.di.appModule
import com.subtracks.ui.theme.ArtworkSeedCache
@@ -24,7 +25,7 @@ class SubtracksApp : Application() {
startKoin {
modules(appModule(this@SubtracksApp, http))
}.koin
ArtworkSeedCache.install(koin.get<ArtworkSeedStore>())
ArtworkSeedCache.install(koin.get<ArtworkSeedStore>(), koin.get<UserPreferences>())
SingletonImageLoader.setSafe { context ->
ImageLoader
.Builder(context)
@@ -4,10 +4,12 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.subtracks.data.net.NetworkMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
enum class AlbumSort { Name, Artist, Year, Added, Starred }
@@ -43,6 +45,12 @@ data class StreamQuality(
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
data class ArtworkSeedValue(
val cacheKey: String,
val primary: Int,
val secondary: Int?,
)
class UserPreferences(
private val store: DataStore<Preferences>,
) {
@@ -69,6 +77,25 @@ class UserPreferences(
private fun streamQualityKey(mode: NetworkMode) = stringPreferencesKey("stream_quality_${mode.key}")
suspend fun lastSeed(): ArtworkSeedValue? {
val prefs = store.data.first()
val cacheKey = prefs[lastSeedKey] ?: return null
val primary = prefs[lastSeedPrimary] ?: return null
return ArtworkSeedValue(cacheKey, primary, prefs[lastSeedSecondary])
}
suspend fun setLastSeed(value: ArtworkSeedValue) {
store.edit { prefs ->
prefs[lastSeedKey] = value.cacheKey
prefs[lastSeedPrimary] = value.primary
if (value.secondary != null) prefs[lastSeedSecondary] = value.secondary else prefs.remove(lastSeedSecondary)
}
}
private val lastSeedKey = stringPreferencesKey("last_seed_key")
private val lastSeedPrimary = intPreferencesKey("last_seed_primary")
private val lastSeedSecondary = intPreferencesKey("last_seed_secondary")
private fun listQueryKey(tab: LibraryListTab) = stringPreferencesKey("list_query_${tab.key}")
private fun encode(query: ListQuery) = "${query.sort}|${if (query.descending) 1 else 0}|${query.starred.ordinal}"
@@ -4,8 +4,10 @@ import android.net.Uri
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
@@ -231,13 +233,16 @@ private fun MainNavigation() {
val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
val keyboardUp = WindowInsets.imeAnimationTarget.getBottom(density) > 0
val showMiniPlayer = playerVisible && !keyboardUp
var miniPlayerRevealed by remember { mutableStateOf(false) }
val firstReveal = !miniPlayerRevealed && showMiniPlayer
LaunchedEffect(showMiniPlayer) { if (showMiniPlayer) miniPlayerRevealed = true }
val bottomInset by animateDpAsState(
targetValue = if (showMiniPlayer) 0.dp else navBarInset,
animationSpec = tween(MINI_PLAYER_ANIM_MS),
animationSpec = if (firstReveal) snap() else tween(MINI_PLAYER_ANIM_MS),
label = "libraryBottomInset",
)
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true))
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), markActive = true)
ArtworkTheme(artwork) {
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().imePadding()) {
@@ -376,7 +381,12 @@ private fun MainNavigation() {
AnimatedVisibility(
visible = showMiniPlayer,
enter = expandVertically(tween(MINI_PLAYER_ANIM_MS), expandFrom = Alignment.Bottom),
enter =
if (firstReveal) {
EnterTransition.None
} else {
expandVertically(tween(MINI_PLAYER_ANIM_MS), expandFrom = Alignment.Bottom)
},
exit = shrinkVertically(tween(MINI_PLAYER_ANIM_MS), shrinkTowards = Alignment.Bottom),
) {
val positionMs by playbackController.positionMs.collectAsStateWithLifecycle()
@@ -38,10 +38,15 @@ fun rememberAnimatedArtworkColors(
LaunchedEffect(target, durationMillis) {
val next = target ?: baseArtworkColors
if (next !== to) {
from = lerpArtworkColors(from, to, progress.value)
to = next
progress.snapTo(0f)
progress.animateTo(1f, tween(durationMillis = durationMillis))
if (to === baseArtworkColors) {
from = next
to = next
} else {
from = lerpArtworkColors(from, to, progress.value)
to = next
progress.snapTo(0f)
progress.animateTo(1f, tween(durationMillis = durationMillis))
}
}
}
@@ -21,6 +21,8 @@ import coil3.request.allowHardware
import coil3.toBitmap
import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.prefs.ArtworkSeedValue
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.ArtworkSeedStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -56,11 +58,38 @@ object ArtworkSeedCache {
@Volatile
private var store: ArtworkSeedStore? = null
fun install(store: ArtworkSeedStore?) {
@Volatile
private var prefs: UserPreferences? = null
@Volatile
private var lastSeed: ArtworkSeedValue? = null
fun install(
store: ArtworkSeedStore?,
prefs: UserPreferences? = null,
) {
this.store = store
this.prefs = prefs
if (prefs == null) {
lastSeed = null
return
}
scope.launch {
ignoreFailure { prefs.lastSeed() }?.let { lastSeed = it }
}
}
fun cached(cacheKey: String): Pair<Int, Int?>? = cache.get(cacheKey)?.let { it.primary to it.secondary }
fun cached(cacheKey: String): Pair<Int, Int?>? =
cache.get(cacheKey)?.let { it.primary to it.secondary }
?: lastSeed?.takeIf { it.cacheKey == cacheKey }?.let { it.primary to it.secondary }
fun markLast(ref: CoverArtRef) {
val prefs = prefs ?: return
val seed = cache.get(ref.cacheKey) ?: return
val value = ArtworkSeedValue(ref.cacheKey, seed.primary, seed.secondary)
lastSeed = value
scope.launch { ignoreFailure { prefs.setLastSeed(value) } }
}
fun cachedBusy(cacheKey: String): Boolean? = cache.get(cacheKey)?.busy
@@ -6,6 +6,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
@@ -182,10 +183,16 @@ private fun blendHue(
fun rememberArtworkColors(
ref: CoverArtRef?,
durationMillis: Int = ARTWORK_THEME_TRANSITION_MS,
markActive: Boolean = false,
): ArtworkColors? {
val cached = remember(ref?.cacheKey) { ref?.cacheKey?.let(ArtworkSeedCache::cached) }
val seeds by rememberArtworkSeed(ref)
val effective = seeds ?: cached
if (markActive) {
LaunchedEffect(ref?.cacheKey, effective) {
if (ref != null && effective != null) ArtworkSeedCache.markLast(ref)
}
}
val target = remember(effective) { effective?.let { (primary, secondary) -> artworkColorsFromSeeds(primary, secondary) } }
val colors = rememberAnimatedArtworkColors(target, durationMillis)
return if (target == null) null else colors
@@ -55,6 +55,27 @@ class UserPreferencesTest {
assertEquals(StreamQuality(320, null), prefs.streamQuality(NetworkMode.Wifi).first())
assertEquals(StreamQuality(96, "opus"), prefs.streamQuality(NetworkMode.Mobile).first())
file.delete()
}
@Test
fun lastSeedRoundTripsWithAndWithoutASecondary() =
runTest {
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val prefs = UserPreferences(store)
assertEquals(null, prefs.lastSeed())
prefs.setLastSeed(ArtworkSeedValue("art:1", 0xFF112233.toInt(), 0xFF445566.toInt()))
assertEquals(
ArtworkSeedValue("art:1", 0xFF112233.toInt(), 0xFF445566.toInt()),
prefs.lastSeed(),
)
prefs.setLastSeed(ArtworkSeedValue("art:2", 0xFF778899.toInt(), null))
assertEquals(ArtworkSeedValue("art:2", 0xFF778899.toInt(), null), prefs.lastSeed())
file.delete()
}
}
@@ -6,6 +6,7 @@ import android.graphics.Color
import androidx.activity.ComponentActivity
import androidx.compose.material3.Text
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.luminance
@@ -92,6 +93,24 @@ class ArtworkExtractionTest {
assertEquals(42, state.value)
}
@Test
fun theFirstArtworkColourAppearsWithoutFadingFromTheBaseScheme() {
val target = artworkColorsFromSeed(0xFF3A7BD5.toInt())
val artwork = mutableStateOf<ArtworkColors?>(null)
val seen = mutableListOf<androidx.compose.ui.graphics.Color>()
composeRule.setContent {
val colors = rememberAnimatedArtworkColors(artwork.value)
SideEffect { seen += colors.scheme.background }
}
composeRule.waitForIdle()
composeRule.runOnIdle { artwork.value = target }
composeRule.waitForIdle()
val firstPainting = seen.first { it != baseArtworkColors.scheme.background }
assertEquals(target.scheme.background, firstPainting)
}
@Test
fun keepsTheStoredSeedWhenTheArtCannotBeDecoded() {
val key = "missing-${System.nanoTime()}"