feat: adjust library and cw cards removal animations

This commit is contained in:
tapframe 2026-07-03 01:40:48 +05:30
parent 5ff165c754
commit e6ef2fd3ae
8 changed files with 665 additions and 50 deletions

View file

@ -0,0 +1,90 @@
package com.nuvio.app.core.ui
import android.graphics.Canvas as AndroidCanvas
import android.graphics.Paint as AndroidPaint
import android.os.Build
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
internal actual class AshSwarmRenderer actual constructor(maxGrains: Int) {
private val batched = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
private val positions = if (batched) FloatArray(maxGrains * 12) else EMPTY_FLOATS
private val vertexColors = if (batched) IntArray(maxGrains * 6) else EMPTY_INTS
private val paint = AndroidPaint()
actual fun draw(
scope: DrawScope,
count: Int,
centersX: FloatArray,
centersY: FloatArray,
halfWidths: FloatArray,
halfHeights: FloatArray,
colors: IntArray,
) {
if (count == 0) return
if (!batched) {
drawUnbatched(scope, count, centersX, centersY, halfWidths, halfHeights, colors)
return
}
var pi = 0
var ci = 0
for (i in 0 until count) {
val x0 = centersX[i] - halfWidths[i]
val x1 = centersX[i] + halfWidths[i]
val y0 = centersY[i] - halfHeights[i]
val y1 = centersY[i] + halfHeights[i]
positions[pi++] = x0; positions[pi++] = y0
positions[pi++] = x1; positions[pi++] = y0
positions[pi++] = x0; positions[pi++] = y1
positions[pi++] = x1; positions[pi++] = y0
positions[pi++] = x1; positions[pi++] = y1
positions[pi++] = x0; positions[pi++] = y1
val c = colors[i]
vertexColors[ci++] = c; vertexColors[ci++] = c; vertexColors[ci++] = c
vertexColors[ci++] = c; vertexColors[ci++] = c; vertexColors[ci++] = c
}
scope.drawContext.canvas.nativeCanvas.drawVertices(
AndroidCanvas.VertexMode.TRIANGLES,
count * 12,
positions,
0,
null,
0,
vertexColors,
0,
null,
0,
0,
paint,
)
}
private fun drawUnbatched(
scope: DrawScope,
count: Int,
centersX: FloatArray,
centersY: FloatArray,
halfWidths: FloatArray,
halfHeights: FloatArray,
colors: IntArray,
) {
for (i in 0 until count) {
val hw = halfWidths[i]
val hh = halfHeights[i]
scope.drawRect(
color = Color(colors[i]),
topLeft = Offset(centersX[i] - hw, centersY[i] - hh),
size = Size(hw * 2f, hh * 2f),
)
}
}
private companion object {
val EMPTY_FLOATS = FloatArray(0)
val EMPTY_INTS = IntArray(0)
}
}

View file

@ -0,0 +1,15 @@
package com.nuvio.app.core.ui
import androidx.compose.ui.graphics.drawscope.DrawScope
internal expect class AshSwarmRenderer(maxGrains: Int) {
fun draw(
scope: DrawScope,
count: Int,
centersX: FloatArray,
centersY: FloatArray,
halfWidths: FloatArray,
halfHeights: FloatArray,
colors: IntArray,
)
}

View file

