diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index c6e70cc7..5213e95a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.imeAnimationTarget import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -46,8 +47,11 @@ import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -345,10 +349,9 @@ private fun MainNavigation() { modifier = Modifier .align(Alignment.TopCenter) - .statusBarScrim() .graphicsLayer { alpha = ((nowPlayingProgress - SCRIM_FADE_START) / (1f - SCRIM_FADE_START)).coerceIn(0f, 1f) - }, + }.statusBarScrim(), ) } @@ -357,10 +360,20 @@ private fun MainNavigation() { enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(OVERLAY_DURATION_MS)), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(OVERLAY_DURATION_MS)), ) { - QueueRoute( - onBack = { showingQueue = false }, - modifier = Modifier.fillMaxSize(), - ) + val queueStoreOwner = + remember { + object : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + } + } + DisposableEffect(queueStoreOwner) { onDispose { queueStoreOwner.viewModelStore.clear() } } + CompositionLocalProvider(LocalViewModelStoreOwner provides queueStoreOwner) { + QueueRoute( + open = showingQueue, + onBack = { showingQueue = false }, + modifier = Modifier.fillMaxSize(), + ) + } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt index 2eaa92b7..7147d781 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign @@ -98,6 +99,7 @@ fun CoverArt( model = model, contentDescription = null, contentScale = ContentScale.Crop, + filterQuality = FilterQuality.High, onSuccess = { success -> success.painter.intrinsicSize .ratioOrNull() @@ -134,6 +136,7 @@ fun CoverArt( }, onError = { failed = true }, contentScale = ContentScale.Fit, + filterQuality = FilterQuality.High, ) val ratio = painter.intrinsicSize.ratioOrNull() diff --git a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt index 66c2eb6e..01483db9 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt @@ -63,6 +63,7 @@ import com.subtracks.data.model.CoverArtRef import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.HeroGradient +import com.subtracks.ui.theme.baseArtworkColors import com.subtracks.ui.theme.heroBarColor private const val FADE_DISTANCE_DP = 64 @@ -84,8 +85,9 @@ fun HeroDetailScaffold( content: LazyListScope.(rowModifier: Modifier) -> Unit, modifier: Modifier = Modifier, ) { + val effectiveArtwork = artwork ?: baseArtworkColors CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { - ArtworkTheme(artwork) { + ArtworkTheme(effectiveArtwork) { BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) { val listState = rememberLazyListState() val fill = rememberViewportFill(listState) @@ -117,14 +119,14 @@ fun HeroDetailScaffold( } } val barColor by - remember(artwork, barHeightPx, screenHeightPx) { + remember(effectiveArtwork, barHeightPx, screenHeightPx) { derivedStateOf { - heroBarColor(artwork, scrollPx, barHeightPx, screenHeightPx) + heroBarColor(effectiveArtwork, scrollPx, barHeightPx, screenHeightPx) } } HeroGradient( - colors = artwork, + colors = effectiveArtwork, scrollPx = { scrollPx }, modifier = Modifier.fillMaxSize(), ) @@ -152,8 +154,8 @@ fun HeroDetailScaffold( Box( Modifier .align(Alignment.TopStart) - .statusBarScrim() - .graphicsLayer { alpha = 1f - barFraction }, + .graphicsLayer { alpha = 1f - barFraction } + .statusBarScrim(), ) TopAppBar( diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index 41d42124..b1124c8b 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -1,5 +1,7 @@ package com.subtracks.ui.library +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -61,6 +63,7 @@ import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.PrefetchArtworkSeeds +import com.subtracks.ui.theme.baseArtworkColors import com.subtracks.ui.theme.heroBarColor import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberOverlaidNameBusy @@ -71,6 +74,7 @@ private val ART_HEIGHT = 420.dp private val TITLE_INSET = 16.dp private val FADE_LEAD = 24.dp private const val THEME_TRANSITION_MS = 100 +private const val SCRIM_FADE_MS = 180 internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim" @Composable @@ -144,16 +148,18 @@ fun ArtistDetailScreen( blurRadius = 10f, ), ) - val nameBusy = rememberOverlaidNameBusy(art ?: artThumbnail) + val nameBusy = rememberOverlaidNameBusy(artThumbnail ?: art) + val nameScrimAlpha by animateFloatAsState(if (nameBusy) 1f else 0f, tween(SCRIM_FADE_MS), label = "artistNameScrim") + val effectiveArtwork = artwork ?: baseArtworkColors CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { - ArtworkTheme(artwork) { + ArtworkTheme(effectiveArtwork) { BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) { val screenHeightPx = with(density) { maxHeight.toPx() } val barHeightPx = with(density) { barHeight.toPx() } - val barColor = heroBarColor(artwork, 0f, barHeightPx, screenHeightPx) + val barColor = heroBarColor(effectiveArtwork, 0f, barHeightPx, screenHeightPx) HeroGradient( - colors = artwork, + colors = effectiveArtwork, scrollPx = { 0f }, modifier = Modifier.fillMaxSize(), ) @@ -174,13 +180,14 @@ fun ArtistDetailScreen( showPlaceholder = art == null, modifier = Modifier.fillMaxWidth().height(ART_HEIGHT), ) - if (nameBusy) { + if (nameScrimAlpha > 0f) { Box( modifier = Modifier .align(Alignment.BottomStart) .fillMaxWidth() .height(ART_HEIGHT * 0.4f) + .graphicsLayer { alpha = nameScrimAlpha } .background( Brush.verticalGradient( listOf(Color.Transparent, Color.Black.copy(alpha = 0.6f)), diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt index 2b31efca..1b25ce89 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt @@ -98,11 +98,24 @@ class QueueViewModel( private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item) + private fun reusedIds(): Map> = + rows.groupBy { it.song.song.id }.mapValues { entry -> entry.value.map { it.id }.toMutableList() } + + private fun rowFor( + item: QueueWindowItem, + reused: Map>, + ): QueueRow { + val ids = reused[item.item.song.id] + val id = if (!ids.isNullOrEmpty()) ids.removeAt(0) else nextId++ + return QueueRow(id, item.position, item.item) + } + fun open() { viewModelScope.launch { mutex.withLock { val snapshot = queueRepository.snapshot() size = snapshot.size + val reused = reusedIds() rows.clear() first = 0 last = -1 @@ -113,7 +126,7 @@ class QueueViewModel( }?.coerceIn(0, size - 1) ?: 0L val start = (cursor - OLDER_LOAD).coerceAtLeast(0) val end = (start + QUEUE_CHUNK - 1).coerceAtMost(size - 1) - rows.addAll(queueRepository.range(snapshot, start, end).map(::newRow)) + rows.addAll(queueRepository.range(snapshot, start, end).map { rowFor(it, reused) }) first = rows.firstOrNull()?.position ?: 0 last = rows.lastOrNull()?.position ?: -1 initialIndex = rows.indexOfFirst { it.position == cursor }.coerceAtLeast(0) @@ -236,13 +249,8 @@ class QueueViewModel( val start = (rows.firstOrNull()?.position ?: 0L).coerceIn(0, size - 1) val end = (start + rows.size.coerceAtLeast(QUEUE_CHUNK) - 1).coerceAtMost(size - 1) val loaded = queueRepository.range(snapshot, start, end) - val ids = rows.groupBy { it.song.song.id }.mapValues { entry -> entry.value.map { it.id }.toMutableList() } - val updated = - loaded.map { item -> - val reused = ids[item.item.song.id] - val id = if (!reused.isNullOrEmpty()) reused.removeAt(0) else nextId++ - QueueRow(id, item.position, item.item) - } + val reused = reusedIds() + val updated = loaded.map { rowFor(it, reused) } rows.clear() rows.addAll(updated) first = rows.firstOrNull()?.position ?: 0 @@ -254,12 +262,13 @@ class QueueViewModel( fun QueueRoute( onBack: () -> Unit, modifier: Modifier = Modifier, + open: Boolean = true, viewModel: QueueViewModel = koinViewModel(), controller: PlaybackController = koinInject(), sourceRepository: SourceRepository = koinInject(), ) { val playback by controller.state.collectAsStateWithLifecycle() - LaunchedEffect(playback.shuffle) { viewModel.open() } + LaunchedEffect(playback.shuffle, open) { if (open) viewModel.open() } QueueScreen( rows = viewModel.rows, ready = viewModel.ready, 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 f5ea4012..7aa6b8de 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt @@ -16,7 +16,7 @@ const val ARTWORK_THEME_TRANSITION_MS = 600 private const val TRANSITION_STEPS = 24 -private val baseArtworkColors = +internal val baseArtworkColors = ArtworkColors( scheme = SubtracksColorScheme, gradientHigh = SubtracksColorScheme.surfaceContainerHigh, diff --git a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt index 48ad0783..66c07046 100644 --- a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt @@ -108,6 +108,23 @@ class QueueViewModelTest { assertEquals(1L, dropTarget(listOf(row(0), row(3), row(1), row(2)), index = 1, from = 3)) } + @Test + fun reopeningReusesRowIdentitySoComposeDoesNotCrossFadeTwoLists() { + runBlocking { seedSongs(200) } + controller.playSongs(1, 100) + await { controller.state.value.position == 100L } + + viewModel.open() + await { viewModel.ready } + val firstIds = viewModel.rows.map { it.id } + val firstGeneration = viewModel.generation + + viewModel.open() + await { viewModel.generation > firstGeneration } + + assertEquals(firstIds, viewModel.rows.map { it.id }) + } + @Test fun reorderingMovesTheRowLocally() { runBlocking { seedSongs(200) }