Stack: Flutter (Dart), targeting Android TV and tvOS. Platforms: Android TV, Apple TV (tvOS).
All source lives under flutter_client/. Run every command from that directory.
This is the TV frontend for the m3u-editor system. It focuses on video playback (Live TV/VOD) and EPG (Electronic Program Guide) rendering. The app is D-pad driven — no touch input assumed on TV targets.
- Navigation:
dpadpackage v3 (Shortcuts + Actions based spatial traversal) +Navigatorfor in-content routing. - State: Riverpod 2 (
flutter_riverpod). Providers live inlib/providers/app_providers.dart.AppStateControlleris the underlying orchestrator — Riverpod owns a thin_AppStateProxywrapper so widgets never referenceAppStateControllerdirectly. - Player:
video_player/ platform player viaPlaybackOrchestrator. - UI: Material 3,
DpadFocusablefor all interactive items,DpadRegionfor focus grouping.
- Tappable custom widgets: Use
DpadInkWell(lib/shared/dpad_ink_well.dart) instead of the manualDpadFocusable + Material + InkWelltriple. It bakes in the fast-tap focus fix (explicitFocusNode.requestFocus()before the action) and auto-matches the border radius to theGradientBorderEffect. SupportsonLongTapfor D-pad hold and touch long-press simultaneously. - Material buttons (
FilledButton,IconButton, etc.): Wrap in plainDpadFocusable— the button provides its own ink/ripple. - Border effects:
GradientBorderEffect(borderRadius: …)matching the widget's corner radius. Pill/stadium →circular(50). Cards →circular(8).DpadInkWellderives this automatically from itsborderRadiusparameter wheneffectsis not set. - Edge navigation: Leaf
DpadRegions usehorizontalEdge: DpadEdgeBehavior.stop+onEdgeto activate the sidebar on left-edge press. - Back handling: Handled globally in
AppShellviaShortcutsmapping Escape / GoBack →_BackIntent.
The app uses Flutter gen_l10n. All user-visible strings must be localized — no hard-coded string literals in widget trees.
- ARB files:
lib/l10n/app_en.arb(source of truth) +app_de.arb,app_es.arb,app_fr.arb,app_zh.arb. - Usage:
AppLocalizations.of(context).<key>— throws if delegates are missing, so always addlocalizationsDelegates: AppLocalizations.localizationsDelegatesto everyMaterialApp(including test helpers). - Generated files: Run
flutter gen-l10nafter adding/changing ARB keys; never editlib/l10n/app_localizations*.dartdirectly. - Import order:
package:m3u_tv/l10n/app_localizations.dartsorts underl10n/— place it after allfeatures/imports and beforenavigation/imports. - Tests: Every
MaterialAppthat renders a localized widget must includelocalizationsDelegates: AppLocalizations.localizationsDelegatesandsupportedLocales: AppLocalizations.supportedLocales. - New keys: Add to all five ARBs before running gen-l10n. Keys follow the
<screen><Concept>pattern (e.g.settingsAccount,liveTvRecord).
The app uses Riverpod 2 for reactive UI state. All new feature screens must follow this pattern:
Reading data — always use providers, never AppStateController directly:
class MyScreen extends ConsumerStatefulWidget { ... }
class _MyScreenState extends ConsumerState<MyScreen> {
@override
Widget build(BuildContext context) {
final channels = ref.watch(liveChannelsProvider); // ✓
// NOT: widget.appState.channels // ✗
}
}Existing providers (all in lib/providers/app_providers.dart):
- Data:
liveChannelsProvider,liveCategoriesProvider,vodItemsProvider,vodCategoriesProvider,seriesListProvider,seriesCategoriesProvider - Status:
isConfiguredProvider,isBootstrappingProvider,isLoadingContentProvider - Services (stable, use
ref.read):epgServiceProvider,liveFavoritesServiceProvider,vodFavoritesServiceProvider,seriesFavoritesServiceProvider - Home extras:
progressListProvider,dvrRecordingsProvider,sourceLabelProvider,sourceErrorProvider,hasDvrFeatureProvider
Adding a new provider — add it to app_providers.dart using ref.watch(appStateControllerProvider) so it reacts to AppStateController.notifyListeners():
final myNewProvider = Provider<MyType>((ref) {
return ref.watch(appStateControllerProvider).appState.myNewField;
});Actions / mutations still go through AppStateController directly (passed as callbacks from AppShell). Screens receive action callbacks as constructor params — they do not call ref.read(appStateControllerProvider).appState.doSomething().
Tests — wrap with ProviderScope and override only the providers the screen watches:
ProviderScope(
overrides: [
isConfiguredProvider.overrideWith((_) => true),
liveChannelsProvider.overrideWith((_) => fakeChannels),
],
child: MaterialApp(home: MyScreen(...)),
)- Material 3 throughout. No
OutlinedButton— useFilledButton,FilledButton.tonal,FilledButton.icon, orFilledButton.tonalIcon. - Match existing file conventions. No new comments unless the WHY is non-obvious.
- Analyze:
cd flutter_client && flutter analyze lib test(scoped to avoid the analyzer treating vendored SPM checkouts underios/build//macos/build/— e.g.firebase_messaging's ownpubspec.yaml— as a nested project to lint;analyzer.excludeglobs don't suppress this once a directory has its ownpubspec.yaml) - Test:
cd flutter_client && flutter test - Run (Android TV):
cd flutter_client && flutter run -d <device-id>