diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index 77111b7e..dfd653ca 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -62,7 +62,7 @@ class TvTextInputController { if (identical(_host, host)) _host = null; } - /// Dismiss the Flutter overlay if it is open and prevent it from reopening + /// Dismiss active native or Flutter text input and prevent it from reopening /// while the field keeps focus. void closeTextInput() => _host?._dismissTvKeyboard(); @@ -730,6 +730,7 @@ typedef _FocusableTextInputBuilder = required FocusNode focusNode, required VoidCallback openKeyboard, required VoidCallback activateNativeTextInput, + required VoidCallback? onEditingComplete, }); class _FocusableTextInputHost extends StatefulWidget { @@ -759,6 +760,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { bool _nativeTextInputActivated = false; bool _hasSeenNativeTextInputFocus = false; bool _suppressNativeTextInputForCurrentFocus = false; + bool _nativeTextInputCompletionHandled = false; FocusNode get _effectiveFocusNode => _focusNodeBinding.node; @@ -849,6 +851,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { void _setNativeTextInputActivated(bool activated) { if (_nativeTextInputActivated == activated) return; + if (activated) _nativeTextInputCompletionHandled = false; if (!mounted) { _nativeTextInputActivated = activated; return; @@ -863,6 +866,31 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { _setNativeTextInputActivated(true); } + VoidCallback? get _effectiveOnEditingComplete { + final input = widget.input; + if (!input._usesNativeTvKeyboard) return input._effectiveOnEditingComplete; + return _handleNativeEditingComplete; + } + + void _handleNativeEditingComplete() { + if (_nativeTextInputCompletionHandled) return; + _nativeTextInputCompletionHandled = true; + _suppressNativeTextInputForCurrentFocus = true; + _setNativeTextInputActivated(false); + + final input = widget.input; + final callback = input._effectiveOnEditingComplete; + if (callback != null) { + callback(); + } else if (input.onSubmitted == null) { + // Supplying this wrapper replaces EditableText's default completion. + // Preserve it when there is no submit callback; submitted TV fields keep + // focus until their callback chooses the next target so D-pad navigation + // cannot dead-end while asynchronous work runs. + _defaultEditingComplete(input.textInputAction); + } + } + void _syncNativeTextInputFocus() { final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard; if (TextInputDiagnostics.enabled) { @@ -993,13 +1021,18 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { ); } - /// Dismiss an open OSK imperatively (e.g. a companion-remote search that must - /// show results instead of the keyboard). Suppresses auto-reopen so a field - /// that regains focus when the dialog route pops does not relaunch it. + /// Dismiss active text input imperatively (e.g. a companion-remote search + /// that must show results instead of the keyboard). Suppresses auto-reopen + /// so a field that keeps or regains focus does not relaunch it. void _dismissTvKeyboard() { - // No-op when nothing is up: setting the suppress flag here (with no - // compensating unfocus to clear it) would block a later legitimate - // auto-open when the user deliberately focuses the field. + if (widget.input._usesNativeTvKeyboard && _nativeTextInputActivated) { + _suppressNativeTextInputForCurrentFocus = true; + _setNativeTextInputActivated(false); + } + + // No-op for the Flutter presentation when no overlay is up: setting its + // suppress flag without a compensating unfocus would block a later + // legitimate auto-open. if (!_tvKeyboardOpen && !_tvKeyboardOpenScheduled) return; _tvKeyboardOpenScheduled = false; _suppressTvKeyboardAutoOpen = true; @@ -1132,6 +1165,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { focusNode: focusNode, openKeyboard: _openTvKeyboard, activateNativeTextInput: _activateNativeTextInput, + onEditingComplete: _effectiveOnEditingComplete, ); } } @@ -1186,6 +1220,7 @@ class FocusableTextField extends _FocusableTextInputBase { required FocusNode focusNode, required VoidCallback openKeyboard, required VoidCallback activateNativeTextInput, + required VoidCallback? onEditingComplete, }) { final tvInput = _tvInputConfiguration( usesTvKeyboard: usesTvKeyboard, @@ -1203,7 +1238,7 @@ class FocusableTextField extends _FocusableTextInputBase { inputFormatters: inputFormatters, onChanged: onChanged, onSubmitted: onSubmitted, - onEditingComplete: _effectiveOnEditingComplete, + onEditingComplete: onEditingComplete, autofocus: autofocus, autocorrect: autocorrect, enableSuggestions: enableSuggestions, @@ -1274,6 +1309,7 @@ class FocusableTextFormField extends _FocusableTextInputBase { required FocusNode focusNode, required VoidCallback openKeyboard, required VoidCallback activateNativeTextInput, + required VoidCallback? onEditingComplete, }) { final tvInput = _tvInputConfiguration( usesTvKeyboard: usesTvKeyboard, @@ -1291,7 +1327,7 @@ class FocusableTextFormField extends _FocusableTextInputBase { inputFormatters: inputFormatters, onChanged: onChanged, onFieldSubmitted: onFieldSubmitted, - onEditingComplete: _effectiveOnEditingComplete, + onEditingComplete: onEditingComplete, validator: validator, autovalidateMode: autovalidateMode, onSaved: onSaved, diff --git a/lib/screens/catalog_search_screen.dart b/lib/screens/catalog_search_screen.dart index 0747522d..072801a1 100644 --- a/lib/screens/catalog_search_screen.dart +++ b/lib/screens/catalog_search_screen.dart @@ -72,7 +72,6 @@ class _CatalogSearchScreenState extends State with Debounce FocusableTextField( controller: searchController, focusNode: searchFocusNode, - tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, textInputAction: TextInputAction.search, onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null, diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index a6295c5c..e91f9b3e 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -226,7 +226,6 @@ class _SearchScreenState extends State controller: searchController, focusNode: searchFocusNode, tvTextInputController: _tvTextInputController, - tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, textInputAction: TextInputAction.search, onNavigateLeft: _navigateToSidebar, onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null, diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 44922f52..239e1349 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -487,7 +487,9 @@ class _AddJellyfinScreenState extends State with AsyncFormSta FocusableTextFormField( controller: _urlController, focusNode: _urlFocus, - tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, + tvTextInputPresentation: PlatformDetector.isAppleTV() + ? TvTextInputPresentation.platform + : TvTextInputPresentation.automatic, autofocus: true, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, keyboardType: TextInputType.url, diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index aab599b2..09161776 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -962,6 +962,7 @@ class _RelayUrlDialogState extends State<_RelayUrlDialog> { } }, onEditingComplete: () => _saveFocusNode.requestFocus(), + onNavigateDown: _saveFocusNode.requestFocus, ), actions: [ DialogActionButton(onPressed: _reset, label: t.settings.resetToDefault), diff --git a/test/screens/search_screen_test.dart b/test/screens/search_screen_test.dart index 5fe0ebd0..1e2f499e 100644 --- a/test/screens/search_screen_test.dart +++ b/test/screens/search_screen_test.dart @@ -78,38 +78,38 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('TV OSK search key moves focus to the first result', (tester) async { + testWidgets('TV native Search action moves focus to the first result', (tester) async { final (client, _) = await _pumpTvSearchScreen(tester); await tester.pumpAndSettle(); - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(tester.widget(find.byType(TextField)).readOnly, isFalse); _searchController(tester).text = 'movie'; - // Let the normal debounce populate results while the OSK remains open. - // DebouncedMediaSearch now uses a fake-clock-aware Timer. + // Let the normal debounce populate results while native input remains active. await tester.pump(const Duration(milliseconds: 500)); await tester.pump(); expect(client.queries, ['movie']); expect(find.text('Movie 1'), findsOneWidget); - await tester.tap(_keyboardDoneKey()); + await tester.showKeyboard(find.byType(TextField)); + await tester.testTextInput.receiveAction(TextInputAction.search); await tester.pumpAndSettle(); - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); expect(find.text('Movie 1'), findsOneWidget); expect(client.queries, ['movie']); }); - testWidgets('TV OSK search key before the debounce fires searches immediately', (tester) async { - final (client, key) = await _pumpTvSearchScreen(tester); + testWidgets('TV native Search action before debounce searches immediately', (tester) async { + final (client, _) = await _pumpTvSearchScreen(tester); await tester.pumpAndSettle(); - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); _searchController(tester).text = 'movie'; await tester.pump(const Duration(milliseconds: 100)); expect(client.queries, isEmpty); - await tester.tap(_keyboardDoneKey()); + await tester.showKeyboard(find.byType(TextField)); + await tester.testTextInput.receiveAction(TextInputAction.search); await tester.pumpAndSettle(); expect(client.queries, ['movie']); @@ -117,88 +117,77 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); }); - testWidgets('companion-remote submitSearchQuery dismisses an open OSK and focuses results', (tester) async { + testWidgets('companion-remote submitSearchQuery closes native input and focuses results', (tester) async { final (client, key) = await _pumpTvSearchScreen(tester); await tester.pumpAndSettle(); - // The search screen autofocuses its input on TV, so the OSK is already up — - // exactly the "keyboard already open when the remote search arrives" flow - // (the phone's Search chip sends tabSearch before the query). - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + expect(tester.widget(find.byType(TextField)).readOnly, isFalse); (key.currentState! as SearchInputFocusable).submitSearchQuery('movie'); await tester.pumpAndSettle(); expect(client.queries, ['movie']); expect(find.text('Movie 1'), findsOneWidget); - // The OSK is dismissed (and does not auto-reopen), focus lands on results. expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); - // Stays closed on subsequent frames, and the selection write from the - // focus change must not re-arm the debounce into a second identical fetch. + // Selection updates must not re-arm the debounce into a second fetch. await tester.pump(const Duration(milliseconds: 600)); await tester.pumpAndSettle(); - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(client.queries, ['movie']); - // Re-submitting already-displayed results requests the input and then the - // existing first result in the same turn. That superseded input request - // must not leave its one-focus-entry keyboard suppression stuck. + // Re-submitting already-displayed results leaves result focus stable. final searchInput = key.currentState! as SearchInputFocusable; searchInput.submitSearchQuery('movie'); await tester.pumpAndSettle(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); expect(client.queries, ['movie']); + // A deliberate return to the input starts a fresh native session. searchInput.focusSearchInput(); await tester.pumpAndSettle(); - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + expect(tester.widget(find.byType(TextField)).readOnly, isFalse); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); await tester.pumpWidget(const SizedBox.shrink()); }); - testWidgets('companion-remote submitSearchQuery with no results focuses the input without the OSK', (tester) async { + testWidgets('companion-remote query with no results keeps native input closed', (tester) async { final (client, key) = await _pumpTvSearchScreen(tester, items: []); await tester.pumpAndSettle(); - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + expect(tester.widget(find.byType(TextField)).readOnly, isFalse); (key.currentState! as SearchInputFocusable).submitSearchQuery('zzz'); await tester.pumpAndSettle(); expect(client.queries, ['zzz']); - // No results: the OSK is dismissed and the input keeps focus WITHOUT the - // keyboard reopening, so the remote isn't stranded. expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchInput'); + expect(tester.widget(find.byType(TextField)).readOnly, isTrue); - // Does not auto-reopen while the input keeps focus. await tester.pump(const Duration(milliseconds: 200)); await tester.pumpAndSettle(); - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(tester.widget(find.byType(TextField)).readOnly, isTrue); await tester.pumpWidget(const SizedBox.shrink()); }); - testWidgets('companion-remote submitSearchQuery whose search fails keeps focus on the input without the OSK', ( - tester, - ) async { + testWidgets('failed companion-remote query keeps native input closed', (tester) async { final (client, key) = await _pumpTvSearchScreen(tester, registerClient: false); await tester.pumpAndSettle(); - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + expect(tester.widget(find.byType(TextField)).readOnly, isFalse); (key.currentState! as SearchInputFocusable).submitSearchQuery('movie'); await tester.pumpAndSettle(); - // performSearchQuery threw (no servers): the failed state renders, the OSK - // is dismissed, and the input keeps focus so the remote isn't stranded. expect(client.queries, isEmpty); expect(find.byIcon(Symbols.error_rounded), findsOneWidget); expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchInput'); + expect(tester.widget(find.byType(TextField)).readOnly, isTrue); await tester.pump(const Duration(milliseconds: 200)); await tester.pumpAndSettle(); - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(tester.widget(find.byType(TextField)).readOnly, isTrue); await tester.pumpWidget(const SizedBox.shrink()); }); @@ -292,9 +281,7 @@ Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchSc bool registerClient = true, List<_FakeMediaServerClient> additionalClients = const [], }) async { - TvDetectionService.debugSetAppleTVOverride(null); - await TvDetectionService.getInstance(forceTv: true); - TvDetectionService.setForceTVSync(true); + TvDetectionService.debugSetAppleTVOverride(true); tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(1280, 720); addTearDown(() { @@ -345,13 +332,6 @@ Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchSc return (client, key); } -Finder _keyboardDoneKey() { - return find.descendant( - of: find.byKey(const Key('tv_virtual_keyboard_panel')), - matching: find.byIcon(Symbols.search_rounded), - ); -} - TextEditingController _searchController(WidgetTester tester) { return tester.widget(find.byType(FocusableTextField)).controller; } diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index f7edd369..4895b0be 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -370,6 +370,7 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(tester.widget(find.byType(TextField)).keyboardType, TextInputType.url); }); testWidgets('Android TV D-pad can leave initial URL focus before keyboard opens', (tester) async { @@ -425,8 +426,9 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pumpAndSettle(); - expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(tester.widget(find.byType(TextField)).readOnly, isFalse); }); testWidgets('D-pad moves from URL through Change to credentials after server is found', (tester) async { diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index 256731a4..652d2788 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -261,6 +261,120 @@ void main() { expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); }); + testWidgets('Apple TV native Done deactivates input before D-pad navigation', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + final controller = TextEditingController(); + final fieldFocusNode = FocusNode(debugLabel: 'native_url_field'); + final nextFocusNode = FocusNode(debugLabel: 'save_button'); + var completed = 0; + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + addTearDown(nextFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + textInputAction: TextInputAction.done, + onEditingComplete: () => completed++, + onNavigateDown: nextFocusNode.requestFocus, + ), + FilledButton(focusNode: nextFocusNode, onPressed: () {}, child: const Text('Save')), + ], + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + await tester.showKeyboard(find.byType(TextField)); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(completed, 1); + expect(fieldFocusNode.hasPrimaryFocus, isTrue); + expect(tester.widget(find.byType(TextField)).readOnly, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(nextFocusNode.hasPrimaryFocus, isTrue); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + }); + + testWidgets('Apple TV native Go submits once and deactivates input', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + final controller = TextEditingController(text: 'https://jellyfin.example.com'); + final fieldFocusNode = FocusNode(debugLabel: 'native_url_field'); + final submissions = []; + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableTextFormField( + controller: controller, + focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.platform, + textInputAction: TextInputAction.go, + onFieldSubmitted: submissions.add, + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + await tester.showKeyboard(find.byType(TextField)); + await tester.testTextInput.receiveAction(TextInputAction.go); + await tester.pump(); + + expect(submissions, ['https://jellyfin.example.com']); + expect(tester.widget(find.byType(TextField)).readOnly, isTrue); + expect(fieldFocusNode.hasPrimaryFocus, isTrue); + + tester.testTextInput.closeConnection(); + await tester.pump(); + expect(submissions, ['https://jellyfin.example.com']); + }); + + testWidgets('Apple TV controller closes native input without losing field focus', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + final controller = TextEditingController(); + final textInputController = TvTextInputController(); + final fieldFocusNode = FocusNode(debugLabel: 'native_search_field'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + tvTextInputController: textInputController, + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(TextField)).readOnly, isFalse); + + textInputController.closeTextInput(); + await tester.pump(); + + expect(fieldFocusNode.hasPrimaryFocus, isTrue); + expect(tester.widget(find.byType(TextField)).readOnly, isTrue); + }); + testWidgets('Apple TV navigation resumes after native keyboard dismissal', (tester) async { TvDetectionService.debugSetAppleTVOverride(true); final controller = TextEditingController();