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.SingletonImageLoader
import coil3.annotation.DelicateCoilApi import coil3.annotation.DelicateCoilApi
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.ArtworkSeedStore import com.subtracks.data.repo.ArtworkSeedStore
import com.subtracks.di.appModule import com.subtracks.di.appModule
import com.subtracks.ui.theme.ArtworkSeedCache import com.subtracks.ui.theme.ArtworkSeedCache
@@ -24,7 +25,7 @@ class SubtracksApp : Application() {
startKoin { startKoin {
modules(appModule(this@SubtracksApp, http)) modules(appModule(this@SubtracksApp, http))
}.koin }.koin
ArtworkSeedCache.install(koin.get<ArtworkSeedStore>()) ArtworkSeedCache.install(koin.get<ArtworkSeedStore>(), koin.get<UserPreferences>())
SingletonImageLoader.setSafe { context -> SingletonImageLoader.setSafe { context ->
ImageLoader ImageLoader
.Builder(context) .Builder(context)
@@ -4,10 +4,12 @@ import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.subtracks.data.net.NetworkMode import com.subtracks.data.net.NetworkMode
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
enum class AlbumSort { Name, Artist, Year, Added, Starred } 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") private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
data class ArtworkSeedValue(
val cacheKey: String,
val primary: Int,
val secondary: Int?,
)
class UserPreferences( class UserPreferences(
private val store: DataStore<Preferences>, private val store: DataStore<Preferences>,
) { ) {
@@ -69,6 +77,25 @@ class UserPreferences(
private fun streamQualityKey(mode: NetworkMode) = stringPreferencesKey("stream_quality_${mode.key}") 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 listQueryKey(tab: LibraryListTab) = stringPreferencesKey("list_query_${tab.key}")
private fun encode(query: ListQuery) = "${query.sort}|${if (query.descending) 1 else 0}|${query.starred.ordinal}" 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.OnBackPressedCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.animate import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -231,13 +233,16 @@ private fun MainNavigation() {
val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
val keyboardUp = WindowInsets.imeAnimationTarget.getBottom(density) > 0 val keyboardUp = WindowInsets.imeAnimationTarget.getBottom(density) > 0
val showMiniPlayer = playerVisible && !keyboardUp val showMiniPlayer = playerVisible && !keyboardUp
var miniPlayerRevealed by remember { mutableStateOf(false) }
val firstReveal = !miniPlayerRevealed && showMiniPlayer
LaunchedEffect(showMiniPlayer) { if (showMiniPlayer) miniPlayerRevealed = true }
val bottomInset by animateDpAsState( val bottomInset by animateDpAsState(
targetValue = if (showMiniPlayer) 0.dp else navBarInset, 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", label = "libraryBottomInset",
) )
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true)) val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), markActive = true)
ArtworkTheme(artwork) { ArtworkTheme(artwork) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().imePadding()) { Column(Modifier.fillMaxSize().imePadding()) {
@@ -376,7 +381,12 @@ private fun MainNavigation() {
AnimatedVisibility( AnimatedVisibility(
visible = showMiniPlayer, 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), exit = shrinkVertically(tween(MINI_PLAYER_ANIM_MS), shrinkTowards = Alignment.Bottom),
) { ) {
val positionMs by playbackController.positionMs.collectAsStateWithLifecycle() val positionMs by playbackController.positionMs.collectAsStateWithLifecycle()
@@ -38,10 +38,15 @@ fun rememberAnimatedArtworkColors(
LaunchedEffect(target, durationMillis) { LaunchedEffect(target, durationMillis) {
val next = target ?: baseArtworkColors val next = target ?: baseArtworkColors
if (next !== to) { if (next !== to) {
from = lerpArtworkColors(from, to, progress.value) if (to === baseArtworkColors) {
to = next from = next
progress.snapTo(0f) to = next
progress.animateTo(1f, tween(durationMillis = durationMillis)) } 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 coil3.toBitmap
import com.subtracks.data.model.ArtworkSeed import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.CoverArtRef 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 com.subtracks.data.repo.ArtworkSeedStore
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -56,11 +58,38 @@ object ArtworkSeedCache {
@Volatile @Volatile
private var store: ArtworkSeedStore? = null 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.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 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.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
@@ -182,10 +183,16 @@ private fun blendHue(
fun rememberArtworkColors( fun rememberArtworkColors(
ref: CoverArtRef?, ref: CoverArtRef?,
durationMillis: Int = ARTWORK_THEME_TRANSITION_MS, durationMillis: Int = ARTWORK_THEME_TRANSITION_MS,
markActive: Boolean = false,
): ArtworkColors? { ): ArtworkColors? {
val cached = remember(ref?.cacheKey) { ref?.cacheKey?.let(ArtworkSeedCache::cached) } val cached = remember(ref?.cacheKey) { ref?.cacheKey?.let(ArtworkSeedCache::cached) }
val seeds by rememberArtworkSeed(ref) val seeds by rememberArtworkSeed(ref)
val effective = seeds ?: cached 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 target = remember(effective) { effective?.let { (primary, secondary) -> artworkColorsFromSeeds(primary, secondary) } }
val colors = rememberAnimatedArtworkColors(target, durationMillis) val colors = rememberAnimatedArtworkColors(target, durationMillis)
return if (target == null) null else colors return if (target == null) null else colors
@@ -55,6 +55,27 @@ class UserPreferencesTest {
assertEquals(StreamQuality(320, null), prefs.streamQuality(NetworkMode.Wifi).first()) assertEquals(StreamQuality(320, null), prefs.streamQuality(NetworkMode.Wifi).first())
assertEquals(StreamQuality(96, "opus"), prefs.streamQuality(NetworkMode.Mobile).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() file.delete()
} }
} }
@@ -6,6 +6,7 @@ import android.graphics.Color
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
@@ -92,6 +93,24 @@ class ArtworkExtractionTest {
assertEquals(42, state.value) 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 @Test
fun keepsTheStoredSeedWhenTheArtCannotBeDecoded() { fun keepsTheStoredSeedWhenTheArtCannotBeDecoded() {
val key = "missing-${System.nanoTime()}" val key = "missing-${System.nanoTime()}"