mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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()}"
|
||||
|
||||
Reference in New Issue
Block a user