Skip to content
/ Klarity Public

A media (video and audio) player for Compose Multiplatform (desktop-only), built on top of the native FFMpeg and PortAudio libraries, and rendered using the Skiko library

License

Notifications You must be signed in to change notification settings

numq/Klarity

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Buy Me a Coffee

Klarity

logo

Klarity is a media (video and audio) player for Compose Multiplatform (desktop-only), built on top of the native FFMpeg and PortAudio libraries, and rendered using the Skiko library.

Since frames are rendered directly into the Composable, this eliminates the need for compatibility components like SwingPanel, making it possible to display any Composable as an overlay on top of a frame.


preview

Table of Content

Changelog

Fixed

  • Video-only media playback synchronization

📦 Previous versions

[!WARNING]
Video-only media playback synchronization doesn't work

Changed

  • Code refactoring
  • Performance optimizations

Fixed

  • Bug fixes and stability improvements

Changed

  • Renderer no longer depends on video format - uses width and height instead

Fixed

  • Fixed incorrect argument names

Changed

  • Enhanced seeking precision
  • Extended external state machine
  • Changed rendering behavior - renders first video frame during: preparation, playback stop, seeking
  • Performance and stability enhancements

Fixed

  • Fixed delayed external state updates after command execution
  • Fixed incorrect first frame display after seeking
  • Fixed event handling issues
  • Fixed improper loop stopping
  • Fixed buffer cleanup errors during channel closure

Changed

  • Improved internal state machine
  • Improved playback synchronization
  • Improved seeking
  • Stable release

Supported platforms

The library provides pre-built JARs for the following platforms:

Platform Architecture JAR
Windows x64 klarity-windows-x64-*.jar
Linux x64 klarity-linux-x64-*.jar
macOS x64 klarity-macos-x64-*.jar
macOS arm64 klarity-macos-arm64-*.jar

Custom builds

If you need support for other platforms or architectures, you can build the library from source using the CI/CD configuration as a reference for the complete build process.

Features

  • Media files probing
  • Audio and video playback of media files
  • Slow down and speed up playback speed without changing pitch
  • Getting a preview of a media file
  • Getting frames (snapshots) of a media file
  • Coroutine/Flow API

Roadmap

Hardware-Accelerated Rendering

While hardware-accelerated decoding is technically available through FFmpeg, its practical application is currently limited:

  • Rendering bottleneck: Decoded frames are processed through CPU-bound Skia components

  • Latency issues: This creates a pipeline bottleneck that negates the benefits of hardware decoding

  • Architectural constraints: DirectX 12 and OpenGL implementations would require compatibility components, eliminating key advantages of the current architecture

Future solution:

  • Implement Vulkan-based rendering when stable support becomes available in Skia, provided it maintains the current seamless Compose integration without compatibility layers.

Architecture

Klarity implements an event-driven architecture designed for Kotlin developers. It focuses on simplicity and easy integration with minimal setup.

Layers

  • JVM Layer (Kotlin):

    • Contains all business logic and state management

    • Provides a modern, coroutine-based public API

    • Uses Kotlin Flows for event-driven communication

    • Manages playback control, seeking, and synchronization

  • JNI Layer::

    • Bridges Kotlin code with native C++ performance

    • Handles efficient data marshaling between layers

    • Minimizes overhead for data transfer

  • Native Layer (C++):

    • Uses FFmpeg for video/audio decoding

    • Employs PortAudio for low-latency audio playback

    • Handles audio playback including polyphonic audio time-stretching

Rendering

The pipeline combines FFmpeg and Skia to decode video frames directly into native memory. The decoded frame data is directly interpreted as a Pixmap via pointer reference, then written to a Skia Surface and rendered to a Compose Canvas.

This efficient approach eliminates compatibility layers like SwingPanel and enables seamless overlaying of any Composable on top of video content.

Dependency graph

