fix queue overlay, focus and artwork loading glitches

- scope QueueViewModel to the queue overlay so its rows are released on close
  and a reopened queue cannot show two overlapping lists; key open() on the
  overlay visibility so a fast reopen still rescans and refocuses
- reuse row ids by song identity when a window (re)loads so Compose does not
  cross-fade the old rows out while the new ones fade in
- fade the artist name scrim in and key its busy check on the thumbnail, the
  same ref the theme already uses, so a cached seed shows it immediately
- start detail gradients from the neutral base artwork until the seed resolves
  instead of inheriting the now playing theme and snapping to the artist colours
- fade the now playing and detail status bar scrims by putting the graphicsLayer
  before statusBarScrim(), which otherwise drew the background outside the layer
- decode full-size and hero covers with FilterQuality.High to stop the moire
  during overscroll stretch
This commit is contained in:
austinried
2026-10-04 00:29:59 +00:00
parent 5fb38a7c9b
commit da71bccb58
7 changed files with 78 additions and 27 deletions
@@ -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(),
)
}
}
}
}
@@ -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()
@@ -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(
@@ -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)),
@@ -98,11 +98,24 @@ class QueueViewModel(
private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item)
private fun reusedIds(): Map<String, MutableList<Long>> =
rows.groupBy { it.song.song.id }.mapValues { entry -> entry.value.map { it.id }.toMutableList() }
private fun rowFor(
item: QueueWindowItem,
reused: Map<String, MutableList<Long>>,
): 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,
@@ -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,
@@ -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) }