diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index ceeca847..905d206f 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -6,6 +6,7 @@ import '../../focus/dpad_navigator.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../models/livetv_dvr.dart'; +import '../../mixins/refreshable.dart'; import '../../mixins/tab_navigation_mixin.dart'; import '../../providers/multi_server_provider.dart'; import '../../utils/app_logger.dart'; @@ -23,7 +24,9 @@ class LiveTvScreen extends StatefulWidget { State createState() => _LiveTvScreenState(); } -class _LiveTvScreenState extends State with SingleTickerProviderStateMixin, TabNavigationMixin { +class _LiveTvScreenState extends State + with SingleTickerProviderStateMixin, TabNavigationMixin + implements FocusableTab { final _guideTabFocusNode = FocusNode(debugLabel: 'tab_chip_guide'); final _whatsOnTabFocusNode = FocusNode(debugLabel: 'tab_chip_whats_on'); final _guideTabKey = GlobalKey(); @@ -216,6 +219,9 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt }); } + @override + void focusActiveTabIfReady() => _focusCurrentTab(); + // --------------------------------------------------------------------------- // Action button key handlers // --------------------------------------------------------------------------- diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 5e84a8cf..4f4ba172 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -375,15 +375,21 @@ class GuideTabState extends State { }); _scrollToProgramTime(program); } + } else { + // Already in program column — move to next program + _navigateToAdjacentProgram(_gridChannelIndex, forward: true); } return KeyEventResult.handled; } if (key.isLeftKey) { if (_gridColumn == 1) { - setState(() { - _gridColumn = 0; - _focusedProgram = null; - }); + // Try moving to previous program; if at first program, go back to channel column + if (!_navigateToAdjacentProgram(_gridChannelIndex, forward: false)) { + setState(() { + _gridColumn = 0; + _focusedProgram = null; + }); + } } else { widget.onBack?.call(); } @@ -424,6 +430,27 @@ class GuideTabState extends State { return programs.firstOrNull; } + /// Navigate to the next or previous program on the same channel. + /// Returns true if navigation succeeded, false if at the boundary. + bool _navigateToAdjacentProgram(int channelIndex, {required bool forward}) { + if (channelIndex < 0 || channelIndex >= widget.channels.length) return false; + final channel = widget.channels[channelIndex]; + final programs = _getProgramsForChannel(channel); + if (programs.isEmpty || _focusedProgram == null) return false; + + final currentIndex = programs.indexWhere((p) => identical(p, _focusedProgram)); + if (currentIndex < 0) return false; + + final nextIndex = forward ? currentIndex + 1 : currentIndex - 1; + if (nextIndex < 0 || nextIndex >= programs.length) return false; + + setState(() { + _focusedProgram = programs[nextIndex]; + }); + _scrollToProgramTime(_focusedProgram); + return true; + } + void _scrollToChannel(int index) { if (!_gridVerticalController.hasClients) return; final targetTop = index * _rowHeight; @@ -440,9 +467,13 @@ class GuideTabState extends State { if (newOffset != null) { final clamped = newOffset.clamp(0.0, _gridVerticalController.position.maxScrollExtent); - _gridVerticalController.jumpTo(clamped); + _gridVerticalController.animateTo(clamped, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); if (_channelVerticalController.hasClients) { - _channelVerticalController.jumpTo(clamped.clamp(0.0, _channelVerticalController.position.maxScrollExtent)); + _channelVerticalController.animateTo( + clamped.clamp(0.0, _channelVerticalController.position.maxScrollExtent), + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ); } } } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 43f08f06..e069a12a 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -671,6 +671,15 @@ class _MainScreenState extends State with RouteAware, WindowListener } }); } + // When content regains focus while on Live TV, focus the active guide/whats-on tab + final liveTvIndex = NavigationTab.indexFor(NavigationTabId.liveTv, isOffline: _isOffline, hasLiveTv: _hasLiveTv); + if (_currentIndex == liveTvIndex && liveTvIndex >= 0) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_liveTvKey.currentState case final FocusableTab focusable) { + focusable.focusActiveTabIfReady(); + } + }); + } // When content regains focus while on Settings, restore focus to last focused setting final settingsIndex = NavigationTab.indexFor( NavigationTabId.settings, @@ -876,6 +885,13 @@ class _MainScreenState extends State with RouteAware, WindowListener focusable.focusActiveTabIfReady(); } } + // Ensure the Live TV screen applies focus when brought into view + final liveTvIdx = NavigationTab.indexFor(NavigationTabId.liveTv, isOffline: _isOffline, hasLiveTv: _hasLiveTv); + if (index == liveTvIdx && liveTvIdx >= 0 && previousIndex != liveTvIdx) { + if (_liveTvKey.currentState case final FocusableTab focusable) { + focusable.focusActiveTabIfReady(); + } + } // Focus search input when selecting Search tab if (NavigationTab.isTabAtIndex(NavigationTabId.search, index, isOffline: _isOffline, hasLiveTv: _hasLiveTv)) { if (_searchKey.currentState case final SearchInputFocusable searchable) {