From e75ab94eb9d4d9e51802de97a7fb5a4c89e70713 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:37:35 +0200 Subject: [PATCH] fix(tv): make osk search key focus results The TV keyboard dialog froze the field's callbacks at open time, so the search screen's done key fell through to unfocus() once results arrived. Resolve callbacks against the latest field widget at invoke time, and give the search screen a TV submit handler that focuses the first result or flushes the pending debounce. --- lib/focus/focusable_text_field.dart | 48 ++++--- lib/screens/search_screen.dart | 35 +++++ test/screens/search_screen_test.dart | 148 +++++++++++++++++++- test/widgets/focusable_text_field_test.dart | 143 +++++++++++++++++++ 4 files changed, 354 insertions(+), 20 deletions(-) diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index bc782bfa..8a3f25f6 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -585,24 +585,6 @@ abstract class _FocusableTextInputBase extends StatelessWidget { return null; } - Future _showTvKeyboard(BuildContext context) { - if (!enabled) return Future.value(); - return showTvVirtualKeyboard( - context: context, - controller: controller, - hintText: _keyboardHint(decoration), - keyboardType: keyboardType, - textInputAction: textInputAction, - inputFormatters: inputFormatters, - obscureText: obscureText, - maxLength: maxLength, - maxLines: maxLines, - onChanged: onChanged, - onSubmitted: onSubmitted, - onAction: _handleTvKeyboardAction, - ); - } - void _handleTvKeyboardAction() { if (onEditingComplete != null) { onEditingComplete!(); @@ -776,8 +758,36 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { _suppressTvKeyboardForCurrentFocus = false; _suppressTvKeyboardAutoOpen = true; _logTvTextInput('Host.openTvKeyboard node=${_installedFocusNode?.debugLabel}'); + // The dialog outlives input rebuilds (e.g. a search field whose + // onNavigateDown appears once results arrive while the keyboard is up), + // so only static configuration may be snapshotted here — the callbacks + // must resolve against widget.input at invoke time. + final input = widget.input; unawaited( - widget.input._showTvKeyboard(context).whenComplete(() { + showTvVirtualKeyboard( + context: context, + controller: input.controller, + hintText: _keyboardHint(input.decoration), + keyboardType: input.keyboardType, + textInputAction: input.textInputAction, + inputFormatters: input.inputFormatters, + obscureText: input.obscureText, + maxLength: input.maxLength, + maxLines: input.maxLines, + onChanged: (text) { + if (!mounted) return; + widget.input.onChanged?.call(text); + }, + onSubmitted: (text) { + if (!mounted) return; + final current = widget.input; + if (current.onSubmitted != null) { + current.onSubmitted!(text); + } else { + current._handleTvKeyboardAction(); + } + }, + ).whenComplete(() { if (!mounted) return; _tvKeyboardOpen = false; WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 3a1ebc5c..87f98ca6 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -12,6 +12,7 @@ import '../mixins/mounted_set_state_mixin.dart'; import '../mixins/refreshable.dart'; import '../providers/multi_server_provider.dart'; import '../utils/app_logger.dart'; +import '../utils/platform_detector.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/loading_indicator_box.dart'; @@ -44,6 +45,7 @@ class _SearchScreenState extends State bool _hasSearched = false; late final Debounce _searchDebounce; String _lastSearchedQuery = ''; + String? _focusResultsForQuery; @override void initState() { @@ -69,6 +71,7 @@ class _SearchScreenState extends State if (query.trim().isEmpty) { _searchDebounce.cancel(); + _focusResultsForQuery = null; setStateIfMounted(() { _searchResults = []; _hasSearched = false; @@ -117,8 +120,10 @@ class _SearchScreenState extends State _isSearching = false; _lastSearchedQuery = query.trim(); }); + _maybeFocusResultsAfterSubmit(query, neutral); } } catch (e) { + _focusResultsForQuery = null; if (mounted) { setStateIfMounted(() { _isSearching = false; @@ -128,6 +133,34 @@ class _SearchScreenState extends State } } + /// OSK "Search" / hardware Enter on TV: jump to results, or force the + /// search to run now and focus results when it lands. + void _handleSearchSubmit() { + final query = _searchController.text.trim(); + if (query.isEmpty) return; + + if (_searchResults.isNotEmpty && !_isSearching && query == _lastSearchedQuery.trim()) { + _firstResultFocusNode.requestFocus(); + return; + } + + _focusResultsForQuery = query; + if (_searchDebounce.isPending || !_isSearching) { + _searchDebounce.cancel(); + _performSearch(query); + } + // else: the in-flight search already covers the current text; its + // completion focuses the results. + } + + void _maybeFocusResultsAfterSubmit(String query, List results) { + if (_focusResultsForQuery == null || _focusResultsForQuery != query.trim()) return; + _focusResultsForQuery = null; + if (results.isEmpty) return; + if (_searchController.text.trim() != query.trim()) return; // user kept editing + FocusUtils.requestFocusAfterBuild(this, _firstResultFocusNode); + } + @override void refresh() { if (!mounted) return; @@ -163,6 +196,7 @@ class _SearchScreenState extends State appLogger.d('SearchScreen.fullRefresh() called - clearing search and reloading'); // Clear search results and search text for new profile _searchController.clear(); + _focusResultsForQuery = null; setStateIfMounted(() { _searchResults.clear(); _isSearching = false; @@ -228,6 +262,7 @@ class _SearchScreenState extends State onNavigateDown: _searchResults.isNotEmpty && !_isSearching ? _firstResultFocusNode.requestFocus : null, + onEditingComplete: PlatformDetector.isTV() ? _handleSearchSubmit : null, onBack: () { if (_searchController.text.isNotEmpty) { _searchController.clear(); diff --git a/test/screens/search_screen_test.dart b/test/screens/search_screen_test.dart index 1df4bfc8..e70ce505 100644 --- a/test/screens/search_screen_test.dart +++ b/test/screens/search_screen_test.dart @@ -1,16 +1,42 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/mixins/refreshable.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/search_screen.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/prefs.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - setUp(() { + setUpAll(() { LocaleSettings.setLocaleSync(AppLocale.en); }); + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + TvDetectionService.setForceTVSync(false); + }); + testWidgets('stale callbacks are no-ops after SearchScreen is disposed', (tester) async { final key = GlobalKey>(); @@ -36,4 +62,124 @@ void main() { expect(() => (state as FocusableTab).focusActiveTabIfReady(), returnsNormally); expect(tester.takeException(), isNull); }); + + testWidgets('TV OSK search key moves focus to the first result', (tester) async { + final (client, key) = await _pumpTvSearchScreen(tester); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + + final state = key.currentState!; + (state as SearchInputFocusable).setSearchQuery('movie'); + // rate_limiter's Debounce compares DateTime.now() against the fake-clock + // timer, so it never invokes under FakeAsync — run the search via + // refresh() (same _performSearch path) to get results behind the dialog. + (state as Refreshable).refresh(); + await tester.pumpAndSettle(); + expect(client.queries, ['movie']); + expect(find.text('Movie 1'), findsOneWidget); + + await tester.tap(_keyboardDoneKey()); + 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); + + // Dispose the screen so its still-armed debounce timer is cancelled. + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('TV OSK search key before the debounce fires searches immediately', (tester) async { + final (client, key) = await _pumpTvSearchScreen(tester); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + + (key.currentState! as SearchInputFocusable).setSearchQuery('movie'); + await tester.pump(const Duration(milliseconds: 100)); + expect(client.queries, isEmpty); + + await tester.tap(_keyboardDoneKey()); + await tester.pumpAndSettle(); + + expect(client.queries, ['movie']); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); + }); +} + +Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchScreen(WidgetTester tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + final client = _FakeMediaServerClient( + items: [ + MediaItem( + id: 'movie_1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie 1', + serverId: 'server_1', + serverName: 'Server', + ), + ], + ); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final provider = MultiServerProvider(manager, DataAggregationService(manager)); + addTearDown(provider.dispose); + + final key = GlobalKey>(); + await tester.pumpWidget( + TranslationProvider( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: monoTheme(dark: true), + home: SearchScreen(key: key), + ), + ), + ), + ); + return (client, key); +} + +Finder _keyboardDoneKey() { + return find.descendant( + of: find.byKey(const Key('tv_virtual_keyboard_panel')), + matching: find.byIcon(Icons.search_rounded), + ); +} + +class _FakeMediaServerClient implements MediaServerClient { + final List items; + final List queries = []; + + _FakeMediaServerClient({required this.items}); + + @override + ServerId get serverId => ServerId('server_1'); + + @override + String? get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => ServerCapabilities.plex; + + @override + Future> searchItems(String query, {int limit = 100}) async { + queries.add(query); + return items; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index 05bb970d..7b1c0aba 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -683,6 +683,145 @@ void main() { expect(find.byType(Dialog), findsNothing); }); + testWidgets('TV keyboard done resolves callbacks against the latest field widget', (tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + await _setTvSurfaceSize(tester); + final controller = TextEditingController(text: 'query'); + final fieldFocusNode = FocusNode(debugLabel: 'search_field'); + var navigateDownCalls = 0; + VoidCallback? onNavigateDown; + late StateSetter rebuild; + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + textInputAction: TextInputAction.search, + onNavigateDown: onNavigateDown, + ); + }, + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + + // Simulates search results arriving while the keyboard is open: the field + // rebuilds and only now gains an onNavigateDown callback. + rebuild(() => onNavigateDown = () => navigateDownCalls++); + await tester.pump(); + + await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded)); + await tester.pumpAndSettle(); + + expect(navigateDownCalls, 1); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(controller.text, 'query'); + }); + + testWidgets('TV keyboard done prefers the latest onSubmitted over navigation', (tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + await _setTvSurfaceSize(tester); + final controller = TextEditingController(text: 'query'); + final fieldFocusNode = FocusNode(debugLabel: 'search_field'); + String? submitted; + var navigateDownCalls = 0; + ValueChanged? onSubmitted; + VoidCallback? onNavigateDown; + late StateSetter rebuild; + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + textInputAction: TextInputAction.search, + onSubmitted: onSubmitted, + onNavigateDown: onNavigateDown, + ); + }, + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + + rebuild(() { + onSubmitted = (value) => submitted = value; + onNavigateDown = () => navigateDownCalls++; + }); + await tester.pump(); + + await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded)); + await tester.pumpAndSettle(); + + expect(submitted, 'query'); + expect(navigateDownCalls, 0); + }); + + testWidgets('TV keyboard stays closed when done keeps field focus', (tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + await _setTvSurfaceSize(tester); + final controller = TextEditingController(text: 'query'); + final fieldFocusNode = FocusNode(debugLabel: 'search_field'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + textInputAction: TextInputAction.search, + onEditingComplete: () {}, + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + + await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded)); + await tester.pumpAndSettle(); + await tester.pump(); + + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(fieldFocusNode.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + }); + testWidgets('tvOS keyboard enter inserts newline for multiline text field', (tester) async { TvDetectionService.debugSetAppleTVOverride(true); final controller = TextEditingController(text: 'a'); @@ -718,6 +857,10 @@ Future _setTvSurfaceSize(WidgetTester tester) async { addTearDown(() => tester.binding.setSurfaceSize(null)); } +Finder _tvKeyboardDoneKey(IconData icon) { + return find.descendant(of: find.byKey(const Key('tv_virtual_keyboard_panel')), matching: find.byIcon(icon)); +} + KeyDownEvent _remoteKey(LogicalKeyboardKey key) { return KeyDownEvent( physicalKey: _physicalKeyFor(key),