From ea09f24844787a880bb2a513da574337e3681241 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:33:35 +0000 Subject: [PATCH] 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. --- .../main/kotlin/com/subtracks/SubtracksApp.kt | 3 +- .../subtracks/data/prefs/UserPreferences.kt | 27 +++++++++++++++ .../main/kotlin/com/subtracks/ui/AppRoot.kt | 16 +++++++-- .../ui/theme/AnimatedArtworkColors.kt | 13 +++++--- .../subtracks/ui/theme/ArtworkSeedCache.kt | 33 +++++++++++++++++-- .../com/subtracks/ui/theme/ArtworkTheme.kt | 7 ++++ .../data/prefs/UserPreferencesTest.kt | 21 ++++++++++++ .../ui/theme/ArtworkExtractionTest.kt | 19 +++++++++++ 8 files changed, 129 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/SubtracksApp.kt b/app/src/main/kotlin/com/subtracks/SubtracksApp.kt index 529f53de..1841b0a1 100644 --- a/app/src/main/kotlin/com/subtracks/SubtracksApp.kt +++ b/app/src/main/kotlin/com/subtracks/SubtracksApp.kt @@ -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()) + ArtworkSeedCache.install(koin.get(), koin.get()) SingletonImageLoader.setSafe { context -> ImageLoader .Builder(context) diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index 2a53f596..1557b92c 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -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 by preferencesDataStore("user_prefs") +data class ArtworkSeedValue( + val cacheKey: String, + val primary: Int, + val secondary: Int?, +) + class UserPreferences( private val store: DataStore, ) { @@ -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}" diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 76bddca1..6a8a493a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -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() diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt b/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt index 7aa6b8de..e50a993b 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt @@ -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)) + } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt index 8d998660..5d0aef1a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt @@ -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? = cache.get(cacheKey)?.let { it.primary to it.secondary } + fun cached(cacheKey: String): Pair? = + 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 diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt index 4ca8c6bf..c85eb6d0 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt @@ -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 diff --git a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt index 7d5ab844..e2aa6bd5 100644 --- a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt @@ -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() } } diff --git a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt index a4e02c27..84893c98 100644 --- a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt @@ -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(null) + val seen = mutableListOf() + 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()}"