fix(jellyfin): defer initial URL keyboard

close #1217
This commit is contained in:
edde746
2026-06-01 11:49:21 +02:00
parent 74b8dc4561
commit caf192fd78
4 changed files with 143 additions and 21 deletions
+44 -1
View File
@@ -13,6 +13,19 @@ bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetec
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
enum TvKeyboardAutoOpenBehavior {
/// Open the TV virtual keyboard whenever the field receives focus.
onFocus,
/// Keep initial focus on the field without opening the keyboard, then open
/// automatically on later focus entries. Explicit tap/select still opens it.
afterFirstFocus,
/// Never auto-open the TV virtual keyboard on focus. Explicit tap/select
/// still opens it.
never,
}
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}';
@@ -513,6 +526,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
final bool autofocus;
final bool enabled;
final bool enableTvKeyboard;
final TvKeyboardAutoOpenBehavior tvKeyboardAutoOpenBehavior;
final bool obscureText;
final bool autocorrect;
final bool enableSuggestions;
@@ -545,6 +559,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
this.autofocus = false,
this.enabled = true,
this.enableTvKeyboard = true,
this.tvKeyboardAutoOpenBehavior = TvKeyboardAutoOpenBehavior.onFocus,
this.obscureText = false,
this.autocorrect = true,
this.enableSuggestions = true,
@@ -654,6 +669,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
bool _tvKeyboardOpen = false;
bool _tvKeyboardOpenScheduled = false;
bool _suppressTvKeyboardAutoOpen = false;
bool _hasSeenTvKeyboardFocus = false;
bool _suppressTvKeyboardForCurrentFocus = false;
FocusNode get _effectiveFocusNode =>
widget.input.focusNode ?? (_ownedFocusNode ??= FocusNode(debugLabel: 'FocusableTextInput'));
@@ -665,6 +682,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
_restoreInstalledHandler();
_suppressTvKeyboardAutoOpen = false;
_tvKeyboardOpenScheduled = false;
_hasSeenTvKeyboardFocus = false;
_suppressTvKeyboardForCurrentFocus = false;
}
_handleFocusChanged();
}
@@ -696,12 +715,15 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
final visible = _canShowTvKeyboard;
_logTvTextInput(
'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled '
'suppressed=$_suppressTvKeyboardAutoOpen installed=${_installedFocusNode?.debugLabel} '
'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvKeyboardAutoOpenBehavior} '
'seenFocus=$_hasSeenTvKeyboardFocus suppressCurrent=$_suppressTvKeyboardForCurrentFocus '
'installed=${_installedFocusNode?.debugLabel} '
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
'usesTvKeyboard=${widget.input._hasTvKeyboard} visible=$visible',
);
if (!focused) {
_suppressTvKeyboardForCurrentFocus = false;
if (!_tvKeyboardOpen && !_tvKeyboardOpenScheduled) {
_suppressTvKeyboardAutoOpen = false;
}
@@ -709,6 +731,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
}
if (!visible) return;
if (!_shouldAutoOpenTvKeyboardForCurrentFocus()) return;
if (_suppressTvKeyboardAutoOpen || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
_tvKeyboardOpenScheduled = true;
@@ -726,6 +749,22 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
return TickerMode.valuesOf(context).enabled && (route?.isCurrent ?? true);
}
bool _shouldAutoOpenTvKeyboardForCurrentFocus() {
switch (widget.input.tvKeyboardAutoOpenBehavior) {
case TvKeyboardAutoOpenBehavior.onFocus:
return true;
case TvKeyboardAutoOpenBehavior.afterFirstFocus:
if (!_hasSeenTvKeyboardFocus) {
_hasSeenTvKeyboardFocus = true;
_suppressTvKeyboardForCurrentFocus = true;
return false;
}
return !_suppressTvKeyboardForCurrentFocus;
case TvKeyboardAutoOpenBehavior.never:
return false;
}
}
void _openTvKeyboard() {
if (!mounted || !widget.input.enabled || !widget.input._hasTvKeyboard || !_canShowTvKeyboard || _tvKeyboardOpen) {
return;
@@ -733,6 +772,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
_tvKeyboardOpenScheduled = false;
_tvKeyboardOpen = true;
_hasSeenTvKeyboardFocus = true;
_suppressTvKeyboardForCurrentFocus = false;
_suppressTvKeyboardAutoOpen = true;
_logTvTextInput('Host.openTvKeyboard node=${_installedFocusNode?.debugLabel}');
unawaited(
@@ -838,6 +879,7 @@ class FocusableTextField extends _FocusableTextInputBase {
super.autofocus,
super.enabled,
super.enableTvKeyboard,
super.tvKeyboardAutoOpenBehavior,
super.obscureText,
super.autocorrect,
super.enableSuggestions,
@@ -913,6 +955,7 @@ class FocusableTextFormField extends _FocusableTextInputBase {
super.autofocus,
super.enabled,
super.enableTvKeyboard,
super.tvKeyboardAutoOpenBehavior,
super.obscureText,
super.autocorrect,
super.enableSuggestions,
@@ -471,6 +471,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
controller: _urlController,
focusNode: _urlFocus,
autofocus: true,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
keyboardType: TextInputType.url,
minLines: 1,
maxLines: 4,
@@ -77,7 +77,7 @@ void main() {
expect(field.autofocus, isTrue);
});
testWidgets('TV initial focus opens the server URL keyboard', (tester) async {
testWidgets('TV initial focus keeps server URL focused without opening keyboard', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
await tester.pumpWidget(
@@ -87,11 +87,11 @@ void main() {
);
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);
});
testWidgets('Android TV remote navigation stays with virtual URL keyboard', (tester) async {
testWidgets('Android TV D-pad can leave initial URL focus before keyboard opens', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true);
@@ -103,21 +103,22 @@ void main() {
);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard');
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard');
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:FindServer');
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard');
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
});
testWidgets('TV discovery does not steal focus from the URL keyboard', (tester) async {
testWidgets('TV discovery keeps initial URL focus and D-pad reaches discovered servers', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
await tester.pumpWidget(
@@ -134,22 +135,19 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Home'), findsOneWidget);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard');
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Discovered:srv-1');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard');
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
});
testWidgets('D-pad moves from URL through Change to credentials after server is found', (tester) async {
@@ -325,6 +325,86 @@ void main() {
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
});
testWidgets('Android TV after-first-focus skips initial auto-open and opens on refocus', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true);
await _setTvSurfaceSize(tester);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'server_url_field');
final otherFocusNode = FocusNode(debugLabel: 'find_server_button');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
addTearDown(otherFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Column(
children: [
FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
),
Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)),
],
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(fieldFocusNode.hasPrimaryFocus, isTrue);
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
otherFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(otherFocusNode.hasPrimaryFocus, isTrue);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
});
testWidgets('Android TV after-first-focus opens on explicit select', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true);
await _setTvSurfaceSize(tester);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'server_url_field');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(fieldFocusNode.hasPrimaryFocus, isTrue);
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
});
testWidgets('Android TV remote keys are passed to native text input', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);