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