fix: dpad focus and navigation for Live TV guide

This commit is contained in:
edde746
2026-02-18 08:06:05 +01:00
parent b419ff5507
commit 64c0c9979b
3 changed files with 60 additions and 7 deletions
+7 -1
View File
@@ -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<LiveTvScreen> createState() => _LiveTvScreenState();
}
class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderStateMixin, TabNavigationMixin {
class _LiveTvScreenState extends State<LiveTvScreen>
with SingleTickerProviderStateMixin, TabNavigationMixin
implements FocusableTab {
final _guideTabFocusNode = FocusNode(debugLabel: 'tab_chip_guide');
final _whatsOnTabFocusNode = FocusNode(debugLabel: 'tab_chip_whats_on');
final _guideTabKey = GlobalKey<GuideTabState>();
@@ -216,6 +219,9 @@ class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderSt
});
}
@override
void focusActiveTabIfReady() => _focusCurrentTab();
// ---------------------------------------------------------------------------
// Action button key handlers
// ---------------------------------------------------------------------------
+37 -6
View File
@@ -375,15 +375,21 @@ class GuideTabState extends State<GuideTab> {
});
_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<GuideTab> {
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<GuideTab> {
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,
);
}
}
}
+16
View File
@@ -671,6 +671,15 @@ class _MainScreenState extends State<MainScreen> 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<MainScreen> 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) {