A television is the worst input device you own, and that single fact shapes more product decisions than any framework choice ever will, because the person holding the remote has five buttons and a directional pad, sits three meters from the screen, cannot hover, cannot right-click, cannot type without suffering, and will abandon any interface that forgets these constraints for more than about thirty seconds, which is why the living room is where ambitious web UIs go to die.

We run Stash for the private media library, and Stash is a genuinely good self-hosted organizer with a capable web interface and a proper GraphQL API, but its web interface was designed for a mouse and a keyboard at a desk, and the couch is not a desk, so we built Sinema, a native Android TV client written in Kotlin on top of Media3 ExoPlayer, with D-pad navigation, a PIN lock, launcher banner art, and no telemetry of any kind, because the right answer to “how do I watch my library from the sofa” was never “open a browser tab on the television.”

Why the web UI was never going to work on the couch

The temptation with any self-hosted service that already has a web UI is to wrap it in a WebView, call it a TV app, and move on, and we have watched that movie enough times to know how it ends: the pointer-driven layout assumes hover states that do not exist on a remote, the grid density assumes a screen you sit close to rather than across the room from, the text assumes reading distance measured in centimeters instead of meters, and every modal dialog becomes a small adventure in D-pad focus management that the original designers never planned for because they never had to.

This is not a criticism of Stash’s interface, which does its job well at the distance it was designed for; it is a criticism of the idea that one layout can serve two input models that share almost nothing, and the industry has a name for the couch model, the ten-foot UI, where every target has to be reachable by directional movement, every focused element has to announce itself visually at a glance, and every flow has to be completable with five buttons, because the moment a user has to think about the remote instead of the content, the app has failed.

There is also a playback problem hiding underneath the navigation problem, since a browser or WebView on Android TV is at the mercy of whatever codecs the system web runtime bothers to support, which on many sets and boxes means no proper hardware-accelerated decode for the formats a personal library actually contains, no audio passthrough to the receiver for the surround tracks, and subtitle rendering that treats styled tracks as an afterthought, and none of that is acceptable when the entire point of the device is to play video well.

What Sinema actually is

Sinema is a native Android TV application written in Kotlin, and it talks to your Stash server over the same GraphQL API the web UI uses, which means the library, the metadata, the artwork, and the organization all stay exactly where you already manage them, on your hardware, with Sinema acting as a presentation layer that happens to live on the biggest screen in the house.

Playback runs on Media3 ExoPlayer, which is the part of the Android media stack that actually earns its keep on television hardware: hardware decoding through the platform’s codec pipeline instead of a browser’s lowest common denominator, audio passthrough for the formats your AV receiver understands, proper subtitle support, and integration with MediaSession so the remote, the on-screen controls, and any assistant-driven playback commands all agree about what is playing, which sounds like table stakes until you have used a TV app where pause works from the remote but not from the UI.

Navigation is built for the directional pad from the first screen, with focus states that are visible from across the room, row-based browsing that matches how Android TV users already move through every other app on the device, and banner art on the launcher so the library sits next to the streaming services it replaces rather than hiding three menus deep, because an app that is annoying to reach is an app that does not get opened.

The PIN lock exists because a television is a shared device in a way a laptop is not, and a private library on a living room screen has a threat model that mostly consists of guests, kids, and the general principle that not everything in a personal collection is appropriate for every pair of eyes that walks into the room, so Sinema locks behind a PIN rather than an account, since the household is the trust boundary and a four-digit code is the right size of gate for it.

And there is no telemetry, no analytics SDK, no crash-reporting pipeline phoning home, which is the same rule every VisorCraft project ships under, but it matters more on a media app than almost anywhere else, because viewing history is among the most personal data a household produces and the whole reason to self-host the library is that no third party gets to build a profile from it, so building one ourselves would defeat the point of the exercise.

Why native instead of retrofit

The honest way to frame the decision is that a retrofit was the cheap option and we priced it properly, since adapting a desktop web UI for the couch means fighting the framework’s assumptions on every screen, shipping focus-management patches upstream that the project may not want, and still ending up with browser-grade playback on a device whose entire job is playback, whereas a native client costs a second codebase but buys the correct input model, the correct media pipeline, and an interface that feels like it belongs on the platform instead of visiting it.

Kotlin and Media3 also make the native path cheaper than it used to be, because the AndroidX TV libraries encode most of the ten-foot conventions as components rather than folklore, ExoPlayer handles the codec zoo that would have been a month of pain a decade ago, and the GraphQL API means the client never has to scrape or guess, it asks the server for exactly the fields each screen renders and moves on, which is the kind of boring integration that lets a small team keep a second UI alive.

What you give up

The tradeoff is real and worth stating: a native client is a second interface to maintain, it only serves Android TV and Google TV devices while the web UI keeps serving everything with a browser, releases move at app-store pace or sideload pace instead of “refresh the page” pace, and every feature Stash adds to its web experience is a feature Sinema has to choose to adopt rather than inheriting for free, so the client deliberately covers the lean-back essentials, browse, search, watch, resume, instead of chasing parity with an interface designed for a different room.

The modern equivalent of this whole argument is the one the home theater community settled years ago with Kodi and its descendants: the server manages the library, the clients are native to their screens, and nobody serious suggests driving the living room through a desktop web page anymore, because the couch won that argument a long time ago and the only thing that changed in 2026 is that a self-hosted library and a native TV client are now a weekend of setup rather than a hobby, which is exactly how it should be.