diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index 3e7906c9..3542c1a8 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -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, diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index 78e33e96..f4b4dd95 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -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); diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index f873ace9..e099e292 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -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'); +}