Flutter TV App Localization: Complete Guide for Android TV, Apple TV & Fire TV
Building Flutter apps for smart TVs opens your app to the living room audience. But TV interfaces have unique localization challenges: remote navigation, large-screen typography, 10-foot UI design, and platform-specific requirements. This guide covers everything you need to know about localizing Flutter apps for Android TV, Apple TV, and Amazon Fire TV.
TV Platform Overview
| Platform | SDK | Unique Considerations |
|---|---|---|
| Android TV | Native Flutter support | D-pad navigation, Leanback launcher |
| Apple TV | flutter_tvos (community) | Siri Remote, Focus engine |
| Fire TV | Android TV compatible | Alexa integration, Amazon guidelines |
| Samsung TV | Tizen (limited Flutter) | Requires custom approach |
Project Setup
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
shared_preferences: ^2.2.2
flutter:
generate: true
For Android TV, update your manifest:
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest>
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android:touchscreen" android:required="false" />
<application
android:banner="@drawable/banner">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale">
<!-- Leanback launcher intent -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
10-Foot UI Typography Considerations
TV users sit 10 feet away from the screen. Text must be larger and clearer:
// lib/theme/tv_text_theme.dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class TvTextTheme {
/// Create locale-aware TV text theme
static TextTheme create(Locale locale) {
// Base sizes for 10-foot viewing
const baseHeadline = 48.0;
const baseBody = 24.0;
const baseCaption = 18.0;
// Adjust for different scripts
final scriptMultiplier = _getScriptMultiplier(locale);
return TextTheme(
displayLarge: TextStyle(
fontSize: baseHeadline * 1.5 * scriptMultiplier,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
displayMedium: TextStyle(
fontSize: baseHeadline * scriptMultiplier,
fontWeight: FontWeight.bold,
),
headlineLarge: TextStyle(
fontSize: 40.0 * scriptMultiplier,
fontWeight: FontWeight.w600,
),
headlineMedium: TextStyle(
fontSize: 32.0 * scriptMultiplier,
fontWeight: FontWeight.w600,
),
bodyLarge: TextStyle(
fontSize: baseBody * 1.2 * scriptMultiplier,
height: 1.5,
),
bodyMedium: TextStyle(
fontSize: baseBody * scriptMultiplier,
height: 1.5,
),
labelLarge: TextStyle(
fontSize: 20.0 * scriptMultiplier,
fontWeight: FontWeight.w500,
),
bodySmall: TextStyle(
fontSize: baseCaption * scriptMultiplier,
color: Colors.grey[400],
),
);
}
/// Different scripts need different size adjustments
static double _getScriptMultiplier(Locale locale) {
switch (locale.languageCode) {
case 'ja': // Japanese - complex characters
case 'zh': // Chinese
case 'ko': // Korean
return 1.1; // Slightly larger for readability
case 'ar': // Arabic
case 'he': // Hebrew
case 'fa': // Persian
return 1.05; // RTL scripts
case 'th': // Thai - tall characters
return 1.15;
default:
return 1.0;
}
}
}
ARB Files for TV Apps
TV apps need specific strings for remote navigation:
// lib/l10n/app_en.arb
{
"@@locale": "en",
"appTitle": "My TV App",
"@appTitle": {
"description": "App title shown in launcher"
},
"navHome": "Home",
"navSearch": "Search",
"navSettings": "Settings",
"navProfile": "Profile",
"focusHintPress": "Press OK to select",
"@focusHintPress": {
"description": "Hint shown when item is focused"
},
"focusHintHold": "Hold OK for options",
"focusHintBack": "Press Back to go back",
"voiceSearchHint": "Say what you want to watch",
"@voiceSearchHint": {
"description": "Voice search prompt"
},
"alexaCommand": "Try saying: Alexa, open {appName}",
"@alexaCommand": {
"placeholders": {
"appName": {"type": "String"}
}
},
"siriCommand": "Try saying: Hey Siri, open {appName}",
"@siriCommand": {
"placeholders": {
"appName": {"type": "String"}
}
},
"continueWatching": "Continue Watching",
"watchNow": "Watch Now",
"addToWatchlist": "Add to Watchlist",
"removeFromWatchlist": "Remove from Watchlist",
"moreInfo": "More Info",
"playFromBeginning": "Play from Beginning",
"resume": "Resume",
"episodeFormat": "S{season}:E{episode}",
"@episodeFormat": {
"description": "Season/Episode format",
"placeholders": {
"season": {"type": "int"},
"episode": {"type": "int"}
}
},
"durationMinutes": "{minutes} min",
"@durationMinutes": {
"placeholders": {
"minutes": {"type": "int"}
}
},
"ratingFormat": "{rating}/10",
"@ratingFormat": {
"placeholders": {
"rating": {"type": "String"}
}
},
"parentalRating": "Rated {rating}",
"@parentalRating": {
"placeholders": {
"rating": {"type": "String"}
}
},
"audioLanguage": "Audio: {language}",
"@audioLanguage": {
"placeholders": {
"language": {"type": "String"}
}
},
"subtitleLanguage": "Subtitles: {language}",
"@subtitleLanguage": {
"placeholders": {
"language": {"type": "String"}
}
},
"subtitlesOff": "Subtitles: Off",
"settingsLanguage": "Language",
"settingsSubtitles": "Subtitles",
"settingsAudio": "Audio",
"settingsParentalControls": "Parental Controls",
"settingsAccessibility": "Accessibility",
"accessibilityHighContrast": "High Contrast",
"accessibilityLargeText": "Large Text",
"accessibilityScreenReader": "Screen Reader",
"accessibilityReduceMotion": "Reduce Motion"
}
// lib/l10n/app_es.arb
{
"@@locale": "es",
"appTitle": "Mi App de TV",
"navHome": "Inicio",
"navSearch": "Buscar",
"navSettings": "Ajustes",
"navProfile": "Perfil",
"focusHintPress": "Pulsa OK para seleccionar",
"focusHintHold": "Manten OK para opciones",
"focusHintBack": "Pulsa Atras para volver",
"voiceSearchHint": "Di lo que quieres ver",
"alexaCommand": "Prueba a decir: Alexa, abre {appName}",
"siriCommand": "Prueba a decir: Oye Siri, abre {appName}",
"continueWatching": "Continuar Viendo",
"watchNow": "Ver Ahora",
"addToWatchlist": "Anadir a Mi Lista",
"removeFromWatchlist": "Quitar de Mi Lista",
"moreInfo": "Mas Informacion",
"playFromBeginning": "Reproducir desde el Inicio",
"resume": "Reanudar",
"episodeFormat": "T{season}:E{episode}",
"durationMinutes": "{minutes} min",
"ratingFormat": "{rating}/10",
"parentalRating": "Clasificacion: {rating}",
"audioLanguage": "Audio: {language}",
"subtitleLanguage": "Subtitulos: {language}",
"subtitlesOff": "Subtitulos: Desactivados",
"settingsLanguage": "Idioma",
"settingsSubtitles": "Subtitulos",
"settingsAudio": "Audio",
"settingsParentalControls": "Control Parental",
"settingsAccessibility": "Accesibilidad",
"accessibilityHighContrast": "Alto Contraste",
"accessibilityLargeText": "Texto Grande",
"accessibilityScreenReader": "Lector de Pantalla",
"accessibilityReduceMotion": "Reducir Movimiento"
}
D-Pad Navigation with Localized Focus
// lib/widgets/tv_focusable_card.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class TvFocusableCard extends StatefulWidget {
final Widget child;
final VoidCallback onSelect;
final VoidCallback? onLongPress;
final String? semanticLabel;
const TvFocusableCard({
super.key,
required this.child,
required this.onSelect,
this.onLongPress,
this.semanticLabel,
});
@override
State<TvFocusableCard> createState() => _TvFocusableCardState();
}
class _TvFocusableCardState extends State<TvFocusableCard> {
bool _isFocused = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: widget.semanticLabel,
hint: _isFocused ? l10n.focusHintPress : null,
button: true,
child: Focus(
onFocusChange: (hasFocus) {
setState(() => _isFocused = hasFocus);
},
onKey: (node, event) {
if (event is RawKeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.select ||
event.logicalKey == LogicalKeyboardKey.enter) {
widget.onSelect();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: GestureDetector(
onTap: widget.onSelect,
onLongPress: widget.onLongPress,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
transform: Matrix4.identity()
..scale(_isFocused ? 1.05 : 1.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _isFocused ? Colors.white : Colors.transparent,
width: 3,
),
boxShadow: _isFocused
? [
BoxShadow(
color: Colors.white.withOpacity(0.3),
blurRadius: 20,
spreadRadius: 2,
),
]
: null,
),
child: Stack(
children: [
widget.child,
if (_isFocused)
Positioned(
bottom: 8,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(4),
),
child: Text(
l10n.focusHintPress,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
),
),
),
],
),
),
),
),
);
}
}
Voice Search Localization
Android TV Voice Search
// lib/services/voice_search_service.dart
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class VoiceSearchService {
static const _channel = MethodChannel('com.example.app/voice');
/// Launch voice search with localized prompt
static Future<String?> startVoiceSearch(AppLocalizations l10n) async {
try {
final result = await _channel.invokeMethod('startVoiceSearch', {
'prompt': l10n.voiceSearchHint,
'language': l10n.localeName,
});
return result as String?;
} on PlatformException {
return null;
}
}
}
Amazon Alexa Integration
// lib/services/alexa_service.dart
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AlexaService {
/// Get localized Alexa command hints
static List<String> getCommandHints(AppLocalizations l10n, String appName) {
return [
l10n.alexaCommand(appName),
// Add more localized commands
];
}
/// Map Alexa intents to app actions
static String? handleIntent(String intent, String locale) {
// Handle localized intents
final intentMap = {
'en': {
'PlayIntent': 'play',
'PauseIntent': 'pause',
'SearchIntent': 'search',
},
'es': {
'ReproducirIntent': 'play',
'PausarIntent': 'pause',
'BuscarIntent': 'search',
},
'de': {
'AbspielenIntent': 'play',
'PausierenIntent': 'pause',
'SuchenIntent': 'search',
},
};
final localeMap = intentMap[locale] ?? intentMap['en']!;
return localeMap[intent];
}
}
Subtitle and Audio Language Selection
// lib/widgets/language_selector.dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class MediaLanguageSelector extends StatelessWidget {
final List<String> audioLanguages;
final List<String> subtitleLanguages;
final String selectedAudio;
final String? selectedSubtitle;
final ValueChanged<String> onAudioChanged;
final ValueChanged<String?> onSubtitleChanged;
const MediaLanguageSelector({
super.key,
required this.audioLanguages,
required this.subtitleLanguages,
required this.selectedAudio,
required this.selectedSubtitle,
required this.onAudioChanged,
required this.onSubtitleChanged,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Audio language
Text(
l10n.settingsAudio,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
_buildLanguageRow(
context,
audioLanguages,
selectedAudio,
onAudioChanged,
false,
),
const SizedBox(height: 32),
// Subtitle language
Text(
l10n.settingsSubtitles,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
_buildLanguageRow(
context,
subtitleLanguages,
selectedSubtitle,
(lang) => onSubtitleChanged(lang),
true,
),
],
);
}
Widget _buildLanguageRow(
BuildContext context,
List<String> languages,
String? selected,
ValueChanged<String> onChanged,
bool allowOff,
) {
final l10n = AppLocalizations.of(context)!;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
if (allowOff)
Padding(
padding: const EdgeInsets.only(right: 16),
child: TvFocusableCard(
onSelect: () => onSubtitleChanged(null),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
decoration: BoxDecoration(
color: selected == null ? Colors.blue : Colors.grey[800],
borderRadius: BorderRadius.circular(8),
),
child: Text(
l10n.subtitlesOff.replaceAll('Subtitles: ', ''),
style: const TextStyle(fontSize: 20),
),
),
),
),
...languages.map((lang) {
final isSelected = lang == selected;
return Padding(
padding: const EdgeInsets.only(right: 16),
child: TvFocusableCard(
onSelect: () => onChanged(lang),
semanticLabel: _getLanguageName(lang),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
decoration: BoxDecoration(
color: isSelected ? Colors.blue : Colors.grey[800],
borderRadius: BorderRadius.circular(8),
),
child: Text(
_getLanguageName(lang),
style: const TextStyle(fontSize: 20),
),
),
),
);
}),
],
),
);
}
String _getLanguageName(String code) {
const names = {
'en': 'English',
'es': 'Espanol',
'de': 'Deutsch',
'fr': 'Francais',
'ja': '日本語',
'zh': '中文',
'ko': '한국어',
'pt': 'Portugues',
'it': 'Italiano',
'ru': 'Русский',
'ar': 'العربية',
};
return names[code] ?? code.toUpperCase();
}
}
TV-Specific Settings Screen
// lib/screens/tv_settings_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:provider/provider.dart';
class TvSettingsScreen extends StatelessWidget {
const TvSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: Row(
children: [
// Settings menu (left side)
Container(
width: 300,
color: Colors.grey[900],
child: ListView(
children: [
_SettingsCategory(
icon: Icons.language,
title: l10n.settingsLanguage,
onSelect: () => _showLanguageSettings(context),
),
_SettingsCategory(
icon: Icons.subtitles,
title: l10n.settingsSubtitles,
onSelect: () => _showSubtitleSettings(context),
),
_SettingsCategory(
icon: Icons.volume_up,
title: l10n.settingsAudio,
onSelect: () => _showAudioSettings(context),
),
_SettingsCategory(
icon: Icons.family_restroom,
title: l10n.settingsParentalControls,
onSelect: () => _showParentalSettings(context),
),
_SettingsCategory(
icon: Icons.accessibility,
title: l10n.settingsAccessibility,
onSelect: () => _showAccessibilitySettings(context),
),
],
),
),
// Settings content (right side)
Expanded(
child: Container(
color: Colors.black,
padding: const EdgeInsets.all(48),
child: const _SettingsContent(),
),
),
],
),
);
}
void _showLanguageSettings(BuildContext context) {
// Show language picker
}
void _showSubtitleSettings(BuildContext context) {
// Show subtitle settings
}
void _showAudioSettings(BuildContext context) {
// Show audio settings
}
void _showParentalSettings(BuildContext context) {
// Show parental controls
}
void _showAccessibilitySettings(BuildContext context) {
// Show accessibility options
}
}
class _SettingsCategory extends StatelessWidget {
final IconData icon;
final String title;
final VoidCallback onSelect;
const _SettingsCategory({
required this.icon,
required this.title,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return TvFocusableCard(
onSelect: onSelect,
semanticLabel: title,
child: ListTile(
leading: Icon(icon, size: 32),
title: Text(
title,
style: const TextStyle(fontSize: 22),
),
trailing: const Icon(Icons.chevron_right),
),
);
}
}
RTL Support for TV
// lib/widgets/tv_content_row.dart
import 'package:flutter/material.dart';
class TvContentRow extends StatelessWidget {
final String title;
final List<Widget> items;
const TvContentRow({
super.key,
required this.title,
required this.items,
});
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(
left: isRtl ? 0 : 48,
right: isRtl ? 48 : 0,
bottom: 16,
),
child: Text(
title,
style: Theme.of(context).textTheme.headlineMedium,
),
),
SizedBox(
height: 250,
child: ListView.builder(
scrollDirection: Axis.horizontal,
reverse: isRtl, // Reverse for RTL
padding: EdgeInsets.only(
left: isRtl ? 0 : 48,
right: isRtl ? 48 : 0,
),
itemCount: items.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(right: 24),
child: items[index],
);
},
),
),
],
);
}
}
Localized App Banner
Create localized launcher banners for Android TV:
// Create banners in these folders:
// android/app/src/main/res/drawable-xhdpi/banner.png (320x180)
// android/app/src/main/res/drawable-xhdpi-de/banner.png (German)
// android/app/src/main/res/drawable-xhdpi-es/banner.png (Spanish)
// android/app/src/main/res/drawable-xhdpi-fr/banner.png (French)
Testing on TV Devices
// test/tv_localization_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('TV Localization', () {
testWidgets('displays localized focus hints', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const TvFocusableCard(
onSelect: () {},
child: Text('Test'),
),
),
);
// Focus the card
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(find.text('Pulsa OK para seleccionar'), findsOneWidget);
});
testWidgets('handles RTL for Arabic', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ar'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const TvContentRow(
title: 'Test',
items: [Text('1'), Text('2'), Text('3')],
),
),
);
final directionality = tester.widget<Directionality>(
find.byType(Directionality).first,
);
expect(directionality.textDirection, TextDirection.rtl);
});
testWidgets('uses larger text for TV viewing', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
theme: ThemeData(
textTheme: TvTextTheme.create(const Locale('en')),
),
home: const Text('Test', style: TextStyle()),
),
);
// TV text should be larger than mobile defaults
final textTheme = Theme.of(tester.element(find.text('Test'))).textTheme;
expect(textTheme.bodyMedium!.fontSize, greaterThan(16));
});
});
}
Platform-Specific Guidelines
Android TV
- Minimum text size: 12sp (but recommend 24sp+ for TV)
- Focus states must be clearly visible
- Support D-pad navigation
- Banner image required for Leanback launcher
Apple TV
- Use focus engine with proper focus guides
- Support Siri Remote gestures
- Minimum text size: 24pt
- Support for tvOS focus system
Fire TV
- Compatible with Android TV guidelines
- Support Alexa voice commands
- Follow Amazon's content guidelines
- Test with Fire TV remote
Summary
TV app localization requires attention to:
- 10-foot UI typography - Larger text, script-aware sizing
- Remote navigation - Localized focus hints and D-pad support
- Voice commands - Platform-specific voice integration
- Media languages - Audio and subtitle selection
- RTL support - Proper layout mirroring for TV interfaces
- Platform banners - Localized launcher assets
With these patterns, your Flutter TV app will provide a native experience for users worldwide.