Skip to content

Bindings

filmstrip-compose holds the Compose Multiplatform bindings: player state, a cache for frame strips and a bare video surface. It draws no controls.

rememberFilmstripPlayer builds a player and closes it when it leaves composition. A new composition is loaded for you, so ordinary editing needs no debounce. VideoSurface renders the player and nothing else, and anything you draw after it composites on top of the video.

val player = rememberFilmstripPlayer(filmstrip, composition)
val surface = rememberVideoSurfaceState(player)
VideoSurface(
player = player,
modifier = Modifier.fillMaxWidth().videoAspect(surface, VideoContentScale.Fit),
)

Size the surface to the composition’s output aspect, not the source’s. videoAspect does that from rememberVideoSurfaceState, and stays inert until the engine reports a size.

contentScale is a fit in the view. VideoContentScale.Fit shows bars in the view that never reach the file, unlike letterboxing in the composition, which writes real pixels.

rememberPlayheadState follows the playhead and the composition’s length. It has position, duration and progress, read at PlayheadDefaults.Tick unless you ask for another tick.

val player = rememberFilmstripPlayer(filmstrip, composition)
val playhead = rememberPlayheadState(player)
BasicText("${playhead.position} of ${playhead.duration}")

rememberScrubState holds the three calls a drag makes. Call onScrubStart on touch down, onScrubTo on every delta and onScrubEnd on touch up, which settles on the exact frame. onScrubTo is ignored outside a gesture. A second form takes your own onSeek, for a preview you run on your own clock. The timeline wires this up for you.

PlaybackEventEffect collects a player’s events for as long as it stays in composition. Use it instead of your own LaunchedEffect, since events replays nothing and a hand-rolled collector can race the command that produced an event.

val player = rememberFilmstripPlayer(filmstrip, composition)
PlaybackEventEffect(player) { event ->
if (event is PlaybackEvent.Failed) println(event.error.message)
}

rememberFilmstripFrame holds one exact frame, for a poster or a clip card. It fetches again when the position, the height or anything that changes a rendered frame moves. The last frame stays in place while the next renders, and a failed render clears it.

val poster = rememberFilmstripFrame(filmstrip, composition, at, heightPx = 360)
if (poster != null) {
Image(poster, contentDescription = null)
}

rememberFilmstripFrames holds a whole strip. Tell it which items are showing with onVisibleRange, since nothing is fetched until you do. It fetches the visible items plus an overscan either side and closes everything outside that window.

val frames = rememberFilmstripFrames(filmstrip, composition, positions, heightPx = 96)
val listState = rememberLazyListState()
LaunchedEffect(frames, listState) {
snapshotFlow { listState.firstVisibleItemIndex to listState.layoutInfo.visibleItemsInfo.size }
.collect { (first, visible) -> frames.onVisibleRange(first, visible) }
}
LazyRow(state = listState) {
items(frames.count) { index ->
Box(Modifier.size(64.dp, 96.dp)) {
frames[index]?.let { frame -> Image(frame, contentDescription = null) }
}
}
}

Requests are served one at a time, since extraction next to a preview contends for the device’s decoders. maxBytes caps how much decoded pixel data it holds. An edit that doesn’t change a rendered frame, like a clip’s gain, keeps every frame.