fix: dpad focus and navigation for Live TV guide
This commit is contained in:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user