fix(tvos): restore native text input navigation

This commit is contained in:
edde746
2026-07-26 07:08:01 +02:00
parent 71735354b9
commit 829d3745a1
8 changed files with 193 additions and 60 deletions
+45 -9
View File
@@ -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,
-1
View File
@@ -72,7 +72,6 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> 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,
-1
View File
@@ -226,7 +226,6 @@ class _SearchScreenState extends State<SearchScreen>
controller: searchController,
focusNode: searchFocusNode,
tvTextInputController: _tvTextInputController,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
textInputAction: TextInputAction.search,
onNavigateLeft: _navigateToSidebar,
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
@@ -487,7 +487,9 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> 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,
@@ -962,6 +962,7 @@ class _RelayUrlDialogState extends State<_RelayUrlDialog> {
}
},
onEditingComplete: () => _saveFocusNode.requestFocus(),
onNavigateDown: _saveFocusNode.requestFocus,
),
actions: [
DialogActionButton(onPressed: _reset, label: t.settings.resetToDefault),
+26 -46
View File
@@ -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<TextField>(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<TextField>(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<TextField>(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<TextField>(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<TextField>(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<TextField>(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<TextField>(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<TextField>(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<TextField>(find.byType(TextField)).readOnly, isTrue);
await tester.pumpWidget(const SizedBox.shrink());
});
@@ -292,9 +281,7 @@ Future<(_FakeMediaServerClient, GlobalKey<State<SearchScreen>>)> _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<State<SearchScreen>>)> _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<FocusableTextField>(find.byType(FocusableTextField)).controller;
}
@@ -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<TextField>(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<TextField>(find.byType(TextField)).readOnly, isFalse);
});
testWidgets('D-pad moves from URL through Change to credentials after server is found', (tester) async {
+114
View File
@@ -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<TextField>(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 = <String>[];
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<TextField>(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<TextField>(find.byType(TextField)).readOnly, isFalse);
textInputController.closeTextInput();
await tester.pump();
expect(fieldFocusNode.hasPrimaryFocus, isTrue);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
});
testWidgets('Apple TV navigation resumes after native keyboard dismissal', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();