DEV Community

Cover image for Why a TV player runs on Kotlin, Compose, two video players and a JavaScript engine
Yonto Player
Yonto Player

Posted on

Why a TV player runs on Kotlin, Compose, two video players and a JavaScript engine

When I started Yonto Player on 15 September 2026, I did not pick a stack from a comparison table. I picked it from a list of things the app had to do, and each piece of the stack is the answer to one of them. This post goes through those choices in the order they came: what each one solved on day one, what I looked at instead, and what it costs me now. It is the reasoning, not a ranking of tools. Most of them would be the wrong answer for a different app.

Kotlin and Compose for TV: the first day

The app is for Android TV, so the first choice was nearly made for me. Kotlin is Android's own language, and Compose is its UI toolkit. For television there is a Compose library of its own, tv-material, with the focus and the card components a remote needs. The first working version already had tv-material and media3 in its build file, the same day the repository started.

What Compose gave me on a TV is not prettier code. It is that focus is state you can reason about. A television has no pointer, so every screen is really a small focus graph: where the cursor is, where Up goes, and where it lands when you press Back. Compose lets me write that down in the same place as the screen.

The cost is that focus is also where most TV bugs live. I keep a page of rules for it in the repo, such as having every screen ask for focus on its primary element, and retrying a focus request across frames when the item it targets is not attached yet. That is the price of a toolkit that gives you the control: you have to use it carefully.

media3 first: the player the device already trusts

Playback on Android TV is a hardware question. Which codecs does this box decode, in hardware, at which size, with which HDR? media3 and ExoPlayer sit on top of the device's own decoders, so what the hardware can decode, it decodes. They also read HLS and DASH, which is how a media server hands over a converted stream.

So media3 is the player a file starts in, and everything else is for what it cannot read.

libVLC: the second player, for what the first cannot play

A hardware-first player has a hole in it. Some files are not something a device's decoders can read: AVI, WMV and RMVB, disc images, and some audio and video profiles a particular box does not decode. For those the app carries a second player, libVLC, and tries the device's own player first and hands over to libVLC when it cannot play the file.

Here is the shape of that rule as a simplified sketch with generic names, not the real code. The real one has more cases, such as containers the device's player is known not to read and a setting that starts a file in the second player.

// Simplified sketch, not the app's real code.
fun playFile(file: MediaFile, prefersSoftware: Boolean) {
    if (prefersSoftware) return software.play(file)
    device.play(file, onCannotPlay = { software.play(file) })
}
Enter fullscreen mode Exit fullscreen mode

The detail I care about is the licence. libVLC as VideoLAN publishes it includes parts under the GPL. I wanted to ship it under the LGPL alone, so the app uses our own build of the engine, made by a script in the repo with the GPL-only pieces left out and no video encoder in it. That is more work than adding a dependency. It is also the reason I can say plainly what the app contains.

The costs are real, and I would rather list them: two players is two code paths to test, the native libraries add to the download, and I maintain a build script for a C library. I think that is a fair trade for the files that otherwise would not play. For sound there is a related piece: on Android the app carries FFmpeg audio decoders for Dolby Digital, DTS and TrueHD, which many devices cannot decode themselves, so those films do not play silent.

QuickJS: why the sources are JavaScript

This is the choice people ask about, because a native app that runs JavaScript sounds odd. The app depends on one interface, ContentSourceAdapter, and every third-party source sits behind it. A source written in Kotlin would need an app release for every new kind, so I made a source a plugin: one JavaScript file with a manifest at the top.

I wrote that design on 18 September. The engine had two hard requirements:

  • It must run in plain JVM unit tests, not only on a device, so plugins are not the one part of the app that cannot be tested.
  • Host functions must be able to suspend, so a plugin can await a network call and Promise.all really runs requests side by side.

I looked at androidx.javascriptengine first. It is real V8 in an isolated process, and it is tempting. But a script inside it cannot call out to the host, so a plugin could not simply await a fetch. It would have to be written as a state machine that is re-entered with each response, and that is the opposite of the authoring experience I wanted. The Kotlin QuickJS binding I chose is the one that met both requirements.

This is what a plugin looks like. These are excerpts from the starter plugin in the open-source repo, with the gaps marked:

/* yonto-plugin
{
  "kind": "content-source",
  "id": "video-blog",
  "name": "Video blog",
  /* ... */
  "contractVersion": 21,
  "provides": "source-type",
  "allowedHosts": [],
  "configSchema": [
    { "id": "feedUrl", "label": "Feed URL", "type": "url", "required": true }
  ]
}
*/
async function readPosts() {
  const response = await yonto.fetch(yonto.config.feedUrl);
  if (response.status !== 200) throw yonto.error.unavailable(`the feed answered ${response.status}`);
  /* ... */
}
Enter fullscreen mode Exit fullscreen mode

The same plugin runs in the app and in a command-line tool on a laptop. Both hosts follow the same plugin contract, a shared conformance suite checks them against each other, and they apply the same limits. The plugin docs and the plugins repo have the contract.

QuickJS is not thread-safe, so every source gets its own runtime, confined to one thread, with a deadline on every call. A plugin that loops forever fails its call; it does not freeze the screen. That is a design that prevents a class of failure, and it is cheap because the engine is small.

Kotlin Multiplatform: so the core outlives the first screen

From 26 September I began moving the core of the app, everything below the screens, to Kotlin Multiplatform. The domain code has no UI in it, so the same code and the same tests run on the JVM and natively, and the phone and tablet app share one Compose Multiplatform UI instead of a second copy of the screens. The television keeps tv-material, because its screens are a different job from touch: they are built around the remote and the focus rules above. I chose this partly so the same core could be shared with Apple devices (iPhone, iPad, Mac and Apple TV). Those versions are not out yet.

The cost is a bigger build, a stricter rule about which code may go where (commonMain cannot touch Android), and platform-specific pieces behind expect and actual declarations. I would pay it again, because the alternative was writing the same domain twice.

What I take from it

None of these choices is clever. Each one answers a requirement: be native on the TV, use the device's decoders, play what they cannot, let a source be code instead of a release, and keep the domain separate from the screen. If your requirements differ, so should your stack.

The method is the part worth copying: write the requirement that rules candidates out, measure against it, and write the cost next to the choice.

Read more

Top comments (0)