graph TD
    KlarityPlayer --> PlayerController
    PlayerController --> Pipeline
    PlayerController --> BufferLoop
    PlayerController --> PlaybackLoop
    PlayerController --> Settings
    PlayerController --> PlayerState
    PlayerController --> BufferTimestamp
    PlayerController --> PlaybackTimestamp
    PlayerController --> Events
    PlayerController --> Renderer
    
    BufferLoop --> Pipeline
    PlaybackLoop --> BufferLoop
    PlaybackLoop --> Pipeline
    PlaybackLoop --> Renderer
    PlaybackLoop --> Settings
    
    subgraph Media Pipeline
        Pipeline --> Media
        Pipeline --> AudioPipeline
        Pipeline --> VideoPipeline
        
        AudioPipeline --> AudioDecoder
        AudioPipeline --> AudioBuffer
        AudioPipeline --> Sampler
        
        VideoPipeline --> VideoDecoder
        VideoPipeline --> VideoBuffer
        VideoPipeline --> VideoPool
    end
    
    subgraph Native Components
        Sampler --> NativeSampler[C++/JNI]
        AudioDecoder --> NativeDecoder[C++/JNI]
        VideoDecoder --> NativeDecoder[C++/JNI]
    end
    
    subgraph Loops
        BufferLoop --> BufferHandler
        PlaybackLoop --> PlaybackHandler
    end
    
    subgraph Media
        Media --> AudioFormat
        Media --> VideoFormat
    end
Loading

State diagram

stateDiagram-v2
    state PlayerState {
        [*] --> Empty
        Empty --> Preparing: Prepare
        Preparing --> Ready: Success
        Preparing --> Error: Error
        Preparing --> Empty: Release
        
        state Ready {
            [*] --> Stopped
            Stopped --> Playing: Play
            Playing --> Paused: Pause
            Playing --> Stopped: Stop
            Playing --> Seeking: SeekTo
            Playing --> Error: Error
            Paused --> Playing: Resume
            Paused --> Stopped: Stop
            Paused --> Seeking: SeekTo
            Paused --> Error: Error
            Stopped --> Completed: Playback Complete
            Stopped --> Seeking: SeekTo
            Stopped --> Error: Error
            Completed --> Stopped: Stop
            Completed --> Seeking: SeekTo
            Completed --> Error: Error
            Seeking --> Paused: Seek Complete
            Seeking --> Stopped: Stop
            Seeking --> Seeking: SeekTo
            Seeking --> Error: Error
        }
        
        Ready --> Releasing: Release
        Releasing --> Empty: Success
        Releasing --> Error: Error
        Error --> Empty: Reset
    }
Loading

Transition table

Current State \ Action Empty Preparing Releasing Ready.Stopped Ready.Playing Ready.Paused Ready.Completed Ready.Seeking Error
Empty - Prepare - - - - - - -
Preparing Release - - Success - - - - Error
Releasing Success - - - - - - - Error
Error Reset - - - - - - - -
Ready.Stopped - - Release - Play - Playback Complete SeekTo Error
Ready.Playing - - Release Stop - Pause - SeekTo Error
Ready.Paused - - Release Stop Resume - - SeekTo Error
Ready.Completed - - Release Stop - - - SeekTo Error
Ready.Seeking - - Release Stop - Seek Complete - SeekTo Error

Installation

Download the latest release and include jar files to your project depending on your system.

Usage

Note

Check out the example to see a full implementation in Clean Architecture using the Reduce & Conquer pattern

Load library

  • The KlarityPlayer.load() method should be called once during the application lifecycle
KlarityPlayer.load().onFailure { t -> }.getOrThrow()

Get probe (information about a media)

val media = ProbeManager.probe("path/to/media").onFailure { t -> }.getOrThrow()

Get video frames (snapshots)

Important

Snapshot must be closed using the close() method

val snapshots = SnapshotManager.snapshots("path/to/media") { timestamps }.getOrThrow()

snapshots.forEach { snapshot ->
    renderer.render(snapshot.frame).getOrThrow()

    snapshot.close().getOrThrow()
}

val snapshot = SnapshotManager.snapshot("path/to/media") { timestamp }.getOrThrow()

renderer.render(snapshot.frame).getOrThrow()

snapshot.close().getOrThrow()

Get preview frames (for example, for the timeline)

Important

PreviewManager must be closed using the close() method

val previewManager = PreviewManager.create("path/to/media").getOrThrow()

previewManager.render(renderer, timestamp).getOrThrow()

previewManager.close().getOrThrow()

Get media file played

Important

KlarityPlayer and Renderer must be closed using the close() method

val player = KlarityPlayer.create().getOrThrow()

val probe = ProbeManager.probe("path/to/media").getOrThrow()

val renderer = probe.videoFormat?.run { Renderer.create(width = width, height = height).getOrThrow() }

if (renderer != null) {
    player.attachRenderer(renderer).getOrThrow()
}

player.prepare("path/to/media").getOrThrow()

player.play().getOrThrow()

player.stop().getOrThrow()

player.detachRenderer().getOrThrow()?.close()?.getOrThrow()

player.close().getOrThrow()

Third-party libraries