From 03fc1c110900f7eef7ec692dab08902dbdce8a0c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:57:51 +0100 Subject: [PATCH] fix: auto-focus first content item on settings screens in dpad mode --- .../settings/external_player_screen.dart | 118 ++++++++---------- lib/screens/settings/logs_screen.dart | 99 +++++++-------- lib/screens/settings/mpv_config_screen.dart | 31 +---- lib/screens/settings/settings_screen.dart | 100 +++++++-------- .../settings/subtitle_styling_screen.dart | 31 +---- lib/widgets/focused_scroll_scaffold.dart | 51 ++++++-- 6 files changed, 197 insertions(+), 233 deletions(-) diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart index 69b99f78..0b0640be 100644 --- a/lib/screens/settings/external_player_screen.dart +++ b/lib/screens/settings/external_player_screen.dart @@ -8,8 +8,7 @@ import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../models/external_player_models.dart'; import '../../services/settings_service.dart'; -import '../../focus/key_event_utils.dart'; -import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; class ExternalPlayerScreen extends StatefulWidget { const ExternalPlayerScreen({super.key}); @@ -47,79 +46,66 @@ class _ExternalPlayerScreenState extends State { @override Widget build(BuildContext context) { if (_isLoading) { - return Focus( - canRequestFocus: false, - onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), - child: FocusScope( - autofocus: true, - child: const Scaffold(body: Center(child: CircularProgressIndicator())), - ), + return FocusedScrollScaffold( + title: Text(t.externalPlayer.title), + slivers: [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], ); } final knownPlayers = KnownPlayers.getForCurrentPlatform(); - return Focus( - canRequestFocus: false, - onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), - child: FocusScope( - autofocus: true, - child: Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar(title: Text(t.externalPlayer.title), pinned: true), - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildListDelegate([ - Card( - child: SwitchListTile( - secondary: const AppIcon(Symbols.open_in_new_rounded, fill: 1), - title: Text(t.externalPlayer.useExternalPlayer), - subtitle: Text(t.externalPlayer.useExternalPlayerDescription), - value: _useExternalPlayer, - onChanged: (value) async { - setState(() => _useExternalPlayer = value); - await _settingsService.setUseExternalPlayer(value); - }, - ), - ), - if (_useExternalPlayer) ...[ - const SizedBox(height: 16), - Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.externalPlayer.selectPlayer, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - // Known players - ...knownPlayers.map((player) => _buildPlayerTile(player)), - // Custom players - if (_customPlayers.isNotEmpty) const Divider(), - ..._customPlayers.map((player) => _buildPlayerTile(player, isCustom: true)), - // Add custom player button - const Divider(), - ListTile( - leading: const AppIcon(Symbols.add_rounded, fill: 1), - title: Text(t.externalPlayer.addCustomPlayer), - onTap: _showAddCustomPlayerDialog, - ), - ], - ), - ), - ], - ]), + return FocusedScrollScaffold( + title: Text(t.externalPlayer.title), + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + Card( + child: SwitchListTile( + secondary: const AppIcon(Symbols.open_in_new_rounded, fill: 1), + title: Text(t.externalPlayer.useExternalPlayer), + subtitle: Text(t.externalPlayer.useExternalPlayerDescription), + value: _useExternalPlayer, + onChanged: (value) async { + setState(() => _useExternalPlayer = value); + await _settingsService.setUseExternalPlayer(value); + }, ), ), - ], + if (_useExternalPlayer) ...[ + const SizedBox(height: 16), + Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + t.externalPlayer.selectPlayer, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + // Known players + ...knownPlayers.map((player) => _buildPlayerTile(player)), + // Custom players + if (_customPlayers.isNotEmpty) const Divider(), + ..._customPlayers.map((player) => _buildPlayerTile(player, isCustom: true)), + // Add custom player button + const Divider(), + ListTile( + leading: const AppIcon(Symbols.add_rounded, fill: 1), + title: Text(t.externalPlayer.addCustomPlayer), + onTap: _showAddCustomPlayerDialog, + ), + ], + ), + ), + ], + ]), ), ), - ), + ], ); } diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index f1319b8e..3f3eaa1f 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -10,8 +10,7 @@ import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../utils/app_logger.dart'; import '../../utils/snackbar_helper.dart'; -import '../../focus/key_event_utils.dart'; -import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; class LogsScreen extends StatefulWidget { const LogsScreen({super.key}); @@ -176,61 +175,49 @@ class _LogsScreenState extends State { @override Widget build(BuildContext context) { - return Focus( - canRequestFocus: false, - onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), - child: FocusScope( - autofocus: true, - child: Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar( - title: Text(t.screens.logs), - pinned: true, - actions: [ - IconButton( - icon: const AppIcon(Symbols.refresh_rounded, fill: 1), - onPressed: _loadLogs, - tooltip: t.common.refresh, - ), - IconButton( - icon: const AppIcon(Symbols.upload_rounded, fill: 1), - onPressed: _logs.isNotEmpty ? _uploadLogs : null, - tooltip: t.logs.uploadLogs, - ), - IconButton( - icon: const AppIcon(Symbols.content_copy_rounded, fill: 1), - onPressed: _logs.isNotEmpty ? _copyAllLogs : null, - tooltip: t.logs.copyLogs, - ), - IconButton( - icon: const AppIcon(Symbols.delete_outline_rounded, fill: 1), - onPressed: _logs.isNotEmpty ? _clearLogs : null, - tooltip: t.logs.clearLogs, - ), - ], - ), - if (_logs.isEmpty) - SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable))) - else - SliverPadding( - padding: const EdgeInsets.all(8), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final log = _logs[index]; - return _LogEntryCard( - log: log, - formatTime: _formatTime, - levelColor: _getLevelColor(log.level), - levelIcon: _getLevelIcon(log.level), - ); - }, childCount: _logs.length), - ), - ), - ], - ), + return FocusedScrollScaffold( + title: Text(t.screens.logs), + actions: [ + IconButton( + icon: const AppIcon(Symbols.refresh_rounded, fill: 1), + onPressed: _loadLogs, + tooltip: t.common.refresh, ), - ), + IconButton( + icon: const AppIcon(Symbols.upload_rounded, fill: 1), + onPressed: _logs.isNotEmpty ? _uploadLogs : null, + tooltip: t.logs.uploadLogs, + ), + IconButton( + icon: const AppIcon(Symbols.content_copy_rounded, fill: 1), + onPressed: _logs.isNotEmpty ? _copyAllLogs : null, + tooltip: t.logs.copyLogs, + ), + IconButton( + icon: const AppIcon(Symbols.delete_outline_rounded, fill: 1), + onPressed: _logs.isNotEmpty ? _clearLogs : null, + tooltip: t.logs.clearLogs, + ), + ], + slivers: [ + if (_logs.isEmpty) + SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable))) + else + SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final log = _logs[index]; + return _LogEntryCard( + log: log, + formatTime: _formatTime, + levelColor: _getLevelColor(log.level), + levelIcon: _getLevelIcon(log.level), + ); + }, childCount: _logs.length), + ), + ), + ], ); } } diff --git a/lib/screens/settings/mpv_config_screen.dart b/lib/screens/settings/mpv_config_screen.dart index 486ee2ba..1c4bd2e9 100644 --- a/lib/screens/settings/mpv_config_screen.dart +++ b/lib/screens/settings/mpv_config_screen.dart @@ -7,8 +7,7 @@ import '../../focus/focusable_button.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../services/settings_service.dart'; -import '../../focus/key_event_utils.dart'; -import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; class MpvConfigScreen extends StatefulWidget { const MpvConfigScreen({super.key}); @@ -263,26 +262,11 @@ class _MpvConfigScreenState extends State { @override Widget build(BuildContext context) { - if (_isLoading) { - return Focus( - canRequestFocus: false, - onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), - child: FocusScope( - autofocus: true, - child: const Scaffold(body: Center(child: CircularProgressIndicator())), - ), - ); - } - - return Focus( - canRequestFocus: false, - onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), - child: FocusScope( - autofocus: true, - child: Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar(title: Text(t.screens.mpvConfig), pinned: true), + return FocusedScrollScaffold( + title: Text(t.screens.mpvConfig), + slivers: _isLoading + ? [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))] + : [ SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( @@ -295,9 +279,6 @@ class _MpvConfigScreenState extends State { ), ), ], - ), - ), - ), ); } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 90ec55ba..85420123 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -27,6 +27,7 @@ import '../../services/update_service.dart'; import '../../utils/snackbar_helper.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/tv_number_spinner.dart'; import 'hotkey_recorder_widget.dart'; import '../../providers/companion_remote_provider.dart'; @@ -212,7 +213,7 @@ class _SettingsScreenState extends State with FocusableTab { onKeyEvent: _handleKeyEvent, child: CustomScrollView( slivers: [ - CustomAppBar(title: Text(t.settings.title), pinned: true), + ExcludeFocus(child: CustomAppBar(title: Text(t.settings.title), pinned: true)), SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( @@ -1754,61 +1755,54 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { @override Widget build(BuildContext context) { - if (_isLoading) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); - } + return FocusedScrollScaffold( + title: Text(t.settings.keyboardShortcuts), + actions: [ + TextButton( + onPressed: () async { + await widget.keyboardService.resetToDefaults(); + await _loadHotkeys(); + if (mounted) { + showSuccessSnackBar(this.context, t.settings.shortcutsReset); + } + }, + child: Text(t.common.reset), + ), + ], + slivers: _isLoading + ? [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))] + : [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final actions = _hotkeys.keys.toList(); + final action = actions[index]; + final hotkey = _hotkeys[action]!; - return Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar( - title: Text(t.settings.keyboardShortcuts), - pinned: true, - actions: [ - TextButton( - onPressed: () async { - await widget.keyboardService.resetToDefaults(); - await _loadHotkeys(); - if (mounted) { - showSuccessSnackBar(this.context, t.settings.shortcutsReset); - } - }, - child: Text(t.common.reset), + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(widget.keyboardService.getActionDisplayName(action)), + subtitle: Text(action), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)), + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + child: Text( + widget.keyboardService.formatHotkey(hotkey), + style: const TextStyle(fontFamily: 'monospace'), + ), + ), + onTap: () => _editHotkey(action, hotkey), + ), + ); + }, childCount: _hotkeys.length), + ), ), ], - ), - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final actions = _hotkeys.keys.toList(); - final action = actions[index]; - final hotkey = _hotkeys[action]!; - - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - title: Text(widget.keyboardService.getActionDisplayName(action)), - subtitle: Text(action), - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)), - borderRadius: const BorderRadius.all(Radius.circular(6)), - ), - child: Text( - widget.keyboardService.formatHotkey(hotkey), - style: const TextStyle(fontFamily: 'monospace'), - ), - ), - onTap: () => _editHotkey(action, hotkey), - ), - ); - }, childCount: _hotkeys.length), - ), - ), - ], - ), ); } diff --git a/lib/screens/settings/subtitle_styling_screen.dart b/lib/screens/settings/subtitle_styling_screen.dart index 8b008820..87c31f99 100644 --- a/lib/screens/settings/subtitle_styling_screen.dart +++ b/lib/screens/settings/subtitle_styling_screen.dart @@ -6,8 +6,7 @@ import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../services/settings_service.dart'; import '../../focus/input_mode_tracker.dart'; -import '../../focus/key_event_utils.dart'; -import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/tv_color_picker.dart'; import '../../widgets/tv_number_spinner.dart'; @@ -301,26 +300,11 @@ class _SubtitleStylingScreenState extends State { @override Widget build(BuildContext context) { - if (_isLoading) { - return Focus( - canRequestFocus: false, - onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), - child: FocusScope( - autofocus: true, - child: const Scaffold(body: Center(child: CircularProgressIndicator())), - ), - ); - } - - return Focus( - canRequestFocus: false, - onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), - child: FocusScope( - autofocus: true, - child: Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar(title: Text(t.screens.subtitleStyling), pinned: true), + return FocusedScrollScaffold( + title: Text(t.screens.subtitleStyling), + slivers: _isLoading + ? [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))] + : [ SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( @@ -328,9 +312,6 @@ class _SubtitleStylingScreenState extends State { ), ), ], - ), - ), - ), ); } diff --git a/lib/widgets/focused_scroll_scaffold.dart b/lib/widgets/focused_scroll_scaffold.dart index 0f7307b6..00c9e808 100644 --- a/lib/widgets/focused_scroll_scaffold.dart +++ b/lib/widgets/focused_scroll_scaffold.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; import 'desktop_app_bar.dart'; @@ -9,7 +10,10 @@ import 'desktop_app_bar.dart'; /// - Keyboard navigation (back key handling) /// - Custom scrollable content with slivers /// - Consistent app bar with title and optional actions -class FocusedScrollScaffold extends StatelessWidget { +/// +/// Automatically focuses the first content item (skipping the app bar) +/// when in keyboard navigation mode. +class FocusedScrollScaffold extends StatefulWidget { /// The title to display in the app bar. /// Can be a Text widget or a more complex widget like Column. final Widget title; @@ -38,23 +42,54 @@ class FocusedScrollScaffold extends StatelessWidget { this.automaticallyImplyLeading = true, }); + @override + State createState() => _FocusedScrollScaffoldState(); +} + +class _FocusedScrollScaffoldState extends State { + final _scopeNode = FocusScopeNode(); + bool _focusRequested = false; + + @override + void dispose() { + _scopeNode.dispose(); + super.dispose(); + } + + void _requestInitialFocus() { + if (_focusRequested || !mounted || !InputModeTracker.isKeyboardMode(context)) return; + _focusRequested = true; + _scopeNode.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + primaryFocus?.nextFocus(); + }); + } + @override Widget build(BuildContext context) { + // Request focus after first build when in keyboard mode + if (!_focusRequested && InputModeTracker.isKeyboardMode(context)) { + WidgetsBinding.instance.addPostFrameCallback((_) => _requestInitialFocus()); + } + return Focus( canRequestFocus: false, onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), child: FocusScope( - autofocus: true, + node: _scopeNode, child: Scaffold( body: CustomScrollView( slivers: [ - CustomAppBar( - title: title, - pinned: pinned, - actions: actions, - automaticallyImplyLeading: automaticallyImplyLeading, + ExcludeFocus( + child: CustomAppBar( + title: widget.title, + pinned: widget.pinned, + actions: widget.actions, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + ), ), - ...slivers, + ...widget.slivers, ], ), ),