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
This commit is contained in:
edde746
2026-08-09 11:12:09 +02:00
parent ff461d9f71
commit 63f2bedf2c
2 changed files with 159 additions and 27 deletions
+17 -14
View File
@@ -585,6 +585,14 @@ class GuideTabState extends State<GuideTab>
]; ];
} }
/// 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<int> get _displayOrderChannelIndexes => [
for (final row in _guideRows)
if (row is _GuideChannelRow) row.channelIndex,
];
double _guideRowHeight(_GuideRow row) { double _guideRowHeight(_GuideRow row) {
return switch (row) { return switch (row) {
_GuideSourceHeaderRow() => _sourceHeaderRowHeight, _GuideSourceHeaderRow() => _sourceHeaderRowHeight,
@@ -791,25 +799,20 @@ class GuideTabState extends State<GuideTab>
} }
KeyEventResult _handleGridKey(LogicalKeyboardKey key) { KeyEventResult _handleGridKey(LogicalKeyboardKey key) {
if (key.isUpKey) { if (key.isUpKey || key.isDownKey) {
if (_gridChannelIndex > 0) { // Move through rows in displayed (source-grouped) order. Stepping the
_updateFocus(() { // flat channel index would interleave sources whose channel numbers
_gridChannelIndex--; // overlap and could dead-end before the last displayed row.
if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); final order = _displayOrderChannelIndexes;
}); final position = order.indexOf(_gridChannelIndex);
_scrollToChannel(_gridChannelIndex); if (key.isUpKey && position <= 0) {
} else {
_updateFocus(() { _updateFocus(() {
_focusZone = _GuideZone.timeNav; _focusZone = _GuideZone.timeNav;
_timeNavIndex = 1; _timeNavIndex = 1;
}); });
} } else if (key.isUpKey || (position != -1 && position < order.length - 1)) {
return KeyEventResult.handled;
}
if (key.isDownKey) {
if (_gridChannelIndex < widget.channels.length - 1) {
_updateFocus(() { _updateFocus(() {
_gridChannelIndex++; _gridChannelIndex = order[key.isUpKey ? position - 1 : position + 1];
if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex);
}); });
_scrollToChannel(_gridChannelIndex); _scrollToChannel(_gridChannelIndex);
+142 -13
View File
@@ -215,6 +215,88 @@ void main() {
expect(find.text('Slot 12'), findsOneWidget); expect(find.text('Slot 12'), findsOneWidget);
expect(find.ancestor(of: find.text('Slot 12'), matching: focusedMaterial), 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() { Finder _rightTimeButton() {
@@ -222,6 +304,30 @@ Finder _rightTimeButton() {
return find.ancestor(of: icon, matching: find.byType(IconButton)); return find.ancestor(of: icon, matching: find.byType(IconButton));
} }
Future<void> _focusGrid(WidgetTester tester) async {
final guideFocus = tester.widget<Focus>(
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 { final class _GuideHarness {
_GuideHarness._({required this.serverA, required this.serverB, required this.provider, required this.channels}); _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.twoServers() => _GuideHarness._create(includeServerB: true);
factory _GuideHarness._create({required bool includeServerB}) { factory _GuideHarness.twoServersWithChannels(List<LiveTvChannel> channels) =>
_GuideHarness._create(includeServerB: true, channels: channels);
factory _GuideHarness._create({required bool includeServerB, List<LiveTvChannel>? channels}) {
final serverA = _FakeMediaServerClient(serverId: 'server-a', stationId: 'station-a'); final serverA = _FakeMediaServerClient(serverId: 'server-a', stationId: 'station-a');
final serverB = includeServerB ? _FakeMediaServerClient(serverId: 'server-b', stationId: 'station-b') : null; final serverB = includeServerB ? _FakeMediaServerClient(serverId: 'server-b', stationId: 'station-b') : null;
final manager = MultiServerManager()..debugRegisterClientForTesting(serverA); final manager = MultiServerManager()..debugRegisterClientForTesting(serverA);
@@ -243,10 +352,12 @@ final class _GuideHarness {
serverA: serverA, serverA: serverA,
serverB: serverB, serverB: serverB,
provider: provider, provider: provider,
channels: [ channels:
_guideChannel(serverId: 'server-a', stationId: 'station-a', callSign: 'A'), channels ??
if (serverB != null) _guideChannel(serverId: 'server-b', stationId: 'station-b', callSign: 'B'), [
], _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); if (serverB != null) expect(find.text('Initial B'), findsOneWidget);
} }
Future<void> 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(); void dispose() => provider.dispose();
} }
LiveTvChannel _guideChannel({required String serverId, required String stationId, required String callSign}) => LiveTvChannel _guideChannel({
LiveTvChannel( required String serverId,
key: 'channel-$stationId', required String stationId,
identifier: stationId, required String callSign,
callSign: callSign, String? number,
serverId: serverId, }) => LiveTvChannel(
liveDvrKey: 'dvr-$serverId', key: 'channel-$stationId',
); identifier: stationId,
callSign: callSign,
serverId: serverId,
liveDvrKey: 'dvr-$serverId',
number: number,
);
final class _FakeMediaServerClient implements MediaServerClient { final class _FakeMediaServerClient implements MediaServerClient {
_FakeMediaServerClient({required String serverId, required String stationId}) _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) { void completeSlots(int index, int count) {
final request = requests[index]; final request = requests[index];
final startEpoch = request.from.millisecondsSinceEpoch ~/ 1000; final startEpoch = request.from.millisecondsSinceEpoch ~/ 1000;