fix: auto-focus first content item on settings screens in dpad mode

This commit is contained in:
edde746
2026-02-22 15:58:10 +01:00
parent d709d638c7
commit 03fc1c1109
6 changed files with 197 additions and 233 deletions
@@ -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<ExternalPlayerScreen> {
@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,
),
],
),
),
],
]),
),
),
),
],
);
}
+43 -56
View File
@@ -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<LogsScreen> {
@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),
),
),
],
);
}
}
+6 -25
View File
@@ -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<MpvConfigScreen> {
@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<MpvConfigScreen> {
),
),
],
),
),
),
);
}
+47 -53
View File
@@ -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<SettingsScreen> 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),
),
),
],
),
);
}
@@ -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<SubtitleStylingScreen> {
@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<SubtitleStylingScreen> {
),
),
],
),
),
),
);
}
+43 -8
View File
@@ -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<FocusedScrollScaffold> createState() => _FocusedScrollScaffoldState();
}
class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
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,
],
),
),