feat: save focus on settings screen
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Reusable focus memory tracker for screens with multiple focusable items.
|
||||
/// Tracks which item was last focused and provides focus restoration.
|
||||
class FocusMemoryTracker {
|
||||
final Map<String, FocusNode> _nodes = {};
|
||||
final Set<String> _focused = {};
|
||||
final VoidCallback? _onFocusChanged;
|
||||
final String _debugLabelPrefix;
|
||||
String? _lastFocusedKey;
|
||||
|
||||
FocusMemoryTracker({VoidCallback? onFocusChanged, String debugLabelPrefix = 'focus'})
|
||||
: _onFocusChanged = onFocusChanged,
|
||||
_debugLabelPrefix = debugLabelPrefix;
|
||||
|
||||
/// Get or create a focus node for the given key
|
||||
FocusNode get(String key, {String? debugLabel}) {
|
||||
return _nodes.putIfAbsent(key, () {
|
||||
final node = FocusNode(debugLabel: debugLabel ?? '${_debugLabelPrefix}_$key');
|
||||
node.addListener(() {
|
||||
final wasFocused = _focused.contains(key);
|
||||
if (node.hasFocus && !wasFocused) {
|
||||
_focused.add(key);
|
||||
_lastFocusedKey = key;
|
||||
_onFocusChanged?.call();
|
||||
} else if (!node.hasFocus && wasFocused) {
|
||||
_focused.remove(key);
|
||||
_onFocusChanged?.call();
|
||||
}
|
||||
});
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
/// Get the last focused key (for focus restoration)
|
||||
String? get lastFocusedKey => _lastFocusedKey;
|
||||
|
||||
/// Check if a key is currently focused
|
||||
bool isFocused(String key) => _focused.contains(key);
|
||||
|
||||
/// Get a node without creating it (returns null if not found)
|
||||
FocusNode? nodeFor(String key) => _nodes[key];
|
||||
|
||||
/// Restore focus to the last focused item, or fallback if provided
|
||||
/// Returns true if focus was successfully restored
|
||||
bool restoreFocus({String? fallbackKey}) {
|
||||
// Try to restore last focused item
|
||||
if (_lastFocusedKey != null) {
|
||||
final node = _nodes[_lastFocusedKey];
|
||||
if (node != null) {
|
||||
node.requestFocus();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Fallback: focus the provided key if available
|
||||
if (fallbackKey != null) {
|
||||
final node = _nodes[fallbackKey];
|
||||
if (node != null) {
|
||||
node.requestFocus();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Remove nodes not in the given set of valid keys (prunes stale nodes)
|
||||
void pruneExcept(Set<String> validKeys) {
|
||||
final toRemove = _nodes.keys.where((k) => !validKeys.contains(k)).toList();
|
||||
for (final key in toRemove) {
|
||||
_nodes[key]?.dispose();
|
||||
_nodes.remove(key);
|
||||
_focused.remove(key);
|
||||
}
|
||||
// Clear last focused if it was pruned
|
||||
if (_lastFocusedKey != null && !validKeys.contains(_lastFocusedKey)) {
|
||||
_lastFocusedKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose all nodes
|
||||
void dispose() {
|
||||
for (final node in _nodes.values) {
|
||||
node.dispose();
|
||||
}
|
||||
_nodes.clear();
|
||||
_focused.clear();
|
||||
}
|
||||
}
|
||||
@@ -399,13 +399,22 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
setState(() => _isSidebarFocused = false);
|
||||
_contentFocusScope.requestFocus();
|
||||
// When content regains focus while on Libraries, retry focusing the active tab
|
||||
if (_currentIndex == 1) {
|
||||
if (_currentIndex == 1 && !_isOffline) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_librariesKey.currentState case final FocusableTab focusable) {
|
||||
focusable.focusActiveTabIfReady();
|
||||
}
|
||||
});
|
||||
}
|
||||
// When content regains focus while on Settings, restore focus to last focused setting
|
||||
final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline);
|
||||
if (_currentIndex == settingsIndex) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_settingsKey.currentState case final FocusableTab focusable) {
|
||||
focusable.focusActiveTabIfReady();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _handleBackKey(KeyEvent event) {
|
||||
@@ -521,23 +530,33 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
}
|
||||
});
|
||||
|
||||
// Skip screen-specific logic in offline mode (only Downloads and Settings available)
|
||||
if (_isOffline) return;
|
||||
// Handle screen-specific logic
|
||||
final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline);
|
||||
|
||||
// Notify discover screen when it becomes visible via tab switch
|
||||
if (index == 0) {
|
||||
_onDiscoverBecameVisible();
|
||||
}
|
||||
// Ensure the libraries screen applies focus when brought into view
|
||||
if (index == 1 && previousIndex != 1) {
|
||||
if (_librariesKey.currentState case final FocusableTab focusable) {
|
||||
focusable.focusActiveTabIfReady();
|
||||
// Skip online-only screen logic in offline mode
|
||||
if (!_isOffline) {
|
||||
// Notify discover screen when it becomes visible via tab switch
|
||||
if (index == 0) {
|
||||
_onDiscoverBecameVisible();
|
||||
}
|
||||
// Ensure the libraries screen applies focus when brought into view
|
||||
if (index == 1 && previousIndex != 1) {
|
||||
if (_librariesKey.currentState case final FocusableTab focusable) {
|
||||
focusable.focusActiveTabIfReady();
|
||||
}
|
||||
}
|
||||
// Focus search input when selecting Search tab
|
||||
if (index == 2) {
|
||||
if (_searchKey.currentState case final SearchInputFocusable searchable) {
|
||||
searchable.focusSearchInput();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Focus search input when selecting Search tab
|
||||
if (index == 2) {
|
||||
if (_searchKey.currentState case final SearchInputFocusable searchable) {
|
||||
searchable.focusSearchInput();
|
||||
|
||||
// Restore focus when switching to Settings tab (works in both online and offline mode)
|
||||
if (index == settingsIndex && previousIndex != settingsIndex) {
|
||||
if (_settingsKey.currentState case final FocusableTab focusable) {
|
||||
focusable.focusActiveTabIfReady();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focus_memory_tracker.dart';
|
||||
import '../models/plex_library.dart';
|
||||
import '../navigation/navigation_tabs.dart';
|
||||
import '../providers/hidden_libraries_provider.dart';
|
||||
@@ -18,67 +19,6 @@ import '../theme/mono_tokens.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Tracks focus state for a set of named items, avoiding repeated boilerplate
|
||||
class _FocusStateTracker {
|
||||
final Map<String, FocusNode> _nodes = {};
|
||||
final Set<String> _focused = {};
|
||||
final VoidCallback _onChanged;
|
||||
String? _lastFocusedKey; // Track last focused item for restoration
|
||||
|
||||
_FocusStateTracker(this._onChanged);
|
||||
|
||||
/// Get or create a focus node for the given key
|
||||
FocusNode get(String key, {String? debugLabel}) {
|
||||
return _nodes.putIfAbsent(key, () {
|
||||
final node = FocusNode(debugLabel: debugLabel ?? 'nav_$key');
|
||||
node.addListener(() {
|
||||
final wasFocused = _focused.contains(key);
|
||||
if (node.hasFocus && !wasFocused) {
|
||||
_focused.add(key);
|
||||
_lastFocusedKey = key; // Remember this key for restoration
|
||||
_onChanged();
|
||||
} else if (!node.hasFocus && wasFocused) {
|
||||
_focused.remove(key);
|
||||
_onChanged();
|
||||
}
|
||||
});
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
/// Get the last focused key (for focus restoration)
|
||||
String? get lastFocusedKey => _lastFocusedKey;
|
||||
|
||||
/// Check if a key is currently focused
|
||||
bool isFocused(String key) => _focused.contains(key);
|
||||
|
||||
/// Check if a node exists for the given key
|
||||
FocusNode? nodeFor(String key) => _nodes[key];
|
||||
|
||||
/// Dispose all nodes
|
||||
void dispose() {
|
||||
for (final node in _nodes.values) {
|
||||
node.dispose();
|
||||
}
|
||||
_nodes.clear();
|
||||
_focused.clear();
|
||||
}
|
||||
|
||||
/// Remove nodes not in the given set of valid keys (prunes stale nodes)
|
||||
void pruneExcept(Set<String> validKeys) {
|
||||
final toRemove = _nodes.keys.where((k) => !validKeys.contains(k)).toList();
|
||||
for (final key in toRemove) {
|
||||
_nodes[key]?.dispose();
|
||||
_nodes.remove(key);
|
||||
_focused.remove(key);
|
||||
}
|
||||
// Clear last focused if it was pruned
|
||||
if (_lastFocusedKey != null && !validKeys.contains(_lastFocusedKey)) {
|
||||
_lastFocusedKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable navigation rail item widget that handles focus, selection, and interaction
|
||||
class NavigationRailItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
@@ -221,7 +161,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
|
||||
static const _kSettings = 'settings';
|
||||
|
||||
// Unified focus state tracker for all nav items (main + libraries)
|
||||
late final _FocusStateTracker _focusTracker;
|
||||
late final FocusMemoryTracker _focusTracker;
|
||||
|
||||
/// Whether the sidebar should be expanded (always, hover, or focus)
|
||||
bool get _shouldExpand => widget.alwaysExpanded || _isHovered || widget.isSidebarFocused;
|
||||
@@ -229,9 +169,12 @@ class SideNavigationRailState extends State<SideNavigationRail> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusTracker = _FocusStateTracker(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_focusTracker = FocusMemoryTracker(
|
||||
onFocusChanged: () {
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
debugLabelPrefix: 'nav',
|
||||
);
|
||||
_loadLibraries();
|
||||
}
|
||||
|
||||
@@ -269,16 +212,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
|
||||
|
||||
/// Focus the last focused nav item, or Home as fallback
|
||||
void focusActiveItem() {
|
||||
// Try to restore last focused item
|
||||
if (_focusTracker.lastFocusedKey != null) {
|
||||
final node = _focusTracker.nodeFor(_focusTracker.lastFocusedKey!);
|
||||
if (node != null) {
|
||||
node.requestFocus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback: focus Home if nothing was previously focused
|
||||
_focusTracker.nodeFor(_kHome)?.requestFocus();
|
||||
_focusTracker.restoreFocus(fallbackKey: _kHome);
|
||||
}
|
||||
|
||||
/// Fetch, filter, and order libraries (pure logic, no state changes)
|
||||
|
||||
Reference in New Issue
Block a user