fix(tv): dismiss on-screen keyboard on companion-remote search submit

close #1514

A query submitted from the companion remote now behaves like a submit
instead of focus-to-type: TvKeyboardController closes an already-open
OSK and lands focus on the input without reopening it, then the shared
handleSearchSubmit path runs the search and focuses the first result.
Also guard DebouncedMediaSearch against selection-only controller
notifications re-arming the debounce into a duplicate fetch.

Co-authored-by: l3gitpanda <12003346+l3gitpanda@users.noreply.github.com>
This commit is contained in:
edde746
2026-07-10 08:48:38 +02:00
co-authored by l3gitpanda
parent 1261852514
commit b46107cb58
7 changed files with 248 additions and 36 deletions
+68
View File
@@ -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,
+9 -1
View File
@@ -34,6 +34,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
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<T extends StatefulWidget> on State<T> {
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.
+5 -1
View File
@@ -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 {
+16 -7
View File
@@ -810,11 +810,15 @@ class _MainScreenState extends State<MainScreen>
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<MainScreen>
}
}
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<MainScreen>
// 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<MainScreen>
_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();
+26 -3
View File
@@ -31,6 +31,7 @@ class SearchScreen extends StatefulWidget {
class _SearchScreenState extends State<SearchScreen>
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch {
String? _focusResultsForQuery;
final _tvKeyboardController = TvKeyboardController();
@override
void initState() {
@@ -101,11 +102,32 @@ class _SearchScreenState extends State<SearchScreen>
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<SearchScreen>
child: FocusableTextField(
controller: searchController,
focusNode: searchFocusNode,
tvKeyboardController: _tvKeyboardController,
textInputAction: TextInputAction.search,
onNavigateLeft: _navigateToSidebar,
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
+7 -8
View File
@@ -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');
});
});
+117 -16
View File
@@ -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<State<SearchScreen>>)> _pumpTvSearchScreen(WidgetTester tester) async {
Future<(_FakeMediaServerClient, GlobalKey<State<SearchScreen>>)> _pumpTvSearchScreen(
WidgetTester tester, {
List<MediaItem>? 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<State<SearchScreen>>)> _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<FocusableTextField>(find.byType(FocusableTextField)).controller;
}
class _FakeMediaServerClient implements MediaServerClient {
final List<MediaItem> items;
final List<String> queries = [];