@ -0,0 +1,259 @@
package com.nuvio.app.core.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.cos
import kotlin.math.sin
import kotlin.random.Random
@Composable
fun DisintegratingContainer(
disintegrating: Boolean,
onDisintegrated: () -> Unit,
modifier: Modifier = Modifier,
durationMillis: Int = 1500,
content: @Composable () -> Unit,
) {
val graphicsLayer = rememberGraphicsLayer()
val progress = remember { Animatable(0f) }
var field by remember { mutableStateOf<AshField?>(null) }
val seed = remember { Random.nextLong() }
val onDisintegratedState = rememberUpdatedState(onDisintegrated)
LaunchedEffect(disintegrating) {
if (!disintegrating) return@LaunchedEffect
val bitmap = runCatching { graphicsLayer.toImageBitmap() }.getOrNull()
if (bitmap == null) {
onDisintegratedState.value()
return@LaunchedEffect
}
field = withContext(Dispatchers.Default) { buildAshField(bitmap, seed) }
progress.snapTo(0f)
progress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing),
)
onDisintegratedState.value()
}
Box(
modifier = modifier.drawWithContent {
val activeField = field
if (activeField == null) {
graphicsLayer.record { this@drawWithContent.drawContent() }
drawLayer(graphicsLayer)
} else {
drawAsh(activeField, progress.value)
}
},
) {
content()
}
}
private class AshField(
val bitmap: ImageBitmap,
val cols: Int,
val rows: Int,
val wavePhase: Float,
val argb: IntArray,
val startDelay: FloatArray,
val dirX: FloatArray,
val speed: FloatArray,
val swirlPhase: FloatArray,
val swirlFreq: FloatArray,
val swirlAmp: FloatArray,
) {
val count = argb.size
val centersX = FloatArray(count)
val centersY = FloatArray(count)
val halfWidths = FloatArray(count)
val halfHeights = FloatArray(count)
val colors = IntArray(count)
val renderer = AshSwarmRenderer(count)
val clipOutline = Path()
}
private const val ASH_COLS = 100
private const val ASH_MAX_TILES = 15000
private const val TILE_LIFESPAN = 0.5f
private const val TAU = 6.2831855f
private fun frontWave(ny: Float, phase: Float): Float =
sin(ny * TAU * 1.15f + phase) * 0.045f + sin(ny * TAU * 2.4f + phase * 1.7f) * 0.02f
private fun frontValue(nx: Float, ny: Float, phase: Float): Float =
0.52f * (1f - ny) + 0.48f * nx + frontWave(ny, phase)
private fun buildAshField(bitmap: ImageBitmap, seed: Long): AshField {
val aspect = bitmap.height.toFloat() / bitmap.width.toFloat()
var cols = ASH_COLS
var rows = (cols * aspect).toInt().coerceAtLeast(8)
if (cols * rows > ASH_MAX_TILES) {
cols = (kotlin.math.sqrt(ASH_MAX_TILES / aspect)).toInt().coerceAtLeast(12)
rows = (cols * aspect).toInt().coerceAtLeast(8)
}
val pixels = bitmap.toPixelMap()
val rnd = Random(seed)
val wavePhase = rnd.nextFloat() * TAU
val count = cols * rows
val argb = IntArray(count)
val startDelay = FloatArray(count)
val dirX = FloatArray(count)
val speed = FloatArray(count)
val swirlPhase = FloatArray(count)
val swirlFreq = FloatArray(count)
val swirlAmp = FloatArray(count)
for (index in 0 until count) {
val nx = (index % cols + 0.5f) / cols
val ny = (index / cols + 0.5f) / rows
val px = (nx * (bitmap.width - 1)).toInt().coerceIn(0, bitmap.width - 1)
val py = (ny * (bitmap.height - 1)).toInt().coerceIn(0, bitmap.height - 1)
val sampled = pixels[px, py]
val shade = 0.80f + rnd.nextFloat() * 0.34f
argb[index] = Color(
red = (sampled.red * shade).coerceIn(0f, 1f),
green = (sampled.green * shade).coerceIn(0f, 1f),
blue = (sampled.blue * shade).coerceIn(0f, 1f),
alpha = sampled.alpha,
).toArgb()
val front = frontValue(nx, ny, wavePhase)
startDelay[index] = ((1f - front) * (1f - TILE_LIFESPAN) - rnd.nextFloat() * 0.05f)
.coerceIn(0f, 1f - TILE_LIFESPAN)
dirX[index] = 0.55f + (rnd.nextFloat() - 0.4f) * 1.3f
speed[index] = 0.7f + rnd.nextFloat() * 0.7f
swirlPhase[index] = rnd.nextFloat() * TAU
swirlFreq[index] = 1.1f + rnd.nextFloat() * 1.8f
swirlAmp[index] = 0.6f + rnd.nextFloat() * 0.9f
}
return AshField(bitmap, cols, rows, wavePhase, argb, startDelay, dirX, speed, swirlPhase, swirlFreq, swirlAmp)
}
private fun DrawScope.drawAsh(field: AshField, p: Float) {
val w = size.width
val h = size.height
if (w <= 0f || h <= 0f) return
val threshold = 1f - p / (1f - TILE_LIFESPAN)
if (threshold > -0.1f) {
val intact = field.clipOutline
intact.rewind()
val steps = 28
for (i in 0..steps) {
val ny = i / steps.toFloat()
val boundary = (threshold - 0.52f * (1f - ny) - frontWave(ny, field.wavePhase)) / 0.48f
val x = (boundary * w).coerceIn(0f, w)
val y = ny * h
if (i == 0) intact.moveTo(x, y) else intact.lineTo(x, y)
}
intact.lineTo(0f, h)
intact.lineTo(0f, 0f)
intact.close()
clipPath(intact) {
drawImage(
image = field.bitmap,
dstOffset = IntOffset.Zero,
dstSize = IntSize(w.toInt().coerceAtLeast(1), h.toInt().coerceAtLeast(1)),
)
}
}
val cols = field.cols
val cellW = w / cols
val cellH = h / field.rows
val tileW = cellW + 0.6f
val tileH = cellH + 0.6f
val riseMax = h * 1.2f
val driftMax = w * 0.6f
val swirlMaxX = w * 0.14f
val swirlMaxY = h * 0.05f
val globalPhase = p * TAU
val invCols = 1f / cols
val invRows = 1f / field.rows
var grainCount = 0
for (index in 0 until field.count) {
val local = (p - field.startDelay[index]) / TILE_LIFESPAN
if (local <= 0f) continue
if (local >= 1f) continue
val alpha = 1f - smoothstep(0.45f, 1f, local)
if (alpha < 0.02f) continue
val nx = (index % cols + 0.5f) * invCols
val ny = (index / cols + 0.5f) * invRows
val ease = local * local
val easeSoft = local * local * (3f - 2f * local)
val rise = riseMax * field.speed[index] * (0.15f * easeSoft + 0.85f * ease * (1f + 0.4f * local))
val t1 = field.swirlPhase[index] + local * TAU * field.swirlFreq[index]
val flowPhase = (nx * 2.3f + ny * 3.7f) * TAU
val t2 = flowPhase + globalPhase
val swirlAmp = field.swirlAmp[index]
val meanderX = (
sin(t2) * 0.65f +
sin(t1) * swirlAmp +
cos(t2 * 0.5f + local * TAU * 1.3f) * 0.4f
) * swirlMaxX * easeSoft
val meanderY = (
cos(t2 * 0.8f) * 0.5f +
sin(t1 * 0.7f + 1.3f) * swirlAmp * 0.6f
) * swirlMaxY * easeSoft
val scale = 1f - 0.4f * easeSoft
val i = grainCount++
field.centersX[i] = nx * w + field.dirX[index] * ease * driftMax + meanderX
field.centersY[i] = ny * h - rise + meanderY
field.halfWidths[i] = tileW * scale * 0.5f
field.halfHeights[i] = tileH * scale * 0.5f
val base = field.argb[index]
val alphaByte = ((base ushr 24) * alpha).toInt().coerceIn(0, 255)
field.colors[i] = (alphaByte shl 24) or (base and 0x00FFFFFF)
}
field.renderer.draw(
scope = this,
count = grainCount,
centersX = field.centersX,
centersY = field.centersY,
halfWidths = field.halfWidths,
halfHeights = field.halfHeights,
colors = field.colors,
)
}
private fun smoothstep(edge0: Float, edge1: Float, x: Float): Float {
val t = ((x - edge0) / (edge1 - edge0)).coerceIn(0f, 1f)
return t * t * (3f - 2f * t)
}

