← Back to books

Chapter 1: The Ten-Foot Experience

You already know Android. You know how to lay out a screen, wire a ViewModel, handle a click, ship an APK. This book is not going to re-teach you any of that.

What it is going to do is take everything you know about how a user touches your app and remove it.

That's the whole of it, really. There is no touch. There is no scroll gesture, no long-press, no pull-to-refresh, no bottom sheet you can flick away with your thumb. There is a person on a sofa, three metres from the screen, holding a plastic rectangle with a directional pad and about five other buttons. Everything they will ever do in your app has to be expressible as up, down, left, right, select, back.

Almost every hard problem in Android TV development is downstream of that single constraint. And almost every bad TV app on the Play Store is bad because someone took a phone app, made the fonts bigger, and shipped it.

So before we write a line of code, let's be precise about what actually changes.

1.1 What actually changes

The distance

The phone is a one-foot experience. Your face is about 30 centimetres from the glass. A 14sp label is comfortable. A 48dp tap target is generous.

A television is a ten-foot experience — that's the industry's term for it, and it's roughly accurate: 2.5 to 3 metres of sofa-to-screen. The screen is physically much bigger, but it subtends a smaller angle of vision than a phone held at reading distance. In practical terms, a 55-inch TV viewed from three metres has less usable angular resolution than the phone in your pocket.

The consequences are unforgiving:

  • Text must be much larger. The TV Material 3 type scale starts where phone type scales end. Body text on TV is typically 16–18sp and that is the small text.
  • Density is your enemy. A phone screen full of information is a TV screen full of noise. Fewer items, larger, with more space between them.
  • Thin strokes disappear. 1dp dividers, hairline borders, and low-contrast greys that look elegant on an OLED phone in your hand simply vanish across a room, especially on a cheap TV panel with a bad viewing angle and the brightness cranked up.
  • The room is dark and the screen is bright. Light themes are physically uncomfortable on a TV at night. Dark is not a preference on this platform; it is the default, and for many apps it is the only theme worth shipping.

The input

The D-pad is the central fact of TV development. Everything else is a footnote.

On a phone, the user points at what they want. On a TV, the user navigates to what they want, one cell at a time, and at every moment exactly one element on screen is focused. Focus is the cursor. Focus is the entire interaction model.

This has a consequence that catches every phone developer off guard: on TV, focus is a first-class piece of application state, and if you get it wrong your app is unusable. Not ugly — unusable. If focus lands somewhere invisible, the user is pressing buttons into a void. If focus gets trapped in a row they can't escape, they will force-quit your app. If focus doesn't return to where they left it when they come back from a detail screen, they will feel the app is broken, even if they can't articulate why.

We will spend an entire chapter (Chapter 3) on nothing but this.

There's a second-order effect too: text entry is miserable. Typing an email address on an on-screen keyboard with a D-pad takes upwards of a minute and users hate every second of it. Any flow in your app that requires typing — sign-in, search — needs a design that minimises or eliminates it. That's why Chapter 10 is largely about how to authenticate a user without them typing anything.

The hardware

Here is the part nobody tells you until you ship.

The Android TV install base is not a Pixel. It skews dramatically toward the bottom of the market: set-top boxes given away by telecom operators, HDMI dongles that cost less than a pizza, and TVs whose SoC was chosen by a procurement department optimising for cents. It is entirely normal to be supporting devices with:

  • 1 GB of RAM (with a meaningful chunk taken by the system UI)
  • a four-core CPU from several generations ago
  • a GPU that will happily render 4K video via a hardware decoder but will fall over if you ask it to composite too many translucent layers
  • flash storage with terrible random-read performance
  • Wi-Fi that drops out when someone opens the microwave

And that device is being asked to render a 1080p or 4K UI, decode an adaptive video stream, and stay responsive to remote input. A phone app that's a bit janky is a phone app that's a bit janky. A TV app that's a bit janky feels broken, because there is no direct manipulation to mask the latency — you press right, and you wait, and nothing appears to happen.

Performance is not an optional final chapter in TV development. It is a design constraint from the first commit. We will return to it constantly, and Chapter 11 makes it explicit.

The context

