diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.android.kt new file mode 100644 index 000000000..83c05358b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.android.kt @@ -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) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.kt new file mode 100644 index 000000000..f168a0814 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.kt @@ -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, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DisintegrationEffect.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DisintegrationEffect.kt new file mode 100644 index 000000000..de6e8e6f9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DisintegrationEffect.kt @@ -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(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) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt index 1569c849a..00d907518 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt @@ -64,6 +64,7 @@ fun 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 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) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index fc5bb09f0..e35f2c50a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -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>() + private var previous = LinkedHashMap>() + private var invalidations by mutableStateOf(0) + + fun onExited(videoId: String) { + if (exiting.remove(videoId) != null) invalidations++ + } + + fun sync(items: List): List { + @Suppress("UNUSED_EXPRESSION") + invalidations + + val current = LinkedHashMap>() + 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(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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index c64c449eb..bd8893bba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -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, + displaySections: List, watchedKeys: Set, 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, +) + +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() + private var previous = LinkedHashMap() + 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, previewLimit: Int): List { + @Suppress("UNUSED_EXPRESSION") + invalidations + + val current = LinkedHashMap() + 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(sections.size) + val result = ArrayList(sections.size + 1) + + for (section in sections) { + seenTypes += section.type + val entries = ArrayList(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 + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.ios.kt new file mode 100644 index 000000000..0d3d84b3b --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.ios.kt @@ -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, + ) + } +} diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 0fbd715d7..f2565ccba 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ CURRENT_PROJECT_VERSION=89 -MARKETING_VERSION=0.2.17 +MARKETING_VERSION=0.1.0