Architecture
The television frontend
How :app-tv is put together, the focus defects that shaped it, and the shake that took five attempts.
# Structure
All of the television UI lives inside :app-tv — screens, the shell, the key
maps. There are no :feature-tv:* modules: there is one consumer, and a module
per screen would be structure without benefit.
Navigation is a hand-rolled sealed overlay state rather than Navigation-Compose. The shell is the null case; playing, a film, a series and settings each replace it whole. That choice is deliberate — the tab bar's focus model was hard-won, and a navigation library's own focus restoration is the most likely thing to undo it.
The profile chooser is not one of those overlays. It stands in front of the whole shell, so no screen below it has a state for "nobody has chosen yet" — see how a profile scopes anything. The phone does the same thing for the same reason.
Five tabs, and which are on the bar is a design decision rather than a listing of the features: Search first and icon-only, then the three catalogues and Favourites. Sources sits in Settings instead, because every position on the bar is one more press between a viewer and what they came to watch, and adding a playlist is done once.
# One frontend, two shells
The television imports the same ViewModels the phone does — the search screen and the phone
would share SearchViewModel if the phone had a unified search screen yet, and the
settings cards on both apps read the same scan state.
That last one is a small thing with a real payoff:
the scan's progress fraction is computed once,
in :core:data, and both settings screens render it. The phone and the television
cannot disagree about what "half way" means, and neither can drift when the states change.
# Three focus defects, and what they teach
Focus is the whole interface on a television, and all three of these were invisible from reading the code.
Selection following focus
The tab bar was five focusables that selected themselves when focused. Any content change that destroyed the focused element left Compose with no target; it fell back to the first focusable in the tree, which was a tab, which selected itself. Content could silently change which tab you were on.
The bar is now one focus target that changes tab on key events.
An icon that could not be reached
The settings gear was focusable, and the bar left the right-key unconsumed at the last tab expecting focus to land on it. It does not: the icon sits inside the bar's own focusable, so a focus search walks past it into the content below. The gear was unreachable by remote for the entire life of the app — and separately, it had no click handler at all.
It is now a position along the bar, matching the tabs. Handing focus across explicitly was tried first and did not hold.
Content stealing focus on composition
The Sources screen requested focus when it first composed, so merely selecting that tab pulled the remote out of the bar — after which you could no longer continue along the bar to the gear. Now only a later change, like a form opening, claims focus.
The pattern in all three: focus is state that something else can take from you. Anything that grabs it must justify why, and "the screen just appeared" is not a reason.
# The shake, and the modifier order behind it
The catalogue twitched upward while the remote walked left and right along a row — on every row except the first. That exception is the whole mechanism, and four confident explanations went past it before anyone took it seriously.
What was happening
The poster applied graphicsLayer { scale } before
clickable. A modifier chain applies outside-in, so the focusable node sat
inside the animating scale. A focus node's bounds resolve through every layer
between it and the scrollable above it, so while the focus animation ran, a focused poster
reported a rectangle that grew a little each frame.
The vertical list reads that rectangle to decide whether the focused thing is on screen. In the first row a poster fits with room to spare, so nothing scrolls. From the second row down it does not fit, so the list scrolls until the poster is flush with the bottom edge — and flush is the one position where the next frame's growth puts it out of view again. Hold the remote down and it does that on every repeat.
Swapping the two modifiers is the entire fix. The animation is untouched.
Modifier order is not style. Which side of an animation the focusable lands on decides what the layout above it is told, every frame.
Why it is measured rather than argued
Four earlier answers were reasoned from the code and all four were wrong, so this one is
measured. TvBrowseScrollStabilityTest runs the real composables under Robolectric
at the panel's geometry, presses the D-pad at Android's key-repeat rate, and reads the
catalogue's position off every frame. Before the fix, the second row moved 11 px and the
fourth 12 while the first stayed at zero — the reported asymmetry, reproduced on the JVM.
After, all three are flat.
The test asserts the property, not the symptom: moving along a row must not move the
catalogue at all, because every poster in a row sits at the same height as its
neighbours. And TvCategoryList was split out so the test drives the real
composable rather than a copy that could drift from it — which is also why the search results
reuse that list whole instead of laying out rows of their own. A second row implementation
would be a second place for the shake to come back unmeasured.
# Ten-foot rules
- Overscan margins. Televisions crop the edges of the frame, and many still do. There is a screen-wide padding constant for this.
- Focus must be loud. A scaled card, a border, full-strength text against a dimmed rest of the list. From the other side of a room, the only way to know where you are is that one thing looks different from everything else.
- Scaling grows past the layout box. A poster scaled to 1.1 about its centre grows roughly 12 dp past its own top edge — more than the gap that used to sit under a category title, which is why focused cards touched the heading above them. Rows now reserve that growth, which also stops the list clipping the top of a card flat.
- Test with the D-pad only. Unplug the mouse. A mouse silently satisfies criteria a D-pad would fail, which is the exact defect the first acceptance criterion exists to catch.