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()}"