feat(tvos): add virtual keyboard input

This commit is contained in:
edde746
2026-05-03 01:32:58 +02:00
parent e9a706fa72
commit 876a44b244
18 changed files with 909 additions and 117 deletions
+276 -46
View File
@@ -1,16 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_detector.dart';
import '../widgets/tv_virtual_keyboard.dart';
import 'dpad_navigator.dart';
/// A [TextField] wrapper that exposes D-pad navigation callbacks with
/// caret-aware edge escapes — so LEFT at the start of the field and RIGHT
/// at the end escape to neighbouring focus targets instead of bouncing
/// against the caret boundary, while UP/DOWN always escape.
///
/// Collapsed selection only: if text is selected, LEFT/RIGHT fall through
/// to the TextField's default caret movement.
class FocusableTextField extends StatelessWidget {
bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetector.isAppleTV();
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
KeyEventResult _handleInputKey({
required TextEditingController controller,
required bool usesTvKeyboard,
required bool enabled,
required VoidCallback openKeyboard,
required KeyEvent event,
VoidCallback? onSelect,
VoidCallback? onBack,
VoidCallback? onNavigateLeft,
VoidCallback? onNavigateRight,
VoidCallback? onNavigateUp,
VoidCallback? onNavigateDown,
}) {
final key = event.logicalKey;
if (usesTvKeyboard && enabled && key.isSelectKey) {
if (event is KeyDownEvent) openKeyboard();
return KeyEventResult.handled;
}
if (onBack != null && key.isBackKey) {
if (event is KeyDownEvent) onBack();
return KeyEventResult.handled;
}
// Enter/numpad enter are left to TextField.onSubmitted. Handle only
// non-text submit keys that TV remotes/gamepads may send while editing.
if (!usesTvKeyboard &&
onSelect != null &&
(key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.gameButtonA)) {
if (event is KeyDownEvent) onSelect();
return KeyEventResult.handled;
}
if (!event.isActionable) return KeyEventResult.ignored;
if (key.isUpKey && onNavigateUp != null) {
onNavigateUp();
return KeyEventResult.handled;
}
if (key.isDownKey && onNavigateDown != null) {
onNavigateDown();
return KeyEventResult.handled;
}
final sel = controller.selection;
if (sel.isCollapsed) {
if (key.isLeftKey && sel.baseOffset == 0 && onNavigateLeft != null) {
onNavigateLeft();
return KeyEventResult.handled;
}
if (key.isRightKey && sel.baseOffset == controller.text.length && onNavigateRight != null) {
onNavigateRight();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
abstract class _FocusableTextInputBase extends StatelessWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final InputDecoration? decoration;
@@ -19,15 +78,29 @@ class FocusableTextField extends StatelessWidget {
final List<TextInputFormatter>? inputFormatters;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onEditingComplete;
final VoidCallback? onSelect;
final VoidCallback? onBack;
final bool autofocus;
final bool enabled;
final bool enableTvKeyboard;
final bool obscureText;
final bool autocorrect;
final bool enableSuggestions;
final bool? enableInteractiveSelection;
final int? maxLength;
final int? maxLines;
final int? minLines;
final TextAlign textAlign;
final TextCapitalization textCapitalization;
final TextStyle? style;
final VoidCallback? onNavigateLeft;
final VoidCallback? onNavigateRight;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateDown;
const FocusableTextField({
const _FocusableTextInputBase({
super.key,
required this.controller,
this.focusNode,
@@ -37,64 +110,221 @@ class FocusableTextField extends StatelessWidget {
this.inputFormatters,
this.onChanged,
this.onSubmitted,
this.onEditingComplete,
this.onSelect,
this.onBack,
this.autofocus = false,
this.enabled = true,
this.enableTvKeyboard = true,
this.obscureText = false,
this.autocorrect = true,
this.enableSuggestions = true,
this.enableInteractiveSelection,
this.maxLength,
this.maxLines = 1,
this.minLines,
this.textAlign = TextAlign.start,
this.textCapitalization = TextCapitalization.none,
this.style,
this.onNavigateLeft,
this.onNavigateRight,
this.onNavigateUp,
this.onNavigateDown,
});
KeyEventResult _handleKey(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
bool get _hasTvKeyboard => _usesTvKeyboard(enableTvKeyboard);
// Enter/numpad enter are left to TextField.onSubmitted. Handle only
// non-text submit keys that TV remotes/gamepads may send while editing.
if (onSelect != null && (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.gameButtonA)) {
if (event is KeyDownEvent) onSelect!();
return KeyEventResult.handled;
}
if (!event.isActionable) return KeyEventResult.ignored;
if (key.isUpKey && onNavigateUp != null) {
onNavigateUp!();
return KeyEventResult.handled;
}
if (key.isDownKey && onNavigateDown != null) {
onNavigateDown!();
return KeyEventResult.handled;
}
final sel = controller.selection;
if (sel.isCollapsed) {
if (key.isLeftKey && sel.baseOffset == 0 && onNavigateLeft != null) {
onNavigateLeft!();
return KeyEventResult.handled;
}
if (key.isRightKey && sel.baseOffset == controller.text.length && onNavigateRight != null) {
onNavigateRight!();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
void _showTvKeyboard(BuildContext context) {
if (!enabled) 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: onEditingComplete ?? onSelect,
);
}
KeyEventResult _handleKey(BuildContext context, FocusNode _, KeyEvent event) {
return _handleInputKey(
controller: controller,
usesTvKeyboard: _hasTvKeyboard,
enabled: enabled,
openKeyboard: () => _showTvKeyboard(context),
event: event,
onSelect: onSelect,
onBack: onBack,
onNavigateLeft: onNavigateLeft,
onNavigateRight: onNavigateRight,
onNavigateUp: onNavigateUp,
onNavigateDown: onNavigateDown,
);
}
Widget buildFocusableInput(BuildContext context, Widget Function(bool usesTvKeyboard) builder) {
final usesTvKeyboard = _hasTvKeyboard;
return Focus(
canRequestFocus: enabled,
onKeyEvent: (node, event) => _handleKey(context, node, event),
child: builder(usesTvKeyboard),
);
}
}
/// A [TextField] wrapper that exposes D-pad navigation callbacks with
/// caret-aware edge escapes — so LEFT at the start of the field and RIGHT
/// at the end escape to neighbouring focus targets instead of bouncing
/// against the caret boundary, while UP/DOWN always escape.
///
/// Collapsed selection only: if text is selected, LEFT/RIGHT fall through
/// to the TextField's default caret movement.
class FocusableTextField extends _FocusableTextInputBase {
const FocusableTextField({
super.key,
required super.controller,
super.focusNode,
super.decoration,
super.keyboardType,
super.textInputAction,
super.inputFormatters,
super.onChanged,
super.onSubmitted,
super.onEditingComplete,
super.onSelect,
super.onBack,
super.autofocus,
super.enabled,
super.enableTvKeyboard,
super.obscureText,
super.autocorrect,
super.enableSuggestions,
super.enableInteractiveSelection,
super.maxLength,
super.maxLines,
super.minLines,
super.textAlign,
super.textCapitalization,
super.style,
super.onNavigateLeft,
super.onNavigateRight,
super.onNavigateUp,
super.onNavigateDown,
});
@override
Widget build(BuildContext context) {
return Focus(
onKeyEvent: _handleKey,
child: TextField(
return buildFocusableInput(
context,
(usesTvKeyboard) => TextField(
controller: controller,
focusNode: focusNode,
enabled: enabled,
decoration: decoration,
keyboardType: keyboardType,
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
textInputAction: textInputAction,
inputFormatters: inputFormatters,
onChanged: onChanged,
onSubmitted: onSubmitted,
onEditingComplete: onEditingComplete,
autofocus: autofocus,
autocorrect: autocorrect,
enableSuggestions: enableSuggestions,
obscureText: obscureText,
maxLength: maxLength,
maxLines: maxLines,
minLines: minLines,
textAlign: textAlign,
textCapitalization: textCapitalization,
style: style,
readOnly: usesTvKeyboard,
showCursor: usesTvKeyboard ? true : null,
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
onTap: usesTvKeyboard ? () => _showTvKeyboard(context) : null,
),
);
}
}
class FocusableTextFormField extends _FocusableTextInputBase {
final ValueChanged<String>? onFieldSubmitted;
final FormFieldValidator<String>? validator;
final AutovalidateMode? autovalidateMode;
final FormFieldSetter<String>? onSaved;
const FocusableTextFormField({
super.key,
required super.controller,
super.focusNode,
super.decoration,
super.keyboardType,
super.textInputAction,
super.inputFormatters,
super.onChanged,
this.onFieldSubmitted,
super.onEditingComplete,
super.onSelect,
super.onBack,
this.validator,
this.autovalidateMode,
this.onSaved,
super.autofocus,
super.enabled,
super.enableTvKeyboard,
super.obscureText,
super.autocorrect,
super.enableSuggestions,
super.enableInteractiveSelection,
super.maxLength,
super.maxLines,
super.minLines,
super.textAlign,
super.textCapitalization,
super.style,
super.onNavigateLeft,
super.onNavigateRight,
super.onNavigateUp,
super.onNavigateDown,
}) : super(onSubmitted: onFieldSubmitted);
@override
Widget build(BuildContext context) {
return buildFocusableInput(
context,
(usesTvKeyboard) => TextFormField(
controller: controller,
focusNode: focusNode,
enabled: enabled,
decoration: decoration,
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
textInputAction: textInputAction,
inputFormatters: inputFormatters,
onChanged: onChanged,
onFieldSubmitted: onFieldSubmitted,
onEditingComplete: onEditingComplete,
validator: validator,
autovalidateMode: autovalidateMode,
onSaved: onSaved,
autofocus: autofocus,
autocorrect: autocorrect,
enableSuggestions: enableSuggestions,
obscureText: obscureText,
maxLength: maxLength,
maxLines: maxLines,
minLines: minLines,
textAlign: textAlign,
textCapitalization: textCapitalization,
style: style,
readOnly: usesTvKeyboard,
showCursor: usesTvKeyboard ? true : null,
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
onTap: usesTvKeyboard ? () => _showTvKeyboard(context) : null,
),
);
}
+2 -1
View File
@@ -16,6 +16,7 @@ import '../i18n/strings.g.dart';
import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_text_field.dart';
import '../media/media_backend.dart';
import '../utils/navigation_transitions.dart';
import '../widgets/backend_badge.dart';
@@ -459,7 +460,7 @@ class _DebugTokenDialogState extends State<_DebugTokenDialog> with ControllerDis
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
FocusableTextFormField(
controller: _tokenController,
decoration: InputDecoration(
labelText: 'Plex Auth Token',
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../focus/focusable_text_field.dart';
import '../../models/companion_remote/remote_command.dart';
import '../../models/companion_remote/remote_session.dart';
import '../../i18n/strings.g.dart';
@@ -678,7 +679,7 @@ class _SearchBottomSheetState extends State<_SearchBottomSheet> with ControllerD
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
FocusableTextField(
controller: _controller,
autofocus: true,
decoration: pillInputDecoration(
+3 -1
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../../focus/focusable_text_field.dart';
/// Bordered "Profile name" text field used by both the new-profile flow and
/// the profile-detail rename row. Optional [trailing] slot for an inline Save
/// button — pass `null` when the screen saves elsewhere (e.g. on Continue).
@@ -13,7 +15,7 @@ class ProfileNameField extends StatelessWidget {
@override
Widget build(BuildContext context) {
final field = TextField(
final field = FocusableTextField(
controller: controller,
textInputAction: TextInputAction.done,
decoration: InputDecoration(hintText: hintText, border: const OutlineInputBorder()),
+29 -51
View File
@@ -4,7 +4,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:rate_limiter/rate_limiter.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focusable_text_field.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../mixins/controller_disposer_mixin.dart';
@@ -168,37 +168,6 @@ class _SearchScreenState extends State<SearchScreen>
MainScreenFocusScope.of(context)?.focusSidebar();
}
/// Handle key events on the search input for D-pad navigation
KeyEventResult _handleSearchInputKeyEvent(FocusNode _, KeyEvent event) {
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
// DOWN: Focus first result if results exist and not loading
if (key.isDownKey && _searchResults.isNotEmpty && !_isSearching) {
_firstResultFocusNode.requestFocus();
return KeyEventResult.handled;
}
// LEFT at cursor position 0: Navigate to sidebar
if (key.isLeftKey && _searchController.selection.baseOffset == 0) {
_navigateToSidebar();
return KeyEventResult.handled;
}
// BACK: Clear search or navigate to sidebar
if (key.isBackKey) {
if (_searchController.text.isNotEmpty) {
_searchController.clear();
} else {
_navigateToSidebar();
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
Widget _buildResultsList(BuildContext context) {
final multiServer = context.watch<MultiServerProvider>();
final showServerName = multiServer.totalServerCount > 1;
@@ -234,25 +203,34 @@ class _SearchScreenState extends State<SearchScreen>
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
child: Focus(
onKeyEvent: _handleSearchInputKeyEvent,
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
decoration: pillInputDecoration(
context,
hintText: t.search.hint,
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const AppIcon(Symbols.clear_rounded, fill: 1),
onPressed: () {
_searchController.clear();
// State update handled by listener
},
)
: null,
),
child: FocusableTextField(
controller: _searchController,
focusNode: _searchFocusNode,
textInputAction: TextInputAction.search,
onNavigateLeft: _navigateToSidebar,
onNavigateDown: _searchResults.isNotEmpty && !_isSearching
? _firstResultFocusNode.requestFocus
: null,
onBack: () {
if (_searchController.text.isNotEmpty) {
_searchController.clear();
} else {
_navigateToSidebar();
}
},
decoration: pillInputDecoration(
context,
hintText: t.search.hint,
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const AppIcon(Symbols.clear_rounded, fill: 1),
onPressed: () {
_searchController.clear();
// State update handled by listener
},
)
: null,
),
),
),
@@ -9,6 +9,7 @@ import 'package:uuid/uuid.dart';
import '../../connection/connection.dart';
import '../../exceptions/media_server_exceptions.dart';
import '../../focus/focusable_text_field.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart';
import '../../profiles/active_profile_binder.dart';
@@ -338,7 +339,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
return [
Text(t.addServer.jellyfinUrlIntro, style: theme.textTheme.bodyMedium),
const SizedBox(height: 16),
TextFormField(
FocusableTextFormField(
controller: _urlController,
keyboardType: TextInputType.url,
autocorrect: false,
@@ -363,7 +364,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
const SizedBox(height: 16),
_buildServerCard(theme),
const SizedBox(height: 16),
TextFormField(
FocusableTextFormField(
controller: _usernameController,
autocorrect: false,
enableSuggestions: false,
@@ -377,7 +378,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
validator: (v) => v == null || v.trim().isEmpty ? t.addServer.required : null,
),
const SizedBox(height: 12),
TextFormField(
FocusableTextFormField(
controller: _passwordController,
focusNode: _passwordFocus,
obscureText: true,
@@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/widgets/app_icon.dart';
import '../../focus/focusable_button.dart';
import '../../focus/focusable_text_field.dart';
import '../../i18n/strings.g.dart';
import '../../models/external_player_models.dart';
import '../../services/settings_service.dart';
@@ -154,7 +155,7 @@ Future<void> _showAddCustomPlayerDialog(BuildContext context) async {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
FocusableTextField(
controller: nameController,
decoration: InputDecoration(labelText: t.externalPlayer.playerName, hintText: 'My Player'),
autofocus: true,
@@ -179,7 +180,7 @@ Future<void> _showAddCustomPlayerDialog(BuildContext context) async {
),
),
const SizedBox(height: 16),
TextField(
FocusableTextField(
controller: valueController,
focusNode: valueFocusNode,
decoration: InputDecoration(labelText: fieldLabel, hintText: fieldHint),
+3 -1
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focusable_text_field.dart';
import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart';
@@ -164,9 +165,10 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
}
return KeyEventResult.ignored;
},
child: TextField(
child: FocusableTextField(
controller: _textController,
focusNode: _textFieldFocusNode,
keyboardType: TextInputType.multiline,
maxLines: null,
minLines: 12,
decoration: InputDecoration(
+2 -1
View File
@@ -10,6 +10,7 @@ import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../focus/focus_memory_tracker.dart';
import '../../focus/focusable_text_field.dart';
import '../../focus/input_mode_tracker.dart';
import '../../i18n/strings.g.dart';
import '../main_screen.dart';
@@ -654,7 +655,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
builder: (BuildContext dialogContext) {
return AlertDialog(
title: Text(t.settings.watchTogetherRelay),
content: TextField(
content: FocusableTextField(
controller: controller,
decoration: InputDecoration(labelText: 'URL', hintText: t.settings.watchTogetherRelayHint),
autofocus: true,
+3 -2
View File
@@ -1,6 +1,7 @@
import 'package:flex_color_picker/flex_color_picker.dart';
import 'package:flutter/material.dart';
import '../../focus/focusable_text_field.dart';
import '../../focus/input_mode_tracker.dart';
import '../../i18n/strings.g.dart';
import '../../widgets/dialog_action_button.dart';
@@ -211,7 +212,7 @@ void _showNumericInputDialogStandard({
context: context,
title: title,
contentBuilder: (_, _, setDialogState, saveFocusNode) {
return TextField(
return FocusableTextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
@@ -350,7 +351,7 @@ void showRegexInputDialog({
context: context,
title: title,
contentBuilder: (_, _, setDialogState, saveFocusNode) {
return TextField(
return FocusableTextField(
controller: controller,
decoration: InputDecoration(labelText: 'Regex', errorText: errorText),
autofocus: true,
+4 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_text_field.dart';
import '../focus/input_mode_tracker.dart';
import '../i18n/strings.g.dart';
import '../mixins/controller_disposer_mixin.dart';
@@ -254,10 +255,11 @@ class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog>
title: Text(widget.title),
content: SizedBox(
width: 400,
child: TextField(
child: FocusableTextField(
controller: _controller,
autofocus: true,
decoration: InputDecoration(labelText: widget.labelText),
keyboardType: TextInputType.multiline,
maxLines: 8,
minLines: 3,
),
@@ -315,7 +317,7 @@ class _TextInputDialogState extends State<_TextInputDialog>
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: TextField(
content: FocusableTextField(
controller: _controller,
autofocus: true,
decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText),
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
import '../../i18n/strings.g.dart';
import '../../focus/focusable_button.dart';
import '../../focus/focusable_text_field.dart';
import '../../focus/focusable_wrapper.dart';
import '../../profiles/active_profile_provider.dart';
import '../../services/settings_service.dart';
@@ -313,7 +314,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> {
context: context,
builder: (context) => AlertDialog(
title: Text(t.watchTogether.renameRoom),
content: TextField(
content: FocusableTextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(hintText: room.code),
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_button.dart';
import '../../focus/focusable_text_field.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart';
@@ -53,7 +54,7 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
const SizedBox(height: 24),
// Session ID input
TextFormField(
FocusableTextFormField(
controller: _sessionIdController,
decoration: InputDecoration(
labelText: t.watchTogether.sessionCode,
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../connection/connection_registry.dart';
import '../../focus/focusable_text_field.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart';
import '../../models/plex/plex_home.dart';
@@ -305,7 +306,7 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
FocusableTextFormField(
controller: _hostAddressController,
decoration: InputDecoration(
labelText: t.companionRemote.session.hostAddress,
+2 -1
View File
@@ -35,6 +35,7 @@ import '../utils/dialogs.dart';
import '../utils/focus_utils.dart';
import '../services/external_player_service.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_text_field.dart';
import '../focus/dpad_navigator.dart';
import '../screens/plex_match_screen.dart';
import '../screens/media_detail_screen.dart';
@@ -1610,7 +1611,7 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
mainAxisSize: MainAxisSize.min,
children: [
if (widget.collections.length >= 10) ...[
TextField(
FocusableTextField(
controller: _filterController,
autofocus: true,
decoration: pillInputDecoration(
+2 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_text_field.dart';
import '../i18n/strings.g.dart';
import '../mixins/controller_disposer_mixin.dart';
import '../widgets/app_icon.dart';
@@ -71,7 +72,7 @@ class _TagEditDialogState extends State<TagEditDialog> with ControllerDisposerMi
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
FocusableTextField(
controller: _controller,
focusNode: _textFieldFocusNode,
autofocus: true,
+2 -1
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focus_theme.dart';
import '../focus/focusable_text_field.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_repeat_helper.dart';
import '../mixins/controller_disposer_mixin.dart';
@@ -169,7 +170,7 @@ class _TvColorPickerState extends State<TvColorPicker> with ControllerDisposerMi
),
const SizedBox(height: 16),
// Hex input
TextField(
FocusableTextField(
controller: _hexController,
focusNode: _hexFocusNode,
decoration: const InputDecoration(prefixText: '#', labelText: 'Hex', border: OutlineInputBorder()),
+566
View File
@@ -0,0 +1,566 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/dpad_navigator.dart';
import '../i18n/strings.g.dart';
import '../utils/platform_detector.dart';
Future<void> showTvVirtualKeyboard({
required BuildContext context,
required TextEditingController controller,
String? hintText,
TextInputType? keyboardType,
TextInputAction? textInputAction,
List<TextInputFormatter>? inputFormatters,
bool obscureText = false,
int? maxLength,
int? maxLines,
ValueChanged<String>? onChanged,
ValueChanged<String>? onSubmitted,
VoidCallback? onAction,
}) {
if (!PlatformDetector.isAppleTV()) return Future.value();
return showDialog<void>(
context: context,
barrierDismissible: true,
builder: (context) => _TvVirtualKeyboardDialog(
controller: controller,
hintText: hintText,
keyboardType: keyboardType,
textInputAction: textInputAction,
inputFormatters: inputFormatters,
obscureText: obscureText,
maxLength: maxLength,
maxLines: maxLines,
onChanged: onChanged,
onSubmitted: onSubmitted,
onAction: onAction,
),
);
}
enum _TvKeyType { spacer, character, shift, space, newline, backspace, clear, cancel, done }
class _TvKey {
final String label;
final String value;
final _TvKeyType type;
final IconData? icon;
const _TvKey.spacer() : label = '', value = '', type = _TvKeyType.spacer, icon = null;
const _TvKey.character(this.value) : label = value, type = _TvKeyType.character, icon = null;
const _TvKey.action(this.label, this.type, {this.icon}) : value = '';
}
class _TvVirtualKeyboardDialog extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final List<TextInputFormatter>? inputFormatters;
final bool obscureText;
final int? maxLength;
final int? maxLines;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onAction;
const _TvVirtualKeyboardDialog({
required this.controller,
this.hintText,
this.keyboardType,
this.textInputAction,
this.inputFormatters,
this.obscureText = false,
this.maxLength,
this.maxLines,
this.onChanged,
this.onSubmitted,
this.onAction,
});
@override
State<_TvVirtualKeyboardDialog> createState() => _TvVirtualKeyboardDialogState();
}
class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> {
static const double _keySize = 60;
static const double _keyGap = 6;
static const double _rowGap = 6;
final _focusNode = FocusNode(debugLabel: 'TvVirtualKeyboard');
int _row = 0;
int _column = 0;
bool _shiftEnabled = false;
List<List<_TvKey>> get _rows => _buildRows();
@override
void initState() {
super.initState();
_column = _firstFocusableColumn(_row) ?? 0;
widget.controller.addListener(_handleTextChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusNode.requestFocus();
});
}
@override
void dispose() {
widget.controller.removeListener(_handleTextChanged);
_focusNode.dispose();
super.dispose();
}
void _handleTextChanged() {
if (mounted) setState(() {});
}
bool get _isNumberKeyboard {
final type = widget.keyboardType;
return type?.index == TextInputType.number.index || type?.index == TextInputType.phone.index;
}
bool get _isMultiline {
final type = widget.keyboardType;
return type?.index == TextInputType.multiline.index || (widget.maxLines != null && widget.maxLines != 1);
}
List<List<_TvKey>> _buildRows() {
if (_isNumberKeyboard) {
return [
_characters('123'),
_characters('456'),
_characters('789'),
[
_TvKey.action(t.common.clear, _TvKeyType.clear, icon: Icons.clear_all_rounded),
const _TvKey.character('0'),
const _TvKey.action('Del', _TvKeyType.backspace, icon: Icons.backspace_outlined),
],
[
_TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Icons.close_rounded),
const _TvKey.character('.'),
_TvKey.action(_doneLabel(), _TvKeyType.done, icon: _doneIcon()),
],
];
}
final actionRow = [
const _TvKey.action('Space', _TvKeyType.space, icon: Icons.space_bar_rounded),
const _TvKey.character('@'),
const _TvKey.character('#'),
const _TvKey.character('_'),
const _TvKey.character('/'),
const _TvKey.character(':'),
_isMultiline
? const _TvKey.action('Line', _TvKeyType.newline, icon: Icons.keyboard_return_rounded)
: const _TvKey.character('&'),
_TvKey.action(t.common.clear, _TvKeyType.clear, icon: Icons.clear_all_rounded),
_TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Icons.close_rounded),
_TvKey.action(_doneLabel(), _TvKeyType.done, icon: _doneIcon()),
];
return [
[const _TvKey.spacer(), ..._characters('1234567890')],
[const _TvKey.spacer(), ..._characters('qwertyuiop')],
[const _TvKey.spacer(), ..._characters('asdfghjkl'), const _TvKey.character("'")],
[
_TvKey.action('Shift', _TvKeyType.shift, icon: Symbols.shift),
..._characters('zxcvbnm.-'),
const _TvKey.action('Del', _TvKeyType.backspace, icon: Icons.backspace_outlined),
],
[const _TvKey.spacer(), ...actionRow],
];
}
List<_TvKey> _characters(String chars) {
return chars
.split('')
.map((c) {
final code = c.codeUnitAt(0);
final shifted = _shiftEnabled && code >= 0x61 && code <= 0x7a ? c.toUpperCase() : c;
return _TvKey.character(shifted);
})
.toList(growable: false);
}
String _doneLabel() {
switch (widget.textInputAction) {
case TextInputAction.search:
return t.common.search;
case TextInputAction.next:
return t.companionRemote.remote.next;
case TextInputAction.go:
return t.common.submit;
default:
return t.common.ok;
}
}
IconData _doneIcon() {
switch (widget.textInputAction) {
case TextInputAction.search:
return Icons.search_rounded;
case TextInputAction.next:
return Icons.arrow_forward_rounded;
case TextInputAction.go:
return Icons.keyboard_double_arrow_right_rounded;
default:
return Icons.check_rounded;
}
}
KeyEventResult _handleKey(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
if (key.isBackKey) {
if (event is KeyDownEvent) Navigator.of(context).pop();
return KeyEventResult.handled;
}
if (event is KeyDownEvent || event is KeyRepeatEvent) {
if (key == LogicalKeyboardKey.backspace || key == LogicalKeyboardKey.delete) {
_backspace();
return KeyEventResult.handled;
}
if (key.isSelectKey) {
_activate(_rows[_row][_column]);
return KeyEventResult.handled;
}
if (key.isUpKey) {
_moveVertical(-1);
return KeyEventResult.handled;
}
if (key.isDownKey) {
_moveVertical(1);
return KeyEventResult.handled;
}
if (key.isLeftKey) {
_moveHorizontal(-1);
return KeyEventResult.handled;
}
if (key.isRightKey) {
_moveHorizontal(1);
return KeyEventResult.handled;
}
final character = event.character;
if (character != null && character.isNotEmpty && !key.isNavigationKey) {
_insert(character);
return KeyEventResult.handled;
}
}
return KeyEventResult.handled;
}
void _moveHorizontal(int delta) {
final nextColumn = _nextFocusableColumn(_row, _column + delta, delta);
if (nextColumn == null) return;
setState(() {
_column = nextColumn;
});
}
void _moveVertical(int delta) {
final rows = _rows;
final nextRow = (_row + delta).clamp(0, rows.length - 1).toInt();
final nextColumn = _nearestFocusableColumn(nextRow, _column);
setState(() {
_row = nextRow;
_column = nextColumn;
});
}
int? _firstFocusableColumn(int row) {
final rows = _rows;
if (row < 0 || row >= rows.length) return null;
final index = rows[row].indexWhere(_isFocusableKey);
return index == -1 ? null : index;
}
int? _nextFocusableColumn(int row, int column, int delta) {
final rows = _rows;
if (row < 0 || row >= rows.length) return null;
for (var c = column; c >= 0 && c < rows[row].length; c += delta) {
if (_isFocusableKey(rows[row][c])) return c;
}
return null;
}
int _nearestFocusableColumn(int row, int preferredColumn) {
final rows = _rows;
if (row < 0 || row >= rows.length) return preferredColumn;
final keys = rows[row];
if (preferredColumn >= 0 && preferredColumn < keys.length && _isFocusableKey(keys[preferredColumn])) {
return preferredColumn;
}
for (var offset = 1; offset < keys.length; offset++) {
final right = preferredColumn + offset;
if (right >= 0 && right < keys.length && _isFocusableKey(keys[right])) return right;
final left = preferredColumn - offset;
if (left >= 0 && left < keys.length && _isFocusableKey(keys[left])) return left;
}
return _firstFocusableColumn(row) ?? 0;
}
bool _isFocusableKey(_TvKey key) => key.type != _TvKeyType.spacer;
void _activate(_TvKey key) {
switch (key.type) {
case _TvKeyType.spacer:
return;
case _TvKeyType.character:
_insert(key.value);
return;
case _TvKeyType.shift:
setState(() => _shiftEnabled = !_shiftEnabled);
return;
case _TvKeyType.space:
_insert(' ');
return;
case _TvKeyType.newline:
_insert('\n');
return;
case _TvKeyType.backspace:
_backspace();
return;
case _TvKeyType.clear:
_replace(TextEditingValue.empty);
return;
case _TvKeyType.cancel:
Navigator.of(context).pop();
return;
case _TvKeyType.done:
_submit();
return;
}
}
void _submit() {
final text = widget.controller.text;
final onSubmitted = widget.onSubmitted;
final onAction = widget.onAction;
Navigator.of(context).pop();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (onSubmitted != null) {
onSubmitted(text);
} else {
onAction?.call();
}
});
}
void _insert(String text) {
final value = widget.controller.value;
final selection = value.selection;
final start = selection.isValid
? (selection.start < selection.end ? selection.start : selection.end)
: value.text.length;
final end = selection.isValid
? (selection.start > selection.end ? selection.start : selection.end)
: value.text.length;
final newText = value.text.replaceRange(start, end, text);
_replace(
value.copyWith(
text: newText,
selection: TextSelection.collapsed(offset: start + text.length),
composing: TextRange.empty,
),
);
}
void _backspace() {
final value = widget.controller.value;
final selection = value.selection;
final start = selection.isValid
? (selection.start < selection.end ? selection.start : selection.end)
: value.text.length;
final end = selection.isValid
? (selection.start > selection.end ? selection.start : selection.end)
: value.text.length;
if (start != end) {
_replace(
value.copyWith(
text: value.text.replaceRange(start, end, ''),
selection: TextSelection.collapsed(offset: start),
),
);
return;
}
if (start == 0) return;
_replace(
value.copyWith(
text: value.text.replaceRange(start - 1, start, ''),
selection: TextSelection.collapsed(offset: start - 1),
composing: TextRange.empty,
),
);
}
void _replace(TextEditingValue nextValue) {
final previousValue = widget.controller.value;
var formattedValue = nextValue;
final maxLength = widget.maxLength;
final formatters = [
...?widget.inputFormatters,
if (maxLength != null && maxLength > 0) LengthLimitingTextInputFormatter(maxLength),
];
for (final formatter in formatters) {
formattedValue = formatter.formatEditUpdate(previousValue, formattedValue);
}
widget.controller.value = formattedValue;
widget.onChanged?.call(formattedValue.text);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final text = widget.controller.text;
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 56, vertical: 32),
backgroundColor: Colors.transparent,
child: Focus(
focusNode: _focusNode,
onKeyEvent: _handleKey,
child: Container(
constraints: const BoxConstraints(maxWidth: 860),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(28),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPreview(context, text),
const SizedBox(height: 12),
for (var row = 0; row < _rows.length; row++) ...[
_buildRow(context, row),
if (row != _rows.length - 1) const SizedBox(height: _rowGap),
],
],
),
),
),
);
}
Widget _buildPreview(BuildContext context, String text) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isEmpty = text.isEmpty;
final displayText = widget.obscureText && !isEmpty ? List.filled(text.length, '*').join() : text;
final previewText = isEmpty ? (widget.hintText ?? '') : displayText;
final multiline = _isMultiline;
return Container(
height: multiline ? 86 : 60,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(18),
),
child: multiline
? SingleChildScrollView(
reverse: true,
child: Text(
previewText,
maxLines: 3,
style: theme.textTheme.titleLarge?.copyWith(
color: isEmpty ? colorScheme.onSurfaceVariant : colorScheme.onSurface,
),
),
)
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: Text(
previewText,
maxLines: 1,
style: theme.textTheme.headlineSmall?.copyWith(
color: isEmpty ? colorScheme.onSurfaceVariant : colorScheme.onSurface,
),
),
),
);
}
Widget _buildRow(BuildContext context, int row) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var column = 0; column < _rows[row].length; column++) ...[
_buildKey(context, _rows[row][column], row, column),
if (column != _rows[row].length - 1) const SizedBox(width: _keyGap),
],
],
);
}
Widget _buildKey(BuildContext context, _TvKey key, int row, int column) {
if (key.type == _TvKeyType.spacer) {
return const SizedBox(width: _keySize, height: _keySize);
}
final colorScheme = Theme.of(context).colorScheme;
final selected = row == _row && column == _column;
final active = key.type == _TvKeyType.shift && _shiftEnabled;
final background = selected
? colorScheme.primary
: active
? colorScheme.secondaryContainer
: colorScheme.surfaceContainerHighest.withValues(alpha: 0.88);
final foreground = selected
? colorScheme.onPrimary
: active
? colorScheme.onSecondaryContainer
: colorScheme.onSurface;
return GestureDetector(
onTap: () {
setState(() {
_row = row;
_column = column;
});
_activate(key);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: _keySize,
height: _keySize,
alignment: Alignment.center,
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: _buildKeyContent(context, key, foreground),
),
),
);
}
Widget _buildKeyContent(BuildContext context, _TvKey key, Color foreground) {
final icon = key.icon;
if (icon != null) {
return Icon(icon, color: foreground, size: key.type == _TvKeyType.space ? 34 : 30);
}
return FittedBox(
fit: BoxFit.scaleDown,
child: Text(
key.label,
maxLines: 1,
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foreground, fontWeight: FontWeight.w800),
),
);
}
}