From caf192fd7890541cf56a86130c433d539af9306b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:38:58 +0200 Subject: [PATCH] fix(jellyfin): defer initial URL keyboard close #1217 --- lib/focus/focusable_text_field.dart | 45 ++++++++++- lib/screens/settings/add_jellyfin_screen.dart | 1 + .../settings/add_jellyfin_screen_test.dart | 38 +++++---- test/widgets/focusable_text_field_test.dart | 80 +++++++++++++++++++ 4 files changed, 143 insertions(+), 21 deletions(-) diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index 6a4c6d4c..bc782bfa 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -13,6 +13,19 @@ bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetec String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText; +enum TvKeyboardAutoOpenBehavior { + /// Open the TV virtual keyboard whenever the field receives focus. + onFocus, + + /// Keep initial focus on the field without opening the keyboard, then open + /// automatically on later focus entries. Explicit tap/select still opens it. + afterFirstFocus, + + /// Never auto-open the TV virtual keyboard on focus. Explicit tap/select + /// still opens it. + never, +} + String _describeTextInputKey(KeyEvent event) { return 'type=${event.runtimeType} logical=${event.logicalKey.keyLabel}/${event.logicalKey.keyId} ' 'physical=${event.physicalKey.usbHidUsage} deviceType=${event.deviceType} character=${event.character}'; @@ -513,6 +526,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget { final bool autofocus; final bool enabled; final bool enableTvKeyboard; + final TvKeyboardAutoOpenBehavior tvKeyboardAutoOpenBehavior; final bool obscureText; final bool autocorrect; final bool enableSuggestions; @@ -545,6 +559,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget { this.autofocus = false, this.enabled = true, this.enableTvKeyboard = true, + this.tvKeyboardAutoOpenBehavior = TvKeyboardAutoOpenBehavior.onFocus, this.obscureText = false, this.autocorrect = true, this.enableSuggestions = true, @@ -654,6 +669,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { bool _tvKeyboardOpen = false; bool _tvKeyboardOpenScheduled = false; bool _suppressTvKeyboardAutoOpen = false; + bool _hasSeenTvKeyboardFocus = false; + bool _suppressTvKeyboardForCurrentFocus = false; FocusNode get _effectiveFocusNode => widget.input.focusNode ?? (_ownedFocusNode ??= FocusNode(debugLabel: 'FocusableTextInput')); @@ -665,6 +682,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { _restoreInstalledHandler(); _suppressTvKeyboardAutoOpen = false; _tvKeyboardOpenScheduled = false; + _hasSeenTvKeyboardFocus = false; + _suppressTvKeyboardForCurrentFocus = false; } _handleFocusChanged(); } @@ -696,12 +715,15 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { final visible = _canShowTvKeyboard; _logTvTextInput( 'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled ' - 'suppressed=$_suppressTvKeyboardAutoOpen installed=${_installedFocusNode?.debugLabel} ' + 'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvKeyboardAutoOpenBehavior} ' + 'seenFocus=$_hasSeenTvKeyboardFocus suppressCurrent=$_suppressTvKeyboardForCurrentFocus ' + 'installed=${_installedFocusNode?.debugLabel} ' 'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} ' 'usesTvKeyboard=${widget.input._hasTvKeyboard} visible=$visible', ); if (!focused) { + _suppressTvKeyboardForCurrentFocus = false; if (!_tvKeyboardOpen && !_tvKeyboardOpenScheduled) { _suppressTvKeyboardAutoOpen = false; } @@ -709,6 +731,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { } if (!visible) return; + if (!_shouldAutoOpenTvKeyboardForCurrentFocus()) return; if (_suppressTvKeyboardAutoOpen || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return; _tvKeyboardOpenScheduled = true; @@ -726,6 +749,22 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { return TickerMode.valuesOf(context).enabled && (route?.isCurrent ?? true); } + bool _shouldAutoOpenTvKeyboardForCurrentFocus() { + switch (widget.input.tvKeyboardAutoOpenBehavior) { + case TvKeyboardAutoOpenBehavior.onFocus: + return true; + case TvKeyboardAutoOpenBehavior.afterFirstFocus: + if (!_hasSeenTvKeyboardFocus) { + _hasSeenTvKeyboardFocus = true; + _suppressTvKeyboardForCurrentFocus = true; + return false; + } + return !_suppressTvKeyboardForCurrentFocus; + case TvKeyboardAutoOpenBehavior.never: + return false; + } + } + void _openTvKeyboard() { if (!mounted || !widget.input.enabled || !widget.input._hasTvKeyboard || !_canShowTvKeyboard || _tvKeyboardOpen) { return; @@ -733,6 +772,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { _tvKeyboardOpenScheduled = false; _tvKeyboardOpen = true; + _hasSeenTvKeyboardFocus = true; + _suppressTvKeyboardForCurrentFocus = false; _suppressTvKeyboardAutoOpen = true; _logTvTextInput('Host.openTvKeyboard node=${_installedFocusNode?.debugLabel}'); unawaited( @@ -838,6 +879,7 @@ class FocusableTextField extends _FocusableTextInputBase { super.autofocus, super.enabled, super.enableTvKeyboard, + super.tvKeyboardAutoOpenBehavior, super.obscureText, super.autocorrect, super.enableSuggestions, @@ -913,6 +955,7 @@ class FocusableTextFormField extends _FocusableTextInputBase { super.autofocus, super.enabled, super.enableTvKeyboard, + super.tvKeyboardAutoOpenBehavior, super.obscureText, super.autocorrect, super.enableSuggestions, diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index a2951c02..1d11dd68 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -471,6 +471,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta controller: _urlController, focusNode: _urlFocus, autofocus: true, + tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, keyboardType: TextInputType.url, minLines: 1, maxLines: 4, diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index 15711006..40dc172f 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -77,7 +77,7 @@ void main() { expect(field.autofocus, isTrue); }); - testWidgets('TV initial focus opens the server URL keyboard', (tester) async { + testWidgets('TV initial focus keeps server URL focused without opening keyboard', (tester) async { TvDetectionService.debugSetAppleTVOverride(true); await tester.pumpWidget( @@ -87,11 +87,11 @@ void main() { ); 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); }); - testWidgets('Android TV remote navigation stays with virtual URL keyboard', (tester) async { + testWidgets('Android TV D-pad can leave initial URL focus before keyboard opens', (tester) async { TvDetectionService.debugSetAppleTVOverride(null); await TvDetectionService.getInstance(forceTv: true); TvDetectionService.setForceTVSync(true); @@ -103,21 +103,22 @@ void main() { ); await tester.pumpAndSettle(); - expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); - expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:FindServer'); - await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pumpAndSettle(); - expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); }); - testWidgets('TV discovery does not steal focus from the URL keyboard', (tester) async { + testWidgets('TV discovery keeps initial URL focus and D-pad reaches discovered servers', (tester) async { TvDetectionService.debugSetAppleTVOverride(true); await tester.pumpWidget( @@ -134,22 +135,19 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Home'), findsOneWidget); - expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); - - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); - - await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB); - await tester.pumpAndSettle(); - expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Discovered:srv-1'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); }); 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 8c33f855..05bb970d 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -325,6 +325,86 @@ void main() { expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); }); + testWidgets('Android TV after-first-focus skips initial auto-open and opens on refocus', (tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + await _setTvSurfaceSize(tester); + final controller = TextEditingController(); + final fieldFocusNode = FocusNode(debugLabel: 'server_url_field'); + final otherFocusNode = FocusNode(debugLabel: 'find_server_button'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + addTearDown(otherFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + FocusableTextFormField( + controller: controller, + focusNode: fieldFocusNode, + tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, + ), + Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)), + ], + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(fieldFocusNode.hasPrimaryFocus, isTrue); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + + otherFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(otherFocusNode.hasPrimaryFocus, isTrue); + + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + }); + + testWidgets('Android TV after-first-focus opens on explicit select', (tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + await _setTvSurfaceSize(tester); + final controller = TextEditingController(); + final fieldFocusNode = FocusNode(debugLabel: 'server_url_field'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableTextFormField( + controller: controller, + focusNode: fieldFocusNode, + tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(fieldFocusNode.hasPrimaryFocus, isTrue); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + }); + testWidgets('Android TV remote keys are passed to native text input', (tester) async { TvDetectionService.debugSetAppleTVOverride(null); await TvDetectionService.getInstance(forceTv: true);