The last change is the softest but it shapes product decisions more than the others.

Phone usage is solitary, brief, and interruptible. TV usage is lean-back: often social, often long, and the user's actual goal is almost never "use your app". It is "watch something". Your app is friction between them and that goal.

This means the sequence that matters is: launch → find something → press play. Everything else in your product is secondary. The best TV apps are ruthless about shortening that path — which is why Chapter 9, on home-screen channels and Watch Next, is arguably the highest-value chapter in this book from a business perspective. Getting the user back into playback in one click from the system home screen is worth more than any feature you can add inside your app.

1.2 The platform landscape

"Android TV" is used loosely to mean several different things. Let's separate them, because it affects what you can rely on.

Android TV OS is the platform: an Android build for televisions and set-top boxes, with the leanback UI paradigm, D-pad navigation, and the TV-specific system APIs (the home screen channel provider, the TV Input Framework, and so on). This is what your app runs on.

Google TV is the launcher and content layer Google puts on top of Android TV OS on newer devices — the content-forward home screen with recommendations, "Continue watching", and the Google TV app itself. From your app's point of view, Google TV is mostly the same platform with a different (and much more aggressive) home screen. That home screen surfaces your content if — and only if — you integrate with it. If you don't, you're a tile in a row of apps, and you are competing for attention with Netflix.

The operator tier is the large population of Android TV boxes shipped by ISPs and telcos, often running older Android versions, often with a customised launcher, and very often with weak hardware. They matter enormously in emerging markets, including much of MENA, LATAM, and South Asia. If your market includes those regions, your minSdk and your performance budget are set by these devices, not by the flagship TV in your living room.

AOSP forks — Amazon's Fire OS being by far the most significant — are not Android TV. Fire TV devices run a fork of Android without Google Play Services and without the Android TV OS home-screen APIs. The good news is that a Compose-based TV app is largely portable to Fire TV; the D-pad and the rendering work fine. The bad news is that everything touching Google services (Play Billing, Play's home screen channels, Assistant, Cast) has an Amazon-specific replacement, and you'll need a build flavour. We deal with this properly in Chapter 12; for now, just be aware that "port to Fire TV" is a real and common business requirement, and it's worth not painting yourself into a corner.

One thing to internalise: Android TV devices update slowly, and many never update at all. A phone user on a five-year-old OS version is an edge case. A TV user on a five-year-old OS version is a normal Tuesday. Set your minSdk with that in mind, and test on something cheap.

1.3 Leanback or Compose? The 2026 answer

For most of Android TV's life, there was exactly one way to build a TV app: the Leanback support library (androidx.leanback). Fragments, BrowseSupportFragment, Presenter, ArrayObjectAdapter, PlaybackTransportControlGlue. It gave you a great deal for free — a home screen with rows, a detail page, playback controls, all D-pad-correct out of the box — at the cost of an API that was rigid, deeply opinionated, and almost impossible to customise beyond a certain point. Every serious TV team eventually hit the wall where the framework wouldn't let them build the design they'd been given.

Compose for TV (androidx.tv:tv-material) is the replacement, and it reached stable in 2024. It is not a port of Leanback. It is a set of TV-flavoured Material 3 components — Surface, Card, Button, NavigationDrawer, Carousel, ListItem — built on the same Compose foundation you already use on phones, with the focus behaviour, the scale-and-glow indication, and the sizing baked in.

The trade-off is honest and worth stating plainly:

Leanback Compose for TV
Time to a conventional TV app Faster Slower
Time to a custom TV app Slower, or impossible Faster
Focus handling Done for you (mostly) Your problem
Ecosystem, samples, StackOverflow Deep, but ageing Shallower, growing fast
Shares code with your phone app No Yes
Where Google is investing Nowhere Here

That last row is the one that decides it. Leanback is in maintenance. New TV components, new samples, and new guidance are all Compose. Google's own reference TV sample (JetStream) is Compose. If you are starting a TV app in 2026, you start it in Compose, and this book does exactly that.

But note the row that says focus handling is your problem. This is the real cost of the migration, and it is not small. Leanback handled a mountain of D-pad edge cases you never had to think about. In Compose, you think about them. This book does not pretend otherwise — Chapter 3 exists precisely because this is where teams bleed.

