The first idea everybody has when an Android TV app grows a phone version is to use one UI for both. Compose makes that sound easy: the same composables, a different window size, done. I went the other way on purpose. Yonto Player has two UI layers, one for the television and one for phones and tablets, and they sit on top of one shared set of view models and plain components. This post is about where that line is, why it is there, and what it costs.
If you read the opening post of this series, this is the "UI" part of the map. Everything below is about the shipping Android apps. The touch UI is shared code so it can also run on Apple devices; those versions are not out yet, and I will leave them for later.
The modules
Here is the dependency picture, simplified, for the Android apps:
:core the domain, plugin runtime, stores; no Compose :app:model view models, UI state, Screen; no Compose :app:common plain composables, shared strings, colours, the ExoPlayer engine :app:tv tv-material screens, D-pad and focus :app:touch Compose Multiplatform material3 screens :app:phone the Android host for :app:touch Read it as a dependency chain: :app:tv and :app:touch both depend on :app:common, which depends on :app:model, which depends on :core (:app:common also uses a few small :core:* modules for the player). The two UI modules never depend on each other. :app:tv is the television app. :app:touch holds every phone and tablet screen, and :app:phone is a thin Android host around it: the activity, the manifest and the icon, with nothing of its own to draw.
The rule that keeps it honest is dull on purpose. A test in :app:common scans the source of the shared modules, :app:common and :app:model, for tv-material imports and fails if it finds one. I would rather have a failing test than a convention I have to remember.
What is shared
Almost everything that is not a pixel.
State. MainViewModel and its neighbours hold what a screen shows: the shelves, the hero picks, the loading and error state, the history, the Watch list. A screen is mostly a function of that state, plus local things like scroll position and focus. Both Home screens collect the same flows. This is a simplified sketch, not the real code, with generic names:
// :app:model, no Compose class MainViewModel(/* ... */) { val homeState: StateFlow<UiState> // loading, error, partial answer val shelves: StateFlow<List<ShelfData>> val hero: StateFlow<HeroCandidates> } // :app:tv, tv-material @Composable fun HomeScreen(viewModel: MainViewModel) { val shelves by viewModel.shelves.collectAsStateWithLifecycle() // rows of cards the remote can land on } // :app:touch, material3 @Composable fun PhoneHomeScreen(viewModel: MainViewModel) { val shelves by viewModel.shelves.collectAsStateWithLifecycle() // a scrolling list with a full-bleed hero } Neither screen needs to know how a shelf was loaded. That is the content-source rule from the opening post paying off again: the view model asks one door, and the two UIs only draw what comes back.
Navigation destinations. There is one Screen type, a serialisable sealed class (Home, Browse, a title page, Search and so on), and both apps keep a stack of it. The shared type also carries a stateKey, so a screen under another one can have its scroll position and last focus parked and restored.
Copy. All shared text lives in one set of Compose resources in :app:common, with a translation per language, read by both apps. A view model hands out a key for a sentence rather than a string, so the same error means the same words on a remote and on a touch screen. What only one app says, such as the TV's D-pad hints, is a shared resource named for that app.
Plain components and colours. The artwork loader, the spinner, the watched bar and the colour palette (YontoColors) are shared. The playback side shares more than people expect: PlaybackUiState and the logic that runs a title from the press to the end live in the shared modules, and the ExoPlayer engine is wired in :app:common.
What cannot be shared
Focus. On a television the user never points at anything. Every screen has to say where the remote lands when it opens, which card a shelf is entered on, where Down goes from the top bar, and where focus returns after a dialog. Compose's focus system can express all of that, but it is a design job on every screen, and nothing in it exists on a touch screen. Some consequences are visible in the product: there are no on-screen Back buttons on the TV, and a card's one secondary action is a long press on the card, because a button inside a card would be out of the D-pad's reach. On a phone the same action is a menu button on the card.
The navigation model. The TV has a top bar and a remote. The phone has NavigationSuiteScaffold, which is a bottom bar on a phone and a rail on a tablet or on a phone turned on its side, and a swipe-back gesture that previews the screen underneath through NavigationBackHandler. Same Screen stack, different way of moving around it.
Layout and insets. A phone has notches, system bars and a keyboard. A tablet is decided by the window: a medium width and a medium height, so a phone on its side keeps the phone layouts. A small helper, TabletOrPhone, also looks at the room a screen has after the rail, since a tablet split in two apps has a phone's width. A television has none of this.
Player chrome. The engine, the state and the rules are shared, but what you press is not. The TV's player is a transport bar driven by the D-pad, with transport keys and a held seek. The phone's player is a picture with gestures: drag to scrub, double-tap to seek, a pinch to zoom, picture-in-picture, and a landscape start. Trying to make one control layer do both would have meant a layer with no good answer for either.
The theme. The TV palette is near-black and dark only, so the focus ring does the talking. The phone follows the system or a pick between light and dark.
Why two layers and not one
Because tv-material (AndroidX) and material3 are different toolkits for different jobs, and the touch UI uses the Compose Multiplatform (JetBrains) build of material3. tv-material's components are built around focus, since a remote can only ever be on one thing at a time. material3's are built around touch targets, ripples, sheets and the rest of the phone vocabulary. I could have forced one set through both, but each screen would have been a compromise for the half of the audience that did not set the design.
A single UI also makes the TV pay for every phone decision, and the phone pay for every TV one. Splitting the layer means each side can follow its platform's conventions without a flag or an if (isTelevision) in every composable.
What it costs
I should be honest here, because it is a real cost.
- Most screens exist twice. Home, Browse, Search, the title page, Settings and the player each have a TV version and a touch version, so a feature is usually two screens, not one.
- A behaviour has to be decided twice. The view model can say what is true, but not how it should feel under a remote or a thumb.
- The shared line needs defending. Logic can drift into a screen, and then the other app has to copy it. The rule I use is that if a decision is not about layout or input, it belongs below the line, in a view model or a plain function with a test.
What I get back is that the parts that are expensive to get right, the loading states, the error wording, the resume logic, the source handling, exist once. A fix there reaches both apps. And since the screens only draw state, either UI layer can change a lot without touching the other.
If you are building something similar
- Put a hard line in the build: a test that fails when a shared module imports a toolkit-specific package is cheap and it keeps working.
- Make view models hold state and expose flows, and keep strings out of them as keys, so two UIs can say the same thing.
- Decide early which things are layout and input (per UI) and which are meaning (shared).
- Do not share a player's controls just because you share its engine.


