mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
feat: adjust library and cw cards removal animations
This commit is contained in:
parent
5ff165c754
commit
e6ef2fd3ae
8 changed files with 665 additions and 50 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
CURRENT_PROJECT_VERSION=89
|
||||
MARKETING_VERSION=0.2.17
|
||||
MARKETING_VERSION=0.1.0
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue