← Back to Blog

Flutter TV App Localization: Complete Guide for Android TV, Apple TV & Fire TV

fluttertvandroid-tvapple-tvfire-tvlocalization

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:

  1. 10-foot UI typography - Larger text, script-aware sizing
  2. Remote navigation - Localized focus hints and D-pad support
  3. Voice commands - Platform-specific voice integration
  4. Media languages - Audio and subtitle selection
  5. RTL support - Proper layout mirroring for TV interfaces
  6. Platform banners - Localized launcher assets

With these patterns, your Flutter TV app will provide a native experience for users worldwide.

Related Resources