From 63f2bedf2c034998f4bd69deb9fdaea9710bd4d5 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:10:12 +0200 Subject: [PATCH] fix(livetv): navigate guide rows in displayed source-group order Vertical D-pad/arrow navigation stepped through the flat channel list, which is number-sorted across servers. With overlapping channel numbers from multiple DVRs, focus interleaved source groups and could dead-end before the last displayed row. Derive the up/down order from the same grouped rows the guide renders. close #1843 --- lib/screens/livetv/tabs/guide_tab.dart | 31 ++--- test/screens/livetv/guide_tab_test.dart | 155 ++++++++++++++++++++++-- 2 files changed, 159 insertions(+), 27 deletions(-) diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 824f9dbd..0c07c98c 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -585,6 +585,14 @@ class GuideTabState extends State ]; } + /// Flat [GuideTab.channels] indexes in displayed row order. Differs from + /// ascending index order when multiple source groups exist: the flat list is + /// number-sorted across sources, while rows are grouped by source. + List get _displayOrderChannelIndexes => [ + for (final row in _guideRows) + if (row is _GuideChannelRow) row.channelIndex, + ]; + double _guideRowHeight(_GuideRow row) { return switch (row) { _GuideSourceHeaderRow() => _sourceHeaderRowHeight, @@ -791,25 +799,20 @@ class GuideTabState extends State } KeyEventResult _handleGridKey(LogicalKeyboardKey key) { - if (key.isUpKey) { - if (_gridChannelIndex > 0) { - _updateFocus(() { - _gridChannelIndex--; - if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); - }); - _scrollToChannel(_gridChannelIndex); - } else { + if (key.isUpKey || key.isDownKey) { + // Move through rows in displayed (source-grouped) order. Stepping the + // flat channel index would interleave sources whose channel numbers + // overlap and could dead-end before the last displayed row. + final order = _displayOrderChannelIndexes; + final position = order.indexOf(_gridChannelIndex); + if (key.isUpKey && position <= 0) { _updateFocus(() { _focusZone = _GuideZone.timeNav; _timeNavIndex = 1; }); - } - return KeyEventResult.handled; - } - if (key.isDownKey) { - if (_gridChannelIndex < widget.channels.length - 1) { + } else if (key.isUpKey || (position != -1 && position < order.length - 1)) { _updateFocus(() { - _gridChannelIndex++; + _gridChannelIndex = order[key.isUpKey ? position - 1 : position + 1]; if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); }); _scrollToChannel(_gridChannelIndex); diff --git a/test/screens/livetv/guide_tab_test.dart b/test/screens/livetv/guide_tab_test.dart index c7be0e3e..dc4723c2 100644 --- a/test/screens/livetv/guide_tab_test.dart +++ b/test/screens/livetv/guide_tab_test.dart @@ -215,6 +215,88 @@ void main() { expect(find.text('Slot 12'), findsOneWidget); expect(find.ancestor(of: find.text('Slot 12'), matching: focusedMaterial), findsOneWidget); }); + + testWidgets('vertical navigation follows displayed source-group order, not flat channel order', (tester) async { + // Flat list mirrors live_tv_screen ordering: number-sorted across servers, + // which interleaves the two source groups when numbers overlap. + final harness = _GuideHarness.twoServersWithChannels([ + _guideChannel(serverId: 'server-a', stationId: 'st-a1', callSign: 'A1', number: '1'), + _guideChannel(serverId: 'server-a', stationId: 'st-a2', callSign: 'A2', number: '2'), + _guideChannel(serverId: 'server-b', stationId: 'st-b21', callSign: 'B21', number: '2.1'), + _guideChannel(serverId: 'server-a', stationId: 'st-a3', callSign: 'A3', number: '3'), + _guideChannel(serverId: 'server-a', stationId: 'st-a4', callSign: 'A4', number: '4'), + _guideChannel(serverId: 'server-b', stationId: 'st-b41', callSign: 'B41', number: '4.1'), + _guideChannel(serverId: 'server-b', stationId: 'st-b43', callSign: 'B43', number: '4.3'), + _guideChannel(serverId: 'server-b', stationId: 'st-b44', callSign: 'B44', number: '4.4'), + _guideChannel(serverId: 'server-a', stationId: 'st-a5', callSign: 'A5', number: '5'), + _guideChannel(serverId: 'server-b', stationId: 'st-b51', callSign: 'B51', number: '5.1'), + ]); + addTearDown(harness.dispose); + await harness.pump(tester); + await harness.completeInitialEmpty(tester); + + await _focusGrid(tester); + _expectFocusedChannel(tester, 'A1'); + + const displayOrder = ['A1', 'A2', 'A3', 'A4', 'A5', 'B21', 'B41', 'B43', 'B44', 'B51']; + for (final callSign in displayOrder.skip(1)) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + _expectFocusedChannel(tester, callSign); + } + + // Down on the last displayed row is a no-op. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + _expectFocusedChannel(tester, 'B51'); + + for (final callSign in displayOrder.reversed.skip(1)) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + _expectFocusedChannel(tester, callSign); + } + }); + + testWidgets('down reaches the last displayed row when the flat-last channel sits mid-guide', (tester) async { + // Flat order: 1 (A), 2 (B), 10 (A). Displayed order groups by source: + // A1, A10, then B2 — the flat-last channel is not the displayed-last row. + final harness = _GuideHarness.twoServersWithChannels([ + _guideChannel(serverId: 'server-a', stationId: 'st-a1', callSign: 'A1', number: '1'), + _guideChannel(serverId: 'server-b', stationId: 'st-b2', callSign: 'B2', number: '2'), + _guideChannel(serverId: 'server-a', stationId: 'st-a10', callSign: 'A10', number: '10'), + ]); + addTearDown(harness.dispose); + await harness.pump(tester); + await harness.completeInitialEmpty(tester); + + await _focusGrid(tester); + _expectFocusedChannel(tester, 'A1'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + _expectFocusedChannel(tester, 'A10'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + _expectFocusedChannel(tester, 'B2'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + _expectFocusedChannel(tester, 'B2'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + _expectFocusedChannel(tester, 'A10'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + _expectFocusedChannel(tester, 'A1'); + + // Up on the first displayed row exits the grid to the time navigation. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(_focusedCellFinder(tester), findsNothing); + }); } Finder _rightTimeButton() { @@ -222,6 +304,30 @@ Finder _rightTimeButton() { return find.ancestor(of: icon, matching: find.byType(IconButton)); } +Future _focusGrid(WidgetTester tester) async { + final guideFocus = tester.widget( + find.byWidgetPredicate((widget) => widget is Focus && widget.focusNode?.debugLabel == 'guide_tab'), + ); + guideFocus.focusNode!.requestFocus(); + await tester.pump(); + // Enters the grid from the time navigation zone. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); +} + +Finder _focusedCellFinder(WidgetTester tester) { + final primary = Theme.of(tester.element(find.byType(GuideTab))).colorScheme.primary; + return find.byWidgetPredicate((widget) => widget is Material && widget.color == primary); +} + +void _expectFocusedChannel(WidgetTester tester, String callSign) { + expect( + find.ancestor(of: find.text(callSign), matching: _focusedCellFinder(tester)), + findsOneWidget, + reason: 'expected focused channel $callSign', + ); +} + final class _GuideHarness { _GuideHarness._({required this.serverA, required this.serverB, required this.provider, required this.channels}); @@ -229,7 +335,10 @@ final class _GuideHarness { factory _GuideHarness.twoServers() => _GuideHarness._create(includeServerB: true); - factory _GuideHarness._create({required bool includeServerB}) { + factory _GuideHarness.twoServersWithChannels(List channels) => + _GuideHarness._create(includeServerB: true, channels: channels); + + factory _GuideHarness._create({required bool includeServerB, List? channels}) { final serverA = _FakeMediaServerClient(serverId: 'server-a', stationId: 'station-a'); final serverB = includeServerB ? _FakeMediaServerClient(serverId: 'server-b', stationId: 'station-b') : null; final manager = MultiServerManager()..debugRegisterClientForTesting(serverA); @@ -243,10 +352,12 @@ final class _GuideHarness { serverA: serverA, serverB: serverB, provider: provider, - channels: [ - _guideChannel(serverId: 'server-a', stationId: 'station-a', callSign: 'A'), - if (serverB != null) _guideChannel(serverId: 'server-b', stationId: 'station-b', callSign: 'B'), - ], + channels: + channels ?? + [ + _guideChannel(serverId: 'server-a', stationId: 'station-a', callSign: 'A'), + if (serverB != null) _guideChannel(serverId: 'server-b', stationId: 'station-b', callSign: 'B'), + ], ); } @@ -292,17 +403,33 @@ final class _GuideHarness { if (serverB != null) expect(find.text('Initial B'), findsOneWidget); } + Future completeInitialEmpty(WidgetTester tester) async { + serverA.schedule.completeEmpty(0); + await tester.pump(); + final serverB = this.serverB; + if (serverB != null) { + expect(serverB.schedule.requests, hasLength(1)); + serverB.schedule.completeEmpty(0); + } + await tester.pumpAndSettle(); + } + void dispose() => provider.dispose(); } -LiveTvChannel _guideChannel({required String serverId, required String stationId, required String callSign}) => - LiveTvChannel( - key: 'channel-$stationId', - identifier: stationId, - callSign: callSign, - serverId: serverId, - liveDvrKey: 'dvr-$serverId', - ); +LiveTvChannel _guideChannel({ + required String serverId, + required String stationId, + required String callSign, + String? number, +}) => LiveTvChannel( + key: 'channel-$stationId', + identifier: stationId, + callSign: callSign, + serverId: serverId, + liveDvrKey: 'dvr-$serverId', + number: number, +); final class _FakeMediaServerClient implements MediaServerClient { _FakeMediaServerClient({required String serverId, required String stationId}) @@ -361,6 +488,8 @@ final class _ControllableLiveTvSupport implements LiveTvSupport { ]); } + void completeEmpty(int index) => requests[index].completer.complete(const []); + void completeSlots(int index, int count) { final request = requests[index]; final startEpoch = request.from.millisecondsSinceEpoch ~/ 1000;