Skip to content

Timeline

filmstrip-compose-ui holds the drawn pieces of an editor, built on Compose foundation with no Material. It sits on top of filmstrip-compose.

FilmstripTimeline assembles a ruler, a scrolling strip, a playhead and an optional trim window.

val composition = remember(source) { compositionOf { clip(source) } }
val player = rememberFilmstripPlayer(filmstrip, composition)
val playerState by player.state.collectAsState()
val playhead = rememberPlayheadState(player)
Column {
VideoSurface(player, Modifier.weight(1f))
FilmstripTimeline(
renderer = filmstrip,
composition = composition,
duration = playhead.duration ?: Duration.ZERO,
position = playhead.positionProvider(),
isPlaying = playerState.isPlaying,
scrub = rememberScrubState(player),
)
}

A drag on the ruler moves the playhead and a drag on the strip moves the view. The strip follows the playhead during playback, and stops as soon as the user scrolls it.

It doesn’t need a player. position comes from whatever clock you run, and a timeline with no scrub is one where a drag on the ruler doesn’t seek.

One track is drawn. A composition with several shows the timeline they lay out on, with no clip edges marked.

The trim window draws when you pass both trim and onTrimChange. The handles only move where trim next puts them, so the range lives in your state.

var trimRange by remember(source) { mutableStateOf(TimeRange.from(Duration.ZERO)) }
val composition = remember(source) { compositionOf { clip(source) } }
val player = rememberFilmstripPlayer(filmstrip, composition)
val playerState by player.state.collectAsState()
val playhead = rememberPlayheadState(player)
FilmstripTimeline(
renderer = filmstrip,
composition = composition,
duration = playhead.duration ?: Duration.ZERO,
position = playhead.positionProvider(),
isPlaying = playerState.isPlaying,
scrub = rememberScrubState(player),
trim = trimRange,
onTrimChange = { range ->
trimRange = range
player.setPlaybackRange(range)
},
)

setPlaybackRange confines playback to the window while the strip keeps covering the whole source, so a drag doesn’t rebuild the composition the player is running. The trim reaches the edit when you build the one you export.

val composition = compositionOf { clip(source) { trim(trimRange) } }

The window keeps to FilmstripTimelineDefaults.Trim, a minimum of 200 milliseconds, unless you set another TrimConstraint. MinDuration, MinMaxDuration and FixedDuration are the options.

zoom sets how far in the timeline is drawn, and null fits the whole composition on screen. A null zoom refits when the width changes, like on a rotation or a window resize, until someone zooms. TimelineZoom.of takes a step from 0 to 10. TimelineOptions holds the rest, such as sizes, followPlayhead and the pinchZoom and wheelZoom switches.

FilmstripTimeline(
renderer = filmstrip,
composition = composition,
duration = duration,
position = position,
isPlaying = isPlaying,
zoom = TimelineZoom.of(4),
options =
TimelineOptions(
followPlayhead = false,
trimConstraint = TrimConstraint.MinDuration(1.seconds),
),
)

Keyboard control doesn’t go through here, since it needs focus and a component that took focus on composition would steal it from your screen. Apply Modifier.timelineKeys yourself, such as on an element in the overlay slot, where TimelineState is at hand.

The overlay slot draws over the tiles and under the trim window and the playhead. Anything FilmstripTimeline doesn’t lay out the way you want, you can build from the same public parts: FilmstripStrip, TimelineRuler, Playhead and TrimOverlay.

VideoStage is a box the shape of the output frame, holding the video and whatever you draw over it. It takes the largest rectangle of that shape that fits, so give it room to fit into, like a padding inside a centred box, and not a fillMaxSize.

CropOverlay inside a stage draws a draggable rectangle over the frame.

val composition = remember(source) { compositionOf { clip(source) } }
val player = rememberFilmstripPlayer(filmstrip, composition)
var rect by remember(source) { mutableStateOf(NormalizedRect.Full) }
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
VideoStage(
player = player,
outputAspect = aspect,
modifier = Modifier.padding(16.dp),
) {
CropOverlay(rect = rect, onRectChange = { rect = it })
}
}

A live player’s frame wins over outputAspect, so the stage and the picture change shape together. Passing a null player draws the fallback slot at the same size instead.

The rectangle addresses the frame under it, so the preview stays uncropped and the crop joins the edit when you export.

val composition =
compositionOf {
clip(source)
effects { crop(rect) }
}