fix(tv): preserve native keyboard navigation

close #1051
This commit is contained in:
edde746
2026-05-15 19:05:38 +02:00
parent e37602421b
commit ef4980db09
3 changed files with 169 additions and 1 deletions
+16
View File
@@ -32,6 +32,10 @@ KeyEventResult _handleInputKey({
}) {
final key = event.logicalKey;
if (_shouldPassNativeTvKeyToPlatform(usesTvKeyboard: usesTvKeyboard, enabled: enabled, event: event)) {
return KeyEventResult.skipRemainingHandlers;
}
if (usesTvKeyboard && enabled && event.isTvSelectEvent) {
if (event is KeyDownEvent) openKeyboard();
return KeyEventResult.handled;
@@ -93,6 +97,18 @@ KeyEventResult _handleInputKey({
return KeyEventResult.ignored;
}
bool _shouldPassNativeTvKeyToPlatform({required bool usesTvKeyboard, required bool enabled, required KeyEvent event}) {
if (!enabled || usesTvKeyboard || !PlatformDetector.isTV()) return false;
// Android TV provides its own IME. Remote keys must reach the platform so
// users can move around that keyboard instead of escaping the app field.
final key = event.logicalKey;
final isEngineSynthesizedTvSelect = key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.gameButtonA;
if (event.isPhysicalKeyboardEvent && !isEngineSynthesizedTvSelect) return false;
return key.isDpadDirection || key.isBackKey || event.isTvSelectEvent;
}
KeyEventResult _handleTvHardwareKeyboardKey({
required TextEditingController controller,
required KeyEvent event,
@@ -1,4 +1,7 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/profiles/profile.dart';
@@ -11,6 +14,7 @@ Profile _profile(String id) =>
void main() {
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
TvDetectionService.setForceTVSync(false);
});
testWidgets('autofocuses the server URL field', (tester) async {
@@ -31,6 +35,32 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
});
testWidgets('Android TV remote navigation stays with native URL keyboard', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true);
await tester.pumpWidget(const InputModeTracker(child: MaterialApp(home: AddJellyfinScreen())));
await tester.pumpAndSettle();
final urlFocus = FocusManager.instance.primaryFocus!;
expect(urlFocus.debugLabel, 'AddJellyfin:Url');
final result = urlFocus.onKeyEvent!(
urlFocus,
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.arrowDown,
logicalKey: LogicalKeyboardKey.arrowDown,
timeStamp: Duration.zero,
deviceType: ui.KeyEventDeviceType.directionalPad,
),
);
await tester.pump();
expect(result, KeyEventResult.skipRemainingHandlers);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
});
group('Jellyfin profile binding decisions', () {
test('creates a local profile only on true first-run with no profiles', () {
expect(shouldCreateLocalJellyfinProfile(targetProfile: null, activeProfile: null, hasProfiles: false), isTrue);
+123 -1
View File
@@ -276,6 +276,112 @@ void main() {
expect(find.byType(Dialog), findsNothing);
});
testWidgets('Android TV remote keys are passed to native text input', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'server_url_field');
final nextFocusNode = FocusNode(debugLabel: 'find_server_button');
var selects = 0;
var backs = 0;
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
addTearDown(nextFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Column(
children: [
FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
onNavigateDown: nextFocusNode.requestFocus,
onSelect: () => selects++,
onBack: () => backs++,
),
FilledButton(focusNode: nextFocusNode, onPressed: () {}, child: const Text('Find server')),
],
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pump();
final handler = fieldFocusNode.onKeyEvent!;
final downResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.arrowDown));
final selectResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.select));
final backResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack));
final synthesizedSelectResult = handler(
fieldFocusNode,
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.select,
logicalKey: LogicalKeyboardKey.select,
timeStamp: Duration.zero,
deviceType: ui.KeyEventDeviceType.keyboard,
),
);
await tester.pump();
expect(downResult, KeyEventResult.skipRemainingHandlers);
expect(selectResult, KeyEventResult.skipRemainingHandlers);
expect(backResult, KeyEventResult.skipRemainingHandlers);
expect(synthesizedSelectResult, KeyEventResult.skipRemainingHandlers);
expect(fieldFocusNode.hasPrimaryFocus, isTrue);
expect(nextFocusNode.hasFocus, isFalse);
expect(selects, 0);
expect(backs, 0);
expect(find.byType(Dialog), findsNothing);
});
testWidgets('Android TV physical keyboard still uses field navigation', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'name_field');
final nextFocusNode = FocusNode(debugLabel: 'next_button');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
addTearDown(nextFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Column(
children: [
FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
onNavigateDown: nextFocusNode.requestFocus,
),
FilledButton(focusNode: nextFocusNode, onPressed: () {}, child: const Text('Next')),
],
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pump();
final result = fieldFocusNode.onKeyEvent!(
fieldFocusNode,
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.arrowDown,
logicalKey: LogicalKeyboardKey.arrowDown,
timeStamp: Duration.zero,
deviceType: ui.KeyEventDeviceType.keyboard,
),
);
await tester.pump();
expect(result, KeyEventResult.handled);
expect(nextFocusNode.hasPrimaryFocus, isTrue);
});
testWidgets('tvOS engine-synthesized select opens the virtual keyboard', (tester) async {
// The custom Flutter tvOS engine emits Siri Remote center-dpad presses
// as `LogicalKeyboardKey.select` with `deviceType=keyboard` (via the
@@ -401,8 +507,24 @@ KeyEventResult _dispatchKey(KeyEvent event) {
FocusNode? node = FocusManager.instance.primaryFocus;
while (node != null) {
final result = node.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored;
if (result == KeyEventResult.handled) return result;
if (result != KeyEventResult.ignored) return result;
node = node.parent;
}
return KeyEventResult.ignored;
}
KeyDownEvent _remoteKey(LogicalKeyboardKey key) {
return KeyDownEvent(
physicalKey: _physicalKeyFor(key),
logicalKey: key,
timeStamp: Duration.zero,
deviceType: ui.KeyEventDeviceType.directionalPad,
);
}
PhysicalKeyboardKey _physicalKeyFor(LogicalKeyboardKey key) {
if (key == LogicalKeyboardKey.arrowDown) return PhysicalKeyboardKey.arrowDown;
if (key == LogicalKeyboardKey.goBack) return PhysicalKeyboardKey.escape;
if (key == LogicalKeyboardKey.select) return PhysicalKeyboardKey.select;
throw ArgumentError.value(key, 'key', 'Unsupported remote key');
}