feat: save focus on settings screen

This commit is contained in:
edde746
2026-01-24 15:32:08 +01:00
parent 54e8e0c621
commit 5e3178e6fe
4 changed files with 224 additions and 91 deletions
+93 -1
View File
@@ -8,7 +8,9 @@ import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../focus/focus_memory_tracker.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/refreshable.dart';
import '../../services/discord_rpc_service.dart';
import '../../services/download_storage_service.dart';
import '../../services/saf_storage_service.dart';
@@ -43,8 +45,46 @@ class SettingsScreen extends StatefulWidget {
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
late settings.SettingsService _settingsService;
late final FocusMemoryTracker _focusTracker;
// Setting keys for focus tracking
static const _kTheme = 'theme';
static const _kLanguage = 'language';
static const _kLibraryDensity = 'library_density';
static const _kViewMode = 'view_mode';
static const _kEpisodePosterMode = 'episode_poster_mode';
static const _kShowHeroSection = 'show_hero_section';
static const _kUseGlobalHubs = 'use_global_hubs';
static const _kShowServerNameOnHubs = 'show_server_name_on_hubs';
static const _kAlwaysKeepSidebarOpen = 'always_keep_sidebar_open';
static const _kPlayerBackend = 'player_backend';
static const _kHardwareDecoding = 'hardware_decoding';
static const _kMatchContentFrameRate = 'match_content_frame_rate';
static const _kBufferSize = 'buffer_size';
static const _kSubtitleStyling = 'subtitle_styling';
static const _kMpvConfig = 'mpv_config';
static const _kSmallSkipDuration = 'small_skip_duration';
static const _kLargeSkipDuration = 'large_skip_duration';
static const _kDefaultSleepTimer = 'default_sleep_timer';
static const _kMaxVolume = 'max_volume';
static const _kDiscordRichPresence = 'discord_rich_presence';
static const _kRememberTrackSelections = 'remember_track_selections';
static const _kClickVideoTogglesPlayback = 'click_video_toggles_playback';
static const _kAutoSkipIntro = 'auto_skip_intro';
static const _kAutoSkipCredits = 'auto_skip_credits';
static const _kAutoSkipDelay = 'auto_skip_delay';
static const _kDownloadLocation = 'download_location';
static const _kDownloadOnWifiOnly = 'download_on_wifi_only';
static const _kVideoPlayerControls = 'video_player_controls';
static const _kVideoPlayerNavigation = 'video_player_navigation';
static const _kDebugLogging = 'debug_logging';
static const _kViewLogs = 'view_logs';
static const _kClearCache = 'clear_cache';
static const _kResetSettings = 'reset_settings';
static const _kCheckForUpdates = 'check_for_updates';
static const _kAbout = 'about';
KeyboardShortcutsService? _keyboardService;
late final bool _keyboardShortcutsSupported = KeyboardShortcutsService.isPlatformSupported();
bool _isLoading = true;
@@ -74,9 +114,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
void initState() {
super.initState();
_focusTracker = FocusMemoryTracker(
onFocusChanged: () {
if (mounted) setState(() {});
},
debugLabelPrefix: 'settings',
);
_loadSettings();
}
@override
void dispose() {
_focusTracker.dispose();
super.dispose();
}
@override
void focusActiveTabIfReady() {
_focusTracker.restoreFocus(fallbackKey: _kTheme);
}
Future<void> _loadSettings() async {
_settingsService = await settings.SettingsService.getInstance();
if (_keyboardShortcutsSupported) {
@@ -154,6 +211,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return ListTile(
focusNode: _focusTracker.get(_kTheme),
leading: AppIcon(themeProvider.themeModeIcon, fill: 1),
title: Text(t.settings.theme),
subtitle: Text(themeProvider.themeModeDisplayName),
@@ -163,6 +221,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
ListTile(
focusNode: _focusTracker.get(_kLanguage),
leading: const AppIcon(Symbols.language_rounded, fill: 1),
title: Text(t.settings.language),
subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)),
@@ -172,6 +231,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return ListTile(
focusNode: _focusTracker.get(_kLibraryDensity),
leading: const AppIcon(Symbols.grid_view_rounded, fill: 1),
title: Text(t.settings.libraryDensity),
subtitle: Text(settingsProvider.libraryDensityDisplayName),
@@ -183,6 +243,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return ListTile(
focusNode: _focusTracker.get(_kViewMode),
leading: const AppIcon(Symbols.view_list_rounded, fill: 1),
title: Text(t.settings.viewMode),
subtitle: Text(
@@ -196,6 +257,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return ListTile(
focusNode: _focusTracker.get(_kEpisodePosterMode),
leading: const AppIcon(Symbols.image_rounded, fill: 1),
title: Text(t.settings.episodePosterMode),
subtitle: Text(settingsProvider.episodePosterModeDisplayName),
@@ -207,6 +269,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SwitchListTile(
focusNode: _focusTracker.get(_kShowHeroSection),
secondary: const AppIcon(Symbols.featured_play_list_rounded, fill: 1),
title: Text(t.settings.showHeroSection),
subtitle: Text(t.settings.showHeroSectionDescription),
@@ -220,6 +283,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SwitchListTile(
focusNode: _focusTracker.get(_kUseGlobalHubs),
secondary: const AppIcon(Symbols.home_rounded, fill: 1),
title: Text(t.settings.useGlobalHubs),
subtitle: Text(t.settings.useGlobalHubsDescription),
@@ -233,6 +297,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SwitchListTile(
focusNode: _focusTracker.get(_kShowServerNameOnHubs),
secondary: const AppIcon(Symbols.dns_rounded, fill: 1),
title: Text(t.settings.showServerNameOnHubs),
subtitle: Text(t.settings.showServerNameOnHubsDescription),
@@ -247,6 +312,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SwitchListTile(
focusNode: _focusTracker.get(_kAlwaysKeepSidebarOpen),
secondary: const AppIcon(Symbols.dock_to_left_rounded, fill: 1),
title: Text(t.settings.alwaysKeepSidebarOpen),
subtitle: Text(t.settings.alwaysKeepSidebarOpenDescription),
@@ -278,6 +344,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
if (Platform.isAndroid)
ListTile(
focusNode: _focusTracker.get(_kPlayerBackend),
leading: const AppIcon(Symbols.play_circle_rounded, fill: 1),
title: Text(t.settings.playerBackend),
subtitle: Text(_useExoPlayer ? t.settings.exoPlayerDescription : t.settings.mpvDescription),
@@ -285,6 +352,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showPlayerBackendDialog(),
),
SwitchListTile(
focusNode: _focusTracker.get(_kHardwareDecoding),
secondary: const AppIcon(Symbols.hardware_rounded, fill: 1),
title: Text(t.settings.hardwareDecoding),
subtitle: Text(t.settings.hardwareDecodingDescription),
@@ -298,6 +366,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
if (Platform.isAndroid)
SwitchListTile(
focusNode: _focusTracker.get(_kMatchContentFrameRate),
secondary: const AppIcon(Symbols.display_settings_rounded, fill: 1),
title: Text(t.settings.matchContentFrameRate),
subtitle: Text(t.settings.matchContentFrameRateDescription),
@@ -310,6 +379,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
ListTile(
focusNode: _focusTracker.get(_kBufferSize),
leading: const AppIcon(Symbols.memory_rounded, fill: 1),
title: Text(t.settings.bufferSize),
subtitle: Text(t.settings.bufferSizeMB(size: _bufferSize.toString())),
@@ -317,6 +387,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showBufferSizeDialog(),
),
ListTile(
focusNode: _focusTracker.get(_kSubtitleStyling),
leading: const AppIcon(Symbols.subtitles_rounded, fill: 1),
title: Text(t.settings.subtitleStyling),
subtitle: Text(t.settings.subtitleStylingDescription),
@@ -328,6 +399,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// MPV Config is only available when using MPV player backend
if (!Platform.isAndroid || !_useExoPlayer)
ListTile(
focusNode: _focusTracker.get(_kMpvConfig),
leading: const AppIcon(Symbols.tune_rounded, fill: 1),
title: Text(t.mpvConfig.title),
subtitle: Text(t.mpvConfig.description),
@@ -337,6 +409,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
ListTile(
focusNode: _focusTracker.get(_kSmallSkipDuration),
leading: const AppIcon(Symbols.replay_10_rounded, fill: 1),
title: Text(t.settings.smallSkipDuration),
subtitle: Text(t.settings.secondsUnit(seconds: _seekTimeSmall.toString())),
@@ -344,6 +417,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showSeekTimeSmallDialog(),
),
ListTile(
focusNode: _focusTracker.get(_kLargeSkipDuration),
leading: const AppIcon(Symbols.replay_30_rounded, fill: 1),
title: Text(t.settings.largeSkipDuration),
subtitle: Text(t.settings.secondsUnit(seconds: _seekTimeLarge.toString())),
@@ -351,6 +425,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showSeekTimeLargeDialog(),
),
ListTile(
focusNode: _focusTracker.get(_kDefaultSleepTimer),
leading: const AppIcon(Symbols.bedtime_rounded, fill: 1),
title: Text(t.settings.defaultSleepTimer),
subtitle: Text(t.settings.minutesUnit(minutes: _sleepTimerDuration.toString())),
@@ -358,6 +433,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showSleepTimerDurationDialog(),
),
ListTile(
focusNode: _focusTracker.get(_kMaxVolume),
leading: const AppIcon(Symbols.volume_up_rounded, fill: 1),
title: Text(t.settings.maxVolume),
subtitle: Text(t.settings.maxVolumePercent(percent: _maxVolume.toString())),
@@ -366,6 +442,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
if (DiscordRPCService.isAvailable)
SwitchListTile(
focusNode: _focusTracker.get(_kDiscordRichPresence),
secondary: const AppIcon(Symbols.chat_rounded, fill: 1),
title: Text(t.settings.discordRichPresence),
subtitle: Text(t.settings.discordRichPresenceDescription),
@@ -377,6 +454,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
SwitchListTile(
focusNode: _focusTracker.get(_kRememberTrackSelections),
secondary: const AppIcon(Symbols.bookmark_rounded, fill: 1),
title: Text(t.settings.rememberTrackSelections),
subtitle: Text(t.settings.rememberTrackSelectionsDescription),
@@ -390,6 +468,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
if (!isMobile)
SwitchListTile(
focusNode: _focusTracker.get(_kClickVideoTogglesPlayback),
secondary: const AppIcon(Symbols.play_pause_rounded, fill: 1),
title: Text(t.settings.clickVideoTogglesPlayback),
subtitle: Text(t.settings.clickVideoTogglesPlaybackDescription),
@@ -413,6 +492,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
SwitchListTile(
focusNode: _focusTracker.get(_kAutoSkipIntro),
secondary: const AppIcon(Symbols.fast_forward_rounded, fill: 1),
title: Text(t.settings.autoSkipIntro),
subtitle: Text(t.settings.autoSkipIntroDescription),
@@ -425,6 +505,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
SwitchListTile(
focusNode: _focusTracker.get(_kAutoSkipCredits),
secondary: const AppIcon(Symbols.skip_next_rounded, fill: 1),
title: Text(t.settings.autoSkipCredits),
subtitle: Text(t.settings.autoSkipCreditsDescription),
@@ -437,6 +518,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
ListTile(
focusNode: _focusTracker.get(_kAutoSkipDelay),
leading: const AppIcon(Symbols.timer_rounded, fill: 1),
title: Text(t.settings.autoSkipDelay),
subtitle: Text(t.settings.autoSkipDelayDescription(seconds: _autoSkipDelay.toString())),
@@ -471,6 +553,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final currentPath = snapshot.data ?? '...';
return ListTile(
focusNode: _focusTracker.get(_kDownloadLocation),
leading: const AppIcon(Symbols.folder_rounded, fill: 1),
title: Text(isCustom ? t.settings.downloadLocationCustom : t.settings.downloadLocationDefault),
subtitle: Text(currentPath, maxLines: 2, overflow: TextOverflow.ellipsis),
@@ -480,6 +563,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
SwitchListTile(
focusNode: _focusTracker.get(_kDownloadOnWifiOnly),
secondary: const AppIcon(Symbols.wifi_rounded, fill: 1),
title: Text(t.settings.downloadOnWifiOnly),
subtitle: Text(t.settings.downloadOnWifiOnlyDescription),
@@ -613,6 +697,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
ListTile(
focusNode: _focusTracker.get(_kVideoPlayerControls),
leading: const AppIcon(Symbols.keyboard_rounded, fill: 1),
title: Text(t.settings.videoPlayerControls),
subtitle: Text(t.settings.keyboardShortcutsDescription),
@@ -620,6 +705,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showKeyboardShortcutsDialog(),
),
SwitchListTile(
focusNode: _focusTracker.get(_kVideoPlayerNavigation),
secondary: const AppIcon(Symbols.gamepad_rounded, fill: 1),
title: Text(t.settings.videoPlayerNavigation),
subtitle: Text(t.settings.videoPlayerNavigationDescription),
@@ -649,6 +735,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
SwitchListTile(
focusNode: _focusTracker.get(_kDebugLogging),
secondary: const AppIcon(Symbols.bug_report_rounded, fill: 1),
title: Text(t.settings.debugLogging),
subtitle: Text(t.settings.debugLoggingDescription),
@@ -661,6 +748,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
ListTile(
focusNode: _focusTracker.get(_kViewLogs),
leading: const AppIcon(Symbols.article_rounded, fill: 1),
title: Text(t.settings.viewLogs),
subtitle: Text(t.settings.viewLogsDescription),
@@ -670,6 +758,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
ListTile(
focusNode: _focusTracker.get(_kClearCache),
leading: const AppIcon(Symbols.cleaning_services_rounded, fill: 1),
title: Text(t.settings.clearCache),
subtitle: Text(t.settings.clearCacheDescription),
@@ -677,6 +766,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showClearCacheDialog(),
),
ListTile(
focusNode: _focusTracker.get(_kResetSettings),
leading: const AppIcon(Symbols.restore_rounded, fill: 1),
title: Text(t.settings.resetSettings),
subtitle: Text(t.settings.resetSettingsDescription),
@@ -703,6 +793,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
ListTile(
focusNode: _focusTracker.get(_kCheckForUpdates),
leading: AppIcon(
hasUpdate ? Symbols.system_update_rounded : Symbols.check_circle_rounded,
fill: 1,
@@ -731,6 +822,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget _buildAboutSection() {
return Card(
child: ListTile(
focusNode: _focusTracker.get(_kAbout),
leading: const AppIcon(Symbols.info_rounded, fill: 1),
title: Text(t.settings.about),
subtitle: Text(t.settings.aboutDescription),