Skip to content

Player

preview opens a VideoPlayer over the same composition value an export takes. Each engine lowers it through the same backend its exports use, so the compositing isn’t a second implementation.

val player = filmstrip.preview(composition)
player.play()

preview returns right away and loads in the background. You own the player and have to close it. Without filmstrip-player registered, the player reports PlaybackError.BackendMissing instead of throwing. A browser reports the same error, naming filmstrip-transform-webcodecs, when webCodecsBackend() isn’t registered.

A preview writes no file, so a device with no video encoder still previews and only its exports are refused. In a browser that preview plays its audio too, given a WebCodecs AudioDecoder. A browser needs a WebCodecs VideoDecoder to draw, and one without it reports PlaybackError.UnsupportedFormat. See Limits.

config sets how hard a preview works. CapHeight limits its height without changing what an export writes.

val player =
filmstrip.preview(
composition,
PlayerConfig(qualityPolicy = PreviewQualityPolicy.CapHeight(720)),
)

state is a StateFlow of consistent snapshots.

player.state.collect { state ->
when (val status = state.status) {
is PlaybackStatus.Error -> println(status.error.message)
PlaybackStatus.Ended -> println("Finished")
else -> println("Playing: ${state.isPlaying}, busy: ${state.isBusy}")
}
}

events carries the things that happen once, like a seek landing or a loop wrapping. It replays nothing, so subscribe before you issue commands.

player.events.collect { event ->
when (event) {
is PlaybackEvent.SeekCompleted -> println("Landed on ${event.position}")
is PlaybackEvent.RangeLooped -> println("Wrapped in ${event.range}")
is PlaybackEvent.Failed -> println(event.error.message)
else -> Unit
}
}

positionFlow follows the playhead, snapped to the tick you ask for. currentPosition reads it once. Both report a seek’s target as soon as it’s issued, so a scrubber thumb doesn’t snap back.

player.positionFlow(tick = 100.milliseconds).collect { position ->
println(position)
}

Each of these has a listener form too, addStateListener, addEventListener and addPositionListener, for callers without coroutines.

player.seekTo(12.seconds)
player.seekTo(30.seconds, SeekAccuracy.Nearest)
player.setLoopRange(TimeRange(10.seconds, 20.seconds))
player.setVolume(0.5f)
if (player.features.supports(PlayerFeature.FrameStepping)) {
player.stepFrames(1)
}
  • seekTo always yields one SeekCompleted, carrying the frame it landed on. SeekAccuracy.Nearest lands on a nearby frame, before or after the one asked for, which suits a moving finger. Its SeekCompleted names the frame it shows. Until the newest seek completes, the playhead reads the position it asked for.
  • beginScrub and endScrub wrap a drag. Between them the engine relaxes seek accuracy, and endScrub settles on the exact frame.
  • setLoopRange wraps playback at the end of the range and reports RangeLooped. A range the composition ends inside wraps there and reports no Ended. A range that starts at or past the end has nothing to loop, so playback runs to the end. A wrap only happens during playback, so a paused seek past the range stays where it lands. A wrap is a seek too, so from the wrap on the playhead reads the range’s first frame and never reports the range’s end again after a lap.
  • setPlaybackRange confines playback without changing the composition, which is how a trim drag previews without a rebuild.
  • setVolume sets monitor volume, which only the player hears. To change the export’s level, see Audio.
  • On Apple, play on an item AVFoundation failed starts nothing and the status stays PlaybackStatus.Error, so a load is what tries again. An item that stops before its end reports PlaybackEvent.Failed once per play or seek, with the platform’s error in it, and the status reads that error until the next play, seek or load.
  • A failure stops the player and leaves playWhenReady as it was, on every backend. Any load after it tries again, the same composition or an edit of one parameter included, and plays if a play is still wanted.

features says what this backend can do on this device. Ask it before you draw a control, since a control for a missing feature looks frozen.

Feature Android Apple Browser JVM
FrameReadback Yes Yes Needs WebGL2 Yes
FrameStepping Yes Yes Needs WebGL2 Yes
LiveParameterRedraw Yes Yes Needs WebGL2 No
AudioMonitoring When media3 can No Needs Web Audio and AudioDecoder No
HdrPreview When the display can No No No
PlaybackSpeed No No No No

readback hands you a rendered preview frame, after effects and before encoding.

setComposition loads a new value. It’s cheap to call often. An equal composition does nothing, a change to effect parameters updates the live pipeline and redraws, and only a structural change rebuilds, like adding a clip or a trim that changes the duration. Debounce structural edits, so a drag-reorder issues one rebuild and not thirty.

when (val result = player.setComposition(edited)) {
is SetCompositionResult.Success -> println("Now showing the edit")
is SetCompositionResult.Failure -> println(result.error.message)
SetCompositionResult.Superseded -> Unit
}

A load with no startAt holds the playhead. The transport stays where it was, so a playing preview keeps playing and a paused one stays paused. Where the playhead lands depends on that transport:

  • A paused playhead lands on the frame it rounds to.
  • A playing playhead lands on the first frame at or after its time.
  • A playhead past the end of a shorter composition lands on the last frame.
  • A playing playhead at or past a loop range’s wrap lands on the start of the range.

previewInfo says what’s being delivered, including its outputSize, its parity with the export and its inertNotices, which match what the export’s plan carries.