Yonto Player talks to a media server, or to a folder, through one interface: ContentSourceAdapter. The rule itself is covered in the opening post of the series. This one is about what sits behind the interface, and how it changed.
The short version is that the source contract started out as an HTTP API, moved into the app as JavaScript plugins, and is now the way every Jellyfin, Plex and Emby source works. I will say why, what a plugin looks like, and what running JavaScript inside a TV app costs.
It started as an HTTP contract
The first idea was a contract in JSON. A source is anything that can answer a few questions: what categories do you have, what is in this one, tell me about this title, search for this. That is written down as contracts/content-source-http.md in the plugins repo, with schemas beside it, and an HTTP adapter in the app speaks it to a hosted service.
It works, and it is still there for anyone who wants to run a service of their own. But it has a cost for the most common case. To read a Jellyfin server I would need a service in the middle that talks Jellyfin on one side and my contract on the other, and someone has to run it. Every field a media server renames becomes a fix in that service and then a deployment.
The idea that changed it: keep the contract's JSON shapes as the one definition, and make the transport an implementation detail. HTTPS to a service is one transport. A call into a JavaScript engine inside the app is another. The code that turns the JSON into the app's titles, details and playback options is shared and knows about neither.
Once that was true, the sources that model somebody else's system had no reason to be Kotlin. A Jellyfin or Emby server has its own idea of what an item is, and that idea changes on the server's schedule, not mine. Putting that knowledge in a file instead of in the app meant two things: an update to a source can be a plugin file rather than a new build of the app, and the app keeps one way for third-party sources instead of two. The exceptions are small on purpose: the fallback shown when nothing is configured, the adapter for the viewer's own files, and the adapter that speaks our own HTTP contract, which has no third-party system to keep up with.
What a plugin is
A plugin is one JavaScript file. Its manifest, which says who it is and which hosts it may reach, is a comment at the top. The app reads that comment as text and never runs the file to learn it, which is why a permission lives in the manifest and not in code. The rest of the file exports functions the host calls: four are required (getCategories, getMediaList, getMediaDetail, search) and the rest, like filters, recommendations, a health check and playback reports, are optional.
Here is the Jellyfin plugin's whole search method, verbatim from the public repo:
async search(query) { return items(await get(['Users', ME, 'Items'], 'search', { searchTerm: query, Recursive: 'true', IncludeItemTypes: ALL_ITEM_TYPES, Fields: LIST_ITEM_FIELDS, EnableUserData: 'true', Limit: String(PAGE_SIZE), })).map(toSummary); }, There is nothing Android in it. It asks the server a question and maps the answer to the contract's summaries.
The host API
A plugin reaches the outside world through one global, yonto, and nothing else. There is no URL, Buffer, console or setTimeout, and a plugin cannot read a file or open a socket. What it gets is yonto.fetch for requests, yonto.config for the settings the viewer filled in, yonto.store for a small cache, yonto.log, HTML and XML parsing, and some hashing and encoding.
Errors are part of the contract too. A plugin does not return a string that looks like an error; it throws one of a few typed errors, and the app picks the screen:
if (response.status === 404) throw yonto.error.notFound(what); if (response.status < 200 || response.status >= 300) { throw yonto.error.unavailable(SERVER_ERROR_MESSAGE); } That is also from the Jellyfin plugin, with a logging line left out. notFound, unauthenticated, misconfigured, unreachable and unavailable each mean something different to the viewer, such as whether to offer a retry, so the plugin says what happened and the app decides how to show it.
The settings form works the same way. A plugin declares its fields in the manifest (text, secret, url, choice, bool) and the host draws the form, so the app does not gain a screen for every new source.
Testing without a TV
The part I care about most is that a plugin can be checked on a laptop. The command-line tool in the same repo, yonto-plugin, runs a plugin against the same contract, the same yonto global and the same limits as the app:
yonto-plugin init my-source yonto-plugin lint yonto-plugin run . search '"sourdough"' yonto-plugin doctor --record yonto-plugin doctor --replay init scaffolds a plugin that already passes. lint checks the manifest against its schema, builds the file and works out the contract version the code needs, without making a request. The contract version is a number in the manifest saying which part of the plugin interface the plugin uses, so an app too old to run it can turn it away. run calls one function and prints its JSON and the requests it made. doctor walks the plugin the way the app does: categories, then a list, then page two, then a title from that list, then a search. Its one opinionated rule is that a successful but empty answer is a failure, because a response whose shape changed can leave a plugin that "works" and returns nothing. With --record and --replay, the responses are saved and served back with the network off, so a change can be checked in a second and by anyone who clones the plugin.
Why QuickJS
Before choosing, I wrote down two hard requirements for the engine. It had to run in ordinary JVM unit tests and not only on a device, since a device-only engine would make plugins the one part of the app I could not test. And it had to let a host function suspend, so a plugin can await yonto.fetch(...) and Promise.all really overlaps its requests.
The candidates failed one or the other. Android's own JavaScript engine is real V8 in an isolated process, but it cannot call back into the host, so a plugin would have had to be written as a state machine that returns the URLs it wants. Two QuickJS wrappers either had no native library for the JVM or no suspending bridge. The binding I picked, quickjs-kt, passed both, and in a synthetic JVM test, three 300 ms host calls under Promise.all took 304 ms, not 900.
What QuickJS gave me beyond that:
- It is small and embeddable, so it can live in a TV app.
- The command-line tool runs a plugin on QuickJS too, with the contract and limits the app uses, which is why "it passed on my machine" means something.
What it costs:
- At the time, the first native library the app carried, with one shared library per CPU architecture, so which architectures to ship became a decision.
- A plain language runtime, not a browser or Node: no timers, no
URL, and anything a plugin wants has to come through the host. - A source runs one call at a time, so a slow call makes the ones behind it wait.
- Every source carries its own JavaScript context, which has to be set up and held in memory.
- JavaScript's regular expressions are not Java's, so what a plugin author writes can behave differently from what a Kotlin developer expects.
- Limits on a call, which a plugin author has to live with: a call's own JavaScript may run for 20 seconds, and a response body is capped at 16 MB.
The app's own tests exercise plugins on the JVM, against small purpose-written plugins and a mock web server, including a plugin that throws, returns malformed JSON or never returns.
I think the trade is right, and I would make it again. The plugin is small, the contract is public, and the tool that proves it works runs anywhere Node does.
Read more
- Write a plugin is the developer documentation, with the contract, the reference and a tutorial.
- Test and debug covers
lint,run,doctorand recordings. - Plugins and repos is what the plugin side looks like to a viewer.
- yonto-player-plugins on GitHub has the contract, the CLI and the public plugins, Jellyfin, Plex and Emby among them, all MIT.
