Tiny efficient fullscreen random video clips. Plays short bursts from a video file in res/raw/ efficiently with a Bayer error diffusion shader. Intended to be used with randomised shaders (see: orllewin.uk/log/compose/rave_text/ and orllewin.uk/log/android/hello_reactive_agsl/)
Nanovid Composable
package io.orllewin.nanovid
import android.graphics.Matrix
import android.graphics.RenderEffect
import android.graphics.RuntimeShader
import android.view.TextureView
import androidx.annotation.OptIn
import androidx.annotation.RawRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.asComposeRenderEffect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SeekParameters
import kotlinx.coroutines.delay
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
private const val DITHER_SHADER_SRC = """
uniform shader inputContent;
float bayer2(float2 p) {
float2 q = mod(floor(p), 2.0);
return q.x * 2.0 + q.y * 3.0 - q.x * q.y * 4.0;
}
float bayerDither8(float2 pos) {
return (bayer2(pos) * 16.0 + bayer2(pos * 0.5) * 4.0 + bayer2(pos * 0.25)) / 64.0;
}
half4 main(float2 fragCoord) {
half4 col = inputContent.eval(fragCoord);
float lum = dot(float3(col.rgb), float3(0.299, 0.587, 0.114));
float threshold = bayerDither8(fragCoord / 1.5);
return half4(half3(step(threshold, lum)), 1.0);
}
"""
@OptIn(UnstableApi::class)
@Composable
fun NanoVid(
@RawRes videoResId: Int,
modifier: Modifier = Modifier,
minClipMs: Long = 2000L,
maxClipMs: Long = 4000L,
onClipFinished: () -> Unit
) {
val context = LocalContext.current
val player = remember(videoResId) {
ExoPlayer.Builder(context)
.setLoadControl(
DefaultLoadControl.Builder()
.setBufferDurationsMs(500, 2000, 100, 250)
.build()
)
.build()
.apply {
val uri = "android.resource://${context.packageName}/$videoResId".toUri()
setMediaItem(MediaItem.fromUri(uri))
setSeekParameters(SeekParameters.CLOSEST_SYNC)
trackSelectionParameters = trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, true)
.build()
repeatMode = Player.REPEAT_MODE_OFF
playWhenReady = true
prepare()
}
}
var durationMs by remember { mutableLongStateOf(0L) }
var ready by remember { mutableStateOf(false) }
var videoSize by remember { mutableStateOf(VideoSize.UNKNOWN) }
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY && !ready) {
durationMs = player.duration.coerceAtLeast(0L)
ready = true
}
}
override fun onVideoSizeChanged(size: VideoSize) {
videoSize = size
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
player.release()
}
}
LaunchedEffect(ready, durationMs) {
if (!ready || durationMs <= 0L) return@LaunchedEffect
while (true) {
val clipMs = Random.nextLong(minClipMs, maxClipMs + 1)
val maxStart = (durationMs - clipMs).coerceAtLeast(0L)
val startMs = if (maxStart == 0L) 0L else Random.nextLong(0L, maxStart)
player.seekTo(startMs)
player.play()
delay(clipMs.milliseconds)
onClipFinished()
}
}
val ditherEffect = remember {
RenderEffect
.createRuntimeShaderEffect(RuntimeShader(DITHER_SHADER_SRC), "inputContent")
.asComposeRenderEffect()
}
AndroidView(
modifier = modifier
.layout { measurable, constraints ->
val halved = Constraints(
minWidth = constraints.minWidth / 2,
maxWidth = constraints.maxWidth / 2,
minHeight = constraints.minHeight / 2,
maxHeight = constraints.maxHeight / 2
)
val placeable = measurable.measure(halved)
layout(constraints.maxWidth, constraints.maxHeight) {
placeable.place(0, 0)
}
}
.graphicsLayer {
scaleX = 2f
scaleY = 2f
transformOrigin = TransformOrigin(0f, 0f)
renderEffect = ditherEffect
},
factory = { ctx ->
TextureView(ctx).apply {
player.setVideoTextureView(this)
addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
applyCenterCropTransform(v as TextureView, videoSize)
}
}
},
update = { textureView ->
applyCenterCropTransform(textureView, videoSize)
}
)
}
private fun applyCenterCropTransform(view: TextureView, size: VideoSize) {
val vw = size.width
val vh = size.height
val viewW = view.width
val viewH = view.height
if (vw <= 0 || vh <= 0 || viewW <= 0 || viewH <= 0) return
val videoAspect = vw.toFloat() / vh.toFloat()
val viewAspect = viewW.toFloat() / viewH.toFloat()
val sx: Float
val sy: Float
if (videoAspect > viewAspect) {
sx = videoAspect / viewAspect
sy = 1f
} else {
sx = 1f
sy = viewAspect / videoAspect
}
view.setTransform(Matrix().apply {
setScale(sx, sy, viewW / 2f, viewH / 2f)
})
}