View file

@ -64,6 +64,7 @@ fun <T> NuvioShelfSection(
onViewAllClick: (() -> Unit)? = null,
viewAllPillSize: NuvioViewAllPillSize = NuvioViewAllPillSize.Default,
key: ((T) -> Any)? = null,
animatePlacement: Boolean = false,
itemContent: @Composable (T) -> Unit,
) {
val tokens = MaterialTheme.nuvio
@ -89,11 +90,19 @@ fun <T> NuvioShelfSection(
items = entries.withDuplicateSafeLazyKeys(key),
key = { entry -> entry.lazyKey },
) { keyedEntry ->
itemContent(keyedEntry.value)
if (animatePlacement) {
Box(modifier = Modifier.animateItem()) { itemContent(keyedEntry.value) }
} else {
itemContent(keyedEntry.value)
}
}
} else {
items(entries) { entry ->
itemContent(entry)
if (animatePlacement) {
Box(modifier = Modifier.animateItem()) { itemContent(entry) }
} else {
itemContent(entry)
}
}
}
}

View file

@ -26,7 +26,9 @@ import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
@ -45,6 +47,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.DisintegratingContainer
import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
@ -240,44 +243,103 @@ private fun HomeContinueWatchingSectionContent(
HomeCatalogSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val disintegration = remember { ContinueWatchingDisintegrationHolder() }
val displayEntries = disintegration.sync(items)
NuvioShelfSection(
title = stringResource(Res.string.compose_settings_page_continue_watching),
entries = items,
entries = displayEntries,
modifier = modifier,
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
itemSpacing = layout.itemGap,
showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
key = { item -> item.videoId },
) { item ->
when (style) {
ContinueWatchingSectionStyle.Card -> ContinueWatchingCard(
item = item,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } },
)
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
item = item,
layout = layout,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } },
)
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
item = item,
layout = layout,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } },
)
key = { entry -> entry.videoId },
animatePlacement = true,
) { entry ->
val item = entry.item
val onClick = if (entry.exiting) null else onItemClick?.let { { it(item) } }
val onLongClick = if (entry.exiting) null else onItemLongPress?.let { { it(item) } }
DisintegratingContainer(
disintegrating = entry.exiting,
onDisintegrated = { disintegration.onExited(entry.videoId) },
) {
when (style) {
ContinueWatchingSectionStyle.Card -> ContinueWatchingCard(
item = item,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onClick,
onLongClick = onLongClick,
)
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
item = item,
layout = layout,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onClick,
onLongClick = onLongClick,
)
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
item = item,
layout = layout,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onClick,
onLongClick = onLongClick,
)
}
}
}
}
private data class ContinueWatchingDisplayEntry(
val videoId: String,
val item: ContinueWatchingItem,
val exiting: Boolean,
)
private class ContinueWatchingDisintegrationHolder {
private val exiting = LinkedHashMap<String, Pair<ContinueWatchingItem, Int>>()
private var previous = LinkedHashMap<String, Pair<ContinueWatchingItem, Int>>()
private var invalidations by mutableStateOf(0)
fun onExited(videoId: String) {
if (exiting.remove(videoId) != null) invalidations++
}
fun sync(items: List<ContinueWatchingItem>): List<ContinueWatchingDisplayEntry> {
@Suppress("UNUSED_EXPRESSION")
invalidations
val current = LinkedHashMap<String, Pair<ContinueWatchingItem, Int>>()
items.forEachIndexed { index, item -> current[item.videoId] = item to index }
for ((videoId, info) in previous) {
if (videoId !in current && videoId !in exiting) {
exiting[videoId] = info
}
}
for (videoId in current.keys) {
exiting.remove(videoId)
}
previous = current
val entries = ArrayList<ContinueWatchingDisplayEntry>(items.size + exiting.size)
items.forEach { item ->
entries += ContinueWatchingDisplayEntry(item.videoId, item, exiting = false)
}
exiting.entries
.sortedBy { it.value.second }
.forEach { (videoId, info) ->
val insertAt = info.second.coerceIn(0, entries.size)
entries.add(insertAt, ContinueWatchingDisplayEntry(videoId, info.first, exiting = true))
}
return entries
}
}
@Composable
fun ContinueWatchingStylePreview(
style: ContinueWatchingSectionStyle,

View file

@ -58,6 +58,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.i18n.localizedByteUnit
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.DisintegratingContainer
import com.nuvio.app.core.ui.NuvioDropdownChip
import com.nuvio.app.core.ui.NuvioDropdownOption
import com.nuvio.app.core.ui.NuvioScreen
@ -171,6 +172,16 @@ fun LibraryScreen(
}
}
val disintegration = remember { LibraryDisintegrationHolder() }
val librarySectionsDisplay = if (
sourceMode != LibraryViewMode.Cloud && uiState.isLoaded && uiState.sections.isNotEmpty()
) {
disintegration.sync(uiState.sections, LIBRARY_SECTION_PREVIEW_LIMIT)
} else {
disintegration.reset()
emptyList()
}
NuvioScreen(
modifier = modifier,
horizontalPadding = 0.dp,
@ -299,12 +310,13 @@ fun LibraryScreen(
else -> {
librarySections(
sections = uiState.sections,
displaySections = librarySectionsDisplay,
watchedKeys = watchedUiState.watchedKeys,
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
onPosterClick = onPosterClick,
onSectionViewAllClick = onSectionViewAllClick,
onPosterLongClick = onPosterLongClick,
onDisintegrated = disintegration::onExited,
)
}
}
@ -986,44 +998,153 @@ private enum class LibraryViewMode {
}
private fun LazyListScope.librarySections(
sections: List<LibrarySection>,
displaySections: List<LibraryDisplaySection>,
watchedKeys: Set<String>,
showHeaderAccent: Boolean,
onPosterClick: ((LibraryItem) -> Unit)?,
onSectionViewAllClick: ((LibrarySection) -> Unit)?,
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?,
onDisintegrated: (String) -> Unit,
) {
items(
items = sections,
items = displaySections,
key = { section -> section.type },
) { section ->
val previewItems = section.items.take(LIBRARY_SECTION_PREVIEW_LIMIT)
NuvioShelfSection(
title = section.displayTitle,
entries = previewItems,
entries = section.previewEntries,
headerHorizontalPadding = 16.dp,
rowContentPadding = PaddingValues(horizontal = 16.dp),
showHeaderAccent = showHeaderAccent,
onViewAllClick = if (section.items.size > LIBRARY_SECTION_PREVIEW_LIMIT) {
onSectionViewAllClick?.let { { it(section) } }
} else {
null
},
onViewAllClick = section.source
?.takeIf { it.items.size > LIBRARY_SECTION_PREVIEW_LIMIT }
?.let { source -> onSectionViewAllClick?.let { { it(source) } } },
viewAllPillSize = NuvioViewAllPillSize.Compact,
key = { item -> "${item.type}:${item.id}" },
) { item ->
key = { entry -> entry.globalKey },
animatePlacement = true,
) { entry ->
val item = entry.item
val posterItem = item.toMetaPreview()
HomePosterCard(
item = posterItem,
isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys,
val entrySource = entry.section
DisintegratingContainer(
disintegrating = entry.exiting,
onDisintegrated = { onDisintegrated(entry.globalKey) },
) {
HomePosterCard(
item = posterItem,
),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = onPosterLongClick?.let { { it(item, section) } },
)
isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys,
item = posterItem,
),
onClick = if (entry.exiting) null else onPosterClick?.let { { it(item) } },
onLongClick = if (entry.exiting || entrySource == null) {
null
} else {
onPosterLongClick?.let { { it(item, entrySource) } }
},
)
}
}
}
}
private const val LIBRARY_SECTION_PREVIEW_LIMIT = 18
private data class LibraryDisplayEntry(
val globalKey: String,
val item: LibraryItem,
val section: LibrarySection?,
val exiting: Boolean,
)
private data class LibraryDisplaySection(
val source: LibrarySection?,
val type: String,
val displayTitle: String,
val previewEntries: List<LibraryDisplayEntry>,
)
private class LibraryExitingEntry(
val item: LibraryItem,
val sectionType: String,
val sectionTitle: String,
val index: Int,
)
private fun libraryGlobalKey(sectionType: String, item: LibraryItem): String =
"$sectionType|${item.type}|${item.id}"
private class LibraryDisintegrationHolder {
private val exiting = LinkedHashMap<String, LibraryExitingEntry>()
private var previous = LinkedHashMap<String, LibraryExitingEntry>()
private var invalidations by mutableStateOf(0)
fun onExited(globalKey: String) {
if (exiting.remove(globalKey) != null) invalidations++
}
fun reset() {
exiting.clear()
previous = LinkedHashMap()
}
fun sync(sections: List<LibrarySection>, previewLimit: Int): List<LibraryDisplaySection> {
@Suppress("UNUSED_EXPRESSION")
invalidations
val current = LinkedHashMap<String, LibraryExitingEntry>()
sections.forEach { section ->
section.items.take(previewLimit).forEachIndexed { index, item ->
val key = libraryGlobalKey(section.type, item)
current[key] = LibraryExitingEntry(item, section.type, section.displayTitle, index)
}
}
for ((key, info) in previous) {
if (key !in current && key !in exiting) {
exiting[key] = info
}
}
for (key in current.keys) {
exiting.remove(key)
}
previous = current
val exitingBySection = exiting.values.groupBy { it.sectionType }
val seenTypes = HashSet<String>(sections.size)
val result = ArrayList<LibraryDisplaySection>(sections.size + 1)
for (section in sections) {
seenTypes += section.type
val entries = ArrayList<LibraryDisplayEntry>(previewLimit + 1)
section.items.take(previewLimit).forEach { item ->
entries += LibraryDisplayEntry(
globalKey = libraryGlobalKey(section.type, item),
item = item,
section = section,
exiting = false,
)
}
exitingBySection[section.type]?.sortedBy { it.index }?.forEach { ex ->
val key = libraryGlobalKey(section.type, ex.item)
if (entries.none { it.globalKey == key }) {
entries.add(
ex.index.coerceIn(0, entries.size),
LibraryDisplayEntry(key, ex.item, section, exiting = true),
)
}
}
result += LibraryDisplaySection(section, section.type, section.displayTitle, entries)
}
for ((type, list) in exitingBySection) {
if (type in seenTypes) continue
val sorted = list.sortedBy { it.index }
val entries = sorted.map { ex ->
LibraryDisplayEntry(libraryGlobalKey(type, ex.item), ex.item, section = null, exiting = true)
}
result += LibraryDisplaySection(null, type, sorted.first().sectionTitle, entries)
}
return result
}
}

View file

@ -0,0 +1,59 @@
package com.nuvio.app.core.ui
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
import org.jetbrains.skia.BlendMode
import org.jetbrains.skia.Paint
import org.jetbrains.skia.VertexMode
internal actual class AshSwarmRenderer actual constructor(maxGrains: Int) {
private val positions = FloatArray(maxGrains * 12)
private val vertexColors = IntArray(maxGrains * 6)
private val paint = Paint()
private var lastVertexCount = 0
actual fun draw(
scope: DrawScope,
count: Int,
centersX: FloatArray,
centersY: FloatArray,
halfWidths: FloatArray,
halfHeights: FloatArray,
colors: IntArray,
) {
var pi = 0
var ci = 0
for (i in 0 until count) {
val x0 = centersX[i] - halfWidths[i]
val x1 = centersX[i] + halfWidths[i]
val y0 = centersY[i] - halfHeights[i]
val y1 = centersY[i] + halfHeights[i]
positions[pi++] = x0; positions[pi++] = y0
positions[pi++] = x1; positions[pi++] = y0
positions[pi++] = x0; positions[pi++] = y1
positions[pi++] = x1; positions[pi++] = y0
positions[pi++] = x1; positions[pi++] = y1
positions[pi++] = x0; positions[pi++] = y1
val c = colors[i]
vertexColors[ci++] = c; vertexColors[ci++] = c; vertexColors[ci++] = c
vertexColors[ci++] = c; vertexColors[ci++] = c; vertexColors[ci++] = c
}
val vertexCount = count * 6
if (vertexCount < lastVertexCount) {
positions.fill(0f, fromIndex = vertexCount * 2, toIndex = lastVertexCount * 2)
vertexColors.fill(0, fromIndex = vertexCount, toIndex = lastVertexCount)
}
lastVertexCount = vertexCount
if (vertexCount == 0) return
scope.drawContext.canvas.nativeCanvas.drawVertices(
vertexMode = VertexMode.TRIANGLES,
positions = positions,
colors = vertexColors,
texCoords = null,
indices = null,
blendMode = BlendMode.DST,
paint = paint,
)
}
}

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=89
MARKETING_VERSION=0.2.17
MARKETING_VERSION=0.1.0