TV app engineering patterns: what broke, what worked, what survived three apps.
On a phone, the user closes a video by swiping away. On a TV, the user presses Back, and the app must decide whether to close the player, go to the previous screen, or exit the app. Get this wrong and the player keeps playing audio while the user is browsing the home screen.
Flutter · Smart TV · Media · Kochi, Kerala · Remote worldwide
Player lifecycle on TV
On a phone, the user closes a video by swiping away. On a TV, the user presses Back, and the app must decide whether to close the player, go to the previous screen, or exit the app. Get this wrong and the player keeps playing audio while the user is browsing the home screen.
The pattern I use across the OTT apps is a three-layer back stack:
- 1Player overlay — If the player controls overlay is open, Back closes the overlay but keeps playing.
- 2Player screen — If the player is full-screen, Back stops playback and returns to the content detail or home screen.
- 3Home screen — If on the home screen with no overlay, Back shows the "exit app" confirmation dialog.
The key detail is stopping the player before navigating away. In the first app, I navigated first and stopped the player in dispose. This caused a half-second of audio to play on the wrong screen. The fix was to stop the player synchronously before the navigation call:
void onBackPressed() {
if (controlsVisible) {
setState(() => controlsVisible = false);
return;
}
playerController.pause();
playerController.dispose();
Navigator.pop(context);
}
For the digital art frame player, the lifecycle is simpler but stricter: the player never exits. It loops through a playlist of videos and images, and the only way to stop it is via a remote playlist update that clears the queue. The app uses KeepScreenOn.turnOn() to prevent the TV from sleeping, and turns it off only during the initial setup flow.
HLS streaming and preloading
All three apps use HLS for video. The OTT apps stream movies and series; the art frame player streams live playlists. The pattern is the same: use the platform-native player (ExoPlayer on Android) through a Flutter wrapper, and let it handle the HLS manifest parsing, bitrate switching and buffering.
The difference is in preloading. On the OTT apps, when the user is browsing a rail of movies, the app preloads the HLS manifest for the card under focus — not the card under a cursor, but the card with the D-pad highlight. This means when the user presses Select, the player already has the manifest and starts within a second.
On the art frame player, preloading is more aggressive. The player maintains a cache of the next item's manifest and first segment while playing the current item. When the current item ends, the next one starts immediately — no spinner, no black screen. The image items use DefaultCacheManager to preload the next image while the current one displays.
Memory management is critical. The art frame player explicitly clears the image cache when memory pressure builds:
ImageCache imageCache = PaintingBinding.instance.imageCache;
imageCache.clear();
imageCache.clearLiveImages();
Without this, a playlist with many high-resolution images will eventually OOM on a low-end TV box with 2GB RAM.
Real-time playlist updates
The digital art frame player receives playlist changes in real time. A content manager updates the playlist on a web dashboard, and the change reaches every connected display within seconds — no app restart, no polling.
The implementation uses WebSocket connections with a per-device channel. Each display registers with a unique key during setup. When the content manager updates a playlist, the server pushes the change to that device's channel. The app receives it, fetches the new playlist and transitions smoothly.
The connection management is the tricky part. TV boxes have unreliable networks. The implementation includes:
- ✓Reconnection with backoff — The client retries automatically when the connection drops, with exponential backoff.
- ✓HTTP polling fallback — If WebSocket is unavailable, the app falls back to polling the playlist endpoint.
- ✓Graceful transition — When a new playlist arrives, the current item finishes before switching. No mid-video cuts.
This pattern — real-time push with HTTP fallback — is worth considering for any TV app that needs live content updates. Live sports scores, breaking news banners, and dynamic pricing can all use the same approach.
On-screen input without a keyboard
TV remotes have 10-15 buttons. None of them are letters. Every text input — search, login, device pairing — requires an on-screen keyboard.
I built a custom on-screen keyboard as a local package, shared across the OTT apps. It's a QWERTY grid where each key is a focusable widget in the keyboardGridItem group. The D-pad navigates the grid: left/right moves within a row, up/down moves between rows, Select types the letter.
The keyboard supports two layouts:
- QWERTYFull alphabet for search and general text input.
- Numeric0-9 for login OTP and phone number entry.
The keyboard is not a TextField wrapper — it's a standalone widget that manages its own text state and fires onChanged callbacks. This gives full control over focus behavior and avoids the conflicts between Flutter's text input focus and the D-pad traversal system.
Case shifting (uppercase/lowercase) is handled by a BLoC that tracks the current case state. The Shift key toggles it; typing a letter auto-lowercases after one character. This mirrors the behavior of mobile keyboards without requiring the same gesture model.
Back navigation that doesn't crash
The back button is the most abused control in TV apps. Users press it when they're confused, when they're done, and when nothing else seems to work. The app must handle every case gracefully.
The pattern I use is a per-screen back handler that checks three conditions in order:
- 01Is there an open dialog or overlay? Close it and restore focus to the opener.
- 02Is there a previous route? Pop the navigator.
- 03Is this the home screen? Show "Exit app?" confirmation.
The focus restoration is the part that breaks most often. When a dialog opens, the widget that opened it loses focus. When the dialog closes, the app must explicitly restore focus to that widget. Without it, focus lands on whatever Flutter decides is the "first" focusable widget — often the sidebar, even though the user was in the content area.
The implementation stores the opener's focus node when the dialog opens:
FocusNode? openerNode;
void openDialog() {
openerNode = FocusScope.of(context).focusedChild;
showDialog(...);
}
void closeDialog() {
Navigator.pop(context);
WidgetsBinding.instance.addPostFrameCallback((_) {
openerNode?.requestFocus();
});
}
The addPostFrameCallback is critical. Without it, the focus request fires before the dialog is fully removed from the tree, and the opener node may no longer be valid.
In-app purchases on TV
One of the OTT apps implements in-app purchases for both Google Play and Amazon Appstore. Unlike phone apps where the platform handles the payment UI, on TV you need to integrate the billing APIs directly and handle the platform-specific flow.
Google Play Billing and Amazon Appstore Billing have different APIs but the same basic pattern: query available products, initiate a purchase, listen for the result, and acknowledge the purchase. The key difference on TV is that the user confirms with the remote, not a fingerprint or face ID.
The implementation abstracts both platforms behind a single interface:
- ✓Product query — On app launch, query available subscriptions and one-time purchases from the platform store.
- ✓Purchase flow — When the user selects a plan, initiate the platform purchase flow. The platform shows its own confirmation dialog.
- ✓Purchase acknowledgment — Once the platform confirms payment, acknowledge the purchase and grant access. Unacknowledged purchases will be refunded automatically.
- ✓Restore purchases — On app reinstall or new device, query existing purchases and restore access without requiring re-payment.
The tricky part is handling pending purchases. If the app crashes or the user force-closes during a purchase, the next launch needs to check for pending transactions and complete or cancel them. A purchase listener that runs on app start handles this case.
QR-based device pairing
For login, the OTT apps support QR-based device pairing. Instead of typing email and password with the D-pad, the user scans a QR code with their phone, confirms login on the phone, and the TV receives the authentication token.
The flow:
- 01TV generates a unique device key and displays it as a QR code.
- 02TV starts polling the server for a pairing response, using the device key as the identifier.
- 03User scans QR with phone, logs in on the phone app/web.
- 04Server associates the phone session with the device key.
- 05TV receives the auth token via the polling response and completes login.
The polling interval is 2 seconds. The timeout is 5 minutes. If the user doesn't scan within 5 minutes, the QR refreshes with a new device key. This prevents stale QR codes from being used.
The device key is also stored locally and reused on subsequent launches — the user only pairs once. The stored key is validated on each login; if the server rejects it, the app shows the QR again.
Kiosk mode and keep-screen-on
The digital art frame player runs in kiosk mode: full-screen, no system UI, no way to exit. This requires two things:
Keep screen on. The keep_screen_on package prevents the TV from entering sleep mode. It's enabled when the player starts and disabled only during setup:
KeepScreenOn.turnOn(); // When player starts
KeepScreenOn.turnOff(); // When player stops (setup/error)
Disable system gestures. On Android TV, the user can swipe from the top edge to reveal the notification shade. In kiosk mode, this must be disabled. The Android manifest includes android:showWhenLocked="true" and android:turnScreenOn="true" to ensure the app starts full-screen even from a cold boot.
Crash recovery. A kiosk app must recover from crashes without human intervention. The Android manifest uses android:restartActivity="true" equivalent patterns — a boot receiver that relaunches the app if it's not running, and the app itself catches top-level errors and restarts the player.
Device testing — what the emulator hides
The Android TV emulator is useful for layout testing. It is not useful for testing how the app behaves on real hardware. Here's what the emulator gets wrong:
| Issue | Emulator | Real device |
|---|---|---|
| Key events | Clean, consistent LogicalKeyboardKey events | Fire TV sends different key codes for some buttons; some manufacturer remotes add custom buttons |
| Performance | Runs on your development machine's CPU | A low-end TV box with a quad-core ARM CPU and 2GB RAM will jank on the same code that runs smoothly on the emulator |
| Scroll + focus | Instant scroll, no visible lag | On a slow device, the 250ms scroll animation may not complete before the next focus event — focus appears to "jump" |
| Memory | Plenty of RAM available | 2GB TV boxes will OOM if you don't manage the image cache and player lifecycle carefully |
| HDMI-CEC | Not simulated | Some TVs send CEC events that Flutter interprets as key presses — your volume button might trigger "Right" |
| Network | Fast, reliable WiFi | TV boxes often have weaker WiFi antennas; HLS buffering behavior is different on a slow connection |
| Screen sleep | Never sleeps | Without keep-screen-on, the TV will sleep after 5-10 minutes, killing the player |
I test on at least three device categories: a Google TV (Sony Bravia or similar), a Fire TV Stick, and a low-end Android TV box (Xiaomi Mi Box or similar). The low-end box is the most important — it exposes every performance issue that the emulator hides.
Architecture decisions that mattered
Across the three apps, I used two different architecture patterns and learned when each one works:
GetX (art frame player) — The digital art frame player uses GetX with its routing and dependency injection. For a simple app with two screens (setup and player), this is fast to build and easy to maintain. The player controller is a GetX controller that manages the video player, WebSocket connection and playlist state. GetX's reactive observables (RxBool, RxString) make it trivial to update the UI when the playlist changes.
MVVM with GetX controllers (OTT apps) — The OTT apps use a more structured approach: GetX for routing and controller lifecycle, but with a clear separation between controllers (state and business logic), models (data classes with JSON serialization) and views (pure UI). The focus traversal system sits between the view and the controller — the view declares focus groups and coordinates, the traversal policy moves focus, and the focus listener notifies the controller of focus changes.
Shared local packages — The on-screen keyboard is a local package, shared between the OTT apps. This means bug fixes and improvements are applied once and propagate to all apps. The package is not published to pub.dev — it's a directory in the project's packages/ folder with a path dependency.
JSON serialization with freezed — The OTT apps use code generation for model serialization. This eliminates manual fromJson/toJson boilerplate and catches schema mismatches at build time, not at runtime. For a TV app where a bad API response can leave the user with a frozen screen and no way to type an error message, compile-time safety matters.
TV app release checklist
- ✓Player stops before navigation
Verify that pressing Back from the player stops playback before the screen changes. No audio should leak to the next screen. - ✓Dialog focus restoration
Open and close every dialog type. Focus should return to the widget that opened the dialog, not the sidebar or app bar. - ✓On-screen keyboard works for all inputs
Test search, login OTP and device key entry. Each layout should navigate correctly with the D-pad. - ✓Back button is safe everywhere
Press Back from every screen. It should never crash, never show a blank screen, and never leave audio playing. - ✓Keep-screen-on works
For kiosk apps, leave the device running for 15 minutes. The screen should not sleep. For OTT apps, verify the screen sleeps when appropriate. - ✓Low-end device test
Run the app on a 2GB RAM TV box. Browse through 20 cards, start playback, press Back, search with the keyboard. No jank, no OOM. - ✓Network loss handling
Kill WiFi while browsing. The app should show an error state, not crash. Restore WiFi — the app should recover without a restart. - ✓Real remote test
Test with a Fire TV remote and an Android TV remote. Verify all directional keys, Select, Back and Home work correctly. Check for manufacturer-specific button interference. - ✓In-app purchase flow
Complete a test purchase on both Google Play and Amazon Appstore. Verify the subscription state updates on the TV and that pending purchases are handled on app restart. - ✓Cold start focus
Force-stop the app and relaunch. The first screen should have focus on a sensible default — not a blank screen waiting for input.
Building a TV app is not building a phone app in 1080p. The input model is different, the performance constraints are different, and the user's tolerance for confusion is lower — because fixing a mistake requires more effort. The patterns above are not theoretical; they're the ones that survived three shipped apps across OTT streaming, digital art frame and everything in between.