A media playback library for Kotlin Multiplatform apps. Its engine is written in Kotlin and plays video, audio and subtitles on Android, iOS, macOS, the desktop JVM and the web, with FFmpeg already inside the artifacts through KiteFFmpeg. It takes mpv and VLC as its models, and aims for their performance and range of features.
Android iOS macOS Desktop JVM Web
Install ·
Play something ·
Control ·
Subtitles ·
Network ·
Platforms ·
Modules
Guides · API reference · Changelog · Contributing
|
A library, not an app. |
Its own engine, not a wrapper. |
|
FFmpeg inside. |
Little from the platform. |
|
Formats
|
Picture
|
|
Sound
|
Subtitles
|
|
Streaming and input
|
Playback
|
|
On the device
|
|
Not every platform has every feature. Where it runs and Limits say what is missing where.
This desktop JVM program plays a song, seeks, and closes the player:
import io.github.yuroyami.kiteplayer.KitePlayer
import io.github.yuroyami.kiteplayer.MediaItem
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.time.Duration.Companion.seconds
fun main() = runBlocking {
val player = KitePlayer()
player.open(MediaItem("/path/to/song.mp3")) // returns when the item is open and paused
player.play()
delay(10.seconds)
player.seek(60.seconds) // returns when the seek has landed
delay(10.seconds)
player.closeAndAwait()
}Note
KitePlayer has not reached 1.0. It plays real media on Android, iOS, macOS and the desktop JVM, and it runs inside a shipping app, but the API can still change between versions. Read Limits before you plan around it.
Pick one line. Both pull in the whole playback stack, and Gradle picks the platform pieces for each target you declare.
commonMain.dependencies {
implementation("io.github.yuroyami:kiteplayer:0.2.0") // native views, no Compose
// or
implementation("io.github.yuroyami:kiteplayer-compose:0.2.0") // Compose, plus everything above
implementation("io.github.yuroyami:kiteplayer-audioviz:0.2.0") // optional: a visualiser for audio
}You do not install FFmpeg, and there is no Gradle plugin. On Android, every artifact needs
minSdk 26 or higher. In an Android-only app, put the line in your usual dependencies { } block.
Modules draws what each line pulls in.
Important
Some setups need one more step. Without it, the link, the App Store upload, the first call or background playback fails.
| If you build | You also need |
|---|---|
An iOS app with a static framework (isStatic = true) |
Linker flags in Xcode. See iOS setup. |
| Any iOS app | Two privacy manifest entries, for boot time and file timestamp APIs. See iOS setup. |
A web app (wasmJs) |
Two WebAssembly modules that the page serves, for FFmpeg and libass, and a third for the worker player. See Web setup. |
| Playback that goes on in the background on Android | A service and three permissions in your manifest. See Background playback. |
iOS setup: the linker flags and the privacy manifest entries
A dynamic framework needs no flags, because Kotlin links it with the system frameworks. A static framework is linked by Xcode instead, so add this to Other Linker Flags:
-ObjC -lz -framework CoreFoundation -framework CoreMedia -framework CoreVideo -framework VideoToolbox -framework AudioToolbox
KitePlayer times playback with mach_absolute_time, which Apple lists as a system boot time API.
It also reads file sizes and dates with stat, fstat and lstat, from FFmpeg's file reader, the
libass chain and its own file readers, which Apple lists as file timestamp APIs. App Store Connect
refuses the upload (ITMS-91053) until both are declared in PrivacyInfo.xcprivacy. Keep only the
reasons that apply to your app: 35F9.1 is time measured between events inside the app, C617.1
is files inside the app container, and 3B52.1 is files that the user picked.
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>35F9.1</string></array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>C617.1</string><string>3B52.1</string></array>
</dict>
</array>For background audio, declare UIBackgroundModes with audio in Info.plist.
Web setup: the modules a page serves
A browser cannot link FFmpeg or libass into the Kotlin binary, so the page serves them as two
WebAssembly modules. Each one comes as a web zip beside its artifact on Maven Central:
- Unpack
kiteffmpeg-wasm-js-<version>-web.zipbesideindex.html, with the KiteFFmpeg version that KitePlayer depends on (0.4.0 for 0.2.0). The page then serveskite.mjs,kite.wasmandlicenses/. - Unpack
kiteplayer-libass-wasm-js-<version>-web.zipthere too, forkiteass.mjsandkiteass.wasm. The first ASS track loads them. Without them, ASS falls back to the built-in styling. - Call
KiteFFmpegWeb.load()before you create a player. It fetches./kite.mjs. Under a bundler, instantiate the module from a plain<script type="module">and pass it toKiteFFmpegWeb.attach()instead.
Serve .mjs as text/javascript and .wasm as application/wasm. With gzip, the codec module is
about 1.42 MiB to download, and CI holds it to that.1 Both modules are single-threaded,
so the page needs no cross-origin isolation headers. A browser starts audio only after the user
interacts with the page, so the position stays at zero until then. A player on the page's own
thread does not play network media, so play files from memory, as Network says, or use
the worker player.
KitePlayerWorker.start(canvas) runs the player in a web worker, so opening, decoding and drawing
leave the page's thread free (#100). The worker draws on the canvas and sends its sound straight to
the page's audio device, and it plays http, https and blob addresses. It loads a third module:
unpack kiteplayer-wasm-js-<version>-web.zip beside index.html too, for
kiteplayer-web-worker.mjs and the three files beside it. With gzip it is about 0.50 MiB to
download, and CI holds it to 0.53 MiB. The worker player has the calls and flows of KitePlayer
with the same names, except those its KDoc lists, such as captureFrame and recording. A setter
it refuses arrives on events as CommandRefused rather than throwing at the call. An item, or an
external subtitle, with a reader of its own cannot cross to the worker; give it an address. The
worker loads kiteass.mjs from beside the page too, so the libass web zip from step 2 serves
both players; pass another libassUrl to KitePlayerWorker.start if the files live elsewhere.
pictureInPictureOrNull() puts the worker's canvas in a picture in picture window, as
KitePlayerPictureInPicture does for the page's own player.
A multi-threaded codec module would need the page served with
Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and
imported without them it hangs rather than failing. KiteWebModules.codecModuleUrl(threaded = ...)
names it only on a page that has them, and the single-threaded module otherwise; pass its answer to
KiteFFmpegWeb.load or KitePlayerWorker.start. KiteFFmpeg publishes only the single-threaded
module today.
Three steps: create a player, show it, open something. The order of the last two does not matter: media may open before the view is on screen.
import io.github.yuroyami.kiteplayer.KitePlayer
val player = KitePlayer()KitePlayer() builds the player on this platform's default stack: FFmpeg, and the platform's own
audio output. Where the platform cannot play, it throws a PlaybackException that says why;
KitePlayer.isAvailable checks that first. Settings go in a block, for example
KitePlayer { subtitles { preferredLanguages = listOf("ja") } }.
In Compose: KitePlayerVideo
In Compose, rememberKitePlayer() builds the player and closes it when the composable leaves.
KitePlayerVideo shows it:
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import io.github.yuroyami.kiteplayer.MediaItem
import io.github.yuroyami.kiteplayer.compose.KitePlayerVideo
import io.github.yuroyami.kiteplayer.compose.rememberKitePlayer
val player = rememberKitePlayer()
KitePlayerVideo(player, Modifier.fillMaxSize())
LaunchedEffect(Unit) {
player.open(MediaItem("https://example.com/movie.mkv"))
player.play()
}[!TIP]
KitePlayerVideodraws in one of two ways.KiteRenderPath.NativeViewhosts the platform's video view: the system compositor shows the frames and the GPU stays idle, which suits long playback, so it is the default.KiteRenderPath.ComposeCanvasdraws the frames inside Compose, so the video takes clipping, alpha and shared element transitions. You can switch while it plays.
[!WARNING] On macOS, a click goes to the topmost native view, so Compose controls drawn over a native view video are painted but never pressed. Use the canvas path there, or keep the controls beside the video.
In a native view: KitePlayerView, KitePlayerUIView, KitePlayerAwtView
For a native view, give the view the player. The views are in io.github.yuroyami.kiteplayer.view:
KitePlayerView on Android, from XML or code, KitePlayerUIView on iOS, and KitePlayerAwtView on
the desktop JVM.
view.player = playerA player from KitePlayer() gives the views their renderer. A player built with KitePlayer.create
on backends of your own also needs view.installMobileRenderer(), or installDesktopRenderer() on
the desktop, from io.github.yuroyami.kiteplayer.mobile.
While a video plays on screen, the display stays awake: every view, KitePlayerVideo, the Mac's
AppKitVideoRenderer and the web's canvas renderers hold it, and let it sleep at a pause, the end,
or with sound only. Pass keepDisplayAwake = false to turn that off. The desktop JVM has no way to
hold its display, so there it does nothing.
Then open media from a coroutine that you own. A call that takes time suspends until it is done:
open, seek and closeAndAwait. play, pause and the setters return at once.
requestSeek is the seek that does not wait, for a seek bar being dragged.
import io.github.yuroyami.kiteplayer.MediaItem
import kotlin.time.Duration.Companion.seconds
player.open(MediaItem("https://example.com/movie.mkv"))
player.play()
player.seek(90.seconds)
// When the screen goes away, unless rememberKitePlayer owns the player:
player.closeAndAwait()From Java: listeners, futures and milliseconds
The player speaks in coroutines and flows, which Java cannot call. On Android and the desktop JVM,
KitePlayerJava adds what Java lacks: listeners called on an executor you name, a
CompletableFuture version of every call that suspends, and milliseconds wherever the Kotlin call
takes a Duration. MediaItemBuilder makes the item, and PlayerConfigBuilder the settings.
KitePlayerJava player = KitePlayerJava.create();
player.addListener(new KitePlayerListener() {
@Override
public void onState(PlayerSnapshot state) {
statusView.setText(state.getStatus().name());
}
@Override
public void onProgress(Progress progress) {
seekBar.setProgress((int) progress.getPositionMillis());
}
}, ContextCompat.getMainExecutor(context));
player.openAsync(new MediaItemBuilder("https://example.com/movie.mkv").build())
.thenRun(() -> player.getPlayer().play());
player.seekAsync(90_000);
// When the screen goes away:
player.close();Cancelling a future cancels its call, as cancelling the coroutine does in Kotlin. Every other call,
such as play(), pause() and setVolume(float), is on getPlayer().
[!NOTE] A listener hears each event that happens after it is added, and none from before: the player replays no event.
A file path or a URL needs nothing more. MediaItem("/sdcard/movie.mkv") goes straight to
FFmpeg's own file reader, which is the fastest way to read a local file. So does the address a
Compose Multiplatform resource has, on every target: MediaItem(Res.getUri("files/intro.mp4"))
plays the bundled file, from the app's assets on Android and from the app's jar on the desktop.
On Android that reads the assets through the application context, which a small content provider
of kiteplayer-io keeps from the moment the app starts, as Compose's own resources do.
For anything else, use a door: a function that turns what you have into a MediaIoFactory
for the item's io field. Each open of the item gets a new reader from it, because a track switch,
a loop or a recovery opens the item again.
| You have | Door | Where |
|---|---|---|
A ByteArray |
MediaIo.ofBytes(bytes) |
Everywhere |
| Bytes that your code pushes, from a socket or a decryptor | PipedMediaIo, a new one in each open |
Everywhere |
A File or a Path |
MediaIo.ofFile(file), MediaIo.ofPath(path) |
JVM Android |
A FileChannel that you keep open |
MediaIo.ofChannel(channel) |
JVM Android |
An InputStream |
MediaIo.ofStream { openStream() } |
JVM Android |
A content:// URI, such as one from the file picker |
MediaIo.ofUri(contentResolver, uri) |
Android |
A file in the app's assets |
MediaIo.ofAsset(assets, "clip.mp4") |
Android |
A Compose Multiplatform resource's Res.getUri address, when you set a resolver of your own |
MediaIo.ofResourceUri(context, uri), MediaIo.ofResourceUri(uri) |
Android JVM |
| A path that every read must pass through Kotlin | MediaIo.ofPath("/path/to/clip.mp4") |
Apple Linux |
| A file URL, such as one from the document picker | MediaIo.ofUrl(url) |
Apple |
import io.github.yuroyami.kiteplayer.MediaIo
import io.github.yuroyami.kiteplayer.MediaItem
import io.github.yuroyami.kiteplayer.from
import io.github.yuroyami.kiteplayer.io.ofUri
player.open(MediaItem.from(MediaIo.ofUri(contentResolver, uri), label = "picked.mkv"))
player.play()The label names the item in logs and helps FFmpeg guess the format. A stream and a pipe read
forward only, so the player cannot seek in them. MediaIo.ofBytes does not copy the array, so keep
it unchanged while playback can read it. The first two doors are in kiteplayer-core, and the
others in kiteplayer-io, which comes with kiteplayer.
A file that is still being written, such as a recording in progress or a download that plays as it
arrives, plays to its current end and on as it grows when the item says so:
MediaItem(path, growth = FileGrowth()). The player waits at the end for more, and ends the item
once the file has not grown for FileGrowth.endsAfter, two seconds by default. Its length grows
with the file, and a seek reaches any part already written. A plain path needs kiteplayer-io for
this; an item with its own io needs nothing more.
Several settings on one item: headers, probing and low latency
Build the item in a block. An empty block gives the same item as MediaItem(uri).
import io.github.yuroyami.kiteplayer.CorruptPackets
import io.github.yuroyami.kiteplayer.ProbeDepth
import io.github.yuroyami.kiteplayer.mediaItem
val item = mediaItem("https://cdn.example.com/live/channel.ts") {
header("Authorization", "Bearer $token")
probe(ProbeDepth.Fast)
corruptPackets(CorruptPackets.Drop)
lowLatency()
}probe, corruptPackets, lowLatency and the other demux settings say how the container opens,
and they fill the item's demux field. Raw FFmpeg options still go in openOptions, but an option
that a typed field also sets refuses the open with a typed error. MediaItem also carries
startPosition, externalSubtitles, videoFilter for an FFmpeg filter chain, and formatHint
when a container needs naming.
Everything here works during playback, and everything is published on player.state, so your UI
can read it back.
| Area | What to call |
|---|---|
| Playback | open, play, pause, stop, seek, requestSeek, stepFrame, close, closeAndAwait |
| Queue | openQueue, next, previous, setLoop, and addToQueue, removeFromQueue, moveInQueue, clearQueue while it plays. Items follow each other on the same audio device with no gap; PlayerConfig.queue turns that off, and the gapless design says when an item opens from scratch instead. QueueConfig.onItemFailure makes the queue skip an item that cannot be opened rather than stop on it. openPlaylist opens an M3U, PLS or XSPF file, or an album's cue sheet as its tracks, which play on one open of the file with every sample heard once, as the queue, and readPlaylist hands its items over to filter or reorder first |
| Shuffle | setShuffle. The items never move. queueOrder tells you what plays next. QueueConfig.reshuffleEachLap draws a new order on each lap under LoopMode.All |
| Speed | setSpeed, 0.25x to 4x with the pitch kept. setPreservePitch(false) lets the pitch change like a tape. setPitch moves the pitch by up to an octave in semitones without changing the speed |
| Sync | setExternalClock makes playback follow a clock your app owns, for watching together. A small difference closes through a speed change of at most 0.5 percent with the pitch kept, and a jump is one seek. Play and pause stay with your commands |
| Sound | setVolume, setMuted, setBalance, setStereoMode (mono, one side only, or swapped), setNightMode (quiet speech up, loud effects down), setDialogueLevel (the centre of a downmix up or down), setSkipSilence (every pause longer than a fifth of a second cut down to that, for podcasts and audiobooks), setEqualizer (ten bands and a preamp), setAudioDelay, setSleepTimer (with a fade), setVideoEnabled(false) for audio only |
| Loudness | PlayerConfig.audio.volumeCeiling allows volume up to 2.0 through a limiter. PlayerConfig.audio.replayGain applies the file's own ReplayGain tags, off by default |
| Surround | Multichannel audio folds into the speakers the device has. PlayerConfig.audio.upmix = UpmixMode.Surround also plays mono and stereo from the other speakers of a surround device, off by default |
| Picture | setVideoScale (fit, fill, stretch), setVideoAdjustments (brightness, contrast, saturation, hue), setVideoTransform (forced aspect, zoom, pan, quarter turns, mirrors) |
| HDR | setHdrPolicy. HDR10 and HLG show as HDR on a display that can: through Metal on a Mac or an iPhone with extended range, and through KitePlayerView on an Android HDR display. Elsewhere they are tone mapped, and PlaybackWarning.HdrToneMapped says so. HdrPolicy.ToneMap tone maps everywhere, and videoDynamicRange says what the screen shows. TrackInfo.dolbyVision names a Dolby Vision track's profile, and a profile 5 or 10.0 track is composed into HDR10 on the processor |
| Subtitles | selectTrack, selectSecondarySubtitle, addExternalSubtitle, seekToSubtitleLine (the line showing, the previous or the next), stepSubtitleDelay (a line forward or back), setSubtitleScale, setSubtitleDelay, setSubtitlePosition, setSubtitleStyle, setSubtitleSafeArea, setForcedPicturesOnly, and subtitleCues to draw the lines yourself. PlayerConfig.subtitles.secondaryLanguages shows a second track in another language at each open, at the top or, with secondaryPlacement, directly above or below the first |
| Sections | setAbLoop repeats between two points. setMarkers fires an event when playback crosses a position |
| Chapters | chapterAt, seekToChapter, nextChapter, previousChapter |
| Resume | memento() saves the item, position, tracks and speed. restore(memento) puts them back |
| Screenshots | captureFrame. kiteplayer-ffmpeg encodes the frame to PNG or JPEG, and makes thumbnails and waveforms |
| Recording | startRecording copies what the player reads into a Matroska file, with no re-encode. stopRecording finishes the file. A seek ends a recording |
| Rendering | attachRenderer, detachRenderer, swappable while media plays. attachRendererAndAwait refuses a renderer that cannot show the running decoder's frames and keeps the one before |
| Diagnosis | diagnosticsDump, warningHistory, supportBundle, and KiteLog as the one logging seam, silent by default. KiteTrace records a timeline that Chrome's trace viewer and Perfetto open, also silent by default |
Five flows tell your UI what is happening: state, progress, stats, events and
subtitleCues. position() reads the current time without collecting anything. Anything the
player cannot do is refused with a typed error, never accepted and ignored, and two players in one
process work.
Tip
SeekMode.Precise is the default seek. SeekMode.KeyframeThenRefine shows the nearest keyframe
at once and replaces it with the exact frame a moment later, which makes scrubbing feel instant
on large files.
Audio devices and screen readers
On the desktop JVM and on macOS, you choose the audio output device when you build the player.
audioOutputDevices() on DesktopOutputBackend or AppleOutputBackend lists the devices, and
withAudioOutputDevice(id) returns the backend bound to one, for PlayerConfig.backends. A bound
player never moves to another device: when its device is gone, the open fails with
PlaybackError.AudioDeviceUnavailable, and so does playback when the device disappears.
On Android and iOS the operating system owns the route.
The Android, iOS and desktop views, and both paths of KitePlayerVideo, tell a screen reader that
they are the video and what the player is doing, for example "Playing, 1:23 of 4:56".
accessibilityVideoLabel and accessibilityStateFormat take translated words. KiteVideo, the
bare canvas, gets its semantics from the modifier you pass. On the web, the page owns the canvas
and labels it.
- SubRip, WebVTT and SubStation Alpha, from the container or from an external file. External files load in the middle of playback.
- Synced lyrics: an
.lrcfile, or LRC lines in a song's own tags (an ID3USLTframe, a Vorbis or MatroskaLYRICS, an MP4©lyr), become a track that shows line by line throughsubtitleCues, selected when nothing else is. Lyrics without times arePlayerSnapshot.lyrics, for the application to show (#443). SubtitleConfig.hearingImpairedNoteshides the notes of subtitles made for deaf and hard-of-hearing viewers:[DOOR SLAMS], a(laughs)that opens a line,JOHN:and♪music lines, and withHideStrictevery parenthesis. ASS scripts are left alone (#493).- An external file added without a language takes one from its name, as
Film.en.srt,Film.eng.forced.srtandFilm.pt-BR.sdh.srtsay it, withforced, andsdh,ccorhi, marking the track, and a file in a preferred language is chosen at open over the container's track in a later one (#514). - A subtitle the player chose by itself follows the audio: switching an anime to the English dub
shows the English signs track made for it, and switching back shows every line again. One the
viewer chose stays.
SubtitleConfig.withMatchingAudio, as mpv'ssubs-with-matching-audio, keeps only forced tracks, or none, under audio in a preferred subtitle language (#506). - WebVTT keeps its colours: the standard's colour classes such as
<c.yellow>and<c.bg_blue>, and the::cuerules of itsSTYLEblocks for colour, background, bold, italic, underline, font and relative size, by class, voice and cue identifier. A rule that asks for anything more is ignored whole (#498). ASubtitleStyleOverridestill wins over the file's colours. - Blu-ray (PGS), DVB, DVD and XSUB image subtitles from the container, placed on the picture they were authored for.
- DVB teletext subtitles from a broadcast recording or an IPTV stream, read in Kotlin with no native library. Each subtitle page the channel lists is a track of its own, with its language and its hearing impaired mark, as VLC lists them, and a page shows its colours, its boxed backgrounds and its double height lines in the top or bottom half of the picture. The letters follow the page's national character set, Latin, Cyrillic, Greek or Hebrew, as libzvbi reads them (#510).
- The closed captions broadcast H.264, HEVC and MPEG-2 carry inside the picture, which have no subtitle stream of their own, become a track, CC1, from the first picture that carries any. Each screen shows as it is sent, as mpv shows them, and like a television the player shows them when the viewer's preferences or the viewer ask, not by default. Pictures a platform decoder draws straight to a surface carry none (#236).
- A disc's forced captions, the signs and foreign dialogue it marks forced among a Blu-ray or DVD
track's pictures, can draw on their own.
setForcedPicturesOnly, as mpv'ssub-forced-events-only, draws only those of the chosen track, andSubtitleConfig.forcedPicturesWhenOffdraws those of the track in the audio's language while no subtitle is chosen, following the audio, as Kodi does (#513). - ASS and SSA tracks are drawn by libass as authored: moving signs, animated transforms, karaoke fills, clips and vector drawings. They re-render every video frame while they move.
- An ASS script's colours are matched to the video through its
YCbCr Matrixheader, as XySubFilter and libass's own notes ask, so a sign coloured to blend into the picture still blends in. A script with no header counts as BT.601 at studio range,Nonekeeps its colours, and so do HDR and RGB video. The built-in styling does the same, andSubtitleConfig.assColorMatching = falsekeeps every colour as authored (#499). - Fonts attached to a Matroska file load for the track, and
SubtitleConfig.fontsadds your own. On Android and Linux, a bounded set of system fonts loads too. setSubtitleSafeAreakeeps the built-in text out of a display cutout, rounded corners or a control bar. Subtitle placement says where subtitles land on every renderer.
libass details: typesetting choices and the web
SubtitleConfig.typesetting = falsekeeps the built-in Kotlin styling instead of libass.PlayerSnapshot.subtitleTypesettersays which engine draws.- A typeset ASS track ignores
SubtitleStyleOverride. Scale and position still apply. Only the primary track is typeset; a secondary track uses the built-in styling at the top of the picture. - On the web, libass is a separate module (see Web setup). A browser has no system
font, so supply fonts as attachments or through
SubtitleConfig.fonts.
HTTP and HTTPS work as soon as kiteplayer-network is on the classpath, and every standard entry
point includes it. You do not build a resolver or a Ktor client.
- Android and the JVM use OkHttp with the platform trust store, and Apple uses NSURLSession.
MediaItem.headersreach whichever transport is selected.- The Android artifact declares the
INTERNETpermission for you. Cleartext HTTP follows your app's own policy. - In a browser, a player on the page's own thread cannot play network media, because a read cannot
wait there.
KitePlayerWorkerplays it from a web worker (#100), see Web setup. It downloads the whole file before it plays, up to 512 MiB, and HLS and DASH do not play there yet. On the page's thread, fetch the file and play it from memory withMediaItem.from(MediaIo.ofBytes(bytes), name).
HLS plays through the same transport. An address that ends in .m3u8, an HLS content type from
the server, or formatHint = "hls" marks a playlist. When none of those does, the first bytes do:
a playlist starts with #EXTM3U, so one behind an address with no extension, sent as text or as
bytes, plays too (#400).
- A master playlist plays one variant: the one that
DemuxPolicy.variantnames, or else the one with the highest bitrate withinDemuxPolicy.maxBitrateandDemuxPolicy.maxVideoHeight.Tracks.variantslists the variants, andKitePlayer.selectVariantplays another one from the current position. The stream opens again for that, so the picture holds for a moment. - The choice follows the screen (#447). An
HDR version plays on a display that shows HDR as HDR, under
HdrPolicy.Auto, and the SDR one elsewhere. Nothing larger plays than the smallest variant that fills the view the picture is drawn into, so a phone does not fetch 4K, and the cap rises when the view grows. The player reads both from the attached renderer at each open and each step. SetDemuxPolicy.fitto decide for it, andVariantFit()for no cap. A DASH manifest's transfer characteristics property counts as HLS'sVIDEO-RANGE. The Apple renderers do not report HDR yet (#540). - The player steps down when the stream reads slower than it plays, or when playback has waited
4 s for data, and
PlaybackWarning.VariantLoweredsays so. - It steps up when the network carries the next higher variant with half again to spare and the
buffer is full. The network reader measures that rate on its downloads, and reports it through
MediaIo.networkBitsPerSecond. A reader of your own that answers null never steps up. - A step up waits 30 s after a step down, and twice as long after each step up that did not last, up to 5 minutes.
- Each step opens the stream again, so the picture holds for a moment. A variant that you
selected stays, and a step up never passes
DemuxPolicy.maxBitrate,maxVideoHeightor the fit, and never moves between SDR and HDR. - MPEG-TS and fMP4 segments, byte ranges, AES-128 keys, separate audio and subtitle renditions, and
live playlists play. A finished playlist can seek. A rendition's
NAMEis its track's title, and itsDEFAULT,FORCEDand accessibilityCHARACTERISTICSset the track's flags. - Only the audio rendition being heard is downloaded, so a stream with six languages does not pay for five that nobody hears. A switch to another language reads it from the moment playing: the picture and the old language go on until the new one is there, usually within a second, and the reads go back once over what they had read ahead. A file switches instantly, as before.
- Playlist variables play:
EXT-X-DEFINEbyNAMEandVALUE, byIMPORTfrom the master playlist, and byQUERYPARAMfrom the playlist's own address, so a token in the master playlist's address reaches every variant, segment and key that names it. A playlist that was redirected takes itsQUERYPARAMand its relative addresses from where it was redirected to. A reference to a variable that nothing defined fails the open, and the error names it. - A segment that cannot be read is skipped, and
PlaybackWarning.SegmentSkippedsays so. A stream that ends while its last segments fail ends withPlaybackError.SourceUnavailable. MediaItem.headersgo only to the scheme, host and port of the item's own address, because a playlist can name segments on any server.- Your own
MediaIocan serve HLS too: report the address it read inlocation, and open the addresses the playlist names inopenRelated.
A Shoutcast or Icecast station names each song as it starts, and the player shows it when it is heard rather than when it is read, seconds ahead (#423). The network reader asks for the titles, takes the title blocks out of the bytes, and reads a title in windows-1251 or another legacy table as the player reads a subtitle file. A chained Ogg's next song and the other tags a stream changes while it plays arrive the same way.
PlayerSnapshot.metadataholds the song asStreamTitle, beside the station'sicy-name.- The media session shows the song as the title and the station on the artist line.
setItemDetailsreplaces the playing item's title, artist and album without opening it again, for a station that publishes its song list somewhere else. It and the station's next song replace each other, whichever came last.- A reader of your own reports tags through
MediaIo.takeTags.
A radio station's link is often a list that names its stream rather than the stream itself, and
it plays as it is (#450). A PLS file is
recognised by its [playlist] first line, an audio/x-scpls type or a .pls address, and an M3U
list by the same marks as an HLS playlist, but with no #EXT-X- tag in it.
- The first stream on the list that opens plays, so the backups a station lists after its main stream take over when that one is down. When none opens, the last failure is reported.
- The stream's title is the one the list gives it,
TitleNin a PLS file and the#EXTINFtext in an M3U list, unless the stream names itself. - The streams open through the list's reader, on its client, so a
MediaIoof your own serves them throughopenRelated, as for HLS. A list that names another list is followed, three levels deep at most. - A station's server closes a listener's connection now and then, after a long pause, when its
encoder restarts or when a load balancer moves the listener. A stream with no length and no
ranges that carries Shoutcast or Icecast
icy-headers connects again and goes on from the live edge, withPlaybackWarning.SourceReconnectingeach time, and ends only when the station still answers 404 or 410 once the reconnects are spent (#508). Without those headers the stream ends where the server stops, because a media server that encodes a song as it sends it answers the same way, and asking it again would play the song again.
A DASH address plays as it is, through the same transport, with no call to make. The manifest is
recognised by its application/dash+xml type, by a path that ends in .mpd, or, when the server
sends it as text, XML or bytes, by its root element
(#400).
- A manifest whose picture and sound are fragmented MP4, MPEG-TS or WebM plays through the HLS path: each video representation is a variant, each audio set an audio rendition, and each subtitle set a subtitle rendition. FFmpeg's HLS reader reads WebM segments although the HLS specification names only the other two (#401). Separate sets play together, a finished presentation seeks, and a live one plays from its live edge and fetches the manifest again after each update period.
- A live manifest counts its window on the time of day its
UTCTimingnames, bydirect,http-xsdate,http-isoorhttp-head, and falls back to the device's clock with a line in the log; a refresh follows itsLocation(#404). In a browserhttp-headanswers only from a server that exposes itsDateheader. - An audio or subtitle track takes its set's
Labelas its title,mainsound is the default,forced-subtitleis a forced track, andcaptionanddescriptionare marked as accessibility tracks. Digital rights management is out of scope: an encrypted set is left out, and an encrypted manifest is refused withDashUnsupportedException. - Segment templates, numbered or with a timeline, segment lists, and single files all play: an
MP4 file through its segment index (
sidx), and a WebM file through itsCues. - Subtitle sets of WebVTT play as they are. Sets of TTML, and of TTML or WebVTT in MP4 segments
(
stpp,wvtt), are served to the player as WebVTT, with their text, line breaks, italic, bold and underline, but not their placement (#402). - A manifest of several Periods, as ad insertion and chapters stitch them, plays as one
presentation (#403). Each later Period
gives each track the set with the same
id, at the same place or in the same language, and the representation nearest its bandwidth. Time runs on across each boundary although each Period's media time starts again, an fMP4 Period of another picture size decodes at its own, because its H.264 or HEVC parameter sets travel with its keyframes, and a live manifest that a refresh gives a new Period plays on into it. An fMP4 Period in another codec than the first is skipped. MediaItem.headersgo to the manifest and to the segments of its own scheme, host and port, as for HLS.Dash.mediaItemForbuilds the item yourself, for a client of your own, aDashUrlPolicyother than the default, or other size ceilings.Dash.manifestreads a manifest without playing it.
Which transport wins, and when a client exists
The item's own io source wins, then a resolver that you set in NetworkConfig.ioResolver, then
the automatic provider. NetworkConfig.autoResolve = false turns the automatic provider off. No
HTTP client exists until network media opens, and the reader that created one closes it.
Android stops a process that plays in the background unless a foreground service holds it.
kiteplayer has that service, KitePlayerMediaService, and your app declares it:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<service
android:name="io.github.yuroyami.kiteplayer.session.KitePlayerMediaService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>Then attach the media session. With notification options, it shows the media notification and
keeps the app playing in the background. It also takes audio focus, and it closes with the player.
smallIcon is your app's monochrome notification icon.
import io.github.yuroyami.kiteplayer.session.MediaNotificationOptions
import io.github.yuroyami.kiteplayer.session.attachMediaSession
player.attachMediaSession(context, MediaNotificationOptions(smallIcon = R.drawable.ic_notification))On iOS, declare UIBackgroundModes with audio and call player.attachMediaSession() for the lock
screen. A desktop app keeps playing without help, and a web page plays while its tab is open.
What the notification does: buttons, wake locks and timeouts
- It shows the title, the artist, previous, play or pause, and next. Set
title,artistandalbumon theMediaItemto choose them; otherwise they come from the file's tags, then its file name.session.setCustomActionsadds your own buttons. The picture is the file's own cover when it carries one, andsession.setArtworkLoadersupplies another that wins over it.player.coverArthands the cover's bytes to your own screens too. - Skip back and skip forward move 15 seconds. Pass
skipIntervaltoattachMediaSessionfor another interval. - The session takes audio focus, so a call or another app pauses or ducks the player, and the
player pauses when the headphones come out. The focus request and the audio track say whether
the item is music, speech or a film, from
MediaItem.audioContent, which by default says film for a picture and music for sound alone.interruptions = nullturns that off, andbackground = nullleaves the app's background behaviour alone. - While the player plays or buffers, the notification keeps the processor and Wi-Fi awake, so a
stream keeps loading with the screen off. That needs
WAKE_LOCK. Pick anotherwakeLockspolicy inMediaNotificationOptions, orWakeLockPolicy.Noneto hold nothing. - After a pause, the service stays in the foreground for ten minutes (
pausedForegroundTimeout). Then the notification can be swiped away, which stops the service and leaves the player paused. - From Android 12, Android can refuse a start from the background.
onForegroundRefusedtells you, and the notification still shows. Android 13 and later need no notification permission for it. - The library declares no service and no permission for background playback, so your manifest
carries every entry above. The one entry the library adds is
INTERNET.
kiteplayer-audioviz draws the sound when the media has no picture. Show it in place of the video
when isAudioOnly says so:
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import io.github.yuroyami.kiteplayer.audioviz.KiteAudioViz
import io.github.yuroyami.kiteplayer.audioviz.isAudioOnly
import io.github.yuroyami.kiteplayer.audioviz.rememberAudioVizState
import io.github.yuroyami.kiteplayer.compose.KitePlayerVideo
val viz = rememberAudioVizState(player)
val snapshot by player.state.collectAsState()
if (snapshot.isAudioOnly) {
KiteAudioViz(viz, Modifier.fillMaxSize())
} else {
KitePlayerVideo(player = player, modifier = Modifier.fillMaxSize())
}Create the state where you create the player's screen, not inside the audio-only branch, so it is already listening when a song starts. Album art does not count as a picture.
Choosing what it draws
viz.drawing,viz.paletteandviz.directedchoose what is drawn. Withdirectedon, the director changes drawings on the song's phrases.viz.mutate()changes the current drawing's recipe now.AudioVizBrowser(viz)shows every drawing live in a searchable grid.AudioVizSettings(viz)holds the drawing's own settings, the palette and the finishing pass.VizPalette.fromImagebuilds a palette from a picture, such as an album cover.viz.reducedMotioncalms the picture; set it from your platform's own setting.viz.framesPerSecondcaps the redraw rate, andviz.visible = falsedraws the background alone while the sound plays on.- The toolkit the drawings are written with is public behind
@AudioVizAuthoringApi.
| Target | What runs, and where |
|---|---|
| Android | Plays real media on phones, checked by hand. CI runs the host tests, and an emulator job runs the device tests of five modules and the sample app on every push. A failure there does not fail the run yet. |
| iOS | Plays real media on devices, checked by hand. CI runs the tests of every iOS module on the simulator. |
| macOS arm64, native and desktop JVM | Plays real media. CI runs every module's tests on both, the format matrix included. |
| Web, wasmJs | Plays through the FFmpeg WebAssembly module with browser audio, from memory. KitePlayerWorker runs the player in a web worker, which plays single files from the network too. CI runs the web tests under Node and in a headless browser. |
| Linux and Windows, native | No audio output and no HTTPS, so KitePlayer() throws and KitePlayer.isAvailable is false. Pass KiteFFmpegMediaBackend() and your own OutputBackend to KitePlayer.create. CI runs the media-free tests. |
| Linux and Windows, desktop JVM | The native libraries are linked. The Linux FFmpeg backend decodes in a container, and neither has played sound on a real machine. |
| tvOS, watchOS, iOS x64, Android native | Only the engine modules build there; CI runs the tvOS and watchOS tests on their simulators. |
| js | The facade reports unavailable. |
KitePlayer's JVM and Android classes are Java 11 bytecode, and so is the KiteFFmpeg 0.5.0 jar, so a desktop app runs on Java 11 or later.
Which artifact publishes which target
iOS means iosArm64 and iosSimulatorArm64; core, subtitles, io and rt also publish iosX64. The
last column covers tvosArm64, tvosSimulatorArm64, the four watchOS targets and the four Android
native targets. ✓ marks a target the artifact publishes, and · one it does not.
| Artifact | Android | iOS | macOS | JVM | Linux | Windows | wasmJs | js | tvOS, watchOS, Android native |
|---|---|---|---|---|---|---|---|---|---|
kiteplayer-core, -subtitles, -io |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
kiteplayer-rt |
· | ✓ | ✓ | · | ✓ | ✓ | · | · | ✓ |
kiteplayer, -libass |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | · |
kiteplayer-ffmpeg, -output |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | · | · |
kiteplayer-network |
✓ | ✓ | ✓ | ✓ | · | · | ✓ | ✓ | · |
kiteplayer-view |
✓ | ✓ | ✓ | ✓ | · | · | ✓ | · | · |
kiteplayer-compose-interop |
✓ | ✓ | · | ✓ | · | · | ✓ | ✓ | · |
kiteplayer-compose, -compose-ui, -compose-video, -view-bindings, -audioviz |
✓ | ✓ | · | ✓ | · | · | · | · | · |
kiteplayer-compose-interop's js and wasmJs variants draw an empty surface, so that shared Compose
code compiles for the web; they show no video. Every CI run of the format matrix writes a
conformance table, uploaded as the conformance-macos-host artifact and printed in the run
summary.
| Topic | What to expect |
|---|---|
| Adaptive streaming | Single-file HTTP and HTTPS work, with an in-memory byte cache, everywhere. In the browser they work only in KitePlayerWorker, which downloads the whole file before it plays.HLS plays one variant at a time. selectVariant changes it, with a short pause while the stream opens again. The player steps down and up by itself with the measured network rate, and each step holds the picture for a moment.A DASH manifest of fMP4, MPEG-TS or WebM segments plays through the HLS path, live ones included, with a variant for each video representation, from its address alone or through Dash.mediaItemFor, and a manifest of several Periods plays as one presentation. A persistent cache does not work yet.A seek bar's preview pictures come from the stream, an HLS image playlist or a DASH thumbnail set, or from a WebVTT thumbnail file that MediaItem.thumbnails names: thumbnailAt gives the grid image and the region of the tile for a position, downloaded only when asked (#433). |
| Native Linux and Windows | No audio output and no HTTPS. Use the desktop JVM target, or pass your own OutputBackend. |
| Desktop JVM sound | Plays on macOS. Linux and Windows have not played audio on a real machine. |
| AV1 on the web | There is no software AV1, because the web build has one thread and dav1d needs threads. Native targets decode AV1 with dav1d, and in hardware where the device has it. |
| Android devices | The emulator runs the device tests on a software GPU. What needs a real phone, such as frame pacing and GPU cost, is checked by hand. |
| API stability | Any release before 1.0 can change the API. Committed ABI dumps make each change visible in review, but they are not a promise. |
Everything else that is open lives in GitHub Issues.
What each install line pulls in. The violet boxes are the two lines you pick from, the magenta one is the optional visualiser, and a dotted arrow is a dependency used at runtime only.
%%{init: {"flowchart": {"nodeSpacing": 14, "rankSpacing": 44}}}%%
flowchart LR
classDef entry fill:#7F52FF,stroke:#7F52FF,color:#ffffff
classDef optional fill:#C518CB,stroke:#C518CB,color:#ffffff
classDef outside stroke-dasharray:4 3
compose([kiteplayer-compose]):::entry
kp([kiteplayer]):::entry
compose --> ui[kiteplayer-compose-ui]
ui -. runtime only .-> interop[kiteplayer-compose-interop]
ui -. runtime only .-> cvideo[kiteplayer-compose-video]
compose --> kp
kp --> bindings[kiteplayer-view-bindings]
bindings --> view[kiteplayer-view]
kp --> output[kiteplayer-output]
kp -- not on Linux and Windows native --> network[kiteplayer-network]
kp --> libass[kiteplayer-libass]
kp --> io[kiteplayer-io]
kp --> ffmpeg[kiteplayer-ffmpeg]
ffmpeg --> subs[kiteplayer-subtitles]
ffmpeg --> kff[(KiteFFmpeg)]:::outside
kp --> core[kiteplayer-core]
core -- native targets only --> rt[kiteplayer-rt]
viz([kiteplayer-audioviz]):::optional
viz --> core
viz --> k3d[(Kite3D)]:::outside
| Artifact | What it is |
|---|---|
kiteplayer-compose |
Everything in kiteplayer, plus both Compose video paths and the switch between them. The complete Compose entry point. |
kiteplayer |
The default playback stack for native views: engine, FFmpeg decoders, audio output, view adapters, HTTP and HTTPS, libass, input doors. |
kiteplayer-audioviz |
Optional. An audio visualiser for files with no picture: presets, palettes, and a director that changes drawings with the music. |
kiteplayer-compose-ui |
Compose presentation only: KitePlayerVideo and both video paths. No player factory, no network. |
kiteplayer-compose-interop |
Compose hosting the platform's native video view: KitePlayerSurface. KitePlayerVideo uses it at runtime; add it yourself only to call KitePlayerSurface directly. |
kiteplayer-compose-video |
Video drawn by Compose itself: KiteVideo. KitePlayerVideo uses it at runtime; add it yourself only to draw with KiteVideo directly, for example in a second window. |
kiteplayer-view |
The native views: KitePlayerView on Android, KitePlayerUIView on iOS, KitePlayerAwtView on the desktop JVM. |
kiteplayer-view-bindings |
The FFmpeg adapters those views need. |
kiteplayer-core |
The engine and its service interfaces. Depends on kotlinx.coroutines and atomicfu, and on kiteplayer-rt on native targets. |
kiteplayer-ffmpeg |
Media source and decoders over KiteFFmpeg, plus snapshots, thumbnails, waveforms and the subtitle parsers. |
kiteplayer-network |
HTTP and HTTPS through Ktor. Registers itself. |
kiteplayer-io |
Input doors for platform types. Comes with kiteplayer. |
kiteplayer-libass |
The libass typesetter for ASS and SSA. Registers itself. |
kiteplayer-output |
Platform audio output, render support and the subtitle rasterisers. |
kiteplayer-subtitles |
SubRip, WebVTT, ASS dialogue and LRC lyrics parsers, in Kotlin. |
kiteplayer-rt |
The real-time audio ring, in C. Comes with kiteplayer-core on native targets; never add it yourself. |
To build your own stack, start from kiteplayer-core and supply backends through
KitePlayer.create(PlayerConfig(backends = Backends(backend, output))). The
SPI cookbook walks through one, and the
module contract says what each entry point promises.
How it is tested
Each push runs the jobs in ci.yml: the real-media suites on macOS
arm64 (JVM and native), the iOS simulator suites and the iOS sample app, the tvOS and watchOS
simulators, the media-free suites on Linux x64 and Linux arm64, Windows x64 native, wasmJs under
Node and in a headless browser, the C audio ring under AddressSanitizer and ThreadSanitizer, and
the Android device tests on an emulator, whose failure does not fail the run yet. Before a commit,
scripts/check-gate.sh runs the local gate that CONTRIBUTING.md describes.
Sample apps: four apps, none of them published
The desktop, Android and iOS apps open on the audio visualiser, playing five songs by Skullbeatz
from the Newgrounds Audio Portal, under
CC BY-SA 3.0.
kiteplayer-sample-shared/media/README.md credits each
one. To play your own song, set kiteplayer.sample.song=/path/to/song.mp3 in local.properties.
To play a file you pick: on Android, open Other samples and choose Play a file you pick; on iOS,
launch with --uikit and tap Open file.
| Module | What it shows | Run it |
|---|---|---|
kiteplayer-sample-android |
The shared screen, and behind Other samples the XML view, both Compose paths, a button that swaps them, and a file picker | ./gradlew :kiteplayer-sample-android:installDebug |
kiteplayer-sample-desktop |
The shared screen in a window; with --modifiers, the Compose drawn path, clipped and animated |
./gradlew :kiteplayer-sample-desktop:run, or add --args='--modifiers' |
kiteplayer-sample |
A macOS window on the native views, and an iOS app on the shared screen, or on the native view with --uikit |
./gradlew :kiteplayer-sample:linkDebugExecutableMacosArm64, then run kiteplayer.kexe testmedia/sync1080p30.mp4 --window; for iOS, link linkDebugFrameworkIosSimulatorArm64 and open kiteplayer-sample/iosApp/KitePlayerSample.xcodeproj |
kiteplayer-sample-web |
The wasmJs measurement harness, not a demo | ./gradlew :kiteplayer-sample-web:wasmJsBrowserDistribution, then read kiteplayer-sample-web/MEASUREMENTS.md |
kiteplayer-sample-shared is the screen that the first three share. The test clips come from
./scripts/testmedia.sh, which needs ffmpeg on your PATH, and are not committed.
Working on KitePlayer: for contributors
CONTRIBUTING.md has the ground rules, the build prerequisites and the test gate. The short version:
./scripts/testmedia.sh # generate the test clips, needs ffmpeg on PATH
./scripts/check-gate.sh tier1 # the checks every change runsLicensing: what an app that ships these artifacts must do
KitePlayer is Apache-2.0. Decoding is done by KiteFFmpeg,
which embeds FFmpeg (LGPL-2.1-or-later) and dav1d (BSD-2-Clause). kiteplayer-libass embeds libass
(ISC), HarfBuzz (MIT), FreeType (FreeType License) and FriBidi (LGPL-2.1-or-later), and its Windows
JVM adapter adds GNU libiconv (LGPL-2.0-or-later). An app that ships them has three LGPL duties for
FFmpeg, FriBidi and libiconv:
| Duty | How to meet it |
|---|---|
| Say that the app uses them, under the LGPL | Ship a notice with the licence texts, which the JVM and Android artifacts carry under META-INF/licenses/. |
| Make their source available to your users | Point at the source that KiteFFmpeg's NOTICE and this repository's NOTICE name. |
| Let users relink against a modified copy | The artifacts link them statically, so publish your object files, or give a written offer for them. |
KiteFFmpeg's licensing guide explains the static linking case in detail.
Apache-2.0. See NOTICE.
Part of the Kite family:
KiteFFmpeg · Kite3D · KitePDF
Back to top
Footnotes
-
Gzipped at level 9 by
scripts/check-web-size.sh, which CI runs on every push. A module that grows past its budget fails the run. ↩