diff --git a/lib/screens/livetv/dvr_recordings_screen.dart b/lib/screens/livetv/dvr_recordings_screen.dart index 78c87f84..7b1d625c 100644 --- a/lib/screens/livetv/dvr_recordings_screen.dart +++ b/lib/screens/livetv/dvr_recordings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../focus/focusable_wrapper.dart'; import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_scheduled_recording.dart'; @@ -102,14 +103,8 @@ class _DvrRecordingsScreenState extends State with SingleTi title: Text(t.liveTv.deleteSubscription), content: Text(t.liveTv.deleteSubscriptionConfirm), actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text(t.common.cancel), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text(t.common.delete), - ), + TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(t.common.cancel)), + FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(t.common.delete)), ], ), ); @@ -117,9 +112,7 @@ class _DvrRecordingsScreenState extends State with SingleTi if (confirmed != true || !mounted) return; final multiServer = context.read(); - final client = subscription.serverId != null - ? multiServer.getClientForServer(subscription.serverId!) - : null; + final client = subscription.serverId != null ? multiServer.getClientForServer(subscription.serverId!) : null; if (client != null) { final success = await client.deleteSubscription(subscription.key); @@ -132,9 +125,7 @@ class _DvrRecordingsScreenState extends State with SingleTi Future _editSubscription(LiveTvSubscription subscription) async { // Filter to visible settings only - final editableSettings = subscription.settings - .where((s) => s.hidden != true) - .toList(); + final editableSettings = subscription.settings.where((s) => s.hidden != true).toList(); if (editableSettings.isEmpty) return; @@ -145,19 +136,14 @@ class _DvrRecordingsScreenState extends State with SingleTi final result = await showDialog?>( context: context, - builder: (dialogContext) => _SubscriptionEditDialog( - subscription: subscription, - settings: editableSettings, - initialPrefs: prefs, - ), + builder: (dialogContext) => + _SubscriptionEditDialog(subscription: subscription, settings: editableSettings, initialPrefs: prefs), ); if (result == null || !mounted) return; final multiServer = context.read(); - final client = subscription.serverId != null - ? multiServer.getClientForServer(subscription.serverId!) - : null; + final client = subscription.serverId != null ? multiServer.getClientForServer(subscription.serverId!) : null; if (client != null) { final success = await client.editSubscription(subscription.key, result); @@ -192,27 +178,24 @@ class _DvrRecordingsScreenState extends State with SingleTi body: _isLoading ? const Center(child: CircularProgressIndicator()) : _error != null - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(_error!, style: theme.textTheme.bodyLarge), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: _loadData, - icon: const AppIcon(Symbols.refresh_rounded), - label: Text(t.common.retry), - ), - ], + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!, style: theme.textTheme.bodyLarge), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _loadData, + icon: const AppIcon(Symbols.refresh_rounded), + label: Text(t.common.retry), ), - ) - : TabBarView( - controller: _tabController, - children: [ - _buildSubscriptionsTab(theme), - _buildScheduledTab(theme), - ], - ), + ], + ), + ) + : TabBarView( + controller: _tabController, + children: [_buildSubscriptionsTab(theme), _buildScheduledTab(theme)], + ), ), ), ); @@ -228,42 +211,45 @@ class _DvrRecordingsScreenState extends State with SingleTi itemCount: _subscriptions.length, itemBuilder: (context, index) { final sub = _subscriptions[index]; - return _buildSubscriptionCard(sub, theme); + return FocusableWrapper( + autofocus: index == 0, + autoScroll: true, + useComfortableZone: true, + onSelect: () => _editSubscription(sub), + onBack: () => Navigator.pop(context), + child: _buildSubscriptionCard(sub, theme), + ); }, ); } Widget _buildSubscriptionCard(LiveTvSubscription subscription, ThemeData theme) { - return Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: ListTile( - leading: const AppIcon(Symbols.fiber_dvr_rounded, size: 32), - title: Text( - subscription.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: subscription.type != null - ? Text( - subscription.type!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + return ExcludeFocus( + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ListTile( + leading: const AppIcon(Symbols.fiber_dvr_rounded, size: 32), + title: Text(subscription.title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: subscription.type != null + ? Text( + subscription.type!, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ) + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (subscription.settings.isNotEmpty) + IconButton( + icon: const AppIcon(Symbols.settings_rounded), + onPressed: () => _editSubscription(subscription), ), - ) - : null, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (subscription.settings.isNotEmpty) IconButton( - icon: const AppIcon(Symbols.settings_rounded), - onPressed: () => _editSubscription(subscription), + icon: AppIcon(Symbols.delete_rounded, color: theme.colorScheme.error), + onPressed: () => _deleteSubscription(subscription), ), - IconButton( - icon: AppIcon(Symbols.delete_rounded, color: theme.colorScheme.error), - onPressed: () => _deleteSubscription(subscription), - ), - ], + ], + ), ), ), ); @@ -279,7 +265,13 @@ class _DvrRecordingsScreenState extends State with SingleTi itemCount: _scheduled.length, itemBuilder: (context, index) { final recording = _scheduled[index]; - return _buildScheduledCard(recording, theme); + return FocusableWrapper( + autofocus: index == 0, + autoScroll: true, + useComfortableZone: true, + onBack: () => Navigator.pop(context), + child: _buildScheduledCard(recording, theme), + ); }, ); } @@ -290,23 +282,19 @@ class _DvrRecordingsScreenState extends State with SingleTi ? '${startTime.month}/${startTime.day} ${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}' : ''; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: ListTile( - leading: const AppIcon(Symbols.fiber_manual_record_rounded, size: 32, color: Colors.red), - title: Text( - recording.displayTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - [ - if (recording.channelCallSign != null) recording.channelCallSign!, - timeStr, - if (recording.durationMinutes > 0) formatDurationTextual(recording.durationMinutes * 60000), - ].join(' · '), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + return ExcludeFocus( + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ListTile( + leading: const AppIcon(Symbols.fiber_manual_record_rounded, size: 32, color: Colors.red), + title: Text(recording.displayTitle, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + [ + if (recording.channelCallSign != null) recording.channelCallSign!, + timeStr, + if (recording.durationMinutes > 0) formatDurationTextual(recording.durationMinutes * 60000), + ].join(' · '), + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ), ), @@ -320,11 +308,7 @@ class _SubscriptionEditDialog extends StatefulWidget { final List settings; final Map initialPrefs; - const _SubscriptionEditDialog({ - required this.subscription, - required this.settings, - required this.initialPrefs, - }); + const _SubscriptionEditDialog({required this.subscription, required this.settings, required this.initialPrefs}); @override State<_SubscriptionEditDialog> createState() => _SubscriptionEditDialogState(); @@ -364,14 +348,8 @@ class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> { ), ), actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(null), - child: Text(t.common.cancel), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(_prefs), - child: Text(t.common.save), - ), + TextButton(onPressed: () => Navigator.of(context).pop(null), child: Text(t.common.cancel)), + FilledButton(onPressed: () => Navigator.of(context).pop(_prefs), child: Text(t.common.save)), ], ); } @@ -413,10 +391,7 @@ class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> { } // Default: text field - final controller = _textControllers.putIfAbsent( - setting.id, - () => TextEditingController(text: value), - ); + final controller = _textControllers.putIfAbsent(setting.id, () => TextEditingController(text: value)); return ListTile( title: Text(setting.label ?? setting.id), subtitle: TextField( diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 3d1d9373..1b4021a0 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../focus/dpad_navigator.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../mixins/tab_navigation_mixin.dart'; @@ -21,10 +23,17 @@ class LiveTvScreen extends StatefulWidget { State createState() => _LiveTvScreenState(); } -class _LiveTvScreenState extends State - with SingleTickerProviderStateMixin, TabNavigationMixin { +class _LiveTvScreenState extends State with SingleTickerProviderStateMixin, TabNavigationMixin { final _guideTabFocusNode = FocusNode(debugLabel: 'tab_chip_guide'); final _whatsOnTabFocusNode = FocusNode(debugLabel: 'tab_chip_whats_on'); + final _guideTabKey = GlobalKey(); + final _whatsOnTabKey = GlobalKey(); + + // App bar action button focus + final _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton'); + final _dvrButtonFocusNode = FocusNode(debugLabel: 'DvrButton'); + bool _isRefreshFocused = false; + bool _isDvrFocused = false; List _channels = []; bool _isLoading = true; @@ -38,6 +47,8 @@ class _LiveTvScreenState extends State super.initState(); suppressAutoFocus = true; initTabNavigation(); + _refreshButtonFocusNode.addListener(_onRefreshFocusChange); + _dvrButtonFocusNode.addListener(_onDvrFocusChange); _loadChannels(); } @@ -45,10 +56,22 @@ class _LiveTvScreenState extends State void dispose() { _guideTabFocusNode.dispose(); _whatsOnTabFocusNode.dispose(); + _refreshButtonFocusNode.removeListener(_onRefreshFocusChange); + _refreshButtonFocusNode.dispose(); + _dvrButtonFocusNode.removeListener(_onDvrFocusChange); + _dvrButtonFocusNode.dispose(); disposeTabNavigation(); super.dispose(); } + void _onRefreshFocusChange() { + if (mounted) setState(() => _isRefreshFocused = _refreshButtonFocusNode.hasFocus); + } + + void _onDvrFocusChange() { + if (mounted) setState(() => _isDvrFocused = _dvrButtonFocusNode.hasFocus); + } + @override void onTabChanged() { if (!tabController.indexIsChanging) { @@ -99,6 +122,12 @@ class _LiveTvScreenState extends State _channels = allChannels; _isLoading = false; }); + + if (allChannels.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focusCurrentTab(); + }); + } } catch (e) { appLogger.e('Failed to load Live TV channels', error: e); if (mounted) { @@ -111,17 +140,76 @@ class _LiveTvScreenState extends State } void _openRecordings() { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const DvrRecordingsScreen()), - ); + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DvrRecordingsScreen())); } void _focusCurrentTab() { + if (tabController.index == 0) { + _guideTabKey.currentState?.focusContent(); + } else if (tabController.index == 1) { + _whatsOnTabKey.currentState?.focusFirstHub(); + } setState(() { suppressAutoFocus = false; }); } + // --------------------------------------------------------------------------- + // Action button key handlers + // --------------------------------------------------------------------------- + + KeyEventResult _handleRefreshKeyEvent(FocusNode node, KeyEvent event) { + if (!event.isActionable) return KeyEventResult.ignored; + final key = event.logicalKey; + + if (key.isLeftKey) { + getTabChipFocusNode(tabCount - 1).requestFocus(); + return KeyEventResult.handled; + } + if (key.isRightKey) { + _dvrButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + if (key.isDownKey) { + _focusCurrentTab(); + return KeyEventResult.handled; + } + if (key.isUpKey) { + return KeyEventResult.handled; + } + if (key.isSelectKey) { + _loadChannels(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + KeyEventResult _handleDvrKeyEvent(FocusNode node, KeyEvent event) { + if (!event.isActionable) return KeyEventResult.ignored; + final key = event.logicalKey; + + if (key.isLeftKey) { + _refreshButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + if (key.isRightKey || key.isUpKey) { + return KeyEventResult.handled; + } + if (key.isDownKey) { + _focusCurrentTab(); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + _openRecordings(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + // --------------------------------------------------------------------------- + // Tab chips + // --------------------------------------------------------------------------- + Widget _buildTabChip(String label, int index) { final isSelected = tabController.index == index; @@ -157,12 +245,16 @@ class _LiveTvScreenState extends State }); getTabChipFocusNode(newIndex).requestFocus(); } - : null, + : () => _refreshButtonFocusNode.requestFocus(), onNavigateDown: _focusCurrentTab, onBack: onTabBarBack, ); } + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -180,68 +272,97 @@ class _LiveTvScreenState extends State ) : Text(t.liveTv.title), actions: [ - IconButton( - icon: const AppIcon(Symbols.refresh_rounded), - tooltip: t.liveTv.reloadGuide, - onPressed: _loadChannels, + Focus( + focusNode: _refreshButtonFocusNode, + onKeyEvent: _handleRefreshKeyEvent, + child: Container( + decoration: BoxDecoration( + color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: IconButton( + icon: const AppIcon(Symbols.refresh_rounded), + tooltip: t.liveTv.reloadGuide, + onPressed: _loadChannels, + ), + ), ), - IconButton( - icon: const AppIcon(Symbols.fiber_dvr_rounded), - tooltip: t.liveTv.recordings, - onPressed: _openRecordings, + Focus( + focusNode: _dvrButtonFocusNode, + onKeyEvent: _handleDvrKeyEvent, + child: Container( + decoration: BoxDecoration( + color: _isDvrFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: IconButton( + icon: const AppIcon(Symbols.fiber_dvr_rounded), + tooltip: t.liveTv.recordings, + onPressed: _openRecordings, + ), + ), ), ], ), body: _isLoading ? const Center(child: CircularProgressIndicator()) : _error != null - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon(Symbols.error_rounded, size: 48, color: theme.colorScheme.error), + const SizedBox(height: 16), + Text(_error!, style: theme.textTheme.bodyLarge), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _loadChannels, + icon: const AppIcon(Symbols.refresh_rounded), + label: Text(t.common.retry), + ), + ], + ), + ) + : _channels.isEmpty + ? Center(child: Text(t.liveTv.noChannels)) + : Column( + children: [ + if (!useSideNav) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildTabChip(t.liveTv.guide, 0), + const SizedBox(width: 8), + _buildTabChip(t.liveTv.whatsOn, 1), + ], + ), + ), + ), + Expanded( + child: TabBarView( + controller: tabController, children: [ - AppIcon(Symbols.error_rounded, - size: 48, color: theme.colorScheme.error), - const SizedBox(height: 16), - Text(_error!, style: theme.textTheme.bodyLarge), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: _loadChannels, - icon: const AppIcon(Symbols.refresh_rounded), - label: Text(t.common.retry), + GuideTab( + key: _guideTabKey, + channels: _channels, + onNavigateUp: focusTabBar, + onBack: onTabBarBack, + ), + WhatsOnTab( + key: _whatsOnTabKey, + channels: _channels, + onNavigateUp: focusTabBar, + onBack: onTabBarBack, ), ], ), - ) - : _channels.isEmpty - ? Center(child: Text(t.liveTv.noChannels)) - : Column( - children: [ - if (!useSideNav) - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - alignment: Alignment.centerLeft, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildTabChip(t.liveTv.guide, 0), - const SizedBox(width: 8), - _buildTabChip(t.liveTv.whatsOn, 1), - ], - ), - ), - ), - Expanded( - child: TabBarView( - controller: tabController, - children: [ - GuideTab(channels: _channels), - WhatsOnTab(channels: _channels), - ], - ), - ), - ], - ), + ), + ], + ), ); } } diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index f618e00e..ab68a9d1 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../focus/focusable_wrapper.dart'; +import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../models/livetv_program.dart'; import '../../providers/multi_server_provider.dart'; @@ -9,6 +11,7 @@ import '../../theme/mono_tokens.dart'; import '../../utils/formatters.dart'; import '../../utils/live_tv_player_navigation.dart'; import '../../utils/plex_image_helper.dart'; +import '../../widgets/app_icon.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import 'program_details_sheet.dart'; @@ -23,12 +26,7 @@ class LiveTvShowScheduleScreen extends StatefulWidget { /// Full channel list for tuning. final List channels; - const LiveTvShowScheduleScreen({ - super.key, - required this.showTitle, - required this.serverId, - required this.channels, - }); + const LiveTvShowScheduleScreen({super.key, required this.showTitle, required this.serverId, required this.channels}); @override State createState() => _LiveTvShowScheduleScreenState(); @@ -86,9 +84,8 @@ class _LiveTvShowScheduleScreenState extends State { Future _tuneChannel(LiveTvChannel channel) async { final multiServer = context.read(); - final serverInfo = multiServer.liveTvServers - .where((s) => s.serverId == channel.serverId) - .firstOrNull ?? + final serverInfo = + multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ?? multiServer.liveTvServers.firstOrNull; if (serverInfo == null) return; @@ -139,24 +136,27 @@ class _LiveTvShowScheduleScreenState extends State { SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms))) else SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final program = _programs[index]; - final channel = _findChannel(program.channelIdentifier); - return _ScheduleListTile( - program: program, - channel: channel, - onTap: () { - if (program.isCurrentlyAiring && channel != null) { - _tuneChannel(channel); - } else { - _showProgramDetails(program, channel); - } - }, - ); - }, - childCount: _programs.length, - ), + delegate: SliverChildBuilderDelegate((context, index) { + final program = _programs[index]; + final channel = _findChannel(program.channelIdentifier); + final onTap = () { + if (program.isCurrentlyAiring && channel != null) { + _tuneChannel(channel); + } else { + _showProgramDetails(program, channel); + } + }; + return FocusableWrapper( + autofocus: index == 0, + autoScroll: true, + useComfortableZone: true, + useBackgroundFocus: true, + disableScale: true, + onSelect: onTap, + onBack: () => Navigator.pop(context), + child: _ScheduleListTile(program: program, channel: channel, onTap: onTap), + ); + }, childCount: _programs.length), ), ], ); @@ -168,11 +168,7 @@ class _ScheduleListTile extends StatelessWidget { final LiveTvChannel? channel; final VoidCallback onTap; - const _ScheduleListTile({ - required this.program, - required this.channel, - required this.onTap, - }); + const _ScheduleListTile({required this.program, required this.channel, required this.onTap}); String _formatTimeInfo() { final now = DateTime.now(); @@ -228,14 +224,13 @@ class _ScheduleListTile extends StatelessWidget { ].join(' — '); return InkWell( + canRequestFocus: false, onTap: onTap, child: Container( decoration: isLive ? BoxDecoration( color: theme.colorScheme.primary.withValues(alpha: 0.08), - border: Border( - left: BorderSide(color: theme.colorScheme.primary, width: 3), - ), + border: Border(left: BorderSide(color: theme.colorScheme.primary, width: 3)), ) : null, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), @@ -247,17 +242,14 @@ class _ScheduleListTile extends StatelessWidget { Expanded( child: Text( titleText, - style: theme.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), + style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (isLive) ...[ const SizedBox(width: 8), - AppIcon(Symbols.play_circle_rounded, - size: 20, color: theme.colorScheme.primary), + AppIcon(Symbols.play_circle_rounded, size: 20, color: theme.colorScheme.primary), ], ], ), @@ -265,21 +257,14 @@ class _ScheduleListTile extends StatelessWidget { const SizedBox(height: 4), Text( subtitle, - style: theme.textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - ), + style: theme.textTheme.bodySmall?.copyWith(color: tokens(context).textMuted), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], if (channel != null) ...[ const SizedBox(height: 2), - Text( - channel!.displayName, - style: theme.textTheme.labelSmall?.copyWith( - color: tokens(context).textMuted, - ), - ), + Text(channel!.displayName, style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted)), ], ], ), diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index 1eac2144..ee8c23d6 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../models/livetv_program.dart'; import '../../utils/formatters.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/focusable_bottom_sheet.dart'; /// Shows a bottom sheet with program details and actions (Record, Watch Channel, Play). void showProgramDetailsSheet( @@ -15,12 +17,178 @@ void showProgramDetailsSheet( required String? posterUrl, required VoidCallback? onTuneChannel, }) { - final theme = Theme.of(context); - showModalBottomSheet( context: context, builder: (sheetContext) { - return Padding( + return _ProgramDetailsSheetContent( + program: program, + channel: channel, + posterUrl: posterUrl, + onTuneChannel: onTuneChannel, + ); + }, + ); +} + +class _ProgramDetailsSheetContent extends StatefulWidget { + final LiveTvProgram program; + final LiveTvChannel? channel; + final String? posterUrl; + final VoidCallback? onTuneChannel; + + const _ProgramDetailsSheetContent({ + required this.program, + required this.channel, + required this.posterUrl, + required this.onTuneChannel, + }); + + @override + State<_ProgramDetailsSheetContent> createState() => _ProgramDetailsSheetContentState(); +} + +class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent> { + final List _buttonFocusNodes = []; + + FocusNode get _initialFocusNode => _buttonFocusNodes.isNotEmpty ? _buttonFocusNodes.first : FocusNode(); + + @override + void initState() { + super.initState(); + _buildButtonFocusNodes(); + } + + @override + void dispose() { + for (final node in _buttonFocusNodes) { + node.dispose(); + } + super.dispose(); + } + + void _buildButtonFocusNodes() { + int count = 0; + if (widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++; + count++; // Record button always present + if (!widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++; + + for (int i = 0; i < count; i++) { + _buttonFocusNodes.add(FocusNode(debugLabel: 'program_sheet_btn_$i')); + } + } + + void _focusButton(int index) { + if (index >= 0 && index < _buttonFocusNodes.length) { + _buttonFocusNodes[index].requestFocus(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final program = widget.program; + final channel = widget.channel; + + // Build the list of action buttons with their focus wrappers + final buttons = []; + int buttonIndex = 0; + + if (program.isCurrentlyAiring && widget.onTuneChannel != null) { + final idx = buttonIndex; + buttons.add( + FocusableWrapper( + focusNode: _buttonFocusNodes[idx], + onSelect: () { + Navigator.of(context).pop(); + widget.onTuneChannel!(); + }, + onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, + onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, + onBack: () => Navigator.of(context).pop(), + borderRadius: 100, + useBackgroundFocus: true, + disableScale: true, + child: FilledButton.icon( + style: FilledButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () { + Navigator.of(context).pop(); + widget.onTuneChannel!(); + }, + icon: const AppIcon(Symbols.play_arrow_rounded), + label: Text(t.common.play), + ), + ), + ); + buttonIndex++; + } + + if (program.isCurrentlyAiring && widget.onTuneChannel != null) { + buttons.add(const SizedBox(width: 8)); + } + + // Record button + { + final idx = buttonIndex; + buttons.add( + FocusableWrapper( + focusNode: _buttonFocusNodes[idx], + onSelect: () { + Navigator.of(context).pop(); + // TODO: Record action + }, + onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, + onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, + onBack: () => Navigator.of(context).pop(), + borderRadius: 100, + useBackgroundFocus: true, + disableScale: true, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () { + Navigator.of(context).pop(); + // TODO: Record action + }, + icon: const AppIcon(Symbols.fiber_manual_record_rounded), + label: Text(t.liveTv.record), + ), + ), + ); + buttonIndex++; + } + + if (!program.isCurrentlyAiring && widget.onTuneChannel != null) { + buttons.add(const SizedBox(width: 8)); + final idx = buttonIndex; + buttons.add( + FocusableWrapper( + focusNode: _buttonFocusNodes[idx], + onSelect: () { + Navigator.of(context).pop(); + widget.onTuneChannel!(); + }, + onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, + onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, + onBack: () => Navigator.of(context).pop(), + borderRadius: 100, + useBackgroundFocus: true, + disableScale: true, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () { + Navigator.of(context).pop(); + widget.onTuneChannel!(); + }, + icon: const AppIcon(Symbols.live_tv_rounded), + label: Text(t.liveTv.watchChannel), + ), + ), + ); + buttonIndex++; + } + + return FocusableBottomSheet( + initialFocusNode: _initialFocusNode, + child: Padding( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, @@ -29,11 +197,11 @@ void showProgramDetailsSheet( Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (posterUrl != null) ...[ + if (widget.posterUrl != null) ...[ ClipRRect( borderRadius: BorderRadius.circular(6), child: Image.network( - posterUrl, + widget.posterUrl!, width: 80, height: 120, fit: BoxFit.cover, @@ -48,26 +216,14 @@ void showProgramDetailsSheet( children: [ Row( children: [ - Expanded( - child: Text( - program.displayTitle, - style: theme.textTheme.titleMedium, - ), - ), + Expanded(child: Text(program.displayTitle, style: theme.textTheme.titleMedium)), if (program.isCurrentlyAiring) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(4), - ), + decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(4)), child: Text( t.liveTv.live, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 11, - ), + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11), ), ), ], @@ -80,9 +236,7 @@ void showProgramDetailsSheet( '${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} - ${program.endTime!.hour.toString().padLeft(2, '0')}:${program.endTime!.minute.toString().padLeft(2, '0')}', if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60000), ].join(' · '), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), if (program.summary != null && program.summary!.isNotEmpty) ...[ const SizedBox(height: 12), @@ -99,42 +253,10 @@ void showProgramDetailsSheet( ], ), const SizedBox(height: 16), - Row( - children: [ - if (program.isCurrentlyAiring && onTuneChannel != null) - FilledButton.icon( - onPressed: () { - Navigator.of(sheetContext).pop(); - onTuneChannel(); - }, - icon: const AppIcon(Symbols.play_arrow_rounded), - label: Text(t.common.play), - ), - if (program.isCurrentlyAiring) const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () { - Navigator.of(sheetContext).pop(); - // TODO: Record action - }, - icon: const AppIcon(Symbols.fiber_manual_record_rounded), - label: Text(t.liveTv.record), - ), - if (!program.isCurrentlyAiring && onTuneChannel != null) ...[ - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () { - Navigator.of(sheetContext).pop(); - onTuneChannel(); - }, - icon: const AppIcon(Symbols.live_tv_rounded), - label: Text(t.liveTv.watchChannel), - ), - ], - ], - ), + Row(children: buttons), ], ), - ); - }, - ); + ), + ); + } } diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 21656f60..e1904f3e 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -1,9 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../../focus/dpad_navigator.dart'; +import '../../../focus/key_event_utils.dart'; import '../../../i18n/strings.g.dart'; import '../../../models/livetv_channel.dart'; import '../../../models/livetv_program.dart'; @@ -13,17 +16,22 @@ import '../../../utils/formatters.dart'; import '../../../utils/plex_image_helper.dart'; import '../../../utils/live_tv_player_navigation.dart'; import '../../../widgets/app_icon.dart'; +import '../program_details_sheet.dart'; class GuideTab extends StatefulWidget { final List channels; + final VoidCallback? onNavigateUp; + final VoidCallback? onBack; - const GuideTab({super.key, required this.channels}); + const GuideTab({super.key, required this.channels, this.onNavigateUp, this.onBack}); @override - State createState() => _GuideTabState(); + State createState() => GuideTabState(); } -class _GuideTabState extends State { +enum _GuideZone { timeNav, grid } + +class GuideTabState extends State { static const _slotWidth = 180.0; static const _channelColumnWidth = 140.0; static const _rowHeight = 64.0; @@ -39,11 +47,44 @@ class _GuideTabState extends State { final ScrollController _headerHorizontalController = ScrollController(); final ScrollController _gridHorizontalController = ScrollController(); final ScrollController _channelVerticalController = ScrollController(); + final ScrollController _gridVerticalController = ScrollController(); bool _syncingScroll = false; Timer? _timeIndicatorTimer; final _dayPickerKey = GlobalKey(); + // Focus state + final FocusNode _guideFocusNode = FocusNode(debugLabel: 'guide_tab'); + _GuideZone _focusZone = _GuideZone.timeNav; + int _timeNavIndex = 1; // 0=left arrow, 1=day picker, 2=right arrow + int _gridChannelIndex = 0; + int _gridColumn = 0; // 0=channel, 1=program + bool _hasFocus = false; + LiveTvProgram? _focusedProgram; + bool _pendingFocus = false; + + /// Focus into the guide content (called from tab bar navigation or initial load). + void focusContent() { + // If still loading programs, defer until the Focus widget is in the tree. + if (_isLoading) { + _pendingFocus = true; + return; + } + _pendingFocus = false; + _guideFocusNode.requestFocus(); + setState(() { + if (widget.channels.isNotEmpty) { + _focusZone = _GuideZone.grid; + _gridColumn = 0; + _gridChannelIndex = 0; + _focusedProgram = null; + } else { + _focusZone = _GuideZone.timeNav; + _timeNavIndex = 1; + } + }); + } + @override void initState() { super.initState(); @@ -58,6 +99,27 @@ class _GuideTabState extends State { }); } + @override + void didUpdateWidget(GuideTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.channels.isNotEmpty && _gridChannelIndex >= widget.channels.length) { + _gridChannelIndex = widget.channels.length - 1; + } + } + + @override + void dispose() { + _guideFocusNode.dispose(); + _gridVerticalController.dispose(); + _gridHorizontalController.removeListener(_syncGridToHeader); + _headerHorizontalController.removeListener(_syncHeaderToGrid); + _headerHorizontalController.dispose(); + _gridHorizontalController.dispose(); + _channelVerticalController.dispose(); + _timeIndicatorTimer?.cancel(); + super.dispose(); + } + void _syncGridToHeader() { if (_syncingScroll) return; _syncingScroll = true; @@ -76,17 +138,6 @@ class _GuideTabState extends State { _syncingScroll = false; } - @override - void dispose() { - _gridHorizontalController.removeListener(_syncGridToHeader); - _headerHorizontalController.removeListener(_syncHeaderToGrid); - _headerHorizontalController.dispose(); - _gridHorizontalController.dispose(); - _channelVerticalController.dispose(); - _timeIndicatorTimer?.cancel(); - super.dispose(); - } - void _initTimeRange() { final now = DateTime.now(); _gridStart = DateTime(now.year, now.month, now.day, now.hour); @@ -154,12 +205,20 @@ class _GuideTabState extends State { if (!mounted) return; + final shouldFocus = _pendingFocus; + setState(() { _programs = allPrograms; _isLoading = false; }); _scrollToNow(); + + if (shouldFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) focusContent(); + }); + } } catch (e) { appLogger.e('Failed to load guide programs', error: e); if (mounted) { @@ -215,6 +274,213 @@ class _GuideTabState extends State { ); } + // --------------------------------------------------------------------------- + // Focus key handling + // --------------------------------------------------------------------------- + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + final key = event.logicalKey; + + // Back key + if (key.isBackKey) { + if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } + if (_focusZone == _GuideZone.grid) { + if (event is KeyUpEvent) { + setState(() { + _focusZone = _GuideZone.timeNav; + _timeNavIndex = 1; + }); + } + return KeyEventResult.handled; + } + return handleBackKeyAction(event, () => widget.onBack?.call()); + } + + if (!event.isActionable) return KeyEventResult.ignored; + + if (_focusZone == _GuideZone.timeNav) { + return _handleTimeNavKey(key); + } else { + return _handleGridKey(key); + } + } + + KeyEventResult _handleTimeNavKey(LogicalKeyboardKey key) { + if (key.isLeftKey) { + if (_timeNavIndex > 0) { + setState(() => _timeNavIndex--); + } else { + widget.onBack?.call(); + } + return KeyEventResult.handled; + } + if (key.isRightKey) { + if (_timeNavIndex < 2) setState(() => _timeNavIndex++); + return KeyEventResult.handled; + } + if (key.isDownKey) { + if (widget.channels.isNotEmpty) { + setState(() { + _focusZone = _GuideZone.grid; + _gridColumn = 0; + _focusedProgram = null; + }); + _scrollToChannel(_gridChannelIndex); + } + return KeyEventResult.handled; + } + if (key.isUpKey) { + widget.onNavigateUp?.call(); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + switch (_timeNavIndex) { + case 0: + _shiftTimeRange(-2); + case 1: + _showDayPicker(); + case 2: + _shiftTimeRange(2); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + KeyEventResult _handleGridKey(LogicalKeyboardKey key) { + if (key.isUpKey) { + if (_gridChannelIndex > 0) { + setState(() { + _gridChannelIndex--; + if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); + }); + _scrollToChannel(_gridChannelIndex); + } else { + setState(() { + _focusZone = _GuideZone.timeNav; + _timeNavIndex = 1; + }); + } + return KeyEventResult.handled; + } + if (key.isDownKey) { + if (_gridChannelIndex < widget.channels.length - 1) { + setState(() { + _gridChannelIndex++; + if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); + }); + _scrollToChannel(_gridChannelIndex); + } + return KeyEventResult.handled; + } + if (key.isRightKey) { + if (_gridColumn == 0) { + final program = _findCurrentProgram(_gridChannelIndex); + if (program != null) { + setState(() { + _gridColumn = 1; + _focusedProgram = program; + }); + _scrollToProgramTime(program); + } + } + return KeyEventResult.handled; + } + if (key.isLeftKey) { + if (_gridColumn == 1) { + setState(() { + _gridColumn = 0; + _focusedProgram = null; + }); + } else { + widget.onBack?.call(); + } + return KeyEventResult.handled; + } + if (key.isSelectKey) { + if (_gridChannelIndex >= 0 && _gridChannelIndex < widget.channels.length) { + final channel = widget.channels[_gridChannelIndex]; + if (_gridColumn == 0) { + _tuneChannel(channel); + } else if (_focusedProgram != null) { + _showProgramDetails(channel, _focusedProgram!); + } + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + // --------------------------------------------------------------------------- + // Focus helpers + // --------------------------------------------------------------------------- + + LiveTvProgram? _findCurrentProgram(int channelIndex) { + if (channelIndex < 0 || channelIndex >= widget.channels.length) return null; + final channel = widget.channels[channelIndex]; + final programs = _getProgramsForChannel(channel); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Currently airing + for (final p in programs) { + if ((p.beginsAt ?? 0) <= now && (p.endsAt ?? 0) > now) return p; + } + // First future program + for (final p in programs) { + if ((p.endsAt ?? 0) > now) return p; + } + return programs.firstOrNull; + } + + void _scrollToChannel(int index) { + if (!_gridVerticalController.hasClients) return; + final targetTop = index * _rowHeight; + final targetBottom = targetTop + _rowHeight; + final viewportTop = _gridVerticalController.offset; + final viewportBottom = viewportTop + _gridVerticalController.position.viewportDimension; + + double? newOffset; + if (targetTop < viewportTop) { + newOffset = targetTop; + } else if (targetBottom > viewportBottom) { + newOffset = targetBottom - _gridVerticalController.position.viewportDimension; + } + + if (newOffset != null) { + final clamped = newOffset.clamp(0.0, _gridVerticalController.position.maxScrollExtent); + _gridVerticalController.jumpTo(clamped); + if (_channelVerticalController.hasClients) { + _channelVerticalController.jumpTo( + clamped.clamp(0.0, _channelVerticalController.position.maxScrollExtent), + ); + } + } + } + + void _scrollToProgramTime(LiveTvProgram? program) { + if (program == null || !_gridHorizontalController.hasClients) return; + + final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; + final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; + final progStart = (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch); + final startOffset = progStart - gridStartEpoch; + final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth; + + final viewportWidth = _gridHorizontalController.position.viewportDimension; + final currentOffset = _gridHorizontalController.offset; + + if (left < currentOffset || left > currentOffset + viewportWidth - 100) { + final maxScroll = _gridHorizontalController.position.maxScrollExtent; + _gridHorizontalController.jumpTo((left - 50).clamp(0.0, maxScroll)); + } + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -223,7 +489,12 @@ class _GuideTabState extends State { return const Center(child: CircularProgressIndicator()); } - return _buildGuideGrid(theme); + return Focus( + focusNode: _guideFocusNode, + onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), + onKeyEvent: _handleKeyEvent, + child: _buildGuideGrid(theme), + ); } Widget _buildGuideGrid(ThemeData theme) { @@ -257,7 +528,7 @@ class _GuideTabState extends State { itemCount: widget.channels.length, itemExtent: _rowHeight, itemBuilder: (context, index) => - _buildChannelCell(widget.channels[index], theme), + _buildChannelCell(widget.channels[index], theme, index: index), ), ), Expanded( @@ -279,12 +550,13 @@ class _GuideTabState extends State { child: SizedBox( width: _totalGridWidth(), child: ListView.builder( + controller: _gridVerticalController, itemCount: widget.channels.length, itemExtent: _rowHeight, itemBuilder: (context, index) { final channel = widget.channels[index]; final programs = _getProgramsForChannel(channel); - return _buildProgramRow(channel, programs, theme); + return _buildProgramRow(channel, programs, theme, channelIndex: index); }, ), ), @@ -382,9 +654,13 @@ class _GuideTabState extends State { }), ], ).then((value) { - if (value == null) return; + if (value == null) { + _guideFocusNode.requestFocus(); + return; + } if (value is String && value == 'now') { _jumpToNow(); + _guideFocusNode.requestFocus(); } else if (value is DateTime) { _showTimeSlotPicker(value); } @@ -421,7 +697,10 @@ class _GuideTabState extends State { }), ], ).then((value) { - if (value == null) return; + if (value == null) { + _guideFocusNode.requestFocus(); + return; + } if (value == -1) { _showDayPicker(); return; @@ -431,9 +710,26 @@ class _GuideTabState extends State { _gridEnd = _gridStart.add(const Duration(hours: 6)); }); _loadPrograms(); + _guideFocusNode.requestFocus(); }); } + // --------------------------------------------------------------------------- + // Time navigation bar + // --------------------------------------------------------------------------- + + Widget _timeNavFocusWrap({required Widget child, required int index, required ThemeData theme}) { + final isFocused = _hasFocus && _focusZone == _GuideZone.timeNav && _timeNavIndex == index; + if (!isFocused) return child; + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: child, + ); + } + Widget _buildTimeNavigation(ThemeData theme) { final format = MaterialLocalizations.of(context); final timeLabel = @@ -449,30 +745,41 @@ class _GuideTabState extends State { ), child: Row( children: [ - IconButton( - icon: const AppIcon(Symbols.chevron_left_rounded), - onPressed: () => _shiftTimeRange(-2), - iconSize: 20, - visualDensity: VisualDensity.compact, + _timeNavFocusWrap( + index: 0, + theme: theme, + child: IconButton( + icon: const AppIcon(Symbols.chevron_left_rounded), + onPressed: () => _shiftTimeRange(-2), + iconSize: 20, + visualDensity: VisualDensity.compact, + ), ), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - GestureDetector( - key: _dayPickerKey, - onTap: _showDayPicker, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - dayLabel, - style: theme.textTheme.labelLarge, + _timeNavFocusWrap( + index: 1, + theme: theme, + child: GestureDetector( + key: _dayPickerKey, + onTap: _showDayPicker, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + dayLabel, + style: theme.textTheme.labelLarge, + ), + const SizedBox(width: 2), + AppIcon(Symbols.arrow_drop_down_rounded, + size: 18, color: theme.colorScheme.onSurface), + ], ), - const SizedBox(width: 2), - AppIcon(Symbols.arrow_drop_down_rounded, - size: 18, color: theme.colorScheme.onSurface), - ], + ), ), ), const SizedBox(width: 8), @@ -483,17 +790,25 @@ class _GuideTabState extends State { ], ), ), - IconButton( - icon: const AppIcon(Symbols.chevron_right_rounded), - onPressed: () => _shiftTimeRange(2), - iconSize: 20, - visualDensity: VisualDensity.compact, + _timeNavFocusWrap( + index: 2, + theme: theme, + child: IconButton( + icon: const AppIcon(Symbols.chevron_right_rounded), + onPressed: () => _shiftTimeRange(2), + iconSize: 20, + visualDensity: VisualDensity.compact, + ), ), ], ), ); } + // --------------------------------------------------------------------------- + // Time header & now indicator + // --------------------------------------------------------------------------- + Widget _buildTimeHeader(ThemeData theme) { final slots = []; var current = _gridStart; @@ -545,7 +860,11 @@ class _GuideTabState extends State { ); } - Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme) { + // --------------------------------------------------------------------------- + // Channel column + // --------------------------------------------------------------------------- + + Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) { final multiServer = context.read(); final client = multiServer.getClientForServer(channel.serverId ?? ''); @@ -561,6 +880,8 @@ class _GuideTabState extends State { ); } + final isFocused = _hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 0 && _gridChannelIndex == index; + return _ChannelCell( rowHeight: _rowHeight, channelColumnWidth: _channelColumnWidth, @@ -568,6 +889,7 @@ class _GuideTabState extends State { channel: channel, theme: theme, onTap: () => _tuneChannel(channel), + isFocused: isFocused, fallbackBuilder: () => _buildChannelNameFallback(channel, theme), ); } @@ -595,8 +917,13 @@ class _GuideTabState extends State { ); } + // --------------------------------------------------------------------------- + // Program grid + // --------------------------------------------------------------------------- + Widget _buildProgramRow( - LiveTvChannel channel, List programs, ThemeData theme) { + LiveTvChannel channel, List programs, ThemeData theme, + {required int channelIndex}) { if (programs.isEmpty) { return Container( height: _rowHeight, @@ -620,6 +947,14 @@ class _GuideTabState extends State { final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; + // Determine which program is focused in this row + final focusProg = (_hasFocus && + _focusZone == _GuideZone.grid && + _gridColumn == 1 && + _gridChannelIndex == channelIndex) + ? _focusedProgram + : null; + for (final program in programs) { final progStart = (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch); @@ -639,7 +974,11 @@ class _GuideTabState extends State { width: width.clamp(2.0, double.infinity), top: 0, bottom: 0, - child: _buildProgramBlock(channel, program, theme, isLast: program == programs.last), + child: _buildProgramBlock( + channel, program, theme, + isLast: program == programs.last, + isFocused: identical(program, focusProg), + ), ), ); } @@ -661,7 +1000,8 @@ class _GuideTabState extends State { } Widget _buildProgramBlock( - LiveTvChannel channel, LiveTvProgram program, ThemeData theme, {bool isLast = false}) { + LiveTvChannel channel, LiveTvProgram program, ThemeData theme, + {bool isLast = false, bool isFocused = false}) { final isCurrentlyAiring = program.isCurrentlyAiring; final isPast = program.endsAt != null && program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -669,71 +1009,83 @@ class _GuideTabState extends State { return Opacity( opacity: isPast ? 0.5 : 1.0, child: Material( - color: isCurrentlyAiring - ? theme.colorScheme.primaryContainer - : theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(4), - child: InkWell( - borderRadius: BorderRadius.circular(4), - onTap: () => _showProgramDetails(channel, program), - child: Container( - decoration: BoxDecoration( - border: Border( - left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), - right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none, - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - program.grandparentTitle ?? program.title, - style: theme.textTheme.bodySmall?.copyWith( - fontWeight: - isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal, - color: isCurrentlyAiring - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurface, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + color: isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.25) + : isCurrentlyAiring + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: isFocused + ? BorderSide(color: theme.colorScheme.primary, width: 2) + : BorderSide.none, + ), + child: InkWell( + canRequestFocus: false, + borderRadius: BorderRadius.circular(4), + onTap: () => _showProgramDetails(channel, program), + child: Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), + right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none, ), - if (program.grandparentTitle != null) + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ Text( - '${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}', - style: theme.textTheme.labelSmall?.copyWith( - color: isCurrentlyAiring - ? theme.colorScheme.onPrimaryContainer - .withValues(alpha: 0.7) - : theme.colorScheme.onSurfaceVariant, + program.grandparentTitle ?? program.title, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: + isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal, + color: isFocused + ? theme.colorScheme.primary + : isCurrentlyAiring + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurface, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (program.startTime != null) - Text( - '${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}', - style: theme.textTheme.labelSmall?.copyWith( - color: isCurrentlyAiring - ? theme.colorScheme.onPrimaryContainer - .withValues(alpha: 0.7) - : theme.colorScheme.onSurfaceVariant, + if (program.grandparentTitle != null) + Text( + '${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}', + style: theme.textTheme.labelSmall?.copyWith( + color: isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.7) + : isCurrentlyAiring + ? theme.colorScheme.onPrimaryContainer + .withValues(alpha: 0.7) + : theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - ), - ], + if (program.startTime != null) + Text( + '${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}', + style: theme.textTheme.labelSmall?.copyWith( + color: isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.7) + : isCurrentlyAiring + ? theme.colorScheme.onPrimaryContainer + .withValues(alpha: 0.7) + : theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + ), + ], + ), ), ), ), - ), ); } void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) { - final theme = Theme.of(context); - final multiServer = context.read(); final client = multiServer.getClientForServer(channel.serverId ?? ''); String? posterUrl; @@ -748,109 +1100,12 @@ class _GuideTabState extends State { ); } - showModalBottomSheet( - context: context, - builder: (sheetContext) { - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (posterUrl != null) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Image.network( - posterUrl, - width: 80, - height: 120, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), - ), - ), - const SizedBox(width: 14), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - program.displayTitle, - style: theme.textTheme.titleMedium, - ), - ), - if (program.isCurrentlyAiring) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - t.liveTv.live, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 11), - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - '${channel.displayName} · ${program.startTime?.hour.toString().padLeft(2, '0')}:${program.startTime?.minute.toString().padLeft(2, '0')} - ${program.endTime?.hour.toString().padLeft(2, '0')}:${program.endTime?.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}', - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - if (program.summary != null && - program.summary!.isNotEmpty) ...[ - const SizedBox(height: 12), - Text( - program.summary!, - style: theme.textTheme.bodyMedium, - maxLines: 4, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - if (program.isCurrentlyAiring) - FilledButton.icon( - onPressed: () { - Navigator.of(sheetContext).pop(); - _tuneChannel(channel); - }, - icon: const AppIcon(Symbols.play_arrow_rounded), - label: Text(t.common.play), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () { - Navigator.of(sheetContext).pop(); - // TODO: Record action - }, - icon: const AppIcon(Symbols.fiber_manual_record_rounded), - label: Text(t.liveTv.record), - ), - ], - ), - ], - ), - ); - }, + showProgramDetailsSheet( + context, + program: program, + channel: channel, + posterUrl: posterUrl, + onTuneChannel: () => _tuneChannel(channel), ); } } @@ -862,6 +1117,7 @@ class _ChannelCell extends StatefulWidget { final LiveTvChannel channel; final ThemeData theme; final VoidCallback onTap; + final bool isFocused; final Widget Function() fallbackBuilder; const _ChannelCell({ @@ -871,6 +1127,7 @@ class _ChannelCell extends StatefulWidget { required this.channel, required this.theme, required this.onTap, + required this.isFocused, required this.fallbackBuilder, }); @@ -884,13 +1141,17 @@ class _ChannelCellState extends State<_ChannelCell> { @override Widget build(BuildContext context) { final theme = widget.theme; + final showAction = _hovered || widget.isFocused; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: Material( - color: Colors.transparent, + color: widget.isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.15) + : Colors.transparent, child: InkWell( + canRequestFocus: false, onTap: widget.onTap, child: Container( height: widget.rowHeight, @@ -907,7 +1168,7 @@ class _ChannelCellState extends State<_ChannelCell> { alignment: Alignment.center, children: [ AnimatedOpacity( - opacity: _hovered ? 0.3 : 1.0, + opacity: showAction ? 0.3 : 1.0, duration: const Duration(milliseconds: 150), child: widget.imageUrl != null && widget.imageUrl!.isNotEmpty ? Image.network( @@ -920,7 +1181,7 @@ class _ChannelCellState extends State<_ChannelCell> { ) : widget.fallbackBuilder(), ), - if (_hovered) + if (showAction) AppIcon( Symbols.play_arrow_rounded, size: 32, diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 76d63d6a..c946c0d9 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -1,9 +1,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../../focus/dpad_navigator.dart'; +import '../../../focus/key_event_utils.dart'; +import '../../../focus/locked_hub_controller.dart'; +import '../../../i18n/strings.g.dart'; import '../../../models/livetv_channel.dart'; import '../../../models/livetv_hub_result.dart'; import '../../../providers/multi_server_provider.dart'; @@ -16,6 +21,7 @@ import '../../../utils/live_tv_player_navigation.dart'; import '../../../utils/plex_image_helper.dart'; import '../../../utils/provider_extensions.dart'; import '../../../widgets/app_icon.dart'; +import '../../../widgets/focus_builders.dart'; import '../../../widgets/horizontal_scroll_with_arrows.dart'; import '../../../widgets/plex_optimized_image.dart'; import '../live_tv_show_schedule_screen.dart'; @@ -23,17 +29,20 @@ import '../program_details_sheet.dart'; class WhatsOnTab extends StatefulWidget { final List channels; + final VoidCallback? onNavigateUp; + final VoidCallback? onBack; - const WhatsOnTab({super.key, required this.channels}); + const WhatsOnTab({super.key, required this.channels, this.onNavigateUp, this.onBack}); @override - State createState() => _WhatsOnTabState(); + State createState() => WhatsOnTabState(); } -class _WhatsOnTabState extends State { +class WhatsOnTabState extends State { List _hubs = []; bool _isLoading = true; Timer? _refreshTimer; + List> _hubKeys = []; @override void initState() { @@ -70,6 +79,7 @@ class _WhatsOnTabState extends State { if (!mounted) return; setState(() { _hubs = allHubs; + _hubKeys = List.generate(allHubs.length, (_) => GlobalKey<_LiveTvHubSectionState>()); _isLoading = false; }); } catch (e) { @@ -78,6 +88,36 @@ class _WhatsOnTabState extends State { } } + /// Focus the first hub (called from parent when tab bar navigates down) + void focusFirstHub() { + if (_hubKeys.isNotEmpty) { + _hubKeys[0].currentState?.requestFocusFromMemory(); + } + } + + bool _handleVerticalNavigation(int hubIndex, bool isUp) { + if (_hubKeys.isEmpty) return false; + + if (isUp && hubIndex == 0) { + widget.onNavigateUp?.call(); + return true; + } + + final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1; + + if (targetIndex < 0 || targetIndex >= _hubKeys.length) { + return true; // At boundary, consume the event + } + + final targetState = _hubKeys[targetIndex].currentState; + if (targetState != null) { + targetState.requestFocusFromMemory(); + return true; + } + + return false; + } + /// Find a channel by its identifier from the channel list. LiveTvChannel? _findChannel(String? channelIdentifier) { if (channelIdentifier == null) return null; @@ -88,9 +128,8 @@ class _WhatsOnTabState extends State { Future _tuneChannel(LiveTvChannel channel) async { final multiServer = context.read(); - final serverInfo = multiServer.liveTvServers - .where((s) => s.serverId == channel.serverId) - .firstOrNull ?? + final serverInfo = + multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ?? multiServer.liveTvServers.firstOrNull; if (serverInfo == null) return; @@ -173,9 +212,12 @@ class _WhatsOnTabState extends State { itemCount: _hubs.length, itemBuilder: (context, index) { return _LiveTvHubSection( + key: _hubKeys[index], hub: _hubs[index], onTap: _onItemTap, onLongPress: (entry) => _showProgramDetails(entry, _findChannel(entry.program.channelIdentifier)), + onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), + onBack: widget.onBack, ); }, ); @@ -184,21 +226,228 @@ class _WhatsOnTabState extends State { // --------------------------------------------------------------------------- // Hub section — horizontal scrolling row of poster cards (always 2:3 aspect) +// Uses locked focus pattern: single Focus node at hub level, visual index in state. // --------------------------------------------------------------------------- -class _LiveTvHubSection extends StatelessWidget { +class _LiveTvHubSection extends StatefulWidget { final LiveTvHubResult hub; final void Function(LiveTvHubEntry) onTap; final void Function(LiveTvHubEntry) onLongPress; + final bool Function(bool isUp)? onVerticalNavigation; + final VoidCallback? onBack; const _LiveTvHubSection({ + super.key, required this.hub, required this.onTap, required this.onLongPress, + this.onVerticalNavigation, + this.onBack, }); + @override + State<_LiveTvHubSection> createState() => _LiveTvHubSectionState(); +} + +class _LiveTvHubSectionState extends State<_LiveTvHubSection> { + static const _longPressDuration = Duration(milliseconds: 500); + + late FocusNode _hubFocusNode; + final ScrollController _scrollController = ScrollController(); + + int _focusedIndex = 0; + double _itemExtent = 0; + static const double _leadingPadding = 12.0; + + Timer? _longPressTimer; + bool _isSelectKeyDown = false; + bool _longPressTriggered = false; + + @override + void initState() { + super.initState(); + _hubFocusNode = FocusNode(debugLabel: 'livetv_hub_${widget.hub.hubKey}'); + _hubFocusNode.addListener(_onFocusChange); + } + + @override + void didUpdateWidget(_LiveTvHubSection oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.hub.entries.length != oldWidget.hub.entries.length) { + final maxIndex = widget.hub.entries.isEmpty ? 0 : widget.hub.entries.length - 1; + if (_focusedIndex > maxIndex) { + _focusedIndex = maxIndex; + } + } + } + + @override + void dispose() { + _longPressTimer?.cancel(); + _hubFocusNode.removeListener(_onFocusChange); + _hubFocusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _onFocusChange() { + if (!_hubFocusNode.hasFocus) { + _longPressTimer?.cancel(); + _isSelectKeyDown = false; + _longPressTriggered = false; + } + if (mounted) setState(() {}); + } + + void requestFocusAt(int index) { + if (widget.hub.entries.isEmpty) return; + + final clamped = index.clamp(0, widget.hub.entries.length - 1); + _focusedIndex = clamped; + HubFocusMemory.setForHub(widget.hub.hubKey, clamped); + _scrollToIndex(clamped); + _hubFocusNode.requestFocus(); + if (mounted) setState(() {}); + _scrollHubIntoView(); + } + + void requestFocusFromMemory() { + final index = HubFocusMemory.getForHub(widget.hub.hubKey, widget.hub.entries.length); + requestFocusAt(index); + } + + void _scrollHubIntoView() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + Scrollable.ensureVisible( + context, + alignment: 0.3, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + }); + } + + void _scrollToIndex(int index, {bool animate = true}) { + if (!_scrollController.hasClients || _itemExtent <= 0) return; + + final viewport = _scrollController.position.viewportDimension; + final targetCenter = _leadingPadding + (index * _itemExtent) + (_itemExtent / 2); + final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, _scrollController.position.maxScrollExtent); + + if (animate) { + _scrollController.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); + } else { + _scrollController.jumpTo(desiredOffset); + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + final key = event.logicalKey; + + if (key.isSelectKey) { + if (event is KeyDownEvent) { + if (!_isSelectKeyDown) { + _isSelectKeyDown = true; + _longPressTriggered = false; + _longPressTimer?.cancel(); + _longPressTimer = Timer(_longPressDuration, () { + if (!mounted) return; + if (_isSelectKeyDown) { + _longPressTriggered = true; + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + _activateLongPress(); + } + }); + } + return KeyEventResult.handled; + } else if (event is KeyRepeatEvent) { + return KeyEventResult.handled; + } else if (event is KeyUpEvent) { + final timerWasActive = _longPressTimer?.isActive ?? false; + _longPressTimer?.cancel(); + if (!_longPressTriggered && timerWasActive && _isSelectKeyDown) { + _activateCurrentItem(); + } + _isSelectKeyDown = false; + _longPressTriggered = false; + return KeyEventResult.handled; + } + } + + if (widget.onBack != null) { + final backResult = handleBackKeyAction(event, widget.onBack!); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + } + + if (!event.isActionable) { + return KeyEventResult.ignored; + } + + final itemCount = widget.hub.entries.length; + if (itemCount == 0) return KeyEventResult.ignored; + + if (key.isLeftKey) { + if (_focusedIndex > 0) { + _focusedIndex--; + HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex); + _scrollToIndex(_focusedIndex); + setState(() {}); + } else { + widget.onBack?.call(); + } + return KeyEventResult.handled; + } + + if (key.isRightKey) { + if (_focusedIndex < itemCount - 1) { + _focusedIndex++; + HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex); + _scrollToIndex(_focusedIndex); + setState(() {}); + } + return KeyEventResult.handled; + } + + if (key.isUpKey) { + widget.onVerticalNavigation?.call(true); + return KeyEventResult.handled; + } + if (key.isDownKey) { + widget.onVerticalNavigation?.call(false); + return KeyEventResult.handled; + } + + if (key.isContextMenuKey) { + _activateLongPress(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + void _activateCurrentItem() { + if (_focusedIndex >= widget.hub.entries.length) return; + widget.onTap(widget.hub.entries[_focusedIndex]); + } + + void _activateLongPress() { + if (_focusedIndex >= widget.hub.entries.length) return; + widget.onLongPress(widget.hub.entries[_focusedIndex]); + } + + void _onItemTapped(int index) { + _focusedIndex = index; + HubFocusMemory.setForHub(widget.hub.hubKey, index); + _hubFocusNode.requestFocus(); + setState(() {}); + } + @override Widget build(BuildContext context) { + final hasFocus = _hubFocusNode.hasFocus; final settings = context.watch(); final densityScale = switch (settings.libraryDensity) { LibraryDensity.compact => 0.8, @@ -220,7 +469,7 @@ class _LiveTvHubSection extends StatelessWidget { const SizedBox(width: 8), Flexible( child: Text( - hub.title, + widget.hub.title, style: Theme.of(context).textTheme.titleLarge, overflow: TextOverflow.ellipsis, maxLines: 1, @@ -230,50 +479,67 @@ class _LiveTvHubSection extends StatelessWidget { ), ), - // Horizontal cards — always poster (2:3) aspect - LayoutBuilder( - builder: (context, constraints) { - final screenWidth = constraints.maxWidth; - final baseCardWidth = (ScreenBreakpoints.isLargeDesktop(screenWidth) - ? 220.0 - : ScreenBreakpoints.isDesktop(screenWidth) + // Horizontal cards with locked focus control + if (widget.hub.entries.isNotEmpty) + Focus( + focusNode: _hubFocusNode, + onKeyEvent: _handleKeyEvent, + child: LayoutBuilder( + builder: (context, constraints) { + final screenWidth = constraints.maxWidth; + final baseCardWidth = + (ScreenBreakpoints.isLargeDesktop(screenWidth) + ? 220.0 + : ScreenBreakpoints.isDesktop(screenWidth) ? 200.0 : ScreenBreakpoints.isWideTablet(screenWidth) - ? 190.0 - : 160.0) * - densityScale; + ? 190.0 + : 160.0) * + densityScale; - final cardWidth = baseCardWidth; - final posterWidth = cardWidth - 16; - final posterHeight = posterWidth * 1.5; // 2:3 aspect - final containerHeight = posterHeight + 66; + final cardWidth = baseCardWidth; + final posterWidth = cardWidth - 16; + final posterHeight = posterWidth * 1.5; // 2:3 aspect + final containerHeight = posterHeight + 66; + _itemExtent = cardWidth + 4; - return SizedBox( - height: containerHeight, - child: HorizontalScrollWithArrows( - builder: (scrollController) => ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - itemCount: hub.entries.length, - itemBuilder: (context, index) { - final entry = hub.entries[index]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: _LiveTvPosterCard( - entry: entry, - width: cardWidth, - posterHeight: posterHeight, - onTap: () => onTap(entry), - onLongPress: () => onLongPress(entry), - ), - ); - }, - ), - ), - ); - }, - ), + return SizedBox( + height: containerHeight, + child: HorizontalScrollWithArrows( + controller: _scrollController, + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + itemCount: widget.hub.entries.length, + itemBuilder: (context, index) { + final entry = widget.hub.entries[index]; + final isItemFocused = hasFocus && index == _focusedIndex; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: _LiveTvPosterCard( + entry: entry, + width: cardWidth, + posterHeight: posterHeight, + isFocused: isItemFocused, + onTap: () { + _onItemTapped(index); + widget.onTap(entry); + }, + onLongPress: () { + _onItemTapped(index); + widget.onLongPress(entry); + }, + ), + ); + }, + ), + ), + ); + }, + ), + ), ], ); } @@ -287,6 +553,7 @@ class _LiveTvPosterCard extends StatelessWidget { final LiveTvHubEntry entry; final double width; final double posterHeight; + final bool isFocused; final VoidCallback onTap; final VoidCallback onLongPress; @@ -294,6 +561,7 @@ class _LiveTvPosterCard extends StatelessWidget { required this.entry, required this.width, required this.posterHeight, + required this.isFocused, required this.onTap, required this.onLongPress, }); @@ -304,13 +572,13 @@ class _LiveTvPosterCard extends StatelessWidget { // Always use poster image: show poster for episodes, thumb for others final posterImage = metadata.grandparentThumb ?? metadata.thumb; - return SizedBox( - width: width, - child: InkWell( - canRequestFocus: false, - onTap: onTap, - onLongPress: onLongPress, - borderRadius: BorderRadius.circular(tokens(context).radiusSm), + return FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + onTap: onTap, + onLongPress: onLongPress, + child: SizedBox( + width: width, child: Padding( padding: const EdgeInsets.all(8), child: Column( @@ -337,11 +605,7 @@ class _LiveTvPosterCard extends StatelessWidget { metadata.displayTitle, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 13, - height: 1.1, - ), + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, height: 1.1), ), // Subtitle if (metadata.displaySubtitle != null) @@ -349,11 +613,9 @@ class _LiveTvPosterCard extends StatelessWidget { metadata.displaySubtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 11, height: 1.1), ), ], ),