
Composable video playback component with adaptive streaming, HDR-aware color pipeline, preflight codec checks, libass subtitle support, customizable UI, PiP/fullscreen, chapters, caching, and diagnostics.
Compose Media Player is a video player library designed for Compose Multiplatform, supporting multiple platforms including Android, macOS, Windows, and Linux. Desktop playback bridges are packaged in the JVM artifact and communicate through pure JNI — no JNA and no Java media bindings. The optional Windows/Linux ASS renderer uses Java 25's Foreign Function API to load its packaged libass runtime. Linux additionally requires a compatible GStreamer 1.x runtime and plugins to be installed on the host. The library leverages:
This repository is a personal fork maintained for my own projects and integration needs. Source code and tagged artifacts are published for transparency and reproducible internal deployments; publication is not an additional grant beyond the permissions in LICENSE. This is not a supported public distribution, and releases may change without compatibility guarantees. JitPack builds are intentionally not provided because their generated coordinates and mutable commit builds are not part of the verified release pipeline. If you need a supported library for external use, use the upstream project instead: kdroidFilter/ComposeMediaPlayer.
Try the online demo here : 🎥 Live Demo
DynamicRangePolicy selects a confirmed native HDR path, a controlled renderer, or a verified HDR-to-SDR fallback. VideoColorPipelineStatus reports source, decoder, active display, surface, metadata handling, planned output, confirmed output, and fallback reason separately.composemediaplayer-mpv is an Android/iOS/JVM adapter
with libass-based ASS/SSA subtitle support. The default artifact has no MPV
dependency; applications add the adapter only to the platform source sets
where they select it.| Format | Windows | Linux | macOS & iOS | Android | WASM |
|---|---|---|---|---|---|
| Player | MediaFoundation | GStreamer | AVPlayer | Media 3 | KMediaPlayer's Movi integration fork 0.3.5-kmp.3 (default), HTML5 video (legacy, non-adaptive) |
| MP4 (H.264) | ✅ | ✅ | ✅ | ✅ | ✅ |
| AVI | ❌ | ✅ | ❌ | ❌ | ✅§ |
| MKV | ✅ | ✅ | ✅§ | ||
| MOV | ✅ | ✅ | ✅ | ❌ | ✅ |
| FLV | ❌ | ✅ | ❌ | ❌ | ❌ |
| WEBM | ✅ | ✅ | ✅ | ||
| WMV | ✅ | ✅ | ❌ | ❌ | ❌ |
| 3GP | ✅ | ✅ | ✅ | ✅ | ❌ |
| MPEG-TS | ✅ | ✅ | ✅ | ✅ | ✅§ |
| HLS (M3U8) | ✅ | ✅ | ✅ | ✅ | ✅§ |
| MPEG-DASH (MPD) | Backend dependent | Backend dependent | Backend dependent | ✅ | ✅§ |
| Smooth Streaming (MSS) | Backend dependent | Backend dependent | Backend dependent | ❌ | ✅§ |
The Android artifact includes the matching Media3 HLS module; consumers do not need to add it separately.
§ Wasm loads the exact
@shusek/movi-player@0.3.5-kmp.3headless engine from a versioned CDN URL at runtime. MoviPlayer and its media Wasm are not bundled in KMediaPlayer artifacts. The fork preserves upstream attribution and ships notices, corresponding source, build recipes and relinking instructions for its LGPL FFmpeg/Wasm payload. Container support still depends on the codecs and browser primitives available at runtime.WebPlaybackEngine.LEGACYuses native HTML video only and rejects recognized HLS, DASH and MSS manifests; it does not contain an independent adaptive-streaming fallback.
† On desktop JVM, MKV/WebM can use native playback, optional in-process libVLC, or the explicitly installed
composemediaplayer-kmediabridgeextension when the platform backend cannot demux the source directly. The default player contains no KMediaBridge or FFmpeg dependency. The optional bridge uses the process-wideKMediaFfmpegRuntime, shared with the MPV adapter; there is noffmpeg/ffprobeexecutable or system installation. Its exact runtime dependency also supplies the single shared libass, FreeType, FriBidi and HarfBuzz stack. The bridge remuxes compatible local video/audio into bounded-memory CMAF/HLS without re-encoding; the macOS runtime can also tone-map explicit HDR10/HDR10+/HLG to limited-range BT.709 forFORCE_SDRand burn embedded text subtitles for confirmed SDR input. Strictly validated HEVC Main 10 HDR10/HDR10+/HLG remains tagged as an HDR sample-copy route under HDR-preserving policies; the platform color pipeline still has to confirm the decoder, surface and display before reporting HDR output. Unsupported conversion requests fail closed or use an explicitly available VLC fallback. See JVM MKV/WebM fallback support.
HDR recognition is not treated as proof of HDR output. In particular, a libVLC native child view on Windows or Linux is not a confirmed HDR route.
AUTOuses a verified SDR tone-map when the native HDR requirements are unavailable, whileREQUIRE_HDRreturns a typed color-pipeline error. See HDR pipeline and platform status.
iPhone and iPad are supported on a non-blocking software-only best-effort basis: device arm64 builds, Simulator tests and shared Metal references are required, but this project has no physical iOS hardware and makes no physical HDR, Dolby Vision, 4K60 or thermal-performance claim. Runtime color status therefore fails closed whenever Apple does not explicitly confirm the format, display and surface.
Flat Android video follows the platform-recommended MediaCodec +
SurfaceViewpath; KMediaPlayer does not overwrite decoder buffers with a guessed dataspace. On Android 9+, a small read-only JNI bridge comparesANativeWindow_getBuffersDataSpace()with the decoded HDR10/HDR10+/HLG signal after the first frame. An exact match isRENDERER_CONFIGURED; Android 14+'s active HDR/SDR composition report is independentSYSTEM_REPORTEDevidence and is retained only for the current decoded output signal, source, surface and display. Dolby Vision remains vendor-managed. If neither public signal confirms the route,AUTOuses a verified controlled or source-bridge SDR fallback when available, whileREQUIRE_HDRfails instead of labeling inferred playback as HDR.
macOS JVM uses one native AVPlayer/EDR layer per player for flat AVFoundation-supported video. Runtime selection is controlled by the 2.0 color policy; see macOS color pipeline for the current behavior.
Linux
libvlc-native-viewcurrently embeds VLC through an X11/XWayland xwindow. Native Wayland needs a separatewl_surfacebackend and is not implemented by this path.
The library exposes a single suspendable preflight query so apps can check player capabilities, source/container support and codecs before creating a VideoPlayerState or navigating to the player screen:
val mediaSupport = MediaSupport.query()
val mkvSupported = mediaSupport.capabilities.supportsMkv
val supportedSchemes = mediaSupport.capabilities.supportedUriSchemes
val canOpenSource = mediaSupport.canPlaySource(
uri = "file:///movie.mkv",
mimeType = "video/x-matroska",
)
val h264Supported = mediaSupport.isCodecSupported(MediaCodec.H264)
val supportedVideoCodecs = mediaSupport.videoCodecs
val supportedAudioCodecs = mediaSupport.audioCodecs
val supportedCodecs = mediaSupport.allCodecs
val supportedHdr = mediaSupport.supportedHdrDynamicRanges
val hdr10PlusSupport = mediaSupport.dynamicRangeSupport(VideoDynamicRange.HDR10_PLUS)
val showHdr10PlusSources = hdr10PlusSupport == VideoDynamicRangeSupport.SUPPORTEDMediaSupport.query() checks the current platform contract for URI schemes and known container/adaptive formats such as MKV and HLS, plus codec support reported by the backend. On browser targets it recognizes Movi's supported containers and still asks HTMLVideoElement.canPlayType for MIME types used by legacy playback.
Display support is a preflight snapshot of the active output, not evidence that a future playback
surface reached HDR. UNSUPPORTED means the platform produced a known capability set without that
range; UNKNOWN remains distinct so strict source catalogs can require SUPPORTED. Query again
after an HDMI or monitor change before filtering a new source list.
[!NOTE] Media support can still depend on the device, OS version, browser, installed system components, or GStreamer plugins. Android and web targets query the current runtime where possible. Desktop and iOS expose conservative backend capability lists. Query methods are suspend functions so callers can run them from
LaunchedEffect,async,produceState, or their own coroutine/lazy strategy.
[!IMPORTANT] These coordinates reproduce the maintainer's tagged internal artifacts. Their availability does not change the permitted uses stated in LICENSE. Every remote release uses an immutable SemVer tag, including RC tags;
devbuilds are local-only.
To add Compose Media Player to your project, add the GitHub Pages Maven repository in your settings.gradle.kts file:
dependencyResolutionManagement {
repositories {
mavenCentral()
maven("https://shusek.github.io/KMediaPlayer/maven")
}
}Then include the dependency in your module build.gradle.kts file:
dependencies {
implementation("io.github.shusek:composemediaplayer:<version>")
}Pipeline integrations are separate modules:
composemediaplayer-ass ──────────┐
composemediaplayer-dolbyvision ──┼──> composemediaplayer-extension-api ──> composemediaplayer-core
composemediaplayer-kmediabridge ─┘ ↑
composemediaplayer-mpv ───────────────────────────────┤
composemediaplayer ───────────────────────────────────┘
The MPV and KMediaBridge adapters are optional. When both are present they transitively select their
own thin native clients and the same exact KMediaFfmpegRuntime; the base composemediaplayer
artifact still contains neither backend.
Normal applications receive composemediaplayer-extension-api transitively. Add it directly only
when authoring an extension. An optional artifact has no effect until its stable extension instance
is registered in VideoPlaybackOptions.extensions; extensionStatuses exposes its typed
AVAILABLE, DEGRADED, or UNAVAILABLE runtime state without turning recognition into a
capability claim.
Full ASS/SSA presentation is an optional component. Add it with the same version as the core artifact and install its extension in the player options:
dependencies {
implementation("io.github.shusek:composemediaplayer-ass:<version>")
}val playbackOptions = remember {
VideoPlaybackOptions(
extensions = listOf(AssSubtitleExtension()),
)
}
val playerState = rememberVideoPlayerState(playbackOptions = playbackOptions)The dependency supplies the platform integration (and the bundled runtime on platforms documented
as bundled) but does not take over subtitle rendering.
Without AssSubtitleExtension(), ASS/SSA keeps the lightweight core fallback and the application
does not receive libass or JASSUB from the core artifact. See the
ASS component documentation for platform behavior and configuration.
Android applications that opt into the KMediaBridge HDR-to-SDR source bridge add only the adapter with the same version as KMediaPlayer:
dependencies {
implementation("io.github.shusek:composemediaplayer-kmediabridge:<version>")
}Install KMediaBridgeAndroidExtension() in VideoPlaybackOptions.extensions. The adapter is also
published for desktop JVM, where KMediaBridgeDesktopExtension() enables bounded container,
sample-copy and controlled SDR routes. The default player has no KMediaBridge dependency. The
client and shared KMediaFfmpegRuntime are transitive dependencies with their own license notices,
source, SBOM and relinking materials;
an explicitly selected external compatible runtime remains the application's responsibility. See the
KMediaBridge adapter documentation.
The optional pinned libdovi bridge is published separately:
dependencies {
implementation("io.github.shusek:composemediaplayer-dolbyvision:<version>")
}It provides the pinned libdovi converter plus bounded playback bridges for unencrypted flat MP4,
fMP4 HLS VOD, and Matroska on Android, iOS, JVM, and browser Wasm. The common Matroska path preserves
AAC, Opus, AC-3, and E-AC-3; JVM can additionally use FFmpeg for compatible inputs outside that
subset. Install DolbyVisionExtension() in VideoPlaybackOptions.extensions to opt in; live HLS
and DRM fail closed. See the component contract.
With DolbyVisionPolicy.AUTO, confirmed native Profile 7 remains first choice; the bridge maps P7
to P8.1 only when the active platform confirms P8 but not P7, before HDR10-base and managed-SDR
fallbacks. Planned and confirmed profile/mapping fields expose the decision without claiming an
unverified P8.1 output.
The conversion bridge accepts selected fMP4 media playlists and master HLS VOD. Android, iOS, and JVM retain the selected variant's referenced VOD audio/subtitle renditions through their local HLS proxy. Browser Wasm preserves the declared default external fMP4 audio rendition with a separate, bounded Media Source buffer when its codec is supported; unsupported layouts fail closed instead of silently dropping audio. Its video/audio buffers use parsed fMP4 clocks for initial, seek, and discontinuity timestamp mapping, and a non-independent seek restarts only from a verified sync sample.
3.0.x line.arm64-v8a and armeabi-v7a with AGP 9.2.1, compileSdk = 37, and minSdk = 23. Android x86/x86_64 is unsupported. Consumer projects should use the same or a newer compatible Android toolchain and must have compileSdk >= 37; HDR still depends on the device and active display.composemediaplayer-mpv uses minSdk = 28 and publishes ARM runtimes only.iosArm64 and iosSimulatorArm64. Its generated CocoaPod has an
exact dependency on the code-signed KMediaMpv client pod, which in turn pins the shared runtime;
native code is embedded by the application build and is never extracted at playback time.iosArm64 and iosSimulatorArm64 only; Intel simulators are unsupported. An Apple Silicon macOS host and a current Xcode/iOS Simulator are required for native compilation and tests.libgstreamer1.0-0, libgstreamer-plugins-base1.0-0, gstreamer1.0-plugins-base, and gstreamer1.0-plugins-good. Confirmed HDR additionally requires JBR 25.0.3+, GStreamer 1.28.5+, WLToolkit, Wayland color-management-v1, and the advertised Vulkan/DMABuf/output capabilities. X11/XWayland is not a confirmed HDR route.pkg-config, a C compiler, full JDK 25 JNI headers, libgstreamer1.0-dev, and libgstreamer-plugins-base1.0-dev.MPV is a separate adapter artifact. The dependency direction is:
composemediaplayer → composemediaplayer-core ← composemediaplayer-mpv
The main artifact never imports MPV. Both modules share only the state, event,
capability, backend-factory, and renderer SPI from composemediaplayer-core.
On Android and desktop JVM, the MPV adapter carries the matching KMediaMpv client transitively, so
the application does not add KMediaMpv coordinates separately. On iOS, the exact KMediaMpv pod
contains the two client XCFrameworks and depends on the exact shared runtime pod. The adapter never
downloads native code at playback time.
This dependency inversion is the reason for the 2.0.0 major version. Existing
applications can keep depending on composemediaplayer; it re-exports core
transitively. Backend authors may depend directly on composemediaplayer-core
and do not pull the default Media3/AVPlayer/JNI implementations.
Add the adapter only to supported platform source sets:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.shusek:composemediaplayer:<player-version>")
}
androidMain.dependencies {
implementation("io.github.shusek:composemediaplayer-mpv:<player-version>")
}
jvmMain.dependencies {
implementation("io.github.shusek:composemediaplayer-mpv:<player-version>")
}
iosMain.dependencies {
implementation("io.github.shusek:composemediaplayer-mpv:<player-version>")
}
}
}The MPV-specific wiring below belongs in androidMain, jvmMain, or iosMain.
A library whose complete target matrix is supported by the adapter may put the
dependency in commonMain instead.
An MPV-only application does not need composemediaplayer at all. Depend on
composemediaplayer-mpv (which brings composemediaplayer-core transitively)
and render with BackendVideoPlayerSurface. This avoids pulling Media3,
AVPlayer, or the default desktop JNI implementations:
val playerState = rememberMpvVideoPlayerState()
BackendVideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize(),
)Then select MPV explicitly:
val playerState = rememberMpvVideoPlayerState()
VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)Call inspectMpvBackend() before creating the state when the application wants
to present its own unsupported-device message.
For dependency injection, select the implementation in the application composition root without exposing it to the rest of the app:
val backend: VideoPlayerBackend = mpvVideoPlayerBackend()
val playerState = rememberVideoPlayerState(backend)The default runtime source is the verified KMediaMpv bundle. Keep that default on Android, Linux, Apple Silicon macOS, and Windows x86_64. An application may still override it with an absolute, application-supplied libmpv path:
val options = MpvPlaybackOptions(
runtimeSource = MpvRuntimeSource.ExplicitPath(
"C:\\Program Files\\MyApp\\runtime\\mpv-2.dll",
),
)On iOS, use the generated ComposeMediaPlayerMpv pod so CocoaPods embeds and
code-signs the exact KMediaMpv, KMediaFfmpegRuntime, and KMediaAssRuntime
framework graph. The default Bundled source resolves that graph. An
application-supplied framework can still be selected with System or an
ExplicitPath inside the signed application bundle.
inspectMpvBackend() reports a missing runtime, unsupported target, or rejected
native payload without falling back silently. rememberVideoPlayerState()
continues to select the normal platform backend.
The verified bundled runtime supports Android API 28+ on arm64-v8a and
armeabi-v7a, Linux on x86_64 and ARM64, macOS on ARM64, and Windows x86_64.
The exact iOS pod supports ARM64 device/simulator. Android x86, Android x86_64,
macOS x86_64, Intel iOS simulators, and Windows ARM64 are not supported. The
bundled runtime has networking disabled, so use local media and subtitle files.
Desktop applications run on Java 25 with --enable-native-access=ALL-UNNAMED.
The core, default player, and adapter code remain under this repository's own license. The separately licensed KMediaMpv artifacts carry the reviewed native payload, notices, exact corresponding source, and recipient relinking path required for the LGPL components. Only the KMediaMpv/native boundary is subject to those separate terms; the application and the backend-neutral player modules are not relicensed as LGPL.
See Player backend architecture for the module rules and the checklist for implementing another backend.
The repository commits Kotlin/Wasm Yarn lockfiles. CI fails instead of silently replacing them when an npm dependency changes; run ./gradlew kotlinWasmUpgradeYarnLock after an intentional npm update and review the resulting lockfile diff.
Gradle dependency verification pins external build artifacts by SHA-256. The only checksum exception is restricted to the locally generated dev and 0.0.0-consumer.* smoke-test publications under this project's own coordinates, whose binaries change with the source under test.
| Library Version | Kotlin Version | Compose Version | Java | Android toolchain |
|---|---|---|---|---|
| 3.0.x | 2.4.0 | 1.11.1 | 25 | AGP 9.2.1 / compileSdk 37 / minSdk 23; MPV minSdk 28 |
| 2.0.x | 2.4.0 | 1.11.1 | 25 | AGP 9.2.1 / compileSdk 37 / minSdk 23; MPV minSdk 28 |
| 1.0.x | 2.4.0 | 1.11.1 | 25 | AGP 9.2.1 / compileSdk 37 / minSdk 23 |
| 0.9.0 | 2.3.20 | 1.10.3 | See release | See release |
| 0.8.6 | 2.3.0 | 1.9.3 | See release | See release |
| 0.8.3 | 2.2.20 | 1.9.0 | See release | See release |
| 0.7.11 | 2.2.0 | 1.8.2 | See release | See release |
| 0.7.10 | 2.1.21 | 1.8.2 | See release | See release |
The current build records the public Kotlin ABI in version control. checkKotlinAbi, the published-consumer smoke project, platform tests, and the Java 25 classfile check must all pass before the single release workflow publishes the same GAV and native payload to Maven Central and the GitHub Pages Maven mirror. To reproduce the isolated consumer check locally, first run ./gradlew publishConsumerSmokeArtifacts -PpublicationVersion=0.0.0-consumer.local -PcomposeMediaPlayer.skipNativeBuild=true --no-configuration-cache, then run ./gradlew consumerSmokeTest -PpublicationVersion=0.0.0-consumer.local -PcomposeMediaPlayer.skipNativeBuild=true --no-configuration-cache in a separate Gradle invocation. The split is intentional: the Maven fixtures must exist before Gradle resolves the consumer configurations.
The release workflow verifies the complete GitHub Pages staging repository and uploads an immutable release-delta artifact before it performs the irreversible Maven Central release. The delta intentionally excludes mutable maven-metadata.xml files. If Maven Central succeeds but the final gh-pages push fails after its retries, overlay that delta on the latest Pages repository, run python3 .github/scripts/rebuild_maven_metadata.py <pages-worktree>/maven, and publish the resulting commit; do not run the Maven Central publication again for the same version.
Before using Compose Media Player, you need to create a state for the video player using the rememberVideoPlayerState function:
val playerState = rememberVideoPlayerState()Wasm uses the programmatic @shusek/movi-player/engine API as an externally hosted, lazily imported
engine. The public state, Compose surface, controls, overlays and fullscreen API stay unchanged.
The exact 0.3.5-kmp.3 module is fetched from jsDelivr only when the first Movi source is opened;
choosing legacy mode does not request it, and KMediaPlayer does not bundle Movi or its media Wasm.
val options = remember {
VideoPlaybackOptions(
webPlaybackEngine = WebPlaybackEngine.MOVI, // default
)
}
val playerState = rememberVideoPlayerState(playbackOptions = options)Offline, strict-CSP, or controlled-hosting deployments can replace the public module URL before opening the first Movi source:
WebMediaDependencyConfig.moviPlayerModuleUrl = "/vendor/movi-player-0.3.5-kmp.3/engine.js"The configured URL executes as an ES module. Pin an immutable compatible build and never place credentials in the URL.
Use the explicit compatibility route when an application requires native HTML5 video:
VideoPlaybackOptions(webPlaybackEngine = WebPlaybackEngine.LEGACY)There is no automatic fallback from Movi to legacy after an error. Clear sources using
REQUIRE_HDR, FORCE_SDR, or a non-AUTO Dolby Vision policy are routed to legacy before player
initialization. DRM combined with one of those strict color policies, projection, or non-default
texture cropping fails closed with VideoPlayerError.DrmError. Recognized HLS, DASH and MSS
manifests require Movi: explicit legacy mode fails with VideoPlayerError.SourceError, while an
adaptive source combined with a strict color policy fails with VideoPlayerError.ColorPipelineError.
For adaptive DRM, media headers and license headers are intentionally separate:
val options = VideoPlaybackOptions(
webDrmConfiguration = WebDrmConfiguration(
licenseUrl = "https://license.example.invalid/widevine",
licenseRequestHeaders = mapOf("Authorization" to "<runtime value>"),
),
)
playerState.openUri(
uri = "https://media.example.invalid/manifest.mpd",
requestHeaders = mapOf("X-Media-Token" to "<runtime value>"),
)DRM values are retained only by the runtime objects. WebDrmConfiguration.toString(), diagnostics
and adapter errors redact the license URL and headers, and the adapter forces Movi's process-wide
logger to SILENT before player construction so upstream error payloads cannot disclose request
data. Applications must likewise avoid logging or persisting their input values. DRM uses
Movi/Shaka's native video element, so canvas-only features are unavailable.
Movi reports source color metadata when available, but KMediaPlayer deliberately leaves decoder,
surface and output dynamic range as UNKNOWN; isHDR is not evidence that HDR reached the display.
SDR projection samples the hidden Movi canvas through KMediaPlayer's existing WebGL projection
renderer. External ASS/SSA can use the optional JASSUB overlay with either engine. For embedded
ASS/SSA, Movi streams raw timed packets and bounded container font bytes directly to the same
host-owned JASSUB renderer through its pluggable subtitle contract; KMediaPlayer no longer exports
a second subtitle file or demuxes the media again. Canvas PiP and a confirmed Movi HDR/projection
route remain outside the first integration release. See
Wasm Movi integration.
The Wasm sample starts with a bundled MKV containing en and pl Opus tracks. Add
?engine=legacy to the sample URL to exercise the compatibility path.
After initializing the player state, you can display the surface of the video using VideoPlayerSurface:
// Video Surface
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize()
)
}[!WARNING] Content scaling support is experimental. The behavior may vary across different platforms.
You can control how the video content is scaled inside the surface using the contentScale parameter:
VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop // Default is ContentScale.Fit
)Available content scale options:
ContentScale.Fit (default): Scales the video to fit within the surface while maintaining aspect ratioContentScale.Crop: Scales the video to fill the surface while maintaining aspect ratio, potentially cropping partsContentScale.FillBounds: Stretches the video to fill the surface, may distort the aspect ratioContentScale.Inside: Similar to Fit, but won't scale up if the video is smaller than the surfaceContentScale.None: No scaling applied[!WARNING] Surface type parameter is supported only for Android target.
Available surface type options:
SurfaceType.Auto (default): uses SurfaceView for flat video, which is Android's HDR-eligible system path, and the controlled Media3 frame processor for projected/stereo video.SurfaceType.SurfaceView: uses SurfaceView for the player view, which is more performant for video playback but has limitations in terms of composability and animations.SurfaceType.TextureView: uses TextureView for the player view, which allows for more complex composable layouts and animations.SurfaceType.SphericalGlSurfaceView: uses Media3's spherical GL surface explicitly. This is intended for equirectangular 360 projection on Android.SurfaceType.ProjectedGlSurfaceView: uses DefaultVideoFrameProcessor and the Android projection GL effect explicitly. This supports flat stereo, equirectangular, fisheye and EAC projection.VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize(),
surfaceType = SurfaceType.SurfaceView // Default is SurfaceType.Auto
)Projection settings live in VideoPlaybackOptions. By default, the player auto-detects common projection tokens from filenames, URLs and metadata when the configured projection is left as flat 2D:
val playerState = rememberVideoPlayerState()
// Example_VR180_SBS.mp4 is detected as equirectangular 180 side-by-side.
playerState.openUri("https://example.com/Example_VR180_SBS.mp4")Apps can pass a known mode when creating the player state:
val playerState = rememberVideoPlayerState(
playbackOptions = VideoPlaybackOptions(
projection = VideoProjectionSettings(
projectionType = VideoProjectionType.Equirect180,
stereoLayout = VideoStereoLayout.SideBySide,
eyeOrder = VideoEyeOrder.LeftRight,
)
)
)The projection can also be changed while the player is alive, which is useful for manual projection controls:
playerState.projection = playerState.projection.nextVideoProjectionPreset().projectionFor sources that include padded or misaligned active video, projectionTextureCrop crops the source texture before
stereo splitting and projection. This is separate from contentScale, which scales the final surface, and from
projectionView.zoom, which changes the projected viewport:
val playerState = rememberVideoPlayerState(
playbackOptions = VideoPlaybackOptions(
projection = VideoProjectionSettings(
projectionType = VideoProjectionType.Equirect180,
stereoLayout = VideoStereoLayout.SideBySide,
),
projectionTextureCrop = VideoTextureCrop(
left = 0.02f,
top = 0.01f,
right = 0.02f,
bottom = 0.01f,
),
)
)
playerState.projectionTextureCrop = playerState.projectionTextureCrop.copy(left = 0.03f)For projected renderers, apps can steer the viewport independently from the source projection:
playerState.projectionView = playerState.projectionView.copy(
yawDegrees = playerState.projectionView.yawDegrees + 15f,
pitchDegrees = 0f,
zoom = 1.2f,
)By default, VideoProjectionViewControlMode.AUTO uses device motion on platforms where it is available for surround projections. It uses Android rotation vector sensors, iOS CoreMotion device motion, and browser DeviceOrientationEvent; this is more stable than using the accelerometer alone. Apps can force or disable sensor control:
val playerState = rememberVideoPlayerState(
playbackOptions = VideoPlaybackOptions(
projectionViewControlMode = VideoProjectionViewControlMode.DEVICE_MOTION,
)
)
playerState.projectionViewControlMode = VideoProjectionViewControlMode.MANUALThe common API includes presets and detection helpers:
val detection = detectVideoProjection(
VideoProjectionDetectionInput(
title = "Example_VR180_SBS.mp4",
videoSizes = listOf(VideoProjectionVideoSize(width = 7680, height = 3840)),
)
)
val nextPreset = playerState.projection.nextVideoProjectionPreset()Supported projection metadata includes flat 2D/3D, equirectangular 180/360, fisheye 180/190/200/220, and EAC 360 with mono, side-by-side, and over-under stereo layouts. A non-default projectionTextureCrop switches SurfaceType.Auto and canvas paths to projection renderers so the crop can be applied.
On Android, SurfaceType.Auto sends every projection mode through Media3's DefaultVideoFrameProcessor so PQ/HLG input cannot accidentally be drawn as washed-out SDR. HDR projection requires GLES3 plus EXT_YUV_target; HDR10/PQ and HLG outputs additionally require the matching EGL colorspace and use RGBA16F intermediates with an RGBA_1010102 output surface. AUTO and PREFER_HDR retry Media3's managed SDR tone mapping if HDR output setup fails, while REQUIRE_HDR reports a typed error. Flat video uses SurfaceView; TextureView is an explicit compositing opt-out and is not advertised as HDR. Android projected surfaces can use device rotation automatically unless projectionViewControlMode is set to MANUAL. iOS projections use AVPlayerItemVideoOutput to pull P010/NV12 frames into CoreVideo Metal textures. One rgba16Float shader handles flat stereo, equirectangular, fisheye, EAC, crops and CoreMotion-backed viewport control; HDR10/HLG is confirmed only after an EDR Metal frame completes, and runtime failure replans to controlled SDR unless REQUIRE_HDR was selected. Flat iOS playback keeps AVPlayerLayer; FORCE_SDR explicitly disables EDR on that layer so AVFoundation performs the SDR presentation. macOS JVM uses the same P010/NV12 and FP16 color/projection ordering in a per-player native Metal layer, follows the active NSScreen, and keeps Compose controls/subtitles in a separate overlay window.
Windows requests Media Foundation hardware transforms and requires a decoded P010 GPU surface before
its D3D11 projection/color shader feeds a flip-model swapchain; it does not invent a decoder
component name that Media Foundation has not exposed. HDR uses an Advanced Color PQ/BT.2020 or
scRGB swapchain. FORCE_SDR, and the retry after a non-strict HDR-output failure, instead run
BT.2390 tone mapping and BT.2020-to-BT.709 gamut conversion in that same renderer before presenting
an 8-bit dithered G22/BT.709 swapchain. These native MP4 routes do not depend on KMediaBridge. A
route is confirmed only after the active IDXGIOutput6, requested swapchain color space, P010 input
and first successful Present agree. Linux flat HDR uses GStreamer waylandsink; projection uses
the optional Vulkan renderer with an exact 10-bit PQ/HLG WSI colorspace. Its current linear P010
DMA-BUF path maps the buffer before the Vulkan upload, so it is bounded but not advertised as
zero-copy. Supported JBR Wayland sessions use sibling video and bounded wl_shm Compose overlay
subsurfaces with input and semantics retained on the parent Compose surface. Missing JBR/WLToolkit,
GStreamer, Wayland color management, DMA-BUF, Vulkan, overlay, or output support replans to verified
SDR; FORCE_SDR explicitly bypasses the HDR Wayland surface.
Web attempts HDR projection only when WebGPU configuration readback and an HDR display are present. It imports a bounded set of current VideoFrame objects, asks the browser to color-manage tagged PQ/HLG into an extended Display-P3 working encoding, and projects them into an rgba16float canvas with extended tone mapping. HDR is confirmed only after the display still reports high dynamic range, the configuration is retained, and the first GPU submission completes; this confirms an HDR canvas element rather than claiming a raw PQ/HLG display transport. If an HDR canvas is rejected, AUTO/PREFER_HDR recreates the route as WebGPU controlled SDR: the extended sRGB texture is converted to linear light, tone-mapped with BT.2390, gamut-mapped through the common ICtCp LUT, dithered, and written to an FP16 standard sRGB canvas. WebGL is reserved for browser-managed SDR projection and is never reported as an HDR-to-SDR converter; without WebGPU the controlled HDR fallback fails closed. REQUIRE_HDR fails instead of falling back. Flat HDR normally stays browser-managed and keeps output status unknown, while FORCE_SDR switches its visible output to the same controlled WebGPU SDR canvas because a native <video> cannot be forced to SDR on an HDR display. Remote web videos must be served with CORS headers whenever a canvas renderer is active, because browsers block cross-origin video texture sampling otherwise. Some mobile browsers, especially iOS Safari, require a user permission gesture before device orientation events are delivered. A libVLC native view is never used as proof of HDR output.
You can add a custom overlay UI that will always be visible, even in fullscreen mode, by using the overlay parameter:
VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize()) {
// This overlay will always be visible
Box(modifier = Modifier.fillMaxSize()) {
// You can customize the UI based on fullscreen state
if (playerState.isFullscreen) {
// Fullscreen UI
IconButton(
onClick = { playerState.toggleFullscreen() },
modifier = Modifier.align(Alignment.TopEnd).padding(16.dp)
) {
Icon(
imageVector = Icons.Default.FullscreenExit,
contentDescription = "Exit Fullscreen",
tint = Color.White
)
}
} else {
// Regular UI
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.5f))
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
// Your custom controls here
IconButton(onClick = {
if (playerState.isPlaying) playerState.pause() else playerState.play()
}) {
Icon(
imageVector = if (playerState.isPlaying)
Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
tint = Color.White
)
}
}
}
}
}You can play a video by providing a direct URL:
// Open a video and automatically start playing (default behavior)
playerState.openUri("http://example.com/video.mp4")
// Open a video but keep it paused initially
playerState.openUri("http://example.com/video.mp4", InitialPlayerState.PAUSE)To play a local video file you can use PlatformFile from FileKit.
val file = FileKit.openFilePicker(type = FileKitType.Video)
// Open a file and automatically start playing (default behavior)
file?.let { playerState.openFile(it) }
// Open a file but keep it paused initially
file?.let { playerState.openFile(it, InitialPlayerState.PAUSE) }The initializePlayerState parameter controls whether the video automatically starts playing after opening:
InitialPlayerState.PLAY (default): The video will automatically start playing after openingInitialPlayerState.PAUSE: The video will be loaded but remain paused until you call play()
On Android, apps that need a custom Media3 source can pass an AndroidMediaSourceProvider. The normal player state
reset, metadata extraction, repeat mode, volume and initial play/pause handling stay inside the library:
@OptIn(UnstableApi::class)
val playerState = rememberVideoPlayerState(
androidMediaSourceProvider = AndroidMediaSourceProvider { request ->
val uri = request.mediaItem.localConfiguration?.uri?.toString().orEmpty()
if (uri.contains("example-cdn.com")) {
customMediaSourceFactory(request.requestHeaders).createMediaSource(request.mediaItem)
} else {
null
}
},
)Check the sample project for a complete example.
Browser playback exposes adaptive video variants from Movi video tracks. Movi's track id -1 is
represented as automatic selection rather than a selectable variant. The list is populated after
the manifest has loaded, so custom controls should handle an empty list as "not available yet" or
"not supported by this backend". Legacy browser playback does not expose adaptive variants.
val quality = playerState.availableHlsQualities.firstOrNull { it.height == 720 }
when (val result = quality?.let { playerState.selectHlsQuality(it.id) } ?: playerState.selectAutoHlsQuality()) {
is HlsQualitySelectionResult.Selected -> println("HLS quality selected: ${result.quality.label}")
HlsQualitySelectionResult.Auto -> println("Automatic HLS quality selection enabled")
is HlsQualitySelectionResult.NotFound -> println("HLS quality not found: ${result.variantId}")
HlsQualitySelectionResult.NotSupported -> println("HLS quality selection is not supported here")
}Use currentHlsQuality and hlsQualityMode to reflect the current state in custom controls. Selected and Auto mean the request was applied; NotFound means the variant id was stale or from another source; NotSupported means the current platform or source does not expose HLS variants. Android, iOS and desktop targets currently report NotSupported for this API.
Chapters are exposed through the common VideoPlayerState API and arrive asynchronously after a
source is opened:
playerState.chapters.forEach { chapter ->
println("${chapter.startMs}..${chapter.endMs}: ${chapter.title}")
}
playerState.currentChapter?.let { chapter ->
println("Now playing: ${chapter.title}")
}
playerState.chapters.firstOrNull()?.let(playerState::seekToChapter)MediaChapter contains start, optional exclusive end, title, BCP 47 language, and
isHidden, plus millisecond and duration convenience properties. Missing ends are inferred from
the next distinct chapter start or the media duration when available. Hidden chapters remain in
the list so the application can decide whether to show them. chapters is cleared when the
logical source changes; internal remux or color-conversion source swaps retain it.
| Source | Android | iOS | Desktop JVM | Web |
|---|---|---|---|---|
| Matroska/WebM editions and chapter atoms | Media3 | AVFoundation when supported | Built-in parser | Browser/backend dependent |
MP4/MOV/3GP Nero chpl and QuickTime chapter tracks |
Media3 | AVFoundation | Built-in parser | Browser/backend dependent |
ID3 CHAP/CTOC
|
Media3 | AVFoundation when supported | Built-in parser | Browser/backend dependent |
| ASF/WMV markers | Backend dependent | — | Built-in parser | — |
| Apple HLS JSON chapters | VOD/Event | VOD/Event | VOD/Event | Movi/backend dependent |
WebVTT <track kind="chapters">
|
Backend dependent | AVFoundation when supported | Backend dependent | Built in |
| Optional MPV backend | Backend API dependent | — | Native chapter-list
|
— |
Matroska selects the default edition, or the first edition when none is marked default, chooses
the best available localized display label, and flattens nested atoms into the public list.
Apple HLS JSON discovery intentionally excludes sliding live playlists; generic HLS
EXT-X-DATERANGE metadata is not treated as chapters.
In WebPlaybackEngine.LEGACY, embedded MKV subtitle extraction loads the exact
matroska-subtitles 3.3.2 browser bundle with a pinned SHA-384 subresource-integrity value.
Movi discovers and renders embedded subtitle tracks itself. Strict-CSP or offline legacy
deployments can self-host the parser bundle; configure both values before creating a player:
WebMediaDependencyConfig.matroskaSubtitlesScriptUrl = "/vendor/matroska-subtitles-3.3.2.min.js"
WebMediaDependencyConfig.matroskaSubtitlesScriptIntegrity = "sha384-..."Setting the URL to an empty string disables embedded MKV subtitle extraction without affecting ordinary browser video playback.
You can detect the current playback state via playerState.isPlaying and configure a Play/Pause button as follows:
Button(onClick = {
if (playerState.isPlaying) {
playerState.pause()
println("Playback paused")
} else {
playerState.play()
println("Playback started")
}
}) {
Text(if (playerState.isPlaying) "Pause" else "Play")
}playerState.stop()
println("Playback stopped")playerState.volume = 0.5f // Set volume to 50%
println("Volume set to 50%")playerState.loop = true // Enable loop playbackYou can listen for loop restarts via the onRestart callback:
playerState.loop = true
playerState.onRestart = {
println("Video restarted from the beginning")
}Restart playback from the beginning. Works reliably from any state, including when the video has ended:
playerState.restart()Get notified when playback reaches the end (only called when loop is false):
playerState.onPlaybackEnded = {
println("Video finished")
}playerState.playbackSpeed = 1.5f // Set playback speed to 1.5x
println("Playback speed set to 1.5x")You can adjust the playback speed between 0.5x (slower) and 2.0x (faster). The default value is 1.0x (normal speed).
To display and control playback progress, use seekStart and seekFinished for slider interactions:
Slider(
value = playerState.sliderPos,
onValueChange = { playerState.seekStart(it) },
onValueChangeFinished = { playerState.seekFinished() },
valueRange = 0f..1000f
)seekStart(value) updates the slider position visually without performing the actual seek, allowing smooth dragging.seekFinished() commits the seek to the player and ends the drag interaction.For programmatic seeking (e.g. skip forward/backward), use seekTo, seekToMs, seekBy or seekByMs:
playerState.seekToMs(60_000)
playerState.seekByMs(10_000)In case of an error, you can display it using println:
playerState.error?.let { error ->
println("Error detected: ${error.message}")
playerState.clearError()
}Compose Media Player internal logging is disabled by default. Enable it only while diagnosing playback behavior:
import io.github.kdroidfilter.composemediaplayer.util.ComposeMediaPlayerLoggingLevel
import io.github.kdroidfilter.composemediaplayer.util.allowComposeMediaPlayerLogging
import io.github.kdroidfilter.composemediaplayer.util.composeMediaPlayerLoggingLevel
allowComposeMediaPlayerLogging = true
composeMediaPlayerLoggingLevel = ComposeMediaPlayerLoggingLevel.DEBUGJVM native backends keep their own diagnostic output disabled by default. Set COMPOSE_MEDIA_PLAYER_NATIVE_LOGGING=true before starting the app to enable native messages from the macOS, Windows and Linux backends.
To detect if the video is buffering:
if (playerState.isLoading) {
CircularProgressIndicator()
}Compose Media Player supports adding subtitles from both URLs and local files. SRT and VTT subtitles are rendered using Compose, providing a uniform appearance across all platforms. The core also parses ASS/SSA timing and dialogue text, but authored styles, positioning and effects require the optional composemediaplayer-ass artifact plus AssSubtitleExtension(). On Android, Apple, Windows and Linux that adapter uses the separately published, process-wide KMediaAssRuntime; the MPV and KMediaBridge adapters reach the same text stack transitively through KMediaFfmpegRuntime. Browser Wasm keeps its JASSUB/libass integration. Windows and Linux users do not install libass separately. Other native routes keep the plain-dialogue fallback unless their playback backend supplies styled subtitle rendering. On desktop JVM, the libVLC MKV/WebM backends can expose and select embedded subtitle tracks. For the optional HLS container fallback, the macOS KMediaBridge runtime can burn selected embedded text tracks into controlled BT.709 SDR; remux-only platform runtimes use the optional VLC fallback when it is available.
The player supports SRT, VTT, ASS and SSA subtitle formats with automatic format detection. With composemediaplayer-ass installed and activated, Android, browser Wasm, Apple targets, and Windows/Linux JVM use the libass-backed routes described below. Android and JVM resolve the exact shared runtime transitively; Apple uses the matching KMediaAssRuntime pod. Without the optional adapter, the core keeps its lightweight ASS/SSA dialogue fallback. A platform-specific backend, such as a native libVLC surface, may independently provide styled rendering.
You can add subtitles by specifying a URL:
val track = SubtitleTrack(
label = "English Subtitles",
language = "en",
src = "https://example.com/subtitles.vtt" // Works with .srt, .vtt, .ass and .ssa files
)
playerState.addSubtitleTrack(track)
when (val result = playerState.selectSubtitleTrack(track)) {
is TrackSelectionResult.Selected -> println("Subtitle track selected: ${result.trackId}")
TrackSelectionResult.Disabled -> println("Subtitles disabled")
is TrackSelectionResult.NotFound -> println("Subtitle track not found: ${result.trackId}")
TrackSelectionResult.NotSupported -> println("Subtitle track selection is not supported")
is TrackSelectionResult.Failed -> println("Failed to select subtitle track: ${result.message}")
TrackSelectionResult.Auto -> println("Automatic track selection restored")
}The Android variant of the optional composemediaplayer-ass artifact contains one thin renderer bridge for arm64-v8a and armeabi-v7a; Intel Android ABIs are not published. Its exact transitive dependency supplies KMediaAssRuntime 0.1.0-rc.3 with libass 0.17.5, FreeType, FriBidi and HarfBuzz. Applications do not need another Maven repository or a manual runtimeOnly dependency. Add AssSubtitleExtension() to VideoPlaybackOptions.extensions to activate it. Media3 still owns playback and demuxing. The extension intercepts only the raw Matroska ASS/SSA packet format and renders it into a transparent screen-space overlay, while other Media3 subtitle formats keep their normal path.
The libass route supports:
.ass and .ssa tracks selected through addSubtitleTrack(...);ASS script styles control this overlay, so subtitleTextStyle and subtitleBackgroundColor apply to the Compose subtitle renderer rather than restyling libass output. A custom AndroidMediaSourceProvider that returns a fully constructed MediaSource must preserve KMediaPlayer's subtitle parser/extractor setup; otherwise that custom source can flatten ASS into ordinary Media3 cues before the libass renderer sees it. Side-loaded SSA supplied directly to Media3 and HLS SSA retain Media3's fallback path.
The thin renderer bridge lives in the optional adapter. Native text libraries,
their notices, corresponding source and LGPL replacement material live once in
kmedia-ass-runtime-android, not in composemediaplayer-ass or the base player.
On a 64-bit JVM, the optional component initializes the exact
kmedia-ass-runtime-desktop dependency and resolves libass through Java's
Foreign Function API. The adapter JAR itself contains no native libraries. The
runtime publishes libass 0.17.5 for macOS ARM64, Linux x86_64/ARM64 and Windows
x86_64, with SHA-256 verification before native loading. Windows uses
DirectWrite font discovery; Linux uses the desktop's normal fontconfig
configuration. External ASS/SSA files are composited into writable BGRA video
frames. Embedded Matroska ASS/SSA tracks exposed by the libVLC canvas backend
use the built-in extractor and pass container font attachments to libass.
Users do not install libass or copy its native files. Register
AssSubtitleExtension() and launch the application with native access enabled:
--enable-native-access=ALL-UNNAMED
There is no system-libass fallback or single-library override. An advanced
deployment can initialize one complete, compatible replacement directory with
KMediaAssRuntime.initialize(RuntimeSource.externalDirectory(...)) before any
native client loads. A second runtime ID is rejected process-wide. If loading,
track extraction, or rendering fails, the player keeps its Compose or libVLC
subtitle fallback. Native HDR/color and LIBVLC_NATIVE surfaces retain their
platform subtitle path because they do not expose a writable CPU video frame.
Full ASS/SSA rendering on wasmJs is provided by the optional composemediaplayer-ass artifact.
Register AssSubtitleExtension() in VideoPlaybackOptions.extensions; external ASS/SSA tracks then
use JASSUB with both the default Movi engine and the legacy engine. For embedded ASS/SSA, Movi
mounts the same extension as its pluggable renderer, forwards the codec header, timed Matroska
packets and bounded font attachments, and drives JASSUB from its media clock. Unsupported embedded
formats remain on Movi's built-in subtitle path. Movi DRM and legacy external playback use the
native video clock. The optional artifact pins JASSUB 2.5.7, so applications do not add JASSUB,
libass, worker scripts or WASM files manually.
Create stable player options and then select an .ass or .ssa subtitle track:
val playbackOptions = remember {
VideoPlaybackOptions(
extensions = listOf(AssSubtitleExtension()),
)
}
val playerState = rememberVideoPlayerState(playbackOptions = playbackOptions)
LaunchedEffect(Unit) {
playerState.openUri("https://example.com/video.mp4")
val subtitleTrack = SubtitleTrack(
label = "English ASS",
language = "en",
src = "https://example.com/subtitles.ass",
)
playerState.addSubtitleTrack(subtitleTrack)
playerState.selectSubtitleTrack(subtitleTrack)
}If you use VideoPlayerControls, the selected track is rendered automatically by VideoPlayerSurface. If you build custom controls, keep using the same playerState.selectSubtitleTrack(...) and playerState.disableSubtitles() APIs and inspect the returned TrackSelectionResult when you need to handle unsupported or missing tracks.
Browser renderer configuration is immutable and belongs to the installed extension instance:
import io.github.kdroidfilter.composemediaplayer.AssSubtitleRendererConfig
import io.github.kdroidfilter.composemediaplayer.AssFontQueryMode
val playbackOptions = remember {
VideoPlaybackOptions(
extensions = listOf(
AssSubtitleExtension(
config = AssSubtitleRendererConfig(
fontQueryMode = AssFontQueryMode.DISABLED,
debug = false,
),
),
),
)
}Configuration options:
| Option | Default | Description |
|---|---|---|
enabled |
true |
Enables or disables browser ASS/SSA rendering. When disabled, ASS/SSA tracks are ignored by the JASSUB renderer. |
workerUrl |
null |
Optional URL for a self-hosted JASSUB worker. Leave unset to use the bundled npm asset. |
wasmUrl |
null |
Optional URL for jassub-worker.wasm. Leave unset to use the bundled npm asset. |
modernWasmUrl |
null |
Optional URL for jassub-worker-modern.wasm. Leave unset to use the bundled npm asset. |
fallbackFontUrl |
null |
Optional URL for the fallback font used by libass. Leave unset to use JASSUB defaults. |
fallbackFontFamily |
"liberation sans" |
Font family name mapped to fallbackFontUrl. |
preloadFontUrls |
emptyList() |
Font URLs fetched eagerly when the renderer starts. |
availableFontUrls |
emptyMap() |
Case-insensitive font-family-to-URL mappings fetched only when required. |
fontQueryMode |
DISABLED |
LOCAL enables browser font discovery; LOCAL_AND_REMOTE also enables JASSUB's remote font lookup and its privacy/network implications. |
debug |
false |
Enables JASSUB debug logging. |
For production deployments you can optionally self-host the worker, WASM and fallback font assets. This is useful when your hosting/CDN needs explicit cache headers, predictable URLs, or a strict Content Security Policy:
AssSubtitleExtension(
config = AssSubtitleRendererConfig(
workerUrl = "/jassub/worker/worker.js",
wasmUrl = "/jassub/wasm/jassub-worker.wasm",
modernWasmUrl = "/jassub/wasm/jassub-worker-modern.wasm",
fallbackFontUrl = "/jassub/default.woff2",
),
)The URLs may be relative to your app base URL or absolute. Prefer same-origin URLs. If you host the video, subtitle, worker, WASM or font files on another domain, configure CORS headers so the browser can load them from your app origin.
Browser notes:
OffscreenCanvas,
canvas.transferControlToOffscreen() and the other browser primitives reported by extension
availability. The video-backed route uses requestVideoFrameCallback() (JASSUB includes its
polyfill); the clear Movi route uses canvas-only manual rendering. Missing support leaves the
Compose fallback active.Cross-Origin-Embedder-Policy: require-corp and
Cross-Origin-Opener-Policy: same-origin. Without them JASSUB automatically uses its
single-threaded fallback.Fit, Crop, FillBounds, FillWidth and FillHeight. Projection
subtitles remain a flat screen-space overlay over the projected canvas.When the backend exposes tracks from the media container, they are available through the player
state. The default Wasm route maps Movi's numeric ids to stable KMediaPlayer string ids and calls
Movi's real track-selection API; a rejected switch returns Failed without changing state or
emitting TrackChanged. Passing null restores the initially selected/default audio track rather
than muting it. Legacy browsers read native audioTracks and textTracks from the <video>
element, Android reads Media3 track groups, and the desktop JVM libVLC/external HLS fallbacks expose
embedded audio/subtitle tracks when probing is available.
val audioTrack = playerState.availableAudioTracks.firstOrNull()
if (audioTrack != null) {
when (val result = playerState.selectAudioTrack(audioTrack)) {
is TrackSelectionResult.Selected -> println("Audio track selected: ${result.trackId}")
TrackSelectionResult.Auto -> println("Automatic audio track selection restored")
is TrackSelectionResult.NotFound -> println("Audio track not found: ${result.trackId}")
TrackSelectionResult.NotSupported -> println("Audio track selection is not supported")
is TrackSelectionResult.Failed -> println("Failed to select audio track: ${result.message}")
TrackSelectionResult.Disabled -> Unit
}
}
val subtitleTrack = playerState.availableSubtitleTracks.firstOrNull { it.isEmbedded }
if (subtitleTrack != null) {
playerState.selectSubtitleTrack(subtitleTrack)
}availableSubtitleTracks can contain both external tracks added by your app through addSubtitleTrack(...) and embedded tracks discovered from the current media. Use replaceExternalSubtitleTracks(...) to replace app-managed tracks while preserving embedded tracks, or removeSubtitleTrack(...) / clearExternalSubtitleTracks() to remove app-managed tracks.
selectAudioTrack(...), selectSubtitleTrack(...) and disableSubtitles() return TrackSelectionResult. Selected, Auto and Disabled mean the request was applied; NotFound, NotSupported and Failed mean the player state was not changed.
You can customize the appearance of subtitles using the following properties:
// Customize subtitle text style
playerState.subtitleTextStyle = TextStyle(
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
// Customize subtitle background color
playerState.subtitleBackgroundColor = Color.Black.copy(alpha = 0.7f)To disable subtitles:
when (playerState.disableSubtitles()) {
TrackSelectionResult.Disabled -> println("Subtitles disabled")
else -> println("Subtitles were not disabled")
}[!WARNING] Fullscreen support is experimental. The behavior may vary across different platforms.
You can toggle between windowed and fullscreen modes using the toggleFullscreen() method:
// Toggle fullscreen mode
playerState.toggleFullscreen()
// Check current fullscreen state
if (playerState.isFullscreen) {
println("Player is in fullscreen mode")
} else {
println("Player is in windowed mode")
}The player doesn't display any UI by default in fullscreen mode - you need to create your own custom UI using the overlay parameter of VideoPlayerSurface. The overlay will be displayed even in fullscreen mode, and you can customize it based on the fullscreen state:
VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize(),
overlay = {
Box(modifier = Modifier.fillMaxSize()) {
// Customize UI based on fullscreen state
if (playerState.isFullscreen) {
// Fullscreen UI
// ...
} else {
// Regular UI
// ...
}
}
}
)See the "Custom Overlay UI" section under "Displaying the Video Surface" for a complete example.
[!WARNING] PiP is supported on Android (8.0+) and iOS only. On desktop and web, it is a no-op.
The player supports Picture-in-Picture mode, allowing users to continue watching video in a floating window while using other apps.
| Platform | Status | Notes |
|---|---|---|
| Android | ✅ | Requires Android 8.0+ (API 26). Add android:supportsPictureInPicture="true" to your Activity in the manifest. |
| iOS | ✅ | Uses AVPictureInPictureController. Enable "Audio, AirPlay, and Picture in Picture" in Background Modes. |
| Desktop | ❌ | No-op |
| Web | ❌ | No-op |
// Check if PiP is supported on the current device
if (playerState.isPipSupported) {
// Enable automatic PiP when the app goes to background
playerState.isPipEnabled = true
// Or enter PiP programmatically
val result = playerState.enterPip()
}On Android, you can use the AutoPipEffect composable to automatically enter PiP mode when the app goes to the background while a video is playing:
AutoPipEffect(playerState)You also need to forward PiP mode changes from your Activity:
class MainActivity : ComponentActivity() {
override fun onPictureInPictureModeChanged(isInPipMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPipMode, newConfig)
DefaultVideoPlayerState.onPictureInPictureModeChanged(isInPipMode)
}
}You can configure how the media player interacts with other apps' audio using the AudioMode parameter:
// Default: exclusive playback, pauses other apps' audio
val playerState = rememberVideoPlayerState()
// Mix with other apps' audio
val playerState = rememberVideoPlayerState(
audioMode = AudioMode(interruptionMode = InterruptionMode.MixWithOthers)
)
// Duck other apps' audio (lower their volume)
val playerState = rememberVideoPlayerState(
audioMode = AudioMode(interruptionMode = InterruptionMode.DuckOthers)
)
// Ambient mode (iOS): respect silent switch, mix with others
val playerState = rememberVideoPlayerState(
audioMode = AudioMode(
interruptionMode = InterruptionMode.MixWithOthers,
playsInSilentMode = false,
)
)| Parameter | Description | Default |
|---|---|---|
interruptionMode |
DoNotMix, MixWithOthers, or DuckOthers
|
DoNotMix |
playsInSilentMode |
iOS only: whether audio plays when the silent switch is on | true |
[!NOTE] Audio mode is only effective on Android and iOS. On desktop and web, the parameter is accepted but ignored.
On iOS the default process-wide manager reference-counts active players so one player cannot deactivate AVAudioSession while another is using it. Applications that own audio-session configuration can opt out before creating any players:
IosAudioSessionPolicy.automaticManagementEnabled = falseOn Android you can enable disk-based caching so that video data fetched via openUri() is stored locally. Subsequent plays of the same URL load from the cache instead of re-downloading, which is especially useful for scroll-based UIs like TikTok/Reels-style VerticalPager.
val playerState = rememberVideoPlayerState(
cacheConfig = CacheConfig(
enabled = true,
maxCacheSizeBytes = 200L * 1024L * 1024L // 200 MB
)
)| Parameter | Description | Default |
|---|---|---|
enabled |
Whether caching is active | false |
maxCacheSizeBytes |
Maximum disk space for the cache (LRU eviction) | 100 MB |
To clear the cache programmatically:
when (val result = playerState.clearCache()) {
CacheClearResult.Cleared -> println("Video cache cleared")
CacheClearResult.Disabled -> println("Video cache is disabled")
CacheClearResult.NotSupported -> println("Video cache is not supported on this platform")
is CacheClearResult.Failed -> println("Failed to clear video cache: ${result.message}")
}| Platform | Status | Implementation |
|---|---|---|
| Android | ✅ | Media3 SimpleCache with LeastRecentlyUsedCacheEvictor
|
| iOS | ❌ | Returns CacheClearResult.NotSupported; the library does not mutate the host application's process-global NSURLCache
|
| Desktop | ❌ | Returns CacheClearResult.NotSupported
|
| Web | ❌ | Returns CacheClearResult.NotSupported; browser manages its own HTTP cache |
[!NOTE] Android caching only applies to URIs opened via
openUri(). Local files and assets are not cached. The cache is shared across Android player instances, so multiple players benefit from the same cached data.
[!WARNING] Metadata support is experimental. There may be inconsistencies between platforms. The default Wasm route maps the fields exposed by Movi; individual containers may omit them.
The player can extract the following metadata:
kotlin.time.Duration)You can access video metadata through the metadata property of the player state:
// Access metadata after loading a video
playerState.openUri("http://example.com/video.mp4") // Auto-plays by default
// Or load without auto-playing:
// playerState.openUri("http://example.com/video.mp4", InitialPlayerState.PAUSE)
// Display metadata information
val metadata = playerState.metadata
println("Video Metadata:")
metadata.title?.let { println("Title: $it") }
metadata.duration?.let { println("Duration: $it") }
metadata.width?.let { width ->
metadata.height?.let { height ->
println("Resolution: ${width}x${height}")
}
}
metadata.bitrate?.let { println("Bitrate: ${it}bps") }
metadata.frameRate?.let { println("Frame Rate: ${it}fps") }
metadata.mimeType?.let { println("MIME Type: $it") }
metadata.audioChannels?.let { println("Audio Channels: $it") }
metadata.audioSampleRate?.let { println("Audio Sample Rate: ${it}Hz") }
VideoPlayerState.currentTime is the observed playback position intended for UI state. If you need the freshest
available playback position for external synchronization or millisecond-level actions, use:
val currentTimeMs = playerState.preciseCurrentTime.inWholeMillisecondsHere is a minimal example of how to integrate the Compose Media Player into your Compose application with a hardcoded URL:
@Composable
fun App() {
val playerState = rememberVideoPlayerState()
MaterialTheme {
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
// Video Surface
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.height(8.dp))
// Playback Controls
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Button(onClick = { playerState.play() }) { Text("Play") }
Button(onClick = { playerState.pause() }) { Text("Pause") }
}
Spacer(modifier = Modifier.height(8.dp))
// Open Video URL buttons
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Button(
onClick = {
val url = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
playerState.openUri(url) // Default: auto-play
}
) {
Text("Play Video")
}
Button(
onClick = {
val url = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
playerState.openUri(url, InitialPlayerState.PAUSE) // Open paused
}
) {
Text("Load Video Paused")
}
}
Spacer(modifier = Modifier.height(8.dp))
// Volume Control
Text("Volume: ${(playerState.volume * 100).toInt()}%")
Slider(
value = playerState.volume,
onValueChange = { playerState.volume = it },
valueRange = 0f..1f
)
}
}
}Compose Media Player is a video player library designed for Compose Multiplatform, supporting multiple platforms including Android, macOS, Windows, and Linux. Desktop playback bridges are packaged in the JVM artifact and communicate through pure JNI — no JNA and no Java media bindings. The optional Windows/Linux ASS renderer uses Java 25's Foreign Function API to load its packaged libass runtime. Linux additionally requires a compatible GStreamer 1.x runtime and plugins to be installed on the host. The library leverages:
This repository is a personal fork maintained for my own projects and integration needs. Source code and tagged artifacts are published for transparency and reproducible internal deployments; publication is not an additional grant beyond the permissions in LICENSE. This is not a supported public distribution, and releases may change without compatibility guarantees. JitPack builds are intentionally not provided because their generated coordinates and mutable commit builds are not part of the verified release pipeline. If you need a supported library for external use, use the upstream project instead: kdroidFilter/ComposeMediaPlayer.
Try the online demo here : 🎥 Live Demo
DynamicRangePolicy selects a confirmed native HDR path, a controlled renderer, or a verified HDR-to-SDR fallback. VideoColorPipelineStatus reports source, decoder, active display, surface, metadata handling, planned output, confirmed output, and fallback reason separately.composemediaplayer-mpv is an Android/iOS/JVM adapter
with libass-based ASS/SSA subtitle support. The default artifact has no MPV
dependency; applications add the adapter only to the platform source sets
where they select it.| Format | Windows | Linux | macOS & iOS | Android | WASM |
|---|---|---|---|---|---|
| Player | MediaFoundation | GStreamer | AVPlayer | Media 3 | KMediaPlayer's Movi integration fork 0.3.5-kmp.3 (default), HTML5 video (legacy, non-adaptive) |
| MP4 (H.264) | ✅ | ✅ | ✅ | ✅ | ✅ |
| AVI | ❌ | ✅ | ❌ | ❌ | ✅§ |
| MKV | ✅ | ✅ | ✅§ | ||
| MOV | ✅ | ✅ | ✅ | ❌ | ✅ |
| FLV | ❌ | ✅ | ❌ | ❌ | ❌ |
| WEBM | ✅ | ✅ | ✅ | ||
| WMV | ✅ | ✅ | ❌ | ❌ | ❌ |
| 3GP | ✅ | ✅ | ✅ | ✅ | ❌ |
| MPEG-TS | ✅ | ✅ | ✅ | ✅ | ✅§ |
| HLS (M3U8) | ✅ | ✅ | ✅ | ✅ | ✅§ |
| MPEG-DASH (MPD) | Backend dependent | Backend dependent | Backend dependent | ✅ | ✅§ |
| Smooth Streaming (MSS) | Backend dependent | Backend dependent | Backend dependent | ❌ | ✅§ |
The Android artifact includes the matching Media3 HLS module; consumers do not need to add it separately.
§ Wasm loads the exact
@shusek/movi-player@0.3.5-kmp.3headless engine from a versioned CDN URL at runtime. MoviPlayer and its media Wasm are not bundled in KMediaPlayer artifacts. The fork preserves upstream attribution and ships notices, corresponding source, build recipes and relinking instructions for its LGPL FFmpeg/Wasm payload. Container support still depends on the codecs and browser primitives available at runtime.WebPlaybackEngine.LEGACYuses native HTML video only and rejects recognized HLS, DASH and MSS manifests; it does not contain an independent adaptive-streaming fallback.
† On desktop JVM, MKV/WebM can use native playback, optional in-process libVLC, or the explicitly installed
composemediaplayer-kmediabridgeextension when the platform backend cannot demux the source directly. The default player contains no KMediaBridge or FFmpeg dependency. The optional bridge uses the process-wideKMediaFfmpegRuntime, shared with the MPV adapter; there is noffmpeg/ffprobeexecutable or system installation. Its exact runtime dependency also supplies the single shared libass, FreeType, FriBidi and HarfBuzz stack. The bridge remuxes compatible local video/audio into bounded-memory CMAF/HLS without re-encoding; the macOS runtime can also tone-map explicit HDR10/HDR10+/HLG to limited-range BT.709 forFORCE_SDRand burn embedded text subtitles for confirmed SDR input. Strictly validated HEVC Main 10 HDR10/HDR10+/HLG remains tagged as an HDR sample-copy route under HDR-preserving policies; the platform color pipeline still has to confirm the decoder, surface and display before reporting HDR output. Unsupported conversion requests fail closed or use an explicitly available VLC fallback. See JVM MKV/WebM fallback support.
HDR recognition is not treated as proof of HDR output. In particular, a libVLC native child view on Windows or Linux is not a confirmed HDR route.
AUTOuses a verified SDR tone-map when the native HDR requirements are unavailable, whileREQUIRE_HDRreturns a typed color-pipeline error. See HDR pipeline and platform status.
iPhone and iPad are supported on a non-blocking software-only best-effort basis: device arm64 builds, Simulator tests and shared Metal references are required, but this project has no physical iOS hardware and makes no physical HDR, Dolby Vision, 4K60 or thermal-performance claim. Runtime color status therefore fails closed whenever Apple does not explicitly confirm the format, display and surface.
Flat Android video follows the platform-recommended MediaCodec +
SurfaceViewpath; KMediaPlayer does not overwrite decoder buffers with a guessed dataspace. On Android 9+, a small read-only JNI bridge comparesANativeWindow_getBuffersDataSpace()with the decoded HDR10/HDR10+/HLG signal after the first frame. An exact match isRENDERER_CONFIGURED; Android 14+'s active HDR/SDR composition report is independentSYSTEM_REPORTEDevidence and is retained only for the current decoded output signal, source, surface and display. Dolby Vision remains vendor-managed. If neither public signal confirms the route,AUTOuses a verified controlled or source-bridge SDR fallback when available, whileREQUIRE_HDRfails instead of labeling inferred playback as HDR.
macOS JVM uses one native AVPlayer/EDR layer per player for flat AVFoundation-supported video. Runtime selection is controlled by the 2.0 color policy; see macOS color pipeline for the current behavior.
Linux
libvlc-native-viewcurrently embeds VLC through an X11/XWayland xwindow. Native Wayland needs a separatewl_surfacebackend and is not implemented by this path.
The library exposes a single suspendable preflight query so apps can check player capabilities, source/container support and codecs before creating a VideoPlayerState or navigating to the player screen:
val mediaSupport = MediaSupport.query()
val mkvSupported = mediaSupport.capabilities.supportsMkv
val supportedSchemes = mediaSupport.capabilities.supportedUriSchemes
val canOpenSource = mediaSupport.canPlaySource(
uri = "file:///movie.mkv",
mimeType = "video/x-matroska",
)
val h264Supported = mediaSupport.isCodecSupported(MediaCodec.H264)
val supportedVideoCodecs = mediaSupport.videoCodecs
val supportedAudioCodecs = mediaSupport.audioCodecs
val supportedCodecs = mediaSupport.allCodecs
val supportedHdr = mediaSupport.supportedHdrDynamicRanges
val hdr10PlusSupport = mediaSupport.dynamicRangeSupport(VideoDynamicRange.HDR10_PLUS)
val showHdr10PlusSources = hdr10PlusSupport == VideoDynamicRangeSupport.SUPPORTEDMediaSupport.query() checks the current platform contract for URI schemes and known container/adaptive formats such as MKV and HLS, plus codec support reported by the backend. On browser targets it recognizes Movi's supported containers and still asks HTMLVideoElement.canPlayType for MIME types used by legacy playback.
Display support is a preflight snapshot of the active output, not evidence that a future playback
surface reached HDR. UNSUPPORTED means the platform produced a known capability set without that
range; UNKNOWN remains distinct so strict source catalogs can require SUPPORTED. Query again
after an HDMI or monitor change before filtering a new source list.
[!NOTE] Media support can still depend on the device, OS version, browser, installed system components, or GStreamer plugins. Android and web targets query the current runtime where possible. Desktop and iOS expose conservative backend capability lists. Query methods are suspend functions so callers can run them from
LaunchedEffect,async,produceState, or their own coroutine/lazy strategy.
[!IMPORTANT] These coordinates reproduce the maintainer's tagged internal artifacts. Their availability does not change the permitted uses stated in LICENSE. Every remote release uses an immutable SemVer tag, including RC tags;
devbuilds are local-only.
To add Compose Media Player to your project, add the GitHub Pages Maven repository in your settings.gradle.kts file:
dependencyResolutionManagement {
repositories {
mavenCentral()
maven("https://shusek.github.io/KMediaPlayer/maven")
}
}Then include the dependency in your module build.gradle.kts file:
dependencies {
implementation("io.github.shusek:composemediaplayer:<version>")
}Pipeline integrations are separate modules:
composemediaplayer-ass ──────────┐
composemediaplayer-dolbyvision ──┼──> composemediaplayer-extension-api ──> composemediaplayer-core
composemediaplayer-kmediabridge ─┘ ↑
composemediaplayer-mpv ───────────────────────────────┤
composemediaplayer ───────────────────────────────────┘
The MPV and KMediaBridge adapters are optional. When both are present they transitively select their
own thin native clients and the same exact KMediaFfmpegRuntime; the base composemediaplayer
artifact still contains neither backend.
Normal applications receive composemediaplayer-extension-api transitively. Add it directly only
when authoring an extension. An optional artifact has no effect until its stable extension instance
is registered in VideoPlaybackOptions.extensions; extensionStatuses exposes its typed
AVAILABLE, DEGRADED, or UNAVAILABLE runtime state without turning recognition into a
capability claim.
Full ASS/SSA presentation is an optional component. Add it with the same version as the core artifact and install its extension in the player options:
dependencies {
implementation("io.github.shusek:composemediaplayer-ass:<version>")
}val playbackOptions = remember {
VideoPlaybackOptions(
extensions = listOf(AssSubtitleExtension()),
)
}
val playerState = rememberVideoPlayerState(playbackOptions = playbackOptions)The dependency supplies the platform integration (and the bundled runtime on platforms documented
as bundled) but does not take over subtitle rendering.
Without AssSubtitleExtension(), ASS/SSA keeps the lightweight core fallback and the application
does not receive libass or JASSUB from the core artifact. See the
ASS component documentation for platform behavior and configuration.
Android applications that opt into the KMediaBridge HDR-to-SDR source bridge add only the adapter with the same version as KMediaPlayer:
dependencies {
implementation("io.github.shusek:composemediaplayer-kmediabridge:<version>")
}Install KMediaBridgeAndroidExtension() in VideoPlaybackOptions.extensions. The adapter is also
published for desktop JVM, where KMediaBridgeDesktopExtension() enables bounded container,
sample-copy and controlled SDR routes. The default player has no KMediaBridge dependency. The
client and shared KMediaFfmpegRuntime are transitive dependencies with their own license notices,
source, SBOM and relinking materials;
an explicitly selected external compatible runtime remains the application's responsibility. See the
KMediaBridge adapter documentation.
The optional pinned libdovi bridge is published separately:
dependencies {
implementation("io.github.shusek:composemediaplayer-dolbyvision:<version>")
}It provides the pinned libdovi converter plus bounded playback bridges for unencrypted flat MP4,
fMP4 HLS VOD, and Matroska on Android, iOS, JVM, and browser Wasm. The common Matroska path preserves
AAC, Opus, AC-3, and E-AC-3; JVM can additionally use FFmpeg for compatible inputs outside that
subset. Install DolbyVisionExtension() in VideoPlaybackOptions.extensions to opt in; live HLS
and DRM fail closed. See the component contract.
With DolbyVisionPolicy.AUTO, confirmed native Profile 7 remains first choice; the bridge maps P7
to P8.1 only when the active platform confirms P8 but not P7, before HDR10-base and managed-SDR
fallbacks. Planned and confirmed profile/mapping fields expose the decision without claiming an
unverified P8.1 output.
The conversion bridge accepts selected fMP4 media playlists and master HLS VOD. Android, iOS, and JVM retain the selected variant's referenced VOD audio/subtitle renditions through their local HLS proxy. Browser Wasm preserves the declared default external fMP4 audio rendition with a separate, bounded Media Source buffer when its codec is supported; unsupported layouts fail closed instead of silently dropping audio. Its video/audio buffers use parsed fMP4 clocks for initial, seek, and discontinuity timestamp mapping, and a non-independent seek restarts only from a verified sync sample.
3.0.x line.arm64-v8a and armeabi-v7a with AGP 9.2.1, compileSdk = 37, and minSdk = 23. Android x86/x86_64 is unsupported. Consumer projects should use the same or a newer compatible Android toolchain and must have compileSdk >= 37; HDR still depends on the device and active display.composemediaplayer-mpv uses minSdk = 28 and publishes ARM runtimes only.iosArm64 and iosSimulatorArm64. Its generated CocoaPod has an
exact dependency on the code-signed KMediaMpv client pod, which in turn pins the shared runtime;
native code is embedded by the application build and is never extracted at playback time.iosArm64 and iosSimulatorArm64 only; Intel simulators are unsupported. An Apple Silicon macOS host and a current Xcode/iOS Simulator are required for native compilation and tests.libgstreamer1.0-0, libgstreamer-plugins-base1.0-0, gstreamer1.0-plugins-base, and gstreamer1.0-plugins-good. Confirmed HDR additionally requires JBR 25.0.3+, GStreamer 1.28.5+, WLToolkit, Wayland color-management-v1, and the advertised Vulkan/DMABuf/output capabilities. X11/XWayland is not a confirmed HDR route.pkg-config, a C compiler, full JDK 25 JNI headers, libgstreamer1.0-dev, and libgstreamer-plugins-base1.0-dev.MPV is a separate adapter artifact. The dependency direction is:
composemediaplayer → composemediaplayer-core ← composemediaplayer-mpv
The main artifact never imports MPV. Both modules share only the state, event,
capability, backend-factory, and renderer SPI from composemediaplayer-core.
On Android and desktop JVM, the MPV adapter carries the matching KMediaMpv client transitively, so
the application does not add KMediaMpv coordinates separately. On iOS, the exact KMediaMpv pod
contains the two client XCFrameworks and depends on the exact shared runtime pod. The adapter never
downloads native code at playback time.
This dependency inversion is the reason for the 2.0.0 major version. Existing
applications can keep depending on composemediaplayer; it re-exports core
transitively. Backend authors may depend directly on composemediaplayer-core
and do not pull the default Media3/AVPlayer/JNI implementations.
Add the adapter only to supported platform source sets:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.shusek:composemediaplayer:<player-version>")
}
androidMain.dependencies {
implementation("io.github.shusek:composemediaplayer-mpv:<player-version>")
}
jvmMain.dependencies {
implementation("io.github.shusek:composemediaplayer-mpv:<player-version>")
}
iosMain.dependencies {
implementation("io.github.shusek:composemediaplayer-mpv:<player-version>")
}
}
}The MPV-specific wiring below belongs in androidMain, jvmMain, or iosMain.
A library whose complete target matrix is supported by the adapter may put the
dependency in commonMain instead.
An MPV-only application does not need composemediaplayer at all. Depend on
composemediaplayer-mpv (which brings composemediaplayer-core transitively)
and render with BackendVideoPlayerSurface. This avoids pulling Media3,
AVPlayer, or the default desktop JNI implementations:
val playerState = rememberMpvVideoPlayerState()
BackendVideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize(),
)Then select MPV explicitly:
val playerState = rememberMpvVideoPlayerState()
VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)Call inspectMpvBackend() before creating the state when the application wants
to present its own unsupported-device message.
For dependency injection, select the implementation in the application composition root without exposing it to the rest of the app:
val backend: VideoPlayerBackend = mpvVideoPlayerBackend()
val playerState = rememberVideoPlayerState(backend)The default runtime source is the verified KMediaMpv bundle. Keep that default on Android, Linux, Apple Silicon macOS, and Windows x86_64. An application may still override it with an absolute, application-supplied libmpv path:
val options = MpvPlaybackOptions(
runtimeSource = MpvRuntimeSource.ExplicitPath(
"C:\\Program Files\\MyApp\\runtime\\mpv-2.dll",
),
)On iOS, use the generated ComposeMediaPlayerMpv pod so CocoaPods embeds and
code-signs the exact KMediaMpv, KMediaFfmpegRuntime, and KMediaAssRuntime
framework graph. The default Bundled source resolves that graph. An
application-supplied framework can still be selected with System or an
ExplicitPath inside the signed application bundle.
inspectMpvBackend() reports a missing runtime, unsupported target, or rejected
native payload without falling back silently. rememberVideoPlayerState()
continues to select the normal platform backend.
The verified bundled runtime supports Android API 28+ on arm64-v8a and
armeabi-v7a, Linux on x86_64 and ARM64, macOS on ARM64, and Windows x86_64.
The exact iOS pod supports ARM64 device/simulator. Android x86, Android x86_64,
macOS x86_64, Intel iOS simulators, and Windows ARM64 are not supported. The
bundled runtime has networking disabled, so use local media and subtitle files.
Desktop applications run on Java 25 with --enable-native-access=ALL-UNNAMED.
The core, default player, and adapter code remain under this repository's own license. The separately licensed KMediaMpv artifacts carry the reviewed native payload, notices, exact corresponding source, and recipient relinking path required for the LGPL components. Only the KMediaMpv/native boundary is subject to those separate terms; the application and the backend-neutral player modules are not relicensed as LGPL.
See Player backend architecture for the module rules and the checklist for implementing another backend.
The repository commits Kotlin/Wasm Yarn lockfiles. CI fails instead of silently replacing them when an npm dependency changes; run ./gradlew kotlinWasmUpgradeYarnLock after an intentional npm update and review the resulting lockfile diff.
Gradle dependency verification pins external build artifacts by SHA-256. The only checksum exception is restricted to the locally generated dev and 0.0.0-consumer.* smoke-test publications under this project's own coordinates, whose binaries change with the source under test.
| Library Version | Kotlin Version | Compose Version | Java | Android toolchain |
|---|---|---|---|---|
| 3.0.x | 2.4.0 | 1.11.1 | 25 | AGP 9.2.1 / compileSdk 37 / minSdk 23; MPV minSdk 28 |
| 2.0.x | 2.4.0 | 1.11.1 | 25 | AGP 9.2.1 / compileSdk 37 / minSdk 23; MPV minSdk 28 |
| 1.0.x | 2.4.0 | 1.11.1 | 25 | AGP 9.2.1 / compileSdk 37 / minSdk 23 |
| 0.9.0 | 2.3.20 | 1.10.3 | See release | See release |
| 0.8.6 | 2.3.0 | 1.9.3 | See release | See release |
| 0.8.3 | 2.2.20 | 1.9.0 | See release | See release |
| 0.7.11 | 2.2.0 | 1.8.2 | See release | See release |
| 0.7.10 | 2.1.21 | 1.8.2 | See release | See release |
The current build records the public Kotlin ABI in version control. checkKotlinAbi, the published-consumer smoke project, platform tests, and the Java 25 classfile check must all pass before the single release workflow publishes the same GAV and native payload to Maven Central and the GitHub Pages Maven mirror. To reproduce the isolated consumer check locally, first run ./gradlew publishConsumerSmokeArtifacts -PpublicationVersion=0.0.0-consumer.local -PcomposeMediaPlayer.skipNativeBuild=true --no-configuration-cache, then run ./gradlew consumerSmokeTest -PpublicationVersion=0.0.0-consumer.local -PcomposeMediaPlayer.skipNativeBuild=true --no-configuration-cache in a separate Gradle invocation. The split is intentional: the Maven fixtures must exist before Gradle resolves the consumer configurations.
The release workflow verifies the complete GitHub Pages staging repository and uploads an immutable release-delta artifact before it performs the irreversible Maven Central release. The delta intentionally excludes mutable maven-metadata.xml files. If Maven Central succeeds but the final gh-pages push fails after its retries, overlay that delta on the latest Pages repository, run python3 .github/scripts/rebuild_maven_metadata.py <pages-worktree>/maven, and publish the resulting commit; do not run the Maven Central publication again for the same version.
Before using Compose Media Player, you need to create a state for the video player using the rememberVideoPlayerState function:
val playerState = rememberVideoPlayerState()Wasm uses the programmatic @shusek/movi-player/engine API as an externally hosted, lazily imported
engine. The public state, Compose surface, controls, overlays and fullscreen API stay unchanged.
The exact 0.3.5-kmp.3 module is fetched from jsDelivr only when the first Movi source is opened;
choosing legacy mode does not request it, and KMediaPlayer does not bundle Movi or its media Wasm.
val options = remember {
VideoPlaybackOptions(
webPlaybackEngine = WebPlaybackEngine.MOVI, // default
)
}
val playerState = rememberVideoPlayerState(playbackOptions = options)Offline, strict-CSP, or controlled-hosting deployments can replace the public module URL before opening the first Movi source:
WebMediaDependencyConfig.moviPlayerModuleUrl = "/vendor/movi-player-0.3.5-kmp.3/engine.js"The configured URL executes as an ES module. Pin an immutable compatible build and never place credentials in the URL.
Use the explicit compatibility route when an application requires native HTML5 video:
VideoPlaybackOptions(webPlaybackEngine = WebPlaybackEngine.LEGACY)There is no automatic fallback from Movi to legacy after an error. Clear sources using
REQUIRE_HDR, FORCE_SDR, or a non-AUTO Dolby Vision policy are routed to legacy before player
initialization. DRM combined with one of those strict color policies, projection, or non-default
texture cropping fails closed with VideoPlayerError.DrmError. Recognized HLS, DASH and MSS
manifests require Movi: explicit legacy mode fails with VideoPlayerError.SourceError, while an
adaptive source combined with a strict color policy fails with VideoPlayerError.ColorPipelineError.
For adaptive DRM, media headers and license headers are intentionally separate:
val options = VideoPlaybackOptions(
webDrmConfiguration = WebDrmConfiguration(
licenseUrl = "https://license.example.invalid/widevine",
licenseRequestHeaders = mapOf("Authorization" to "<runtime value>"),
),
)
playerState.openUri(
uri = "https://media.example.invalid/manifest.mpd",
requestHeaders = mapOf("X-Media-Token" to "<runtime value>"),
)DRM values are retained only by the runtime objects. WebDrmConfiguration.toString(), diagnostics
and adapter errors redact the license URL and headers, and the adapter forces Movi's process-wide
logger to SILENT before player construction so upstream error payloads cannot disclose request
data. Applications must likewise avoid logging or persisting their input values. DRM uses
Movi/Shaka's native video element, so canvas-only features are unavailable.
Movi reports source color metadata when available, but KMediaPlayer deliberately leaves decoder,
surface and output dynamic range as UNKNOWN; isHDR is not evidence that HDR reached the display.
SDR projection samples the hidden Movi canvas through KMediaPlayer's existing WebGL projection
renderer. External ASS/SSA can use the optional JASSUB overlay with either engine. For embedded
ASS/SSA, Movi streams raw timed packets and bounded container font bytes directly to the same
host-owned JASSUB renderer through its pluggable subtitle contract; KMediaPlayer no longer exports
a second subtitle file or demuxes the media again. Canvas PiP and a confirmed Movi HDR/projection
route remain outside the first integration release. See
Wasm Movi integration.
The Wasm sample starts with a bundled MKV containing en and pl Opus tracks. Add
?engine=legacy to the sample URL to exercise the compatibility path.
After initializing the player state, you can display the surface of the video using VideoPlayerSurface:
// Video Surface
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize()
)
}[!WARNING] Content scaling support is experimental. The behavior may vary across different platforms.
You can control how the video content is scaled inside the surface using the contentScale parameter:
VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop // Default is ContentScale.Fit
)Available content scale options:
ContentScale.Fit (default): Scales the video to fit within the surface while maintaining aspect ratioContentScale.Crop: Scales the video to fill the surface while maintaining aspect ratio, potentially cropping partsContentScale.FillBounds: Stretches the video to fill the surface, may distort the aspect ratioContentScale.Inside: Similar to Fit, but won't scale up if the video is smaller than the surfaceContentScale.None: No scaling applied[!WARNING] Surface type parameter is supported only for Android target.
Available surface type options:
SurfaceType.Auto (default): uses SurfaceView for flat video, which is Android's HDR-eligible system path, and the controlled Media3 frame processor for projected/stereo video.SurfaceType.SurfaceView: uses SurfaceView for the player view, which is more performant for video playback but has limitations in terms of composability and animations.SurfaceType.TextureView: uses TextureView for the player view, which allows for more complex composable layouts and animations.SurfaceType.SphericalGlSurfaceView: uses Media3's spherical GL surface explicitly. This is intended for equirectangular 360 projection on Android.SurfaceType.ProjectedGlSurfaceView: uses DefaultVideoFrameProcessor and the Android projection GL effect explicitly. This supports flat stereo, equirectangular, fisheye and EAC projection.VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize(),
surfaceType = SurfaceType.SurfaceView // Default is SurfaceType.Auto
)Projection settings live in VideoPlaybackOptions. By default, the player auto-detects common projection tokens from filenames, URLs and metadata when the configured projection is left as flat 2D:
val playerState = rememberVideoPlayerState()
// Example_VR180_SBS.mp4 is detected as equirectangular 180 side-by-side.
playerState.openUri("https://example.com/Example_VR180_SBS.mp4")Apps can pass a known mode when creating the player state:
val playerState = rememberVideoPlayerState(
playbackOptions = VideoPlaybackOptions(
projection = VideoProjectionSettings(
projectionType = VideoProjectionType.Equirect180,
stereoLayout = VideoStereoLayout.SideBySide,
eyeOrder = VideoEyeOrder.LeftRight,
)
)
)The projection can also be changed while the player is alive, which is useful for manual projection controls:
playerState.projection = playerState.projection.nextVideoProjectionPreset().projectionFor sources that include padded or misaligned active video, projectionTextureCrop crops the source texture before
stereo splitting and projection. This is separate from contentScale, which scales the final surface, and from
projectionView.zoom, which changes the projected viewport:
val playerState = rememberVideoPlayerState(
playbackOptions = VideoPlaybackOptions(
projection = VideoProjectionSettings(
projectionType = VideoProjectionType.Equirect180,
stereoLayout = VideoStereoLayout.SideBySide,
),
projectionTextureCrop = VideoTextureCrop(
left = 0.02f,
top = 0.01f,
right = 0.02f,
bottom = 0.01f,
),
)
)
playerState.projectionTextureCrop = playerState.projectionTextureCrop.copy(left = 0.03f)For projected renderers, apps can steer the viewport independently from the source projection:
playerState.projectionView = playerState.projectionView.copy(
yawDegrees = playerState.projectionView.yawDegrees + 15f,
pitchDegrees = 0f,
zoom = 1.2f,
)By default, VideoProjectionViewControlMode.AUTO uses device motion on platforms where it is available for surround projections. It uses Android rotation vector sensors, iOS CoreMotion device motion, and browser DeviceOrientationEvent; this is more stable than using the accelerometer alone. Apps can force or disable sensor control:
val playerState = rememberVideoPlayerState(
playbackOptions = VideoPlaybackOptions(
projectionViewControlMode = VideoProjectionViewControlMode.DEVICE_MOTION,
)
)
playerState.projectionViewControlMode = VideoProjectionViewControlMode.MANUALThe common API includes presets and detection helpers:
val detection = detectVideoProjection(
VideoProjectionDetectionInput(
title = "Example_VR180_SBS.mp4",
videoSizes = listOf(VideoProjectionVideoSize(width = 7680, height = 3840)),
)
)
val nextPreset = playerState.projection.nextVideoProjectionPreset()Supported projection metadata includes flat 2D/3D, equirectangular 180/360, fisheye 180/190/200/220, and EAC 360 with mono, side-by-side, and over-under stereo layouts. A non-default projectionTextureCrop switches SurfaceType.Auto and canvas paths to projection renderers so the crop can be applied.
On Android, SurfaceType.Auto sends every projection mode through Media3's DefaultVideoFrameProcessor so PQ/HLG input cannot accidentally be drawn as washed-out SDR. HDR projection requires GLES3 plus EXT_YUV_target; HDR10/PQ and HLG outputs additionally require the matching EGL colorspace and use RGBA16F intermediates with an RGBA_1010102 output surface. AUTO and PREFER_HDR retry Media3's managed SDR tone mapping if HDR output setup fails, while REQUIRE_HDR reports a typed error. Flat video uses SurfaceView; TextureView is an explicit compositing opt-out and is not advertised as HDR. Android projected surfaces can use device rotation automatically unless projectionViewControlMode is set to MANUAL. iOS projections use AVPlayerItemVideoOutput to pull P010/NV12 frames into CoreVideo Metal textures. One rgba16Float shader handles flat stereo, equirectangular, fisheye, EAC, crops and CoreMotion-backed viewport control; HDR10/HLG is confirmed only after an EDR Metal frame completes, and runtime failure replans to controlled SDR unless REQUIRE_HDR was selected. Flat iOS playback keeps AVPlayerLayer; FORCE_SDR explicitly disables EDR on that layer so AVFoundation performs the SDR presentation. macOS JVM uses the same P010/NV12 and FP16 color/projection ordering in a per-player native Metal layer, follows the active NSScreen, and keeps Compose controls/subtitles in a separate overlay window.
Windows requests Media Foundation hardware transforms and requires a decoded P010 GPU surface before
its D3D11 projection/color shader feeds a flip-model swapchain; it does not invent a decoder
component name that Media Foundation has not exposed. HDR uses an Advanced Color PQ/BT.2020 or
scRGB swapchain. FORCE_SDR, and the retry after a non-strict HDR-output failure, instead run
BT.2390 tone mapping and BT.2020-to-BT.709 gamut conversion in that same renderer before presenting
an 8-bit dithered G22/BT.709 swapchain. These native MP4 routes do not depend on KMediaBridge. A
route is confirmed only after the active IDXGIOutput6, requested swapchain color space, P010 input
and first successful Present agree. Linux flat HDR uses GStreamer waylandsink; projection uses
the optional Vulkan renderer with an exact 10-bit PQ/HLG WSI colorspace. Its current linear P010
DMA-BUF path maps the buffer before the Vulkan upload, so it is bounded but not advertised as
zero-copy. Supported JBR Wayland sessions use sibling video and bounded wl_shm Compose overlay
subsurfaces with input and semantics retained on the parent Compose surface. Missing JBR/WLToolkit,
GStreamer, Wayland color management, DMA-BUF, Vulkan, overlay, or output support replans to verified
SDR; FORCE_SDR explicitly bypasses the HDR Wayland surface.
Web attempts HDR projection only when WebGPU configuration readback and an HDR display are present. It imports a bounded set of current VideoFrame objects, asks the browser to color-manage tagged PQ/HLG into an extended Display-P3 working encoding, and projects them into an rgba16float canvas with extended tone mapping. HDR is confirmed only after the display still reports high dynamic range, the configuration is retained, and the first GPU submission completes; this confirms an HDR canvas element rather than claiming a raw PQ/HLG display transport. If an HDR canvas is rejected, AUTO/PREFER_HDR recreates the route as WebGPU controlled SDR: the extended sRGB texture is converted to linear light, tone-mapped with BT.2390, gamut-mapped through the common ICtCp LUT, dithered, and written to an FP16 standard sRGB canvas. WebGL is reserved for browser-managed SDR projection and is never reported as an HDR-to-SDR converter; without WebGPU the controlled HDR fallback fails closed. REQUIRE_HDR fails instead of falling back. Flat HDR normally stays browser-managed and keeps output status unknown, while FORCE_SDR switches its visible output to the same controlled WebGPU SDR canvas because a native <video> cannot be forced to SDR on an HDR display. Remote web videos must be served with CORS headers whenever a canvas renderer is active, because browsers block cross-origin video texture sampling otherwise. Some mobile browsers, especially iOS Safari, require a user permission gesture before device orientation events are delivered. A libVLC native view is never used as proof of HDR output.
You can add a custom overlay UI that will always be visible, even in fullscreen mode, by using the overlay parameter:
VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize()) {
// This overlay will always be visible
Box(modifier = Modifier.fillMaxSize()) {
// You can customize the UI based on fullscreen state
if (playerState.isFullscreen) {
// Fullscreen UI
IconButton(
onClick = { playerState.toggleFullscreen() },
modifier = Modifier.align(Alignment.TopEnd).padding(16.dp)
) {
Icon(
imageVector = Icons.Default.FullscreenExit,
contentDescription = "Exit Fullscreen",
tint = Color.White
)
}
} else {
// Regular UI
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.5f))
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
// Your custom controls here
IconButton(onClick = {
if (playerState.isPlaying) playerState.pause() else playerState.play()
}) {
Icon(
imageVector = if (playerState.isPlaying)
Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
tint = Color.White
)
}
}
}
}
}You can play a video by providing a direct URL:
// Open a video and automatically start playing (default behavior)
playerState.openUri("http://example.com/video.mp4")
// Open a video but keep it paused initially
playerState.openUri("http://example.com/video.mp4", InitialPlayerState.PAUSE)To play a local video file you can use PlatformFile from FileKit.
val file = FileKit.openFilePicker(type = FileKitType.Video)
// Open a file and automatically start playing (default behavior)
file?.let { playerState.openFile(it) }
// Open a file but keep it paused initially
file?.let { playerState.openFile(it, InitialPlayerState.PAUSE) }The initializePlayerState parameter controls whether the video automatically starts playing after opening:
InitialPlayerState.PLAY (default): The video will automatically start playing after openingInitialPlayerState.PAUSE: The video will be loaded but remain paused until you call play()
On Android, apps that need a custom Media3 source can pass an AndroidMediaSourceProvider. The normal player state
reset, metadata extraction, repeat mode, volume and initial play/pause handling stay inside the library:
@OptIn(UnstableApi::class)
val playerState = rememberVideoPlayerState(
androidMediaSourceProvider = AndroidMediaSourceProvider { request ->
val uri = request.mediaItem.localConfiguration?.uri?.toString().orEmpty()
if (uri.contains("example-cdn.com")) {
customMediaSourceFactory(request.requestHeaders).createMediaSource(request.mediaItem)
} else {
null
}
},
)Check the sample project for a complete example.
Browser playback exposes adaptive video variants from Movi video tracks. Movi's track id -1 is
represented as automatic selection rather than a selectable variant. The list is populated after
the manifest has loaded, so custom controls should handle an empty list as "not available yet" or
"not supported by this backend". Legacy browser playback does not expose adaptive variants.
val quality = playerState.availableHlsQualities.firstOrNull { it.height == 720 }
when (val result = quality?.let { playerState.selectHlsQuality(it.id) } ?: playerState.selectAutoHlsQuality()) {
is HlsQualitySelectionResult.Selected -> println("HLS quality selected: ${result.quality.label}")
HlsQualitySelectionResult.Auto -> println("Automatic HLS quality selection enabled")
is HlsQualitySelectionResult.NotFound -> println("HLS quality not found: ${result.variantId}")
HlsQualitySelectionResult.NotSupported -> println("HLS quality selection is not supported here")
}Use currentHlsQuality and hlsQualityMode to reflect the current state in custom controls. Selected and Auto mean the request was applied; NotFound means the variant id was stale or from another source; NotSupported means the current platform or source does not expose HLS variants. Android, iOS and desktop targets currently report NotSupported for this API.
Chapters are exposed through the common VideoPlayerState API and arrive asynchronously after a
source is opened:
playerState.chapters.forEach { chapter ->
println("${chapter.startMs}..${chapter.endMs}: ${chapter.title}")
}
playerState.currentChapter?.let { chapter ->
println("Now playing: ${chapter.title}")
}
playerState.chapters.firstOrNull()?.let(playerState::seekToChapter)MediaChapter contains start, optional exclusive end, title, BCP 47 language, and
isHidden, plus millisecond and duration convenience properties. Missing ends are inferred from
the next distinct chapter start or the media duration when available. Hidden chapters remain in
the list so the application can decide whether to show them. chapters is cleared when the
logical source changes; internal remux or color-conversion source swaps retain it.
| Source | Android | iOS | Desktop JVM | Web |
|---|---|---|---|---|
| Matroska/WebM editions and chapter atoms | Media3 | AVFoundation when supported | Built-in parser | Browser/backend dependent |
MP4/MOV/3GP Nero chpl and QuickTime chapter tracks |
Media3 | AVFoundation | Built-in parser | Browser/backend dependent |
ID3 CHAP/CTOC
|
Media3 | AVFoundation when supported | Built-in parser | Browser/backend dependent |
| ASF/WMV markers | Backend dependent | — | Built-in parser | — |
| Apple HLS JSON chapters | VOD/Event | VOD/Event | VOD/Event | Movi/backend dependent |
WebVTT <track kind="chapters">
|
Backend dependent | AVFoundation when supported | Backend dependent | Built in |
| Optional MPV backend | Backend API dependent | — | Native chapter-list
|
— |
Matroska selects the default edition, or the first edition when none is marked default, chooses
the best available localized display label, and flattens nested atoms into the public list.
Apple HLS JSON discovery intentionally excludes sliding live playlists; generic HLS
EXT-X-DATERANGE metadata is not treated as chapters.
In WebPlaybackEngine.LEGACY, embedded MKV subtitle extraction loads the exact
matroska-subtitles 3.3.2 browser bundle with a pinned SHA-384 subresource-integrity value.
Movi discovers and renders embedded subtitle tracks itself. Strict-CSP or offline legacy
deployments can self-host the parser bundle; configure both values before creating a player:
WebMediaDependencyConfig.matroskaSubtitlesScriptUrl = "/vendor/matroska-subtitles-3.3.2.min.js"
WebMediaDependencyConfig.matroskaSubtitlesScriptIntegrity = "sha384-..."Setting the URL to an empty string disables embedded MKV subtitle extraction without affecting ordinary browser video playback.
You can detect the current playback state via playerState.isPlaying and configure a Play/Pause button as follows:
Button(onClick = {
if (playerState.isPlaying) {
playerState.pause()
println("Playback paused")
} else {
playerState.play()
println("Playback started")
}
}) {
Text(if (playerState.isPlaying) "Pause" else "Play")
}playerState.stop()
println("Playback stopped")playerState.volume = 0.5f // Set volume to 50%
println("Volume set to 50%")playerState.loop = true // Enable loop playbackYou can listen for loop restarts via the onRestart callback:
playerState.loop = true
playerState.onRestart = {
println("Video restarted from the beginning")
}Restart playback from the beginning. Works reliably from any state, including when the video has ended:
playerState.restart()Get notified when playback reaches the end (only called when loop is false):
playerState.onPlaybackEnded = {
println("Video finished")
}playerState.playbackSpeed = 1.5f // Set playback speed to 1.5x
println("Playback speed set to 1.5x")You can adjust the playback speed between 0.5x (slower) and 2.0x (faster). The default value is 1.0x (normal speed).
To display and control playback progress, use seekStart and seekFinished for slider interactions:
Slider(
value = playerState.sliderPos,
onValueChange = { playerState.seekStart(it) },
onValueChangeFinished = { playerState.seekFinished() },
valueRange = 0f..1000f
)seekStart(value) updates the slider position visually without performing the actual seek, allowing smooth dragging.seekFinished() commits the seek to the player and ends the drag interaction.For programmatic seeking (e.g. skip forward/backward), use seekTo, seekToMs, seekBy or seekByMs:
playerState.seekToMs(60_000)
playerState.seekByMs(10_000)In case of an error, you can display it using println:
playerState.error?.let { error ->
println("Error detected: ${error.message}")
playerState.clearError()
}Compose Media Player internal logging is disabled by default. Enable it only while diagnosing playback behavior:
import io.github.kdroidfilter.composemediaplayer.util.ComposeMediaPlayerLoggingLevel
import io.github.kdroidfilter.composemediaplayer.util.allowComposeMediaPlayerLogging
import io.github.kdroidfilter.composemediaplayer.util.composeMediaPlayerLoggingLevel
allowComposeMediaPlayerLogging = true
composeMediaPlayerLoggingLevel = ComposeMediaPlayerLoggingLevel.DEBUGJVM native backends keep their own diagnostic output disabled by default. Set COMPOSE_MEDIA_PLAYER_NATIVE_LOGGING=true before starting the app to enable native messages from the macOS, Windows and Linux backends.
To detect if the video is buffering:
if (playerState.isLoading) {
CircularProgressIndicator()
}Compose Media Player supports adding subtitles from both URLs and local files. SRT and VTT subtitles are rendered using Compose, providing a uniform appearance across all platforms. The core also parses ASS/SSA timing and dialogue text, but authored styles, positioning and effects require the optional composemediaplayer-ass artifact plus AssSubtitleExtension(). On Android, Apple, Windows and Linux that adapter uses the separately published, process-wide KMediaAssRuntime; the MPV and KMediaBridge adapters reach the same text stack transitively through KMediaFfmpegRuntime. Browser Wasm keeps its JASSUB/libass integration. Windows and Linux users do not install libass separately. Other native routes keep the plain-dialogue fallback unless their playback backend supplies styled subtitle rendering. On desktop JVM, the libVLC MKV/WebM backends can expose and select embedded subtitle tracks. For the optional HLS container fallback, the macOS KMediaBridge runtime can burn selected embedded text tracks into controlled BT.709 SDR; remux-only platform runtimes use the optional VLC fallback when it is available.
The player supports SRT, VTT, ASS and SSA subtitle formats with automatic format detection. With composemediaplayer-ass installed and activated, Android, browser Wasm, Apple targets, and Windows/Linux JVM use the libass-backed routes described below. Android and JVM resolve the exact shared runtime transitively; Apple uses the matching KMediaAssRuntime pod. Without the optional adapter, the core keeps its lightweight ASS/SSA dialogue fallback. A platform-specific backend, such as a native libVLC surface, may independently provide styled rendering.
You can add subtitles by specifying a URL:
val track = SubtitleTrack(
label = "English Subtitles",
language = "en",
src = "https://example.com/subtitles.vtt" // Works with .srt, .vtt, .ass and .ssa files
)
playerState.addSubtitleTrack(track)
when (val result = playerState.selectSubtitleTrack(track)) {
is TrackSelectionResult.Selected -> println("Subtitle track selected: ${result.trackId}")
TrackSelectionResult.Disabled -> println("Subtitles disabled")
is TrackSelectionResult.NotFound -> println("Subtitle track not found: ${result.trackId}")
TrackSelectionResult.NotSupported -> println("Subtitle track selection is not supported")
is TrackSelectionResult.Failed -> println("Failed to select subtitle track: ${result.message}")
TrackSelectionResult.Auto -> println("Automatic track selection restored")
}The Android variant of the optional composemediaplayer-ass artifact contains one thin renderer bridge for arm64-v8a and armeabi-v7a; Intel Android ABIs are not published. Its exact transitive dependency supplies KMediaAssRuntime 0.1.0-rc.3 with libass 0.17.5, FreeType, FriBidi and HarfBuzz. Applications do not need another Maven repository or a manual runtimeOnly dependency. Add AssSubtitleExtension() to VideoPlaybackOptions.extensions to activate it. Media3 still owns playback and demuxing. The extension intercepts only the raw Matroska ASS/SSA packet format and renders it into a transparent screen-space overlay, while other Media3 subtitle formats keep their normal path.
The libass route supports:
.ass and .ssa tracks selected through addSubtitleTrack(...);ASS script styles control this overlay, so subtitleTextStyle and subtitleBackgroundColor apply to the Compose subtitle renderer rather than restyling libass output. A custom AndroidMediaSourceProvider that returns a fully constructed MediaSource must preserve KMediaPlayer's subtitle parser/extractor setup; otherwise that custom source can flatten ASS into ordinary Media3 cues before the libass renderer sees it. Side-loaded SSA supplied directly to Media3 and HLS SSA retain Media3's fallback path.
The thin renderer bridge lives in the optional adapter. Native text libraries,
their notices, corresponding source and LGPL replacement material live once in
kmedia-ass-runtime-android, not in composemediaplayer-ass or the base player.
On a 64-bit JVM, the optional component initializes the exact
kmedia-ass-runtime-desktop dependency and resolves libass through Java's
Foreign Function API. The adapter JAR itself contains no native libraries. The
runtime publishes libass 0.17.5 for macOS ARM64, Linux x86_64/ARM64 and Windows
x86_64, with SHA-256 verification before native loading. Windows uses
DirectWrite font discovery; Linux uses the desktop's normal fontconfig
configuration. External ASS/SSA files are composited into writable BGRA video
frames. Embedded Matroska ASS/SSA tracks exposed by the libVLC canvas backend
use the built-in extractor and pass container font attachments to libass.
Users do not install libass or copy its native files. Register
AssSubtitleExtension() and launch the application with native access enabled:
--enable-native-access=ALL-UNNAMED
There is no system-libass fallback or single-library override. An advanced
deployment can initialize one complete, compatible replacement directory with
KMediaAssRuntime.initialize(RuntimeSource.externalDirectory(...)) before any
native client loads. A second runtime ID is rejected process-wide. If loading,
track extraction, or rendering fails, the player keeps its Compose or libVLC
subtitle fallback. Native HDR/color and LIBVLC_NATIVE surfaces retain their
platform subtitle path because they do not expose a writable CPU video frame.
Full ASS/SSA rendering on wasmJs is provided by the optional composemediaplayer-ass artifact.
Register AssSubtitleExtension() in VideoPlaybackOptions.extensions; external ASS/SSA tracks then
use JASSUB with both the default Movi engine and the legacy engine. For embedded ASS/SSA, Movi
mounts the same extension as its pluggable renderer, forwards the codec header, timed Matroska
packets and bounded font attachments, and drives JASSUB from its media clock. Unsupported embedded
formats remain on Movi's built-in subtitle path. Movi DRM and legacy external playback use the
native video clock. The optional artifact pins JASSUB 2.5.7, so applications do not add JASSUB,
libass, worker scripts or WASM files manually.
Create stable player options and then select an .ass or .ssa subtitle track:
val playbackOptions = remember {
VideoPlaybackOptions(
extensions = listOf(AssSubtitleExtension()),
)
}
val playerState = rememberVideoPlayerState(playbackOptions = playbackOptions)
LaunchedEffect(Unit) {
playerState.openUri("https://example.com/video.mp4")
val subtitleTrack = SubtitleTrack(
label = "English ASS",
language = "en",
src = "https://example.com/subtitles.ass",
)
playerState.addSubtitleTrack(subtitleTrack)
playerState.selectSubtitleTrack(subtitleTrack)
}If you use VideoPlayerControls, the selected track is rendered automatically by VideoPlayerSurface. If you build custom controls, keep using the same playerState.selectSubtitleTrack(...) and playerState.disableSubtitles() APIs and inspect the returned TrackSelectionResult when you need to handle unsupported or missing tracks.
Browser renderer configuration is immutable and belongs to the installed extension instance:
import io.github.kdroidfilter.composemediaplayer.AssSubtitleRendererConfig
import io.github.kdroidfilter.composemediaplayer.AssFontQueryMode
val playbackOptions = remember {
VideoPlaybackOptions(
extensions = listOf(
AssSubtitleExtension(
config = AssSubtitleRendererConfig(
fontQueryMode = AssFontQueryMode.DISABLED,
debug = false,
),
),
),
)
}Configuration options:
| Option | Default | Description |
|---|---|---|
enabled |
true |
Enables or disables browser ASS/SSA rendering. When disabled, ASS/SSA tracks are ignored by the JASSUB renderer. |
workerUrl |
null |
Optional URL for a self-hosted JASSUB worker. Leave unset to use the bundled npm asset. |
wasmUrl |
null |
Optional URL for jassub-worker.wasm. Leave unset to use the bundled npm asset. |
modernWasmUrl |
null |
Optional URL for jassub-worker-modern.wasm. Leave unset to use the bundled npm asset. |
fallbackFontUrl |
null |
Optional URL for the fallback font used by libass. Leave unset to use JASSUB defaults. |
fallbackFontFamily |
"liberation sans" |
Font family name mapped to fallbackFontUrl. |
preloadFontUrls |
emptyList() |
Font URLs fetched eagerly when the renderer starts. |
availableFontUrls |
emptyMap() |
Case-insensitive font-family-to-URL mappings fetched only when required. |
fontQueryMode |
DISABLED |
LOCAL enables browser font discovery; LOCAL_AND_REMOTE also enables JASSUB's remote font lookup and its privacy/network implications. |
debug |
false |
Enables JASSUB debug logging. |
For production deployments you can optionally self-host the worker, WASM and fallback font assets. This is useful when your hosting/CDN needs explicit cache headers, predictable URLs, or a strict Content Security Policy:
AssSubtitleExtension(
config = AssSubtitleRendererConfig(
workerUrl = "/jassub/worker/worker.js",
wasmUrl = "/jassub/wasm/jassub-worker.wasm",
modernWasmUrl = "/jassub/wasm/jassub-worker-modern.wasm",
fallbackFontUrl = "/jassub/default.woff2",
),
)The URLs may be relative to your app base URL or absolute. Prefer same-origin URLs. If you host the video, subtitle, worker, WASM or font files on another domain, configure CORS headers so the browser can load them from your app origin.
Browser notes:
OffscreenCanvas,
canvas.transferControlToOffscreen() and the other browser primitives reported by extension
availability. The video-backed route uses requestVideoFrameCallback() (JASSUB includes its
polyfill); the clear Movi route uses canvas-only manual rendering. Missing support leaves the
Compose fallback active.Cross-Origin-Embedder-Policy: require-corp and
Cross-Origin-Opener-Policy: same-origin. Without them JASSUB automatically uses its
single-threaded fallback.Fit, Crop, FillBounds, FillWidth and FillHeight. Projection
subtitles remain a flat screen-space overlay over the projected canvas.When the backend exposes tracks from the media container, they are available through the player
state. The default Wasm route maps Movi's numeric ids to stable KMediaPlayer string ids and calls
Movi's real track-selection API; a rejected switch returns Failed without changing state or
emitting TrackChanged. Passing null restores the initially selected/default audio track rather
than muting it. Legacy browsers read native audioTracks and textTracks from the <video>
element, Android reads Media3 track groups, and the desktop JVM libVLC/external HLS fallbacks expose
embedded audio/subtitle tracks when probing is available.
val audioTrack = playerState.availableAudioTracks.firstOrNull()
if (audioTrack != null) {
when (val result = playerState.selectAudioTrack(audioTrack)) {
is TrackSelectionResult.Selected -> println("Audio track selected: ${result.trackId}")
TrackSelectionResult.Auto -> println("Automatic audio track selection restored")
is TrackSelectionResult.NotFound -> println("Audio track not found: ${result.trackId}")
TrackSelectionResult.NotSupported -> println("Audio track selection is not supported")
is TrackSelectionResult.Failed -> println("Failed to select audio track: ${result.message}")
TrackSelectionResult.Disabled -> Unit
}
}
val subtitleTrack = playerState.availableSubtitleTracks.firstOrNull { it.isEmbedded }
if (subtitleTrack != null) {
playerState.selectSubtitleTrack(subtitleTrack)
}availableSubtitleTracks can contain both external tracks added by your app through addSubtitleTrack(...) and embedded tracks discovered from the current media. Use replaceExternalSubtitleTracks(...) to replace app-managed tracks while preserving embedded tracks, or removeSubtitleTrack(...) / clearExternalSubtitleTracks() to remove app-managed tracks.
selectAudioTrack(...), selectSubtitleTrack(...) and disableSubtitles() return TrackSelectionResult. Selected, Auto and Disabled mean the request was applied; NotFound, NotSupported and Failed mean the player state was not changed.
You can customize the appearance of subtitles using the following properties:
// Customize subtitle text style
playerState.subtitleTextStyle = TextStyle(
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
// Customize subtitle background color
playerState.subtitleBackgroundColor = Color.Black.copy(alpha = 0.7f)To disable subtitles:
when (playerState.disableSubtitles()) {
TrackSelectionResult.Disabled -> println("Subtitles disabled")
else -> println("Subtitles were not disabled")
}[!WARNING] Fullscreen support is experimental. The behavior may vary across different platforms.
You can toggle between windowed and fullscreen modes using the toggleFullscreen() method:
// Toggle fullscreen mode
playerState.toggleFullscreen()
// Check current fullscreen state
if (playerState.isFullscreen) {
println("Player is in fullscreen mode")
} else {
println("Player is in windowed mode")
}The player doesn't display any UI by default in fullscreen mode - you need to create your own custom UI using the overlay parameter of VideoPlayerSurface. The overlay will be displayed even in fullscreen mode, and you can customize it based on the fullscreen state:
VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize(),
overlay = {
Box(modifier = Modifier.fillMaxSize()) {
// Customize UI based on fullscreen state
if (playerState.isFullscreen) {
// Fullscreen UI
// ...
} else {
// Regular UI
// ...
}
}
}
)See the "Custom Overlay UI" section under "Displaying the Video Surface" for a complete example.
[!WARNING] PiP is supported on Android (8.0+) and iOS only. On desktop and web, it is a no-op.
The player supports Picture-in-Picture mode, allowing users to continue watching video in a floating window while using other apps.
| Platform | Status | Notes |
|---|---|---|
| Android | ✅ | Requires Android 8.0+ (API 26). Add android:supportsPictureInPicture="true" to your Activity in the manifest. |
| iOS | ✅ | Uses AVPictureInPictureController. Enable "Audio, AirPlay, and Picture in Picture" in Background Modes. |
| Desktop | ❌ | No-op |
| Web | ❌ | No-op |
// Check if PiP is supported on the current device
if (playerState.isPipSupported) {
// Enable automatic PiP when the app goes to background
playerState.isPipEnabled = true
// Or enter PiP programmatically
val result = playerState.enterPip()
}On Android, you can use the AutoPipEffect composable to automatically enter PiP mode when the app goes to the background while a video is playing:
AutoPipEffect(playerState)You also need to forward PiP mode changes from your Activity:
class MainActivity : ComponentActivity() {
override fun onPictureInPictureModeChanged(isInPipMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPipMode, newConfig)
DefaultVideoPlayerState.onPictureInPictureModeChanged(isInPipMode)
}
}You can configure how the media player interacts with other apps' audio using the AudioMode parameter:
// Default: exclusive playback, pauses other apps' audio
val playerState = rememberVideoPlayerState()
// Mix with other apps' audio
val playerState = rememberVideoPlayerState(
audioMode = AudioMode(interruptionMode = InterruptionMode.MixWithOthers)
)
// Duck other apps' audio (lower their volume)
val playerState = rememberVideoPlayerState(
audioMode = AudioMode(interruptionMode = InterruptionMode.DuckOthers)
)
// Ambient mode (iOS): respect silent switch, mix with others
val playerState = rememberVideoPlayerState(
audioMode = AudioMode(
interruptionMode = InterruptionMode.MixWithOthers,
playsInSilentMode = false,
)
)| Parameter | Description | Default |
|---|---|---|
interruptionMode |
DoNotMix, MixWithOthers, or DuckOthers
|
DoNotMix |
playsInSilentMode |
iOS only: whether audio plays when the silent switch is on | true |
[!NOTE] Audio mode is only effective on Android and iOS. On desktop and web, the parameter is accepted but ignored.
On iOS the default process-wide manager reference-counts active players so one player cannot deactivate AVAudioSession while another is using it. Applications that own audio-session configuration can opt out before creating any players:
IosAudioSessionPolicy.automaticManagementEnabled = falseOn Android you can enable disk-based caching so that video data fetched via openUri() is stored locally. Subsequent plays of the same URL load from the cache instead of re-downloading, which is especially useful for scroll-based UIs like TikTok/Reels-style VerticalPager.
val playerState = rememberVideoPlayerState(
cacheConfig = CacheConfig(
enabled = true,
maxCacheSizeBytes = 200L * 1024L * 1024L // 200 MB
)
)| Parameter | Description | Default |
|---|---|---|
enabled |
Whether caching is active | false |
maxCacheSizeBytes |
Maximum disk space for the cache (LRU eviction) | 100 MB |
To clear the cache programmatically:
when (val result = playerState.clearCache()) {
CacheClearResult.Cleared -> println("Video cache cleared")
CacheClearResult.Disabled -> println("Video cache is disabled")
CacheClearResult.NotSupported -> println("Video cache is not supported on this platform")
is CacheClearResult.Failed -> println("Failed to clear video cache: ${result.message}")
}| Platform | Status | Implementation |
|---|---|---|
| Android | ✅ | Media3 SimpleCache with LeastRecentlyUsedCacheEvictor
|
| iOS | ❌ | Returns CacheClearResult.NotSupported; the library does not mutate the host application's process-global NSURLCache
|
| Desktop | ❌ | Returns CacheClearResult.NotSupported
|
| Web | ❌ | Returns CacheClearResult.NotSupported; browser manages its own HTTP cache |
[!NOTE] Android caching only applies to URIs opened via
openUri(). Local files and assets are not cached. The cache is shared across Android player instances, so multiple players benefit from the same cached data.
[!WARNING] Metadata support is experimental. There may be inconsistencies between platforms. The default Wasm route maps the fields exposed by Movi; individual containers may omit them.
The player can extract the following metadata:
kotlin.time.Duration)You can access video metadata through the metadata property of the player state:
// Access metadata after loading a video
playerState.openUri("http://example.com/video.mp4") // Auto-plays by default
// Or load without auto-playing:
// playerState.openUri("http://example.com/video.mp4", InitialPlayerState.PAUSE)
// Display metadata information
val metadata = playerState.metadata
println("Video Metadata:")
metadata.title?.let { println("Title: $it") }
metadata.duration?.let { println("Duration: $it") }
metadata.width?.let { width ->
metadata.height?.let { height ->
println("Resolution: ${width}x${height}")
}
}
metadata.bitrate?.let { println("Bitrate: ${it}bps") }
metadata.frameRate?.let { println("Frame Rate: ${it}fps") }
metadata.mimeType?.let { println("MIME Type: $it") }
metadata.audioChannels?.let { println("Audio Channels: $it") }
metadata.audioSampleRate?.let { println("Audio Sample Rate: ${it}Hz") }
VideoPlayerState.currentTime is the observed playback position intended for UI state. If you need the freshest
available playback position for external synchronization or millisecond-level actions, use:
val currentTimeMs = playerState.preciseCurrentTime.inWholeMillisecondsHere is a minimal example of how to integrate the Compose Media Player into your Compose application with a hardcoded URL:
@Composable
fun App() {
val playerState = rememberVideoPlayerState()
MaterialTheme {
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
// Video Surface
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
VideoPlayerSurface(
playerState = playerState,
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.height(8.dp))
// Playback Controls
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Button(onClick = { playerState.play() }) { Text("Play") }
Button(onClick = { playerState.pause() }) { Text("Pause") }
}
Spacer(modifier = Modifier.height(8.dp))
// Open Video URL buttons
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Button(
onClick = {
val url = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
playerState.openUri(url) // Default: auto-play
}
) {
Text("Play Video")
}
Button(
onClick = {
val url = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
playerState.openUri(url, InitialPlayerState.PAUSE) // Open paused
}
) {
Text("Load Video Paused")
}
}
Spacer(modifier = Modifier.height(8.dp))
// Volume Control
Text("Volume: ${(playerState.volume * 100).toInt()}%")
Slider(
value = playerState.volume,
onValueChange = { playerState.volume = it },
valueRange = 0f..1f
)
}
}
}