Five TV platforms, one team
There is no touchscreen, no keyboard, and the person using your app is three meters away holding five buttons. Here is what that actually…
Five TV platforms, one team
There is no touchscreen, no keyboard, and the person using your app is three meters away holding five buttons. Here is what that actually costs to build.
By Balamurugan V · Habuild Engineering · 13-Aug-2026

Our members do a live guided session every morning. For a long time that meant a phone propped against a wall, screen dimming halfway through a stretch, someone squinting at a five-inch instructor.
The obvious fix is the largest screen in the house. The non-obvious part is that shipping to it means shipping to five different platforms, across two entirely different runtimes, through five app stores that have never agreed on anything — including which button means “back”.
This is what we learned building it.
The five platforms are really two problems
“Smart TV” is not a platform. It is a category containing at least five, and the useful way to group them is not by vendor but by what runs your code.
Android TV, Fire TV and Apple TV run native applications. We build those from a single React Native codebase using the TV fork of React Native, which gives us the platform’s real focus engine and real remote key events.
LG’s webOS and Samsung’s Tizen do not run native apps in any sense we can use. They run web applications — a bundle of HTML, CSS and JavaScript, packaged with a vendor manifest and submitted as a web app. So those two are served by a separate React build, packaged twice: once through Tizen Studio, once through LG’s CLI packager.
That split is the single most important architectural decision in the project, and it is worth being precise about where the line falls.

Where sharing stops. Auth, scheduling and attendance rules are written once in plain TypeScript and imported by both runtimes. Everything that touches focus, input or video is written twice — because a native focus engine and a DOM focus loop are not the same abstraction in different clothing, and pretending otherwise produces an adapter nobody can debug.
We tried, briefly, to unify the two behind a single component layer. It failed for a reason worth stating plainly: the two runtimes disagree about who owns focus. On native, the operating system owns it and you influence it. On the web, nobody owns it and you have to implement it. An abstraction over those two is a lie that shows up as a bug six months later, on one platform, on one screen, at the bottom of a scroll view.
Share the rules. Duplicate the input layer. It is less code overall than the abstraction that tries to hide the difference.
Focus is the entire interface
On a phone, focus is an accessibility concern. On a TV it is the whole interaction model. The user has four arrows and an OK button. Every element they can reach, they reach by pressing an arrow and landing somewhere. If the wrong thing gets highlighted, the app is broken — not degraded, broken, because there is no way to point at the right thing instead.
The two runtimes solve this in completely different ways.
Native: declare intent, let the OS resolve it
On the React Native side you mostly annotate. A component says it would like focus when a screen mounts, and the platform’s focus engine walks the view hierarchy and decides what is “to the right of” the current element based on the actual laid-out geometry.
// You express intent. The platform resolves it.
<Button
label="Start session"
hasTVPreferredFocus
onPress={startSession}
/>
The work here is not the arrows — it’s the focused state. Every interactive element needs a visibly different appearance when focused, legible from across a room. That means a focus treatment on the container, the label and any icon inside it, which is why our button component takes a focused variant for each of those three things rather than a single style override. On a phone this would be over-engineering. On a TV it is the difference between a usable app and a guessing game.
Web: there is no focus engine, so you write one
On webOS and Tizen, the browser gives you sequential tab order and nothing else. Tab order is a list; a TV interface is a plane. Pressing right from a card in a grid should land on the card to its right, not on whatever happens to be next in the DOM.
So the web build runs a spatial navigation library that keeps a registry of focusable nodes, measures their bounding rectangles, and on each arrow key press picks the nearest candidate in that direction. We use Norigin Media’s library rather than writing our own, which we recommend — the algorithm is simple to describe and genuinely fiddly to get right at the edges.

The same arrow press, resolved two different ways. The native engine already knows the layout tree, so an annotation is enough. On the web, the library must maintain its own registry of focusable nodes and score candidates by geometry on every key press — which also means it is your job to restore focus when a node unmounts, when a list re-renders, and when a modal closes.
One more web-only detail that will cost you an afternoon if you don’t know it: the arrow keys are standard, and the back button is not.

The directional keys agree because they inherited them from browsers. Back does not, because each vendor added it independently. If you take one practical thing from this post, make it this: put every key code in one map, resolve the platform once at startup, and never compare a raw keyCode anywhere else in the codebase.
Designing for three metres
Mobile apps scale from a reference width — typically a 375-point design — and normalise by device pixel density. That is the right model for phones, where the screen is a hand’s width away and the variable is how many physical pixels sit in a point.
For television it is the wrong model, and inverting it is the cleanest idea in this post.
Televisions are all roughly the same shape. A 43-inch panel and a 65-inch panel are both 16:9, and both are usually 1080p or presented to your app as 1080p. Pixel density is nearly irrelevant. The variable that actually changes is viewing distance — and no API will ever tell you what it is.
So we design against a fixed 1920×1080 grid and scale by the ratio of the actual viewport to that reference, taking the smaller of the two axes so the aspect ratio holds. Then we clamp it hard:
const BASE_WIDTH = 1920;
const BASE_HEIGHT = 1080;
const normalize = (size: number): number => {
const { width, height } = getViewport();
// Smaller axis wins, so a slightly odd viewport
// never stretches type out of proportion.
const scale = Math.min(width / BASE_WIDTH, height / BASE_HEIGHT);
// The clamp is the whole point. A 4K panel reporting
// its true pixel size must not double our type; a small
// or odd viewport must not shrink it below legibility.
const bounded = Math.max(0.8, Math.min(1.2, scale));
return roundToDevicePixel(size * bounded);
};
The clamp is doing the real work. Unbounded scaling is how you end up with a 4K panel rendering headings at twice the intended size, or a set-top box reporting an unusual viewport and shrinking your body text to something nobody three metres away can read. A ±20% band means the design degrades gracefully in both directions and never becomes unusable in either.
The practical rules that fell out of this: nothing below roughly 24px at the reference scale, focus states that change more than colour, and a safe margin at the screen edge — some panels still overscan, and content flush to the edge of your design is not flush to the edge of the display.
Logging in without a keyboard
Now the fun part. A member has to sign in, and their input device is a plastic rectangle with five buttons.
The industry answer to this is the on-screen keyboard, which is a genuinely miserable experience: an email address is forty-plus D-pad presses, and every mistake means arrowing back to a delete key. We support a version of it as a fallback, but the primary path avoids text entry altogether.
The member already has our app on a phone, and on that phone they are already signed in. So the TV never handles a credential at all — it displays a short-lived pairing code as a QR, and asks the phone to approve it.

The credential never touches the television. The TV holds only a short-lived pairing code and polls for its own approval; the phone, which already has a session and a real keyboard, gives consent. Note the expiry — a pairing code rendered on a screen in a living room is visible to everyone in the room, and to anyone who photographs it.
Three implementation notes that mattered more than we expected:
Poll slowly. The instinct is to poll every second so approval feels instant. Don’t. TV hardware is weak — many of these boxes have less compute than a mid-range phone from several years ago — and a tight polling loop competes with rendering. More importantly, the human sequence here is “notice the QR, find phone, unlock, open camera, scan, tap approve”. That is tens of seconds. We poll on a slow fixed interval measured in tens of seconds, and nobody has ever described it as slow, because the phone-side work dominates the wait.
Guard against overlapping polls. On a slow connection a status request can outlive its own interval, and you end up with several in flight, all racing to complete the same login. A single in-flight flag fixes it in two lines and prevents a class of bug that is miserable to reproduce.
Treat expiry as a normal state, not an error. When the code expires, the natural response is to silently mint a new one and re-render the QR. A member who wandered off to find their phone should come back to a working screen, not an error with a retry button.
The numeric fallback is worth building too, and it’s cheaper than it sounds. Phone number plus a one-time code is digits only, which means a 12-button grid that is trivially D-pad navigable — and TV remotes have physical number keys, so we listen for those as well and let people type the code without touching the on-screen grid at all.
Five stores, five ways to be told no
The part nobody budgets for. Every platform has its own submission pipeline, its own certification checklist, and its own review timeline. Android TV and Fire TV both take Android artifacts and are otherwise unrelated processes. Apple TV goes through the usual Apple review. Tizen and webOS each want a web bundle wrapped in their own manifest format, submitted through their own developer portal, tested on their own device matrix.
Three things that would have saved us weeks:
- Buy the actual hardware early. Emulators do not reproduce remote behaviour, and they especially do not reproduce how slow these devices are. A layout that is fine in an emulator can drop frames on a three-year-old panel.
- Version consistently across all five. When a member says “the TV app is broken”, your first question is which platform and which build. We surface the version on screen in non-production builds and sync one version number into every native project from a single source.
- Expect one platform to be perpetually behind. With five review queues, they will never all be on the same version. Design your API contracts to tolerate that, because you will be supporting an older client somewhere for as long as the app exists.
And the dependency you can’t upgrade
One last thing, because every real codebase has one of these and pretending otherwise is what makes engineering blogs boring.
Our TV app pins its storage library to a major version behind current. The newer major is built on React Native’s new architecture. Migrating to the new architecture on a TV fork, across three native platforms, to gain storage features we do not need, is not a trade we are ready to make. So the pin stays, with a comment explaining why, and it gets revisited when we migrate for reasons that actually matter.
That is the honest texture of multi-platform work. Not one elegant abstraction covering five targets — one set of shared rules, two runtimes that genuinely differ, five submission pipelines.
We’re a small engineering team building habit-forming health products for a few hundred thousand members.
메타데이터
- post_id
- 6fe87b13c3f4
- slug
- five-tv-platforms-one-team-6fe87b13c3f4
- url
- https://medium.com/@engineering_44587/five-tv-platforms-one-team-6fe87b13c3f4
- canonical_url
- https://medium.com/@engineering_44587/five-tv-platforms-one-team-6fe87b13c3f4
- author_url
- https://medium.com/@engineering_44587
- status
- ok
- fetched_at
- 2026-08-21 10:52:22