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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user