Yonto Player is a media player for your own library: your Jellyfin, Plex or Emby server, or the files on your NAS or a USB drive, on Android TV, phones and tablets, with iPhone, iPad, Mac and Apple TV apps built from the same core and now in their final polish. The Apple apps are not out yet, so this post describes how they are built, not something you can install today. It provides no content, shows no ads and needs no account, and I have been building it since 15 September 2026. That is seven weeks, more than 6,000 commits and nearly 80 releases, with AI coding agents doing most of the typing and me deciding what gets built.
This is the first post of a series on how it is built. I want to give you the map first, so the later posts have somewhere to hang.
The one rule
The app does not know where a video comes from. The screens that browse, search and play all ask one interface, ContentSourceAdapter, and work in a handful of plain domain models: a title, a season, an episode, a stream. A Jellyfin server, a Plex server, a folder on a NAS and a file on the phone all sit behind that one interface, and the screens never branch on which one they are talking to. A simplified sketch of it:
interface ContentSourceAdapter {
val sourceId: String
val displayName: String
suspend fun getCategories(): Result<List<MediaCategory>>
suspend fun getMediaList(categoryId: String, page: Int = 1, filters: Map<String, String> = emptyMap()): Result<List<MediaSummary>>
suspend fun getMediaDetail(mediaId: String): Result<MediaDetail>
// ...plus calls for filters, search, turning a playback option into a stream,
// continue watching, and reporting playback; the optional ones have defaults
}
Those three are the heart of it.
That sounds like a textbook adapter pattern, and on day one it was. It turned out to be the decision everything else leaned on. When a source is a black box, adding one never touches a screen, and a screen never needs to learn what a Jellyfin library looks like. It has a cost: anything not every source can do, like filters, a search or a library scan, has to be an optional call with a sensible default, so the interface is a dozen calls and not three. The rest of the series keeps coming back to it.
Sources are plugins
The sources started as Kotlin classes behind that interface and grew into plugins. Now every third-party source, Jellyfin, Plex and Emby among them, is a small JavaScript plugin run on QuickJS, a small embeddable JavaScript engine, with a documented host API and a manifest. The one kind that is not a plugin is the viewer's own files, a folder on the device, a NAS share or a USB drive, which have their own source behind the same interface. The reason is the same as the rule above: the app should not need a release to learn a new kind of source, and a plugin should be testable on a laptop without a TV in the room.
The apps themselves are closed source. The plugin contract, the command-line tool that scaffolds, lints and runs a plugin, and the public plugins are open source under the MIT licence in the plugins repo. A plugin starts with a manifest in its header. This is a trimmed excerpt from the Jellyfin plugin, which is in that repo:
{
"kind": "content-source",
"id": "jellyfin",
"name": "Jellyfin",
"description": "A Jellyfin media server, reached with an ordinary login or with a server-generated API key.",
"configSchema": [
{ "id": "serverUrl", "label": "Server URL", "type": "url", "required": true },
{ "id": "username", "label": "Username", "type": "text" },
{ "id": "password", "label": "Password", "type": "secret" }
]
}
There is a copy of the contract written for strangers in the developer docs, so you can write a plugin without reading the app.
The stack, in a paragraph
It is mostly Kotlin. The screens are Jetpack Compose, with tv-material3 for the television and Compose Multiplatform for the touch UI, so a phone and a tablet share one UI. Playback is media3 and ExoPlayer first, with libVLC as a second player that picks up what the device's own player cannot read. The core, everything below the screens, is Kotlin Multiplatform, so the same domain code and the same tests run on the JVM and natively. That is what lets the Android apps and the Apple apps share one core: the touch UI is shared Compose on the iPhone, iPad and Mac inside a native shell, and the Apple TV app is SwiftUI over the same Kotlin framework. Your sources, your history, your resume points and your watch list live in one SQLite database.
None of those choices was clever on its own. What mattered was picking things that stay predictable, so that an agent, or a person, can read a change and know what it touches.
How the AI works
Yonto Player is built with AI coding agents. The way it works is less magic than people expect, and it is mostly paperwork.
-
One file briefs every agent.
AGENTS.mdat the root says what the project is, how to build and test it, the rules that must not be broken, and which deeper document to read for which job. Its shape, in a sketch:
# AGENTS.md
Guidance for coding agents working in this repo.
## The standing rule
Docs change in the same PR as the code, never as a follow-up.
## What this is
(the product in a paragraph, and its one architectural rule)
## Build & test
(the exact commands, per module, and why)
## Read this when…
| You are doing… | Read |
| ----------------------- | ----------------------------- |
| a build or test fails | docs/agents/build-and-test.md |
| writing a source plugin | docs/agents/plugins.md |
- Workflows are Markdown. A quality round, fixing an issue, opening a pull request and writing a plugin are skills any agent can follow, so the process does not depend on one tool.
- A reviewer that did not write the code reads a change cold and reports what it finds, and about 900 of the 2,000 Kotlin files are tests, so a change has something to prove itself against.
- Docs change in the same pull request as the code. There are 59 dated design documents, and the docs are what the next session of an agent reads, so a stale doc is a bug.
- I decide. Agents write; I choose what is built, read what matters and say when something ships.
I will give each of those its own post, because the details are where they are useful.
What is coming in this series
- How I chose the stack, and what each piece solved.
- The black box and the plugin runtime in detail.
- Building the core in Kotlin Multiplatform, and what is shared and what is not across Android, iPhone, the Mac and the Apple TV.
- Why the iPhone app is native UIKit around Compose, and why the Apple TV app is SwiftUI.
- Moving a television app from Android TV to the Apple TV: what carried over and what was redone, plus how files play and how it is tested on Apple platforms.
- The plugin system: writing a plugin, what it may do, and testing one without a device.
- Ten practices for running AI agents in parallel, how
AGENTS.mdand skills work, and why you cannot prompt a whole app in one go. - Tricks, testing and privacy: scripted tests or an agent walkthrough, and how a player can know as little about you as possible.
- Two video players in one app, and the rule that decides between them.
- How a TV is set up from a phone, how skip intro works, how casting decides what to send.
Tell me in the comments which one you want first.
Read more
The documentation has the full picture of what the app does, the developer docs describe the plugin contract, and every release is in the changelog. The design documents I keep mentioning are the source of most of what these posts say.
Top comments (0)