There is one place Leanback is still genuinely the right answer: if you are building a live TV / EPG / tuner app using the TV Input Framework. That world is still View-based, and it's outside the scope of this book. Everything else — streaming catalogues, VOD, music, podcasts, photo apps, fitness apps, utilities — is Compose territory now.

1.4 Meet Hearth

Every chapter of this book builds on one app.

Hearth is a streaming catalogue app. It will end the book with a browse screen with immersive hero art and content rows, a detail page, a Media3-based player with DRM and resume, in-app and voice search, home-screen channels with Watch Next, a subscription paywall with a no-typing sign-in flow, a test suite, and a release build ready for the Play Console.

It is deliberately a streaming app, because that's what the overwhelming majority of Android TV work is, and because it exercises every hard part of the platform: dense focusable grids, heavy image loading on weak hardware, video playback, system integration, and monetisation.

By the end of this chapter, Hearth will be a project that builds, launches on a TV, shows one screen, and — crucially — has a visible focus indicator that moves when you press the D-pad. That sounds trivial. It is the moment the platform starts making sense.

1.5 The toolchain

Here is what this book is pinned to. I've verified these against the current releases as of mid-2026; where something is likely to move under you, I say so.

Component Version Notes
Android Gradle Plugin 9.2.0 AGP 9 is a significant break — see below
Gradle 9.x Required by AGP 9
Kotlin (KGP) 2.3.21 Supplied by AGP; overridden explicitly here
JDK 17 AGP 9 minimum
compileSdk / targetSdk 37 Android 17, stable since June 2026
minSdk 23 See the discussion below
Compose BOM 2026.06.00 Core Compose 1.11.x
androidx.tv:tv-material 1.1.0-rc01 See the discussion below
Media3 1.10.x Introduced in Chapter 6

Two of these need explaining, because they're the kind of thing that costs you an afternoon.

AGP 9 removed the Kotlin plugin

If your muscle memory says every Android module starts with:

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)   // <- this
}

…unlearn it. AGP 9 has built-in Kotlin support and enables it by default. Applying org.jetbrains.kotlin.android alongside it is not just unnecessary, it's an error — you'll get a build failure complaining that an extension named kotlin is already registered.

You do still apply the Compose compiler plugin (org.jetbrains.kotlin.plugin.compose), and you do still set buildFeatures { compose = true }.

A couple of related knock-ons:

  • android.kotlinOptions { } is gone; use the kotlin.compilerOptions { } DSL.
  • kotlin-kapt is replaced by com.android.legacy-kapt. (You should be on KSP anyway — Hilt has supported it for years, and we use KSP in Chapter 5.)
  • If some third-party Gradle plugin in your stack isn't AGP 9-ready yet, you can set android.builtInKotlin=false in gradle.properties as an escape hatch and go back to applying the Kotlin plugin manually. Treat that as temporary.

You almost certainly do not need tv-foundation

Older tutorials, blog posts, and a lot of the sample code still floating around will tell you to add two dependencies:

implementation("androidx.tv:tv-foundation:...")
implementation("androidx.tv:tv-material:...")

and to use TvLazyRow, TvLazyColumn, TvLazyVerticalGrid and friends.

Those composables no longer exist. They were deprecated and then removed. Their behaviour — most importantly, scrolling so that the focused item stays in a stable "pivot" position on screen rather than sliding to the edge — was merged directly into the standard compose-foundation lazy layouts. Today you use plain LazyRow, LazyColumn, and LazyVerticalGrid from androidx.compose.foundation.lazy, and they do the right thing on TV.

So: one TV dependency, not two. If you find yourself adding tv-foundation, you are following an out-of-date guide.

As for the version: tv-material 1.0.1 is the last stable release, and it is from mid-2025. The 1.1.x line — currently at 1.1.0-rc01 — contains a year's worth of fixes that matter to us, particularly around Carousel focus and the onEnter/onExit focus-properties API we'll lean on heavily in Chapter 3. This book uses 1.1.0-rc01. By the time you read this, 1.1.0 may well be stable; check, and use it if so. If your organisation's policy forbids release candidates in production, 1.0.1 will work for most of this book, but a handful of focus APIs in Chapter 3 will differ and I'll flag them where they do.

Note also that tv-material is not part of the Compose BOM. You version it yourself. This is a common source of confusion — bumping the BOM does not bump your TV components.

minSdk

We use minSdk = 23.

The honest reasoning: androidx's own default is 23, and going lower buys you almost nothing on TV. Going higher is tempting — TV devices in the wild running Android 6 are rare — but the operator boxes keep the long tail alive, particularly outside North America and Western Europe.

One TV-specific wrinkle worth knowing now: the glow focus indication in tv-material (a soft shadow around the focused element) is silently disabled below API 28, because the platform can't render it. Your focus indication must therefore never depend on glow alone. Scale and border work everywhere. Design your focus state around those two, and treat glow as a garnish. This is the first of many places where "it looks fine on my emulator" and "it looks fine on the customer's ₹2,000 box" diverge.

1.6 Setting up your development environment

Android Studio

Use the current stable Android Studio. AGP 9 requires a recent one (Otter 3 Feature Drop or later); if Studio refuses to sync the project we're about to build, that's the first thing to check.

The one Studio setting worth changing immediately: in your Compose previews, you'll want a TV device profile. @Preview(device = Devices.TV_1080p) gets you a correctly-shaped preview canvas. Previews will not, however, tell you anything about focus — the preview renderer has no D-pad. Previews are for layout; the emulator is for behaviour.

The emulator

Create an AVD from the Television category — the 1080p profile is the right default. Use an Android TV or Google TV system image at API 34 or higher; you don't need an image at API 37 to develop, only to test the newest behaviour changes.

The single most important thing about the TV emulator: learn to drive it from the keyboard. Reaching for the mouse to click a card defeats the entire purpose, because a mouse click is not how any of your users will ever interact with your app, and it will hide focus bugs from you.

Remote Keyboard
D-pad up/down/left/right Arrow keys
Select / OK Enter
Back Esc
Home Home key, or the emulator toolbar
Menu Ctrl/Cmd + M

Get in the habit today: unplug the mouse, metaphorically. If you can't get somewhere with the arrow keys and Enter, your users can't get there either.

The emulator is also a liar about performance. It runs on your development machine's CPU and GPU. It will render your app at a buttery 60fps while the ₹2,000 box you're actually shipping to drops half its frames. Which brings us to the next point.

A real device

Buy the cheapest Android TV device you can find, and make it your primary test device.

I mean this seriously and it is the highest-value paragraph in this chapter. Not the nicest TV. The worst one. A budget HDMI dongle or an operator box. That device is your ground truth for performance, for focus feel, for image-loading behaviour on slow flash, and for how your app looks on a panel with mediocre contrast.

Connecting to it is straightforward, because TV devices have no USB port you'd want to use:

  1. On the device, go to Settings → System → About and click the Build number seven times to enable developer options.
  2. In Developer options, enable USB debugging (which, on TV, also gates network debugging) and Network debugging if it's a separate toggle.
  3. Find the device's IP address (Settings → Network).
  4. From your machine:
adb connect 192.168.1.42:5555
adb devices

You should see the device listed. adb install, adb logcat, and Studio's Run button all work exactly as they do over USB. Studio will also happily deploy to it.

If adb connect refuses, the usual culprits are: the device dropping to a different IP after a reboot (set a DHCP reservation), or ADB over network being disabled after a reboot (some devices reset it). And on many boxes you'll need to re-accept the debugging prompt on the TV itself — which, if the box is in another room, is exactly as annoying as it sounds.

1.7 Creating the Hearth project

Start from an Empty Activity (phone) template rather than the TV templates — the TV templates in Studio still tend to scaffold Leanback or an outdated Compose setup, and it's less work to configure a clean project than to unpick theirs.

Then replace the build files with the following.

gradle/libs.versions.toml

[versions]
agp = "9.2.0"
kotlin = "2.3.21"
coreKtx = "1.17.0"
lifecycle = "2.9.4"
activityCompose = "1.11.0"
composeBom = "2026.06.00"
tvMaterial = "1.1.0-rc01"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }

# Compose (versions come from the BOM)
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }

# TV — note: NOT covered by the Compose BOM, and no tv-foundation.
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

Two things to notice, both of which we discussed above: there is no kotlin-android plugin, and there is no tv-foundation.

Root build.gradle.kts

plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.compose.compiler) apply false
}

app/build.gradle.kts

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.compose.compiler)
}

android {
    namespace = "dev.hearth.tv"
    compileSdk = 37

    defaultConfig {
        applicationId = "dev.hearth.tv"
        minSdk = 23
        targetSdk = 37
        versionCode = 1
        versionName = "1.0"
    }

    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
            )
        }
    }

    buildFeatures {
        compose = true
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
}

kotlin {
    compilerOptions {
        jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
    }
}

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.activity.compose)

    val composeBom = platform(libs.androidx.compose.bom)
    implementation(composeBom)
    androidTestImplementation(composeBom)

    implementation(libs.androidx.compose.foundation)
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.tooling.preview)
    debugImplementation(libs.androidx.compose.ui.tooling)

    implementation(libs.androidx.tv.material)

    androidTestImplementation(libs.androidx.compose.ui.test.junit4)
    debugImplementation(libs.androidx.compose.ui.test.manifest)
}

Notice what is absent from the dependency list: androidx.compose.material3. On TV you use androidx.tv.material3 instead. The two are separate libraries with overlapping names, and mixing them is a classic and infuriating source of bugs — you import Surface from the wrong package, your focus indication silently stops working, and you spend an hour wondering why. We'll come back to this in Chapter 2 with a rule for keeping them straight. For now, keep phone Material 3 out of the project entirely and you cannot make the mistake.

(Icon, Text, MaterialTheme, Button, Card, Surface, ListItem, Tab, NavigationDrawer — all of these have TV versions in androidx.tv.material3. Layout primitives, modifiers, LazyRow/LazyColumn, animation, and everything else in androidx.compose.* are shared and used unchanged.)

Sync. If it builds, the toolchain is right.

1.8 The manifest: the TV contract

This is the part that determines whether your app is installable on a TV at all, whether it appears in the TV launcher, and whether the Play Console will accept it into the TV track. Get it wrong and you will discover it at submission time, which is the worst possible time.

app/src/main/AndroidManifest.xml:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />

    <!-- Declares this app as a TV app. Required for the Play TV track. -->
    <uses-feature
        android:name="android.software.leanback"
        android:required="true" />

    <!-- Televisions have no touchscreen. If this is omitted, Play assumes
         a touchscreen is required and will not offer your app on TV. -->
    <uses-feature
        android:name="android.hardware.touchscreen"
        android:required="false" />

    <application
        android:name=".HearthApplication"
        android:allowBackup="true"
        android:banner="@drawable/tv_banner"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/Theme.Hearth">

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:theme="@style/Theme.Hearth">

            <!-- LEANBACK_LAUNCHER is what puts you on the TV home screen.
                 Without it, your app installs but is invisible. -->
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

Let's walk the four things that actually matter.

android.software.leanback, required. This is the declaration that says "I am a TV app." Setting required="true" means the app will only be offered on devices that have the leanback feature — i.e. televisions. This is correct for a TV-only app, and it is what the Play Console's TV track expects.

If you are shipping a single APK/AAB that runs on both phones and TVs, you set required="false" instead and add a second intent filter with android.intent.category.LAUNCHER alongside the leanback one, so the app appears in both launchers. That's a legitimate architecture and I'll cover its trade-offs in Chapter 12. Hearth is TV-only, which keeps the code honest.

android.hardware.touchscreen, not required. This one catches everyone. Android assumes, by default, that an app requires a touchscreen. Televisions do not have one. If you forget this line, Play will filter your app out of TV devices entirely and you will get a rejection or, worse, a silently empty TV audience. It's one line and it is not optional.

LEANBACK_LAUNCHER. The TV home screen builds its app row from activities with this category — not from android.intent.category.LAUNCHER. Ship without it and your app is installed, running, invisible, and unlaunchable. This is a rite of passage; skip it.

android:banner. The TV launcher does not show your square app icon. It shows a banner: a 320×180 px image (place it in res/drawable-xhdpi/) that includes your app's name baked into the artwork. Not a logo with the name underneath — the launcher does not add a label. The banner is the whole thing. Get a designer to do this properly; a stretched icon looks exactly as amateurish as it sounds, and it is the very first impression a user has of your app.

A few things that are conspicuously not in this manifest, and shouldn't be:

  • No android:screenOrientation. TVs are landscape. Always. Furthermore, from Android 17 (API 37) onward, orientation and resizability attributes are ignored on large screens anyway. Don't bother.
  • No android:usesCleartextTraffic="true". This attribute is deprecated as of Android 17, and if you target API 37 and set it without a proper network security config, you can end up with all plaintext traffic blocked. Use HTTPS. If you need a local dev server, declare it explicitly in a network_security_config.xml — we do exactly this in Chapter 6 when we point the player at a local test stream.

Now the theme. res/values/themes.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="Theme.Hearth" parent="@android:style/Theme.Material.NoActionBar">
        <item name="android:windowBackground">@android:color/black</item>
        <item name="android:colorBackground">@android:color/black</item>
    </style>
</resources>

Deliberately minimal. We're doing everything in Compose, so the XML theme's only jobs are: no action bar, and a black window background so there's no white flash on cold start. On a TV, a white flash on a dark screen at night is genuinely unpleasant, and cold starts on weak hardware are long enough that you'll definitely see it.

1.9 Hearth's first screen

Now the code. Four small files.

HearthApplication.kt

package dev.hearth.tv

import android.app.Application

class HearthApplication : Application()

Empty for now. It earns its keep in Chapter 5 when dependency injection arrives.

ui/theme/Theme.kt

package dev.hearth.tv.ui.theme

import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme

private val HearthColorScheme = darkColorScheme(
    primary = Color(0xFFE8B44A),          // warm amber — the "hearth"
    onPrimary = Color(0xFF1A1206),
    background = Color(0xFF0B0B0D),
    onBackground = Color(0xFFF2F2F5),
    surface = Color(0xFF16161A),
    onSurface = Color(0xFFF2F2F5),
    surfaceVariant = Color(0xFF24242A),
    onSurfaceVariant = Color(0xFFB8B8C0),
)

@Composable
fun HearthTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = HearthColorScheme,
        content = content,
    )
}

Note the import: androidx.tv.material3.MaterialTheme and androidx.tv.material3.darkColorScheme. There is no light scheme. This is intentional — Hearth is a dark app, because it is a TV app, and we will not be building a light theme. If that feels like a shortcut, consider that Netflix, Disney+, Prime Video, and YouTube TV have all reached the same conclusion.

We'll build out typography and the full colour system properly in Chapter 2. This is enough to see something on screen.

ui/home/HomeScreen.kt

Here is the first thing that is actually TV code:

package dev.hearth.tv.ui.home

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.hearth.tv.ui.theme.HearthTheme

@Composable
fun HomeScreen(modifier: Modifier = Modifier) {
    var selected by remember { mutableStateOf("nothing yet") }

    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(horizontal = 48.dp, vertical = 27.dp),
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        Text(
            text = "Hearth",
            style = MaterialTheme.typography.headlineLarge,
            color = MaterialTheme.colorScheme.primary,
        )

        Text(
            text = "Last selected: $selected",
            style = MaterialTheme.typography.bodyLarge,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )

        Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
            listOf("Continue", "Browse", "Search", "Settings").forEach { label ->
                FocusTile(
                    label = label,
                    onClick = { selected = label },
                )
            }
        }
    }
}

@Composable
private fun FocusTile(
    label: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val shape = RoundedCornerShape(12.dp)

    Surface(
        onClick = onClick,
        modifier = modifier.size(width = 220.dp, height = 124.dp),
        shape = ClickableSurfaceDefaults.shape(shape = shape),
        colors = ClickableSurfaceDefaults.colors(
            containerColor = MaterialTheme.colorScheme.surfaceVariant,
            contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
            focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
            focusedContentColor = MaterialTheme.colorScheme.onSurface,
        ),
        scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
        border = ClickableSurfaceDefaults.border(
            focusedBorder = Border(
                border = BorderStroke(3.dp, MaterialTheme.colorScheme.primary),
                shape = shape,
            ),
        ),
    ) {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center,
        ) {
            Text(text = label, style = MaterialTheme.typography.titleMedium)
        }
    }
}

@Preview(device = Devices.TV_1080p, showBackground = true)
@Composable
private fun HomeScreenPreview() {
    HearthTheme { HomeScreen() }
}

Read FocusTile carefully, because it is the smallest complete example of the thing that makes TV Compose different from phone Compose.

The Surface here is the clickable overload from androidx.tv.material3. Because it takes an onClick, it is automatically focusable — it will participate in D-pad navigation, and Compose will route focus to it. And it takes three parameters that have no phone equivalent:

  • scale — the focused element grows. Here, to 108% of its size. This is the primary focus signal on TV, and it works on every device regardless of API level.
  • border — a stroke drawn around the focused element. Our second signal. Also universal.
  • glow (not used here) — a soft shadow. Pretty, but as noted earlier, silently absent below API 28, so never rely on it alone.

You will notice we specify focusedContainerColor explicitly even though it's the same as the unfocused one. That's deliberate: we're signalling focus through scale and border, not through a colour change. Colour-only focus indication is a common and bad instinct carried over from the web — it's often too subtle to read across a dark room, and it fails entirely for users with low vision or colour deficiency. Two signals, at least one of them geometric. We'll formalise this into a rule in Chapter 2.

Also notice what we did not have to do: we didn't write any key handling, we didn't manage a focus index, we didn't call requestFocus. Compose's focus system found the four tiles in the Row and wired up left/right between them for free. This is the happy path, and it is genuinely pleasant.

It is also about the last time it will be this easy. Chapter 3.

MainActivity.kt

package dev.hearth.tv

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.SurfaceDefaults
import dev.hearth.tv.ui.home.HomeScreen
import dev.hearth.tv.ui.theme.HearthTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            HearthTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    colors = SurfaceDefaults.colors(
                        containerColor = MaterialTheme.colorScheme.background,
                    ),
                ) {
                    HomeScreen()
                }
            }
        }
    }
}

This Surface is the non-interactive overload — no onClick, so it isn't focusable. It's just our root container, painting the background.

That's it. Run it.

1.10 What you should see, and what to do about it

Deploy to the emulator or, better, your cheap TV box. You should see the Hearth title, a status line, and four tiles. Press right. A tile should grow slightly and gain an amber border. Press right again. The focus moves.

Press Enter. The status line changes.

Congratulations — that is a working Android TV app, and it already respects the fundamental contract of the platform: at every moment, exactly one thing is focused, and it is obvious which one.

Now do the exercise that actually teaches you something:

Sit three metres from the screen. Not at your desk. Actually get up and go sit where a user sits.

You will immediately notice things you cannot notice from 60cm away. The status line text is too small. The 3dp border might be thinner than you'd like. The gap between tiles reads differently. Whether the amber has enough contrast against the grey to be legible at all. Whether the 8% scale is a clear enough signal, or whether it just looks like the tile twitched.

This is the single habit that separates good TV developers from bad ones, and it costs nothing. Every design decision in a TV app must be validated from the sofa. Not from the desk. Not from the preview pane. From the sofa.

Adjust the numbers in FocusTile until it reads clearly from across the room. Whatever values you land on, write them down — they're the beginning of your design system, and we'll formalise them in Chapter 2.

1.11 One more thing: overscan

There's a piece of television history embedded in your app's layout, and it will bite you if you don't know about it.

Older televisions — CRTs, and a surprising number of modern panels in their default configuration — do not display the entire signal they receive. They overscan: they crop the edges of the image, sometimes by as much as 5% on each side, and stretch the rest to fill the screen. The reason is a decades-old broadcast compatibility hack, and the reason it survives is that TV manufacturers are conservative.

The consequence for you is direct: content flush against the edge of your layout may simply not be visible on some televisions. Not clipped in a visible way — just gone.

The mitigation is a safe area: a margin around the entire UI inside which you place anything that matters. The conventional figures are 5% horizontally and 5% vertically, which on a 1920×1080 canvas works out to roughly 48dp on the left and right and 27dp on the top and bottom.

Look back at HomeScreen. Those numbers are already there:

.padding(horizontal = 48.dp, vertical = 27.dp)

That is not an arbitrary aesthetic choice. It's the overscan safe area. You'll see those numbers throughout this book, and in Chapter 2 we'll lift them into a proper HearthSpacing object so they stop being magic numbers scattered through the codebase.

The nuance — and this is a real trade-off, not a rule — is that full-bleed background imagery is supposed to extend past the safe area. A hero image that stops 48dp short of the edge looks broken. So the rule is: backgrounds bleed; content doesn't. Anything the user must read or focus lives inside the safe area. Anything decorative can run to the edge and let the TV crop it. We build exactly this in Chapter 4, when the immersive hero header arrives.

1.12 Your debugging toolkit

A short list of things you'll reach for constantly. Bookmark it.

See what's focused, right now. The single most useful command in TV development:

adb shell dumpsys activity top | grep -i focus

More precisely for Compose, drop a temporary logger into any composable you suspect:

Modifier.onFocusChanged { state ->
    Log.d("Focus", "$label focused=${state.isFocused} hasFocus=${state.hasFocus}")
}

You will use this more than you expect. isFocused means this node has focus; hasFocus means this node or a descendant does. Confusing the two is responsible for a meaningful share of all TV focus bugs, and we'll dissect the difference in Chapter 3.

Send D-pad events without a remote. Useful for scripted repro and for CI:

adb shell input keyevent KEYCODE_DPAD_RIGHT
adb shell input keyevent KEYCODE_DPAD_CENTER   # select
adb shell input keyevent KEYCODE_BACK

Check that the launcher can see you. If your app isn't appearing on the home screen:

adb shell dumpsys package dev.hearth.tv | grep -i leanback

No output means your intent filter is wrong.

Watch the frame times on the real device. Developer options → "Profile HWUI rendering" → "On screen as bars". Crude, but it will tell you in ten seconds whether the box is struggling, and it works on hardware where more sophisticated tools won't attach. Chapter 11 gets serious about this.

1.13 Where we are

Hearth builds, launches on a television, appears in the TV launcher with a banner, and has a focus indicator you can see from the sofa. The toolchain is pinned to something current and honest, with no phantom tv-foundation dependency and no Kotlin plugin fighting AGP 9.

More importantly, you should now have the platform's shape in your head:

  • Focus is the cursor, and it is application state you are responsible for.
  • The hardware is worse than you think, and the emulator is lying to you about it.
  • The safe area is real, and backgrounds bleed while content doesn't.
  • Everything the user wants is on the other side of "press play", and your job is to shorten that path.

In Chapter 2 we build the design foundation properly: the TV Material 3 component set and how it differs from the phone one, a typographic scale that's legible at three metres, the spacing and safe-area system, and a focus indication standard that works from API 23 to API 37 and on a panel that cost forty dollars.

Then, in Chapter 3, we go to war with the focus system.


Exercises

  1. Break the manifest deliberately. Remove the LEANBACK_LAUNCHER category, rebuild, and install. Confirm that the app is installed (adb shell pm list packages | grep hearth) but does not appear on the home screen. Put it back. You will never make this mistake again.

  2. Break the focus indication. Set focusedScale = 1.0f and remove the border parameter, leaving only a colour change between focused and unfocused. Sit three metres away and try to use the app. This is what a large number of shipped TV apps actually feel like.

  3. Add a fifth tile and a second row. Put four tiles in one Row and one in a second Row below it. Navigate down from the third tile in the top row. Where does focus land? Was that what you expected? (It probably wasn't. Hold that thought — this is the opening question of Chapter 3.)

  4. Measure your banner. Create a proper 320×180 banner for Hearth with the name baked into the artwork, drop it in res/drawable-xhdpi/tv_banner.png, and look at it on the actual home screen next to Netflix and YouTube. Be honest about how it compares.