diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index de217496..c34a96cb 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -26,6 +26,30 @@ enum TvKeyboardAutoOpenBehavior { never, } +/// Imperative handle to the TV on-screen keyboard of a [FocusableTextField] / +/// [FocusableTextFormField]. Pass the same instance to the field's +/// `tvKeyboardController`; the field's host attaches itself on mount. +/// +/// Only meaningful on TV. On other platforms the OSK never exists, so every +/// method is an effective no-op. +class TvKeyboardController { + _FocusableTextInputHostState? _host; + + void _attach(_FocusableTextInputHostState host) => _host = host; + void _detach(_FocusableTextInputHostState host) { + if (identical(_host, host)) _host = null; + } + + /// Dismiss the OSK if it is open (and prevent it from auto-reopening while + /// the field keeps focus). No-op when nothing is open. + void closeKeyboard() => _host?._dismissTvKeyboard(); + + /// Focus the field but do NOT open the OSK for this focus entry. Used as a + /// fallback landing spot (e.g. a remote search that returned no results) so + /// the remote isn't stranded on an off-screen element. + void focusInputWithoutKeyboard() => _host?._focusWithoutKeyboard(); +} + 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}'; @@ -527,6 +551,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget { final bool enabled; final bool enableTvKeyboard; final TvKeyboardAutoOpenBehavior tvKeyboardAutoOpenBehavior; + final TvKeyboardController? tvKeyboardController; final bool obscureText; final bool autocorrect; final bool enableSuggestions; @@ -560,6 +585,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget { this.enabled = true, this.enableTvKeyboard = true, this.tvKeyboardAutoOpenBehavior = TvKeyboardAutoOpenBehavior.onFocus, + this.tvKeyboardController, this.obscureText = false, this.autocorrect = true, this.enableSuggestions = true, @@ -658,9 +684,19 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { FocusNode get _effectiveFocusNode => widget.input.focusNode ?? (_ownedFocusNode ??= FocusNode(debugLabel: 'FocusableTextInput')); + @override + void initState() { + super.initState(); + widget.input.tvKeyboardController?._attach(this); + } + @override void didUpdateWidget(_FocusableTextInputHost oldWidget) { super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.input.tvKeyboardController, widget.input.tvKeyboardController)) { + oldWidget.input.tvKeyboardController?._detach(this); + widget.input.tvKeyboardController?._attach(this); + } if (oldWidget.input.focusNode != widget.input.focusNode) { // An open keyboard dialog intentionally survives rebuilds and focusNode // swaps; it is closed only when this host unmounts — see dispose. @@ -675,6 +711,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { @override void dispose() { + widget.input.tvKeyboardController?._detach(this); _restoreInstalledHandler(); // The keyboard is a navigator route — it must not outlive the field that // opened it (e.g. a form section swapped out while the keyboard is up). @@ -818,6 +855,35 @@ 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. + 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 (!_tvKeyboardOpen && !_tvKeyboardOpenScheduled) return; + _tvKeyboardOpenScheduled = false; + _suppressTvKeyboardAutoOpen = true; + _tvKeyboardHandle?.close(); + } + + /// Focus the field without opening the OSK for this focus entry. Suppress is + /// set BEFORE requestFocus so the resulting focus-change sync already sees it; + /// the flag persists for this focus and resets on the next unfocus. If a + /// same-turn focus request supersedes this one, clear the unused suppression + /// after Flutter resolves its pending focus change. + void _focusWithoutKeyboard() { + _suppressTvKeyboardAutoOpen = true; + _tvKeyboardOpenScheduled = false; + final focusNode = _installedFocusNode ?? _effectiveFocusNode; + focusNode.requestFocus(); + scheduleMicrotask(() { + if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return; + _suppressTvKeyboardAutoOpen = false; + }); + } + void _setNativeTextInputFocused(bool focused) { if (_reportedNativeTextInputFocused == focused) { _logTvTextInput('Host.setNativeTextInputFocused no-op focused=$focused'); @@ -907,6 +973,7 @@ class FocusableTextField extends _FocusableTextInputBase { super.enabled, super.enableTvKeyboard, super.tvKeyboardAutoOpenBehavior, + super.tvKeyboardController, super.obscureText, super.autocorrect, super.enableSuggestions, @@ -983,6 +1050,7 @@ class FocusableTextFormField extends _FocusableTextInputBase { super.enabled, super.enableTvKeyboard, super.tvKeyboardAutoOpenBehavior, + super.tvKeyboardController, super.obscureText, super.autocorrect, super.enableSuggestions, diff --git a/lib/mixins/debounced_media_search.dart b/lib/mixins/debounced_media_search.dart index 8114d323..1fd7ce19 100644 --- a/lib/mixins/debounced_media_search.dart +++ b/lib/mixins/debounced_media_search.dart @@ -34,6 +34,7 @@ mixin DebouncedMediaSearch on State { int _searchGeneration = 0; String? _inFlightQuery; bool _showedClearButton = false; + String _lastObservedText = ''; /// Names the focus nodes and log lines. String get searchDebugLabel => widget.runtimeType.toString(); @@ -68,7 +69,14 @@ mixin DebouncedMediaSearch on State { void _onSearchTextChanged() { if (!mounted) return; - final query = searchController.text.trim(); + // The controller also notifies on selection/composing changes (e.g. the + // focus gain after an external text set writes a collapsed selection); a + // selection-only notification mid-flight would re-arm the debounce and + // re-run the identical query against the servers. + final text = searchController.text; + if (text == _lastObservedText) return; + _lastObservedText = text; + final query = text.trim(); // The clear affordance tracks text emptiness; without this rebuild it // only appeared when a search landed ~500ms later. diff --git a/lib/mixins/refreshable.dart b/lib/mixins/refreshable.dart index 3b97f208..216494a3 100644 --- a/lib/mixins/refreshable.dart +++ b/lib/mixins/refreshable.dart @@ -12,7 +12,11 @@ mixin FocusableTab { mixin SearchInputFocusable { void focusSearchInput(); - void setSearchQuery(String query); + + /// Apply a complete query submitted from outside the field (e.g. the Plezy + /// companion remote): run the search and land focus on the results without + /// leaving the TV on-screen keyboard open. + void submitSearchQuery(String query); } mixin LibraryLoadable { diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 78f4d0a3..e8ff5f67 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -810,11 +810,15 @@ class _MainScreenState extends State receiver.onTabSettings = () => _selectTab(NavigationTabId.settings); receiver.onHome = () => _selectTab(NavigationTabId.discover); receiver.onSearchAction = (query) { - _selectTab(NavigationTabId.search); - if (query != null && query.isNotEmpty) { + final trimmed = query?.trim() ?? ''; + final hasQuery = trimmed.isNotEmpty; + // With a query, don't focus the input (which would auto-open the TV + // keyboard); submitSearchQuery runs the search and focuses results. + _selectTab(NavigationTabId.search, focusSearchInput: !hasQuery); + if (hasQuery) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_searchKey.currentState case final SearchInputFocusable searchable) { - searchable.setSearchQuery(query); + searchable.submitSearchQuery(trimmed); } }); } @@ -1423,7 +1427,7 @@ class _MainScreenState extends State } } - void _selectTab(NavigationTabId tab) { + void _selectTab(NavigationTabId tab, {bool focusSearchInput = true}) { // Guard: ignore if tab isn't available in current mode if (!_getVisibleTabs(_isOffline).any((t) => t.id == tab)) return; @@ -1454,7 +1458,10 @@ class _MainScreenState extends State // Back-to-home keeps the sidebar focused (chain: content → sidebar → // home → exit); stealing focus here left _isSidebarFocused stuck true // while real focus sat on a content card (#1411). - if (!_isSidebarFocused) { + // A companion-remote search (focusSearchInput: false) must NOT focus the + // search input, since focusing it auto-opens the on-screen keyboard; the + // query submit focuses results instead. + if (!_isSidebarFocused && (tab != NavigationTabId.search || focusSearchInput)) { if (newState case final FocusableTab focusable) { focusable.focusActiveTabIfReady(); } @@ -1466,8 +1473,10 @@ class _MainScreenState extends State _onDiscoverBecameVisible(); } - // Focus search input after rebuild so IndexedStack has made it visible - if (tab == NavigationTabId.search) { + // Focus search input after rebuild so IndexedStack has made it visible. + // Skipped for a companion-remote search (focusSearchInput: false), whose + // submit runs the search and focuses results without opening the keyboard. + if (tab == NavigationTabId.search && focusSearchInput) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_searchKey.currentState case final SearchInputFocusable searchable) { searchable.focusSearchInput(); diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 23b60d12..a67337db 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -31,6 +31,7 @@ class SearchScreen extends StatefulWidget { class _SearchScreenState extends State with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch { String? _focusResultsForQuery; + final _tvKeyboardController = TvKeyboardController(); @override void initState() { @@ -101,11 +102,32 @@ class _SearchScreenState extends State searchFocusNode.requestFocus(); } - /// Set the search query externally (e.g. from companion remote) + /// Apply a complete query submitted from the Plezy companion remote: set the + /// text, dismiss any open on-screen keyboard, land focus on the input without + /// (re)opening the OSK, and run the search now — the first result takes focus + /// when it lands (via onSearchCompleted). The user already typed the query on + /// their phone, so the TV keyboard must never be up afterwards. @override - void setSearchQuery(String query) { + void submitSearchQuery(String query) { if (!mounted) return; - searchController.text = query; + final trimmed = query.trim(); + searchController.text = trimmed; // listener arms the debounce / resets state + + // Focusing the field normally auto-opens the OSK; a remote search must not + // show it, and must dismiss one the TV user already had open (the phone's + // Search chip sends tabSearch before the query arrives). + _tvKeyboardController.closeKeyboard(); + if (trimmed.isEmpty) return; + + // Land focus on the (visible) input immediately so the D-pad remote is + // never stranded on the hidden previous tab — while the search is in + // flight, when it fails, and when it returns nothing. + _tvKeyboardController.focusInputWithoutKeyboard(); + + // Same path as the OSK Search key: jumps straight to already-matching + // results, or cancels the debounce and runs now; the screen override arms + // _focusResultsForQuery so results take focus when they land. + handleSearchSubmit(); } // Public method to fully reload all content (for profile switches) @@ -173,6 +195,7 @@ class _SearchScreenState extends State child: FocusableTextField( controller: searchController, focusNode: searchFocusNode, + tvKeyboardController: _tvKeyboardController, textInputAction: TextInputAction.search, onNavigateLeft: _navigateToSidebar, onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, diff --git a/test/mixins/refreshable_test.dart b/test/mixins/refreshable_test.dart index 6bcdaf1d..f4ed6f84 100644 --- a/test/mixins/refreshable_test.dart +++ b/test/mixins/refreshable_test.dart @@ -16,7 +16,7 @@ class _RefreshProbeState extends State<_RefreshProbe> int fullRefreshCalls = 0; int focusActiveTabCalls = 0; int focusSearchInputCalls = 0; - String? lastSearchQuery; + String? lastSubmittedQuery; String? lastLibraryKey; @override @@ -38,7 +38,7 @@ class _RefreshProbeState extends State<_RefreshProbe> void focusSearchInput() => focusSearchInputCalls++; @override - void setSearchQuery(String query) => lastSearchQuery = query; + void submitSearchQuery(String query) => lastSubmittedQuery = query; @override void loadLibraryByKey(String libraryGlobalKey) => lastLibraryKey = libraryGlobalKey; @@ -173,15 +173,14 @@ void main() { expect(state.focusSearchInputCalls, 1); }); - testWidgets('setSearchQuery() forwards the query argument', (tester) async { + testWidgets('submitSearchQuery() forwards the query argument', (tester) async { late _RefreshProbeState state; await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s)); - state.setSearchQuery('hello'); - expect(state.lastSearchQuery, 'hello'); - - state.setSearchQuery(''); - expect(state.lastSearchQuery, ''); + if (state case final SearchInputFocusable s) { + s.submitSearchQuery('movie'); + } + expect(state.lastSubmittedQuery, 'movie'); }); }); diff --git a/test/screens/search_screen_test.dart b/test/screens/search_screen_test.dart index e70ce505..8b9b2267 100644 --- a/test/screens/search_screen_test.dart +++ b/test/screens/search_screen_test.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/focus/focusable_text_field.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; @@ -48,7 +50,7 @@ void main() { final state = key.currentState!; final searchInput = state as SearchInputFocusable; - searchInput.setSearchQuery('movie'); + _searchController(tester).text = 'movie'; await tester.pump(); await tester.pumpWidget(const SizedBox.shrink()); @@ -58,7 +60,7 @@ void main() { expect(() => (state as Refreshable).refresh(), returnsNormally); expect(() => (state as dynamic).updateItem('movie_1'), returnsNormally); expect(() => (state as FullRefreshable).fullRefresh(), returnsNormally); - expect(() => searchInput.setSearchQuery('new movie'), returnsNormally); + expect(() => searchInput.submitSearchQuery('new movie'), returnsNormally); expect(() => (state as FocusableTab).focusActiveTabIfReady(), returnsNormally); expect(tester.takeException(), isNull); }); @@ -69,7 +71,7 @@ void main() { expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); final state = key.currentState!; - (state as SearchInputFocusable).setSearchQuery('movie'); + _searchController(tester).text = '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. @@ -94,7 +96,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); - (key.currentState! as SearchInputFocusable).setSearchQuery('movie'); + _searchController(tester).text = 'movie'; await tester.pump(const Duration(milliseconds: 100)); expect(client.queries, isEmpty); @@ -105,9 +107,101 @@ void main() { expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); }); + + testWidgets('companion-remote submitSearchQuery dismisses an open OSK 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); + + (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. + 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. + final searchInput = key.currentState! as SearchInputFocusable; + searchInput.submitSearchQuery('movie'); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); + expect(client.queries, ['movie']); + + searchInput.focusSearchInput(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('companion-remote submitSearchQuery with no results focuses the input without the OSK', (tester) async { + final (client, key) = await _pumpTvSearchScreen(tester, items: []); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + + (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'); + + // 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); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('companion-remote submitSearchQuery whose search fails keeps focus on the input without the OSK', ( + tester, + ) async { + final (client, key) = await _pumpTvSearchScreen(tester, registerClient: false); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + + (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'); + + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + + await tester.pumpWidget(const SizedBox.shrink()); + }); } -Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchScreen(WidgetTester tester) async { +Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchScreen( + WidgetTester tester, { + List? items, + // When false, no server is registered, so performSearchQuery throws — the + // path a companion-remote submit hits when the search fails outright. + bool registerClient = true, +}) async { TvDetectionService.debugSetAppleTVOverride(null); await TvDetectionService.getInstance(forceTv: true); TvDetectionService.setForceTVSync(true); @@ -119,18 +213,21 @@ Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchSc }); final client = _FakeMediaServerClient( - items: [ - MediaItem( - id: 'movie_1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - title: 'Movie 1', - serverId: 'server_1', - serverName: 'Server', - ), - ], + items: + 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 manager = MultiServerManager(); + if (registerClient) manager.debugRegisterClientForTesting(client); final provider = MultiServerProvider(manager, DataAggregationService(manager)); addTearDown(provider.dispose); @@ -156,6 +253,10 @@ Finder _keyboardDoneKey() { ); } +TextEditingController _searchController(WidgetTester tester) { + return tester.widget(find.byType(FocusableTextField)).controller; +} + class _FakeMediaServerClient implements MediaServerClient { final List items; final List queries = [];