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:
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user