From c1cfb9609e930b428ddbea01da2203f043bfbf03 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:44:05 +0200 Subject: [PATCH] perf(livetv): virtualize guide rendering --- lib/screens/livetv/tabs/guide_tab.dart | 539 +++++++++++++++--------- test/screens/livetv/guide_tab_test.dart | 47 +++ 2 files changed, 386 insertions(+), 200 deletions(-) diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 66045c14..6d1051ec 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -1,6 +1,7 @@ import 'dart:async'; import '../../../media/ids.dart'; +import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:flutter/services.dart'; @@ -82,6 +83,15 @@ class GuideTab extends StatefulWidget { enum _GuideZone { timeNav, grid } +typedef _GuideFocusSnapshot = ({ + bool hasFocus, + _GuideZone zone, + int timeNavIndex, + int channelIndex, + int gridColumn, + LiveTvProgram? program, +}); + sealed class _GuideRow { const _GuideRow(); } @@ -112,6 +122,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi static const _realignAfterAway = Duration(minutes: 30); List _programs = []; + Map> _programsByChannelScope = const {}; Set _scheduledRecordingKeys = const {}; bool _isLoading = true; int _programLoadGeneration = 0; @@ -143,7 +154,14 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi int _gridChannelIndex = 0; int _gridColumn = 0; // 0=channel, 1=program bool _hasFocus = false; - final ValueNotifier _hasFocusNotifier = ValueNotifier(false); + final ValueNotifier<_GuideFocusSnapshot> _focusSnapshot = ValueNotifier(( + hasFocus: false, + zone: _GuideZone.timeNav, + timeNavIndex: 1, + channelIndex: 0, + gridColumn: 0, + program: null, + )); LiveTvProgram? _focusedProgram; bool _pendingFocus = false; @@ -157,7 +175,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi } _pendingFocus = false; _guideFocusNode.requestFocus(); - setState(() { + _updateFocus(() { if (widget.channels.isNotEmpty) { _focusZone = _GuideZone.grid; _gridColumn = 0; @@ -233,8 +251,14 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi @override void didUpdateWidget(GuideTab oldWidget) { super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.channels, widget.channels)) { + _programsByChannelScope = _indexProgramsByChannel(_programs, widget.channels); + } if (widget.channels.isNotEmpty && _gridChannelIndex >= widget.channels.length) { _gridChannelIndex = widget.channels.length - 1; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _publishFocusSnapshot(); + }); } } @@ -251,7 +275,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi _gridHorizontalController.dispose(); _channelVerticalController.dispose(); _timeIndicatorTimer?.cancel(); - _hasFocusNotifier.dispose(); + _focusSnapshot.dispose(); super.dispose(); } @@ -259,7 +283,23 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi if (_hasFocus == hasFocus) return; if (!hasFocus) _resetProgramSelectLongPressState(); _hasFocus = hasFocus; - _hasFocusNotifier.value = hasFocus; + _publishFocusSnapshot(); + } + + void _updateFocus(VoidCallback update) { + update(); + _publishFocusSnapshot(); + } + + void _publishFocusSnapshot() { + _focusSnapshot.value = ( + hasFocus: _hasFocus, + zone: _focusZone, + timeNavIndex: _timeNavIndex, + channelIndex: _gridChannelIndex, + gridColumn: _gridColumn, + program: _focusedProgram, + ); } void _resetProgramSelectLongPressState() => _programSelectController.reset(); @@ -344,10 +384,10 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi setState(() => _isLoading = true); try { - final multiServer = context.read(); - final liveTvServers = List.of(multiServer.liveTvServers); final allPrograms = []; final scheduledRecordingKeys = {}; + final multiServer = context.read(); + final liveTvServers = List.of(multiServer.liveTvServers); final queriedServers = {}; for (final serverInfo in liveTvServers) { @@ -374,9 +414,11 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi if (!_isCurrentProgramLoad(loadGeneration)) return; final shouldFocus = _pendingFocus; + final programsByChannelScope = _indexProgramsByChannel(allPrograms, widget.channels); setState(() { _programs = allPrograms; + _programsByChannelScope = programsByChannelScope; _scheduledRecordingKeys = scheduledRecordingKeys; _isLoading = false; // Focus tracking compares by identity, so a reload orphans the @@ -388,6 +430,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi } } }); + _publishFocusSnapshot(); _scrollToNow(loadGeneration: loadGeneration); @@ -580,9 +623,31 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi }); } + Map> _indexProgramsByChannel(List programs, List channels) { + final programsByIdentifier = >{}; + for (final program in programs) { + final identifier = program.channelIdentifier?.trim(); + if (identifier == null || identifier.isEmpty) continue; + (programsByIdentifier[identifier] ??= []).add(program); + } + + final indexed = >{}; + for (final channel in channels) { + final candidates = {}; + candidates.addAll(programsByIdentifier[channel.key] ?? const []); + final identifier = channel.identifier; + if (identifier != null && identifier != channel.key) { + candidates.addAll(programsByIdentifier[identifier] ?? const []); + } + final matching = candidates.where((program) => liveTvProgramMatchesChannel(program, channel)).toList() + ..sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0)); + indexed[liveTvChannelScopeKey(channel)] = matching; + } + return indexed; + } + List _getProgramsForChannel(LiveTvChannel channel) { - return _programs.where((program) => liveTvProgramMatchesChannel(program, channel)).toList() - ..sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0)); + return _programsByChannelScope[liveTvChannelScopeKey(channel)] ?? const []; } double _totalGridWidth() { @@ -665,7 +730,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi } if (_focusZone == _GuideZone.grid) { if (event is KeyUpEvent) { - setState(() { + _updateFocus(() { _focusZone = _GuideZone.timeNav; _timeNavIndex = 1; }); @@ -691,19 +756,19 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi KeyEventResult _handleTimeNavKey(LogicalKeyboardKey key) { if (key.isLeftKey) { if (_timeNavIndex > 0) { - setState(() => _timeNavIndex--); + _updateFocus(() => _timeNavIndex--); } else { widget.onBack?.call(); } return KeyEventResult.handled; } if (key.isRightKey) { - if (_timeNavIndex < 2) setState(() => _timeNavIndex++); + if (_timeNavIndex < 2) _updateFocus(() => _timeNavIndex++); return KeyEventResult.handled; } if (key.isDownKey) { if (widget.channels.isNotEmpty) { - setState(() { + _updateFocus(() { _focusZone = _GuideZone.grid; _gridColumn = 0; _focusedProgram = null; @@ -733,13 +798,13 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi KeyEventResult _handleGridKey(LogicalKeyboardKey key) { if (key.isUpKey) { if (_gridChannelIndex > 0) { - setState(() { + _updateFocus(() { _gridChannelIndex--; if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); }); _scrollToChannel(_gridChannelIndex); } else { - setState(() { + _updateFocus(() { _focusZone = _GuideZone.timeNav; _timeNavIndex = 1; }); @@ -748,7 +813,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi } if (key.isDownKey) { if (_gridChannelIndex < widget.channels.length - 1) { - setState(() { + _updateFocus(() { _gridChannelIndex++; if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); }); @@ -760,7 +825,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi if (_gridColumn == 0) { final program = _findCurrentProgram(_gridChannelIndex); if (program != null) { - setState(() { + _updateFocus(() { _gridColumn = 1; _focusedProgram = program; }); @@ -776,7 +841,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi if (_gridColumn == 1) { // Try moving to previous program; if at first program, go back to channel column if (!_navigateToAdjacentProgram(_gridChannelIndex, forward: false)) { - setState(() { + _updateFocus(() { _gridColumn = 0; _focusedProgram = null; }); @@ -838,10 +903,9 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi final nextIndex = forward ? currentIndex + 1 : currentIndex - 1; if (nextIndex < 0 || nextIndex >= programs.length) return false; - setState(() { - _focusedProgram = programs[nextIndex]; - }); - _scrollToProgramTime(_focusedProgram); + final nextProgram = programs[nextIndex]; + _updateFocus(() => _focusedProgram = nextProgram); + _scrollToProgramTime(nextProgram); return true; } @@ -907,47 +971,47 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi } Widget _buildGuideGrid(ThemeData theme) { - return ValueListenableBuilder( - valueListenable: _hasFocusNotifier, - builder: (context, hasFocus, child) { - final rows = _guideRows; - return Column( - children: [ - _buildTimeNavigation(theme), - Expanded( - child: ListenableBuilder( - listenable: _gridHorizontalController, - builder: (context, child) { - return Stack(children: [child!, _buildNowIndicatorOverlay(theme)]); - }, - child: Column( + final rows = _guideRows; + return Column( + children: [ + _buildTimeNavigation(theme), + Expanded( + child: ListenableBuilder( + listenable: _gridHorizontalController, + builder: (context, child) { + return Stack(children: [child!, _buildNowIndicatorOverlay(theme)]); + }, + child: Column( + children: [ + Row( children: [ - Row( - children: [ - const SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), - Expanded( - child: SingleChildScrollView( - controller: _headerHorizontalController, - scrollDirection: Axis.horizontal, - physics: const ClampingScrollPhysics(), - child: SizedBox( - width: _totalGridWidth(), - height: _timeHeaderHeight, - child: _buildTimeHeader(theme), - ), - ), - ), - ], - ), + const SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), Expanded( - child: Row( - children: [ - SizedBox( - width: _channelColumnWidth, - child: ListView.builder( - controller: _channelVerticalController, - physics: const NeverScrollableScrollPhysics(), + child: SingleChildScrollView( + controller: _headerHorizontalController, + scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), + child: SizedBox( + width: _totalGridWidth(), + height: _timeHeaderHeight, + child: _buildTimeHeader(theme), + ), + ), + ), + ], + ), + Expanded( + child: Row( + children: [ + SizedBox( + width: _channelColumnWidth, + child: CustomScrollView( + controller: _channelVerticalController, + physics: const NeverScrollableScrollPhysics(), + slivers: [ + SliverVariedExtentList.builder( itemCount: rows.length, + itemExtentBuilder: (index, _) => _guideRowHeight(rows[index]), itemBuilder: (context, index) { final row = rows[index]; return switch (row) { @@ -960,27 +1024,32 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi }; }, ), - ), - Expanded( - child: NotificationListener( - onNotification: (notification) { - if (notification is ScrollUpdateNotification && - notification.metrics.axis == Axis.vertical) { - if (_channelVerticalController.hasClients) { - _channelVerticalController.jumpTo(notification.metrics.pixels); - } - } - return false; - }, - child: SingleChildScrollView( - controller: _gridHorizontalController, - scrollDirection: Axis.horizontal, - physics: const ClampingScrollPhysics(), - child: SizedBox( - width: _totalGridWidth(), - child: ListView.builder( - controller: _gridVerticalController, + ], + ), + ), + Expanded( + child: NotificationListener( + onNotification: (notification) { + if (notification is ScrollUpdateNotification && + notification.metrics.axis == Axis.vertical) { + if (_channelVerticalController.hasClients) { + _channelVerticalController.jumpTo(notification.metrics.pixels); + } + } + return false; + }, + child: SingleChildScrollView( + controller: _gridHorizontalController, + scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), + child: SizedBox( + width: _totalGridWidth(), + child: CustomScrollView( + controller: _gridVerticalController, + slivers: [ + SliverVariedExtentList.builder( itemCount: rows.length, + itemExtentBuilder: (index, _) => _guideRowHeight(rows[index]), itemBuilder: (context, index) { final row = rows[index]; return switch (row) { @@ -994,20 +1063,20 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi }; }, ), - ), + ], ), ), ), - ], + ), ), - ), - ], + ], + ), ), - ), + ], ), - ], - ); - }, + ), + ), + ], ); } @@ -1142,11 +1211,16 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi } Widget _timeNavFocusWrap({required Widget child, required int index}) { - final isFocused = _hasFocus && _focusZone == _GuideZone.timeNav && _timeNavIndex == index; - if (!isFocused) return child; - return Container( - decoration: FocusTheme.textFillFocusDecoration(context, isFocused: true, borderRadius: MonoTokens.radiusFull), - child: child, + return _GuideFocusSelector( + valueListenable: _focusSnapshot, + isSelected: (focus) => focus.hasFocus && focus.zone == _GuideZone.timeNav && focus.timeNavIndex == index, + builder: (context, isFocused) { + if (!isFocused) return child; + return Container( + decoration: FocusTheme.textFillFocusDecoration(context, isFocused: true, borderRadius: MonoTokens.radiusFull), + child: child, + ); + }, ); } @@ -1289,20 +1363,25 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi final serverId = serverIdOrNull(channel.serverId); final client = serverId == null ? null : multiServer.getClientForServer(serverId); - final isFocused = _hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 0 && _gridChannelIndex == index; - - return _ChannelCell( - rowHeight: _rowHeight, - channelColumnWidth: _channelColumnWidth, - channelThumb: channel.thumb, - client: client, - channel: channel, - theme: theme, - onTap: () => _tuneChannel(channel), - onLongPress: widget.onToggleFavorite != null ? () => widget.onToggleFavorite!(channel) : null, - isFocused: isFocused, - isFavorite: widget.isFavoriteChannel?.call(channel) ?? false, - fallbackBuilder: () => _buildChannelNameFallback(channel, theme), + return _GuideFocusSelector( + valueListenable: _focusSnapshot, + isSelected: (focus) => + focus.hasFocus && focus.zone == _GuideZone.grid && focus.gridColumn == 0 && focus.channelIndex == index, + builder: (context, isFocused) { + return _ChannelCell( + rowHeight: _rowHeight, + channelColumnWidth: _channelColumnWidth, + channelThumb: channel.thumb, + client: client, + channel: channel, + theme: theme, + onTap: () => _tuneChannel(channel), + onLongPress: widget.onToggleFavorite != null ? () => widget.onToggleFavorite!(channel) : null, + isFocused: isFocused, + isFavorite: widget.isFavoriteChannel?.call(channel) ?? false, + fallbackBuilder: () => _buildChannelNameFallback(channel, theme), + ); + }, ); } @@ -1350,50 +1429,55 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi ); } - final blocks = []; 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); - final progEnd = (program.endsAt ?? gridEndEpoch).clamp(gridStartEpoch, gridEndEpoch); - - if (progEnd <= progStart) continue; - - final startOffset = progStart - gridStartEpoch; - final duration = progEnd - progStart; - final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth; - final width = (duration / (_minutesPerSlot * 60)) * _slotWidth; - // Keep slivers wide enough to survive the trailing groupGap padding. - final clampedWidth = width.clamp(6.0, double.infinity); - - blocks.add( - Positioned( - left: left, - width: clampedWidth, - top: 0, - bottom: 0, - child: _buildProgramBlock( - channel, - program, - theme, - isFocused: identical(program, focusProg), - tileLeft: left, - tileWidth: clampedWidth, - ), - ), - ); - } - return SizedBox( height: _rowHeight, - child: Stack(children: blocks), + child: ListenableBuilder( + listenable: _gridHorizontalController, + builder: (context, _) { + final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0; + // Keep one slot of overscan so the next D-pad target exists before the horizontal jump. + final visibleStart = scrollOffset > _slotWidth ? scrollOffset - _slotWidth : 0.0; + final visibleEnd = scrollOffset + MediaQuery.sizeOf(context).width + _slotWidth; + final blocks = []; + + for (final program in programs) { + final progStart = (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch); + final progEnd = (program.endsAt ?? gridEndEpoch).clamp(gridStartEpoch, gridEndEpoch); + if (progEnd <= progStart) continue; + + final startOffset = progStart - gridStartEpoch; + final duration = progEnd - progStart; + final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth; + final width = (duration / (_minutesPerSlot * 60)) * _slotWidth; + final clampedWidth = width.clamp(6.0, double.infinity); + if (left + clampedWidth < visibleStart || left > visibleEnd) continue; + + blocks.add( + Positioned( + key: ObjectKey(program), + left: left, + width: clampedWidth, + top: 0, + bottom: 0, + child: _buildProgramBlock( + channel, + program, + theme, + channelIndex: channelIndex, + tileLeft: left, + tileWidth: clampedWidth, + scrollOffset: scrollOffset, + ), + ), + ); + } + + return Stack(children: blocks); + }, + ), ); } @@ -1401,60 +1485,68 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi LiveTvChannel channel, LiveTvProgram program, ThemeData theme, { - bool isFocused = false, - double tileLeft = 0, - double tileWidth = 0, + required int channelIndex, + required double tileLeft, + required double tileWidth, + required double scrollOffset, }) { - final tk = tokens(context); - final isCurrentlyAiring = program.isCurrentlyAiring; - final isPast = program.endsAt != null && program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000; - final isRecordingScheduled = _isRecordingScheduled(program); + return _GuideFocusSelector( + valueListenable: _focusSnapshot, + isSelected: (focus) => + focus.hasFocus && + focus.zone == _GuideZone.grid && + focus.gridColumn == 1 && + focus.channelIndex == channelIndex && + identical(focus.program, program), + builder: (context, isFocused) { + final tk = tokens(context); + final isCurrentlyAiring = program.isCurrentlyAiring; + final isPast = program.endsAt != null && program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000; + final isRecordingScheduled = _isRecordingScheduled(program); - final Color fillColor; - final Color titleColor; - final Color subtitleColor; - if (isFocused) { - // Inverted focus card: primary == text in the mono theme, so the cursor - // reads as a solid inverted cell (white card, dark text in dark mode). - fillColor = theme.colorScheme.primary; - titleColor = theme.colorScheme.onPrimary; - subtitleColor = theme.colorScheme.onPrimary.withValues(alpha: 0.7); - } else if (isPast) { - fillColor = Color.alphaBlend(tk.surface.withValues(alpha: 0.5), tk.bg); - titleColor = tk.text.withValues(alpha: 0.5); - subtitleColor = tk.text.withValues(alpha: 0.3); - } else if (isCurrentlyAiring) { - fillColor = airingFill(context); - titleColor = tk.text; - subtitleColor = tk.textMuted; - } else { - fillColor = tk.surface; - titleColor = tk.text; - subtitleColor = tk.textMuted; - } - final radius = BorderRadius.circular(isFocused ? tk.radiusSm : tk.radiusXs); + final Color fillColor; + final Color titleColor; + final Color subtitleColor; + if (isFocused) { + // Inverted focus card: primary == text in the mono theme, so the cursor + // reads as a solid inverted cell (white card, dark text in dark mode). + fillColor = theme.colorScheme.primary; + titleColor = theme.colorScheme.onPrimary; + subtitleColor = theme.colorScheme.onPrimary.withValues(alpha: 0.7); + } else if (isPast) { + fillColor = Color.alphaBlend(tk.surface.withValues(alpha: 0.5), tk.bg); + titleColor = tk.text.withValues(alpha: 0.5); + subtitleColor = tk.text.withValues(alpha: 0.3); + } else if (isCurrentlyAiring) { + fillColor = airingFill(context); + titleColor = tk.text; + subtitleColor = tk.textMuted; + } else { + fillColor = tk.surface; + titleColor = tk.text; + subtitleColor = tk.textMuted; + } + final radius = BorderRadius.circular(isFocused ? tk.radiusSm : tk.radiusXs); - return Padding( - padding: EdgeInsets.only(right: tk.groupGap, bottom: tk.groupGap), - child: Material( - color: fillColor, - shape: RoundedRectangleBorder(borderRadius: radius), - child: InkWell( - borderRadius: radius, - mouseCursor: SystemMouseCursors.click, - canRequestFocus: false, - onTap: () => _activateProgram(channel, program), - onLongPress: () => _showProgramDetails(channel, program), - onSecondaryTap: () => _showProgramDetails(channel, program), - child: ListenableBuilder( - listenable: _gridHorizontalController, - builder: (context, _) { - const basePadding = 6.0; - final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0; - final maxInset = (tileWidth - tk.groupGap - 2 * basePadding - 20).clamp(0.0, double.infinity); - final leftInset = (scrollOffset - tileLeft).clamp(0.0, maxInset); - return Padding( - padding: .fromLTRB(basePadding + leftInset, 4, basePadding, 4), + return Padding( + padding: EdgeInsets.only(right: tk.groupGap, bottom: tk.groupGap), + child: Material( + color: fillColor, + shape: RoundedRectangleBorder(borderRadius: radius), + child: InkWell( + borderRadius: radius, + mouseCursor: SystemMouseCursors.click, + canRequestFocus: false, + onTap: () => _activateProgram(channel, program), + onLongPress: () => _showProgramDetails(channel, program), + onSecondaryTap: () => _showProgramDetails(channel, program), + child: Padding( + padding: .fromLTRB( + 6 + (scrollOffset - tileLeft).clamp(0.0, (tileWidth - tk.groupGap - 32).clamp(0.0, double.infinity)), + 4, + 6, + 4, + ), child: Column( crossAxisAlignment: .start, mainAxisAlignment: .center, @@ -1491,11 +1583,11 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi ), ], ), - ); - }, + ), + ), ), - ), - ), + ); + }, ); } @@ -1527,6 +1619,53 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi } } +class _GuideFocusSelector extends StatefulWidget { + const _GuideFocusSelector({required this.valueListenable, required this.isSelected, required this.builder}); + + final ValueListenable<_GuideFocusSnapshot> valueListenable; + final bool Function(_GuideFocusSnapshot focus) isSelected; + final Widget Function(BuildContext context, bool isSelected) builder; + + @override + State<_GuideFocusSelector> createState() => _GuideFocusSelectorState(); +} + +class _GuideFocusSelectorState extends State<_GuideFocusSelector> { + late bool _isSelected; + + @override + void initState() { + super.initState(); + _isSelected = widget.isSelected(widget.valueListenable.value); + widget.valueListenable.addListener(_handleValueChanged); + } + + @override + void didUpdateWidget(_GuideFocusSelector oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.valueListenable != widget.valueListenable) { + oldWidget.valueListenable.removeListener(_handleValueChanged); + widget.valueListenable.addListener(_handleValueChanged); + } + _isSelected = widget.isSelected(widget.valueListenable.value); + } + + @override + void dispose() { + widget.valueListenable.removeListener(_handleValueChanged); + super.dispose(); + } + + void _handleValueChanged() { + final isSelected = widget.isSelected(widget.valueListenable.value); + if (isSelected == _isSelected) return; + setState(() => _isSelected = isSelected); + } + + @override + Widget build(BuildContext context) => widget.builder(context, _isSelected); +} + class _RecordingDot extends StatelessWidget { final Color color; final String tooltip; diff --git a/test/screens/livetv/guide_tab_test.dart b/test/screens/livetv/guide_tab_test.dart index 8789ee26..eee2954b 100644 --- a/test/screens/livetv/guide_tab_test.dart +++ b/test/screens/livetv/guide_tab_test.dart @@ -183,6 +183,37 @@ void main() { expect(find.text('Current'), findsOneWidget); expect(find.text('Obsolete'), findsNothing); }); + + testWidgets('horizontal guide virtualization keeps the D-pad focus target rendered', (tester) async { + final harness = _GuideHarness.oneServer(); + addTearDown(harness.dispose); + await harness.pump(tester); + + harness.serverA.schedule.completeSlots(0, 12); + await tester.pumpAndSettle(); + expect(find.text('Slot 12'), findsNothing); + + final guideFocus = tester.widget( + find.byWidgetPredicate((widget) => widget is Focus && widget.focusNode?.debugLabel == 'guide_tab'), + ); + guideFocus.focusNode!.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + for (var index = 0; index < 12; index++) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + } + + final primary = Theme.of(tester.element(find.byType(GuideTab))).colorScheme.primary; + final focusedMaterial = find.byWidgetPredicate((widget) => widget is Material && widget.color == primary); + expect(find.text('Slot 1'), findsNothing); + expect(find.text('Slot 12'), findsOneWidget); + expect(find.ancestor(of: find.text('Slot 12'), matching: focusedMaterial), findsOneWidget); + }); } Finder _rightTimeButton() { @@ -329,6 +360,22 @@ final class _ControllableLiveTvSupport implements LiveTvSupport { ]); } + void completeSlots(int index, int count) { + final request = requests[index]; + final startEpoch = request.from.millisecondsSinceEpoch ~/ 1000; + request.completer.complete([ + for (var slot = 0; slot < count; slot++) + LiveTvProgram( + ratingKey: '$serverId-$index-$slot', + title: 'Slot ${slot + 1}', + beginsAt: startEpoch + slot * 30 * 60, + endsAt: startEpoch + (slot + 1) * 30 * 60, + channelIdentifier: stationId, + serverId: serverId, + ), + ]); + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }