mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +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.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
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.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelStore
|
||||||
|
import androidx.lifecycle.ViewModelStoreOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
@@ -345,10 +349,9 @@ private fun MainNavigation() {
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.statusBarScrim()
|
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
alpha = ((nowPlayingProgress - SCRIM_FADE_START) / (1f - SCRIM_FADE_START)).coerceIn(0f, 1f)
|
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)),
|
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(OVERLAY_DURATION_MS)),
|
||||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(OVERLAY_DURATION_MS)),
|
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(OVERLAY_DURATION_MS)),
|
||||||
) {
|
) {
|
||||||
QueueRoute(
|
val queueStoreOwner =
|
||||||
onBack = { showingQueue = false },
|
remember {
|
||||||
modifier = Modifier.fillMaxSize(),
|
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.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.FilterQuality
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -98,6 +99,7 @@ fun CoverArt(
|
|||||||
model = model,
|
model = model,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
|
filterQuality = FilterQuality.High,
|
||||||
onSuccess = { success ->
|
onSuccess = { success ->
|
||||||
success.painter.intrinsicSize
|
success.painter.intrinsicSize
|
||||||
.ratioOrNull()
|
.ratioOrNull()
|
||||||
@@ -134,6 +136,7 @@ fun CoverArt(
|
|||||||
},
|
},
|
||||||
onError = { failed = true },
|
onError = { failed = true },
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
|
filterQuality = FilterQuality.High,
|
||||||
)
|
)
|
||||||
val ratio =
|
val ratio =
|
||||||
painter.intrinsicSize.ratioOrNull()
|
painter.intrinsicSize.ratioOrNull()
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import com.subtracks.data.model.CoverArtRef
|
|||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.ArtworkTheme
|
import com.subtracks.ui.theme.ArtworkTheme
|
||||||
import com.subtracks.ui.theme.HeroGradient
|
import com.subtracks.ui.theme.HeroGradient
|
||||||
|
import com.subtracks.ui.theme.baseArtworkColors
|
||||||
import com.subtracks.ui.theme.heroBarColor
|
import com.subtracks.ui.theme.heroBarColor
|
||||||
|
|
||||||
private const val FADE_DISTANCE_DP = 64
|
private const val FADE_DISTANCE_DP = 64
|
||||||
@@ -84,8 +85,9 @@ fun HeroDetailScaffold(
|
|||||||
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val effectiveArtwork = artwork ?: baseArtworkColors
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
ArtworkTheme(artwork) {
|
ArtworkTheme(effectiveArtwork) {
|
||||||
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
|
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
@@ -117,14 +119,14 @@ fun HeroDetailScaffold(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val barColor by
|
val barColor by
|
||||||
remember(artwork, barHeightPx, screenHeightPx) {
|
remember(effectiveArtwork, barHeightPx, screenHeightPx) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
heroBarColor(artwork, scrollPx, barHeightPx, screenHeightPx)
|
heroBarColor(effectiveArtwork, scrollPx, barHeightPx, screenHeightPx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HeroGradient(
|
HeroGradient(
|
||||||
colors = artwork,
|
colors = effectiveArtwork,
|
||||||
scrollPx = { scrollPx },
|
scrollPx = { scrollPx },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
@@ -152,8 +154,8 @@ fun HeroDetailScaffold(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.statusBarScrim()
|
.graphicsLayer { alpha = 1f - barFraction }
|
||||||
.graphicsLayer { alpha = 1f - barFraction },
|
.statusBarScrim(),
|
||||||
)
|
)
|
||||||
|
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.subtracks.ui.library
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
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.ArtworkTheme
|
||||||
import com.subtracks.ui.theme.HeroGradient
|
import com.subtracks.ui.theme.HeroGradient
|
||||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||||
|
import com.subtracks.ui.theme.baseArtworkColors
|
||||||
import com.subtracks.ui.theme.heroBarColor
|
import com.subtracks.ui.theme.heroBarColor
|
||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
||||||
@@ -71,6 +74,7 @@ private val ART_HEIGHT = 420.dp
|
|||||||
private val TITLE_INSET = 16.dp
|
private val TITLE_INSET = 16.dp
|
||||||
private val FADE_LEAD = 24.dp
|
private val FADE_LEAD = 24.dp
|
||||||
private const val THEME_TRANSITION_MS = 100
|
private const val THEME_TRANSITION_MS = 100
|
||||||
|
private const val SCRIM_FADE_MS = 180
|
||||||
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -144,16 +148,18 @@ fun ArtistDetailScreen(
|
|||||||
blurRadius = 10f,
|
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) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
ArtworkTheme(artwork) {
|
ArtworkTheme(effectiveArtwork) {
|
||||||
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
|
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
|
||||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||||
val barHeightPx = with(density) { barHeight.toPx() }
|
val barHeightPx = with(density) { barHeight.toPx() }
|
||||||
val barColor = heroBarColor(artwork, 0f, barHeightPx, screenHeightPx)
|
val barColor = heroBarColor(effectiveArtwork, 0f, barHeightPx, screenHeightPx)
|
||||||
HeroGradient(
|
HeroGradient(
|
||||||
colors = artwork,
|
colors = effectiveArtwork,
|
||||||
scrollPx = { 0f },
|
scrollPx = { 0f },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
@@ -174,13 +180,14 @@ fun ArtistDetailScreen(
|
|||||||
showPlaceholder = art == null,
|
showPlaceholder = art == null,
|
||||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||||
)
|
)
|
||||||
if (nameBusy) {
|
if (nameScrimAlpha > 0f) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(ART_HEIGHT * 0.4f)
|
.height(ART_HEIGHT * 0.4f)
|
||||||
|
.graphicsLayer { alpha = nameScrimAlpha }
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
listOf(Color.Transparent, Color.Black.copy(alpha = 0.6f)),
|
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 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() {
|
fun open() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val snapshot = queueRepository.snapshot()
|
val snapshot = queueRepository.snapshot()
|
||||||
size = snapshot.size
|
size = snapshot.size
|
||||||
|
val reused = reusedIds()
|
||||||
rows.clear()
|
rows.clear()
|
||||||
first = 0
|
first = 0
|
||||||
last = -1
|
last = -1
|
||||||
@@ -113,7 +126,7 @@ class QueueViewModel(
|
|||||||
}?.coerceIn(0, size - 1) ?: 0L
|
}?.coerceIn(0, size - 1) ?: 0L
|
||||||
val start = (cursor - OLDER_LOAD).coerceAtLeast(0)
|
val start = (cursor - OLDER_LOAD).coerceAtLeast(0)
|
||||||
val end = (start + QUEUE_CHUNK - 1).coerceAtMost(size - 1)
|
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
|
first = rows.firstOrNull()?.position ?: 0
|
||||||
last = rows.lastOrNull()?.position ?: -1
|
last = rows.lastOrNull()?.position ?: -1
|
||||||
initialIndex = rows.indexOfFirst { it.position == cursor }.coerceAtLeast(0)
|
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 start = (rows.firstOrNull()?.position ?: 0L).coerceIn(0, size - 1)
|
||||||
val end = (start + rows.size.coerceAtLeast(QUEUE_CHUNK) - 1).coerceAtMost(size - 1)
|
val end = (start + rows.size.coerceAtLeast(QUEUE_CHUNK) - 1).coerceAtMost(size - 1)
|
||||||
val loaded = queueRepository.range(snapshot, start, end)
|
val loaded = queueRepository.range(snapshot, start, end)
|
||||||
val ids = rows.groupBy { it.song.song.id }.mapValues { entry -> entry.value.map { it.id }.toMutableList() }
|
val reused = reusedIds()
|
||||||
val updated =
|
val updated = loaded.map { rowFor(it, reused) }
|
||||||
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)
|
|
||||||
}
|
|
||||||
rows.clear()
|
rows.clear()
|
||||||
rows.addAll(updated)
|
rows.addAll(updated)
|
||||||
first = rows.firstOrNull()?.position ?: 0
|
first = rows.firstOrNull()?.position ?: 0
|
||||||
@@ -254,12 +262,13 @@ class QueueViewModel(
|
|||||||
fun QueueRoute(
|
fun QueueRoute(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
open: Boolean = true,
|
||||||
viewModel: QueueViewModel = koinViewModel(),
|
viewModel: QueueViewModel = koinViewModel(),
|
||||||
controller: PlaybackController = koinInject(),
|
controller: PlaybackController = koinInject(),
|
||||||
sourceRepository: SourceRepository = koinInject(),
|
sourceRepository: SourceRepository = koinInject(),
|
||||||
) {
|
) {
|
||||||
val playback by controller.state.collectAsStateWithLifecycle()
|
val playback by controller.state.collectAsStateWithLifecycle()
|
||||||
LaunchedEffect(playback.shuffle) { viewModel.open() }
|
LaunchedEffect(playback.shuffle, open) { if (open) viewModel.open() }
|
||||||
QueueScreen(
|
QueueScreen(
|
||||||
rows = viewModel.rows,
|
rows = viewModel.rows,
|
||||||
ready = viewModel.ready,
|
ready = viewModel.ready,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const val ARTWORK_THEME_TRANSITION_MS = 600
|
|||||||
|
|
||||||
private const val TRANSITION_STEPS = 24
|
private const val TRANSITION_STEPS = 24
|
||||||
|
|
||||||
private val baseArtworkColors =
|
internal val baseArtworkColors =
|
||||||
ArtworkColors(
|
ArtworkColors(
|
||||||
scheme = SubtracksColorScheme,
|
scheme = SubtracksColorScheme,
|
||||||
gradientHigh = SubtracksColorScheme.surfaceContainerHigh,
|
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))
|
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
|
@Test
|
||||||
fun reorderingMovesTheRowLocally() {
|
fun reorderingMovesTheRowLocally() {
|
||||||
runBlocking { seedSongs(200) }
|
runBlocking { seedSongs(200) }
|
||||||
|
|||||||
Reference in New Issue
Block a user