import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../models/hotkey_model.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../focus/focus_memory_tracker.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; import '../main_screen.dart'; import '../../mixins/refreshable.dart'; import '../../services/discord_rpc_service.dart'; import '../../services/download_storage_service.dart'; import '../../services/saf_storage_service.dart'; import '../../providers/settings_provider.dart'; import '../../providers/theme_provider.dart'; import '../../providers/user_profile_provider.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../mpv/player/platform/player_android.dart'; import '../../services/settings_service.dart' as settings; 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'; import '../../screens/companion_remote/mobile_remote_screen.dart'; import '../../widgets/companion_remote/remote_session_dialog.dart'; import 'about_screen.dart'; import 'external_player_screen.dart'; import 'logs_screen.dart'; import 'mpv_config_screen.dart'; import 'subtitle_styling_screen.dart'; /// Helper class for option selection dialog items class _DialogOption { final T value; final String title; final String? subtitle; const _DialogOption({required this.value, required this.title, this.subtitle}); } class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State 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 _kShowUnwatchedCount = 'show_unwatched_count'; static const _kHideSpoilers = 'hide_spoilers'; static const _kShowNavBarLabels = 'show_nav_bar_labels'; static const _kRequireProfileSelectionOnOpen = 'require_profile_selection_on_open'; static const _kConfirmExitOnBack = 'confirm_exit_on_back'; static const _kPlayerBackend = 'player_backend'; static const _kExternalPlayer = 'external_player'; static const _kHardwareDecoding = 'hardware_decoding'; static const _kAutoPip = 'auto_pip'; static const _kMatchContentFrameRate = 'match_content_frame_rate'; static const _kTunneledPlayback = 'tunneled_playback'; 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 _kCrashReporting = 'crash_reporting'; 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; bool _crashReporting = true; bool _enableDebugLogging = false; bool _enableHardwareDecoding = true; int _bufferSize = 0; int _seekTimeSmall = 10; int _seekTimeLarge = 30; int _sleepTimerDuration = 30; bool _rememberTrackSelections = true; bool _clickVideoTogglesPlayback = false; bool _autoSkipIntro = false; bool _autoSkipCredits = false; int _autoSkipDelay = 5; bool _downloadOnWifiOnly = false; bool _videoPlayerNavigationEnabled = false; int _maxVolume = 100; bool _enableDiscordRPC = false; bool _autoPip = true; bool _matchContentFrameRate = false; bool _tunneledPlayback = true; bool _useExoPlayer = true; // Android only: ExoPlayer vs MPV bool _requireProfileSelectionOnOpen = false; bool _useExternalPlayer = false; bool _confirmExitOnBack = true; String _selectedExternalPlayerName = ''; // Update checking state bool _isCheckingForUpdate = false; Map? _updateInfo; @override void initState() { super.initState(); _focusTracker = FocusMemoryTracker( onFocusChanged: () { // ignore: no-empty-block - setState triggers rebuild to update focus styling if (mounted) setState(() {}); }, debugLabelPrefix: 'settings', ); _loadSettings(); } @override void dispose() { _focusTracker.dispose(); super.dispose(); } @override void focusActiveTabIfReady() { if (InputModeTracker.isKeyboardMode(context)) { _focusTracker.restoreFocus(fallbackKey: _kTheme); } } /// Navigate focus to the sidebar void _navigateToSidebar() { MainScreenFocusScope.of(context)?.focusSidebar(); } /// Handle key events for LEFT arrow → sidebar navigation KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowLeft) { _navigateToSidebar(); return KeyEventResult.handled; } return KeyEventResult.ignored; } Future _loadSettings() async { _settingsService = await settings.SettingsService.getInstance(); if (_keyboardShortcutsSupported) { _keyboardService = await KeyboardShortcutsService.getInstance(); } if (!mounted) return; setState(() { _crashReporting = _settingsService.getCrashReporting(); _enableDebugLogging = _settingsService.getEnableDebugLogging(); _enableHardwareDecoding = _settingsService.getEnableHardwareDecoding(); _bufferSize = _settingsService.getBufferSize(); _seekTimeSmall = _settingsService.getSeekTimeSmall(); _seekTimeLarge = _settingsService.getSeekTimeLarge(); _sleepTimerDuration = _settingsService.getSleepTimerDuration(); _rememberTrackSelections = _settingsService.getRememberTrackSelections(); _clickVideoTogglesPlayback = _settingsService.getClickVideoTogglesPlayback(); _autoSkipIntro = _settingsService.getAutoSkipIntro(); _autoSkipCredits = _settingsService.getAutoSkipCredits(); _autoSkipDelay = _settingsService.getAutoSkipDelay(); _downloadOnWifiOnly = _settingsService.getDownloadOnWifiOnly(); _videoPlayerNavigationEnabled = _settingsService.getVideoPlayerNavigationEnabled(); _maxVolume = _settingsService.getMaxVolume(); _enableDiscordRPC = _settingsService.getEnableDiscordRPC(); _autoPip = _settingsService.getAutoPip(); _matchContentFrameRate = _settingsService.getMatchContentFrameRate(); _tunneledPlayback = _settingsService.getTunneledPlayback(); _useExoPlayer = _settingsService.getUseExoPlayer(); _requireProfileSelectionOnOpen = _settingsService.getRequireProfileSelectionOnOpen(); _useExternalPlayer = _settingsService.getUseExternalPlayer(); _selectedExternalPlayerName = _settingsService.getSelectedExternalPlayer().name; _confirmExitOnBack = _settingsService.getConfirmExitOnBack(); _isLoading = false; }); } @override Widget build(BuildContext context) { if (_isLoading) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } return Scaffold( body: Focus( onKeyEvent: _handleKeyEvent, child: CustomScrollView( slivers: [ ExcludeFocus(child: CustomAppBar(title: Text(t.settings.title), pinned: true)), SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( delegate: SliverChildListDelegate([ _buildAppearanceSection(), const SizedBox(height: 24), _buildVideoPlaybackSection(), const SizedBox(height: 24), _buildDownloadsSection(), const SizedBox(height: 24), if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection(), const SizedBox(height: 24)], _buildCompanionRemoteSection(), const SizedBox(height: 24), _buildAdvancedSection(), const SizedBox(height: 24), if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection(), const SizedBox(height: 24)], _buildAboutSection(), const SizedBox(height: 24), ]), ), ), ], ), ), ); } Widget _buildAppearanceSection() { return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( t.settings.appearance, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), Consumer( 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), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showThemeDialog(themeProvider), ); }, ), ListTile( focusNode: _focusTracker.get(_kLanguage), leading: const AppIcon(Symbols.language_rounded, fill: 1), title: Text(t.settings.language), subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showLanguageDialog(), ), Consumer( 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), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showLibraryDensityDialog(), ); }, ), Consumer( 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( settingsProvider.viewMode == settings.ViewMode.grid ? t.settings.gridView : t.settings.listView, ), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showViewModeDialog(), ); }, ), Consumer( 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), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showEpisodePosterModeDialog(), ); }, ), Consumer( 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), value: settingsProvider.showHeroSection, onChanged: (value) async { await settingsProvider.setShowHeroSection(value); }, ); }, ), Consumer( 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), value: settingsProvider.useGlobalHubs, onChanged: (value) async { await settingsProvider.setUseGlobalHubs(value); }, ); }, ), Consumer( 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), value: settingsProvider.showServerNameOnHubs, onChanged: (value) async { await settingsProvider.setShowServerNameOnHubs(value); }, ); }, ), if (PlatformDetector.shouldUseSideNavigation(context)) Consumer( 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), value: settingsProvider.alwaysKeepSidebarOpen, onChanged: (value) async { await settingsProvider.setAlwaysKeepSidebarOpen(value); }, ); }, ), if (!PlatformDetector.shouldUseSideNavigation(context)) Consumer( builder: (context, settingsProvider, child) { return SwitchListTile( focusNode: _focusTracker.get(_kShowNavBarLabels), secondary: const AppIcon(Symbols.label_rounded, fill: 1), title: Text(t.settings.showNavBarLabels), subtitle: Text(t.settings.showNavBarLabelsDescription), value: settingsProvider.showNavBarLabels, onChanged: (value) async { await settingsProvider.setShowNavBarLabels(value); }, ); }, ), Consumer( builder: (context, settingsProvider, child) { return SwitchListTile( focusNode: _focusTracker.get(_kShowUnwatchedCount), secondary: const AppIcon(Symbols.counter_1_rounded, fill: 1), title: Text(t.settings.showUnwatchedCount), subtitle: Text(t.settings.showUnwatchedCountDescription), value: settingsProvider.showUnwatchedCount, onChanged: (value) async { await settingsProvider.setShowUnwatchedCount(value); }, ); }, ), Consumer( builder: (context, settingsProvider, child) { return SwitchListTile( focusNode: _focusTracker.get(_kHideSpoilers), secondary: const AppIcon(Symbols.visibility_off_rounded, fill: 1), title: Text(t.settings.hideSpoilers), subtitle: Text(t.settings.hideSpoilersDescription), value: settingsProvider.hideSpoilers, onChanged: (value) async { await settingsProvider.setHideSpoilers(value); }, ); }, ), Consumer( builder: (context, userProfileProvider, child) { if (!userProfileProvider.hasMultipleUsers) return const SizedBox.shrink(); return SwitchListTile( focusNode: _focusTracker.get(_kRequireProfileSelectionOnOpen), secondary: const AppIcon(Symbols.person_rounded, fill: 1), title: Text(t.settings.requireProfileSelectionOnOpen), subtitle: Text(t.settings.requireProfileSelectionOnOpenDescription), value: _requireProfileSelectionOnOpen, onChanged: (value) async { setState(() => _requireProfileSelectionOnOpen = value); await _settingsService.setRequireProfileSelectionOnOpen(value); }, ); }, ), if (PlatformDetector.isTV()) SwitchListTile( focusNode: _focusTracker.get(_kConfirmExitOnBack), secondary: const AppIcon(Symbols.exit_to_app_rounded, fill: 1), title: Text(t.settings.confirmExitOnBack), subtitle: Text(t.settings.confirmExitOnBackDescription), value: _confirmExitOnBack, onChanged: (value) async { setState(() => _confirmExitOnBack = value); await _settingsService.setConfirmExitOnBack(value); }, ), ], ), ); } Widget _buildVideoPlaybackSection() { final isMobile = PlatformDetector.isMobile(context); return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( t.settings.videoPlayback, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), 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), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showPlayerBackendDialog(), ), ListTile( focusNode: _focusTracker.get(_kExternalPlayer), leading: const AppIcon(Symbols.open_in_new_rounded, fill: 1), title: Text(t.externalPlayer.title), subtitle: Text(_useExternalPlayer ? _selectedExternalPlayerName : t.externalPlayer.off), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () async { await Navigator.push(context, MaterialPageRoute(builder: (context) => const ExternalPlayerScreen())); // Reload to reflect any changes final s = await settings.SettingsService.getInstance(); if (!mounted) return; setState(() { _useExternalPlayer = s.getUseExternalPlayer(); _selectedExternalPlayerName = s.getSelectedExternalPlayer().name; }); }, ), SwitchListTile( focusNode: _focusTracker.get(_kHardwareDecoding), secondary: const AppIcon(Symbols.hardware_rounded, fill: 1), title: Text(t.settings.hardwareDecoding), subtitle: Text(t.settings.hardwareDecodingDescription), value: _enableHardwareDecoding, onChanged: (value) async { setState(() { _enableHardwareDecoding = value; }); await _settingsService.setEnableHardwareDecoding(value); }, ), if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) SwitchListTile( focusNode: _focusTracker.get(_kAutoPip), secondary: const AppIcon(Symbols.picture_in_picture_alt_rounded, fill: 1), title: Text(t.settings.autoPip), subtitle: Text(t.settings.autoPipDescription), value: _autoPip, onChanged: (value) async { setState(() { _autoPip = value; }); await _settingsService.setAutoPip(value); }, ), 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), value: _matchContentFrameRate, onChanged: (value) async { setState(() { _matchContentFrameRate = value; }); await _settingsService.setMatchContentFrameRate(value); }, ), if (Platform.isAndroid && _useExoPlayer) SwitchListTile( focusNode: _focusTracker.get(_kTunneledPlayback), secondary: const AppIcon(Symbols.tv_options_input_settings_rounded, fill: 1), title: Text(t.settings.tunneledPlayback), subtitle: Text(t.settings.tunneledPlaybackDescription), value: _tunneledPlayback, onChanged: (value) async { setState(() { _tunneledPlayback = value; }); await _settingsService.setTunneledPlayback(value); }, ), ListTile( focusNode: _focusTracker.get(_kBufferSize), leading: const AppIcon(Symbols.memory_rounded, fill: 1), title: Text(t.settings.bufferSize), subtitle: Text(_bufferSize == 0 ? t.settings.bufferSizeAuto : t.settings.bufferSizeMB(size: _bufferSize.toString())), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), 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), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () { Navigator.push(context, MaterialPageRoute(builder: (context) => const SubtitleStylingScreen())); }, ), // 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), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () { Navigator.push(context, MaterialPageRoute(builder: (context) => const MpvConfigScreen())); }, ), 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())), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), 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())), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), 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())), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), 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())), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showMaxVolumeDialog(), ), 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), value: _enableDiscordRPC, onChanged: (value) async { setState(() => _enableDiscordRPC = value); await _settingsService.setEnableDiscordRPC(value); await DiscordRPCService.instance.setEnabled(value); }, ), SwitchListTile( focusNode: _focusTracker.get(_kRememberTrackSelections), secondary: const AppIcon(Symbols.bookmark_rounded, fill: 1), title: Text(t.settings.rememberTrackSelections), subtitle: Text(t.settings.rememberTrackSelectionsDescription), value: _rememberTrackSelections, onChanged: (value) async { setState(() { _rememberTrackSelections = value; }); await _settingsService.setRememberTrackSelections(value); }, ), 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), value: _clickVideoTogglesPlayback, onChanged: (value) async { setState(() { _clickVideoTogglesPlayback = value; }); await _settingsService.setClickVideoTogglesPlayback(value); }, ), const Divider(), Padding( padding: const EdgeInsets.only(left: 16, top: 8, right: 16), child: Text( t.settings.autoSkip, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.primary, ), ), ), SwitchListTile( focusNode: _focusTracker.get(_kAutoSkipIntro), secondary: const AppIcon(Symbols.fast_forward_rounded, fill: 1), title: Text(t.settings.autoSkipIntro), subtitle: Text(t.settings.autoSkipIntroDescription), value: _autoSkipIntro, onChanged: (value) async { setState(() { _autoSkipIntro = value; }); await _settingsService.setAutoSkipIntro(value); }, ), SwitchListTile( focusNode: _focusTracker.get(_kAutoSkipCredits), secondary: const AppIcon(Symbols.skip_next_rounded, fill: 1), title: Text(t.settings.autoSkipCredits), subtitle: Text(t.settings.autoSkipCreditsDescription), value: _autoSkipCredits, onChanged: (value) async { setState(() { _autoSkipCredits = value; }); await _settingsService.setAutoSkipCredits(value); }, ), 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())), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showAutoSkipDelayDialog(), ), ], ), ); } Widget _buildDownloadsSection() { final storageService = DownloadStorageService.instance; final isCustom = storageService.isUsingCustomPath(); return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( t.settings.downloads, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), // Download location picker - not available on iOS if (!Platform.isIOS) FutureBuilder( future: storageService.getCurrentDownloadPathDisplay(), builder: (context, snapshot) { 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), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showDownloadLocationDialog(), ); }, ), SwitchListTile( focusNode: _focusTracker.get(_kDownloadOnWifiOnly), secondary: const AppIcon(Symbols.wifi_rounded, fill: 1), title: Text(t.settings.downloadOnWifiOnly), subtitle: Text(t.settings.downloadOnWifiOnlyDescription), value: _downloadOnWifiOnly, onChanged: (value) async { setState(() => _downloadOnWifiOnly = value); await _settingsService.setDownloadOnWifiOnly(value); }, ), ], ), ); } Future _showDownloadLocationDialog() async { final storageService = DownloadStorageService.instance; final isCustom = storageService.isUsingCustomPath(); showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(t.settings.downloads), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(t.settings.downloadLocationDescription), const SizedBox(height: 16), FutureBuilder( future: storageService.getCurrentDownloadPathDisplay(), builder: (context, snapshot) { return Text( t.settings.currentPath(path: snapshot.data ?? '...'), style: Theme.of(context).textTheme.bodySmall, ); }, ), ], ), actions: [ if (isCustom) TextButton( onPressed: () async { Navigator.pop(dialogContext); await _resetDownloadLocation(); }, child: Text(t.settings.resetToDefault), ), TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), FilledButton( onPressed: () async { Navigator.pop(dialogContext); await _selectDownloadLocation(); }, child: Text(t.settings.selectFolder), ), ], ), ); } Future _selectDownloadLocation() async { try { String? selectedPath; String pathType = 'file'; if (Platform.isAndroid) { // Use SAF on Android final safService = SafStorageService.instance; selectedPath = await safService.pickDirectory(); if (selectedPath != null) { pathType = 'saf'; } } else { // Use file_picker on desktop final result = await FilePicker.platform.getDirectoryPath(dialogTitle: t.settings.selectFolder); selectedPath = result; } if (selectedPath != null) { // Validate the path is writable (for non-SAF paths) if (pathType == 'file') { final dir = Directory(selectedPath); final isWritable = await DownloadStorageService.instance.isDirectoryWritable(dir); if (!isWritable) { if (mounted) { showErrorSnackBar(context, t.settings.downloadLocationInvalid); } return; } } // Save the setting await _settingsService.setCustomDownloadPath(selectedPath, type: pathType); await DownloadStorageService.instance.refreshCustomPath(); if (mounted) { // ignore: no-empty-block - setState triggers rebuild to reflect new download path setState(() {}); showSuccessSnackBar(context, t.settings.downloadLocationChanged); } } } catch (e) { if (mounted) { showErrorSnackBar(context, t.settings.downloadLocationSelectError); } } } Future _resetDownloadLocation() async { await _settingsService.setCustomDownloadPath(null); await DownloadStorageService.instance.refreshCustomPath(); if (mounted) { // ignore: no-empty-block - setState triggers rebuild to reflect reset path setState(() {}); showAppSnackBar(context, t.settings.downloadLocationReset); } } Widget _buildKeyboardShortcutsSection() { if (_keyboardService == null) return const SizedBox.shrink(); return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( t.settings.keyboardShortcuts, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), ListTile( focusNode: _focusTracker.get(_kVideoPlayerControls), leading: const AppIcon(Symbols.keyboard_rounded, fill: 1), title: Text(t.settings.videoPlayerControls), subtitle: Text(t.settings.keyboardShortcutsDescription), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), 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), value: _videoPlayerNavigationEnabled, onChanged: (value) async { setState(() { _videoPlayerNavigationEnabled = value; }); await _settingsService.setVideoPlayerNavigationEnabled(value); }, ), ], ), ); } Widget _buildCompanionRemoteSection() { return Consumer( builder: (context, companionRemote, child) { return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( t.companionRemote.title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), if (PlatformDetector.isDesktop(context)) ListTile( leading: const AppIcon(Symbols.phone_android_rounded, fill: 1), title: Text(t.companionRemote.hostRemoteSession), subtitle: companionRemote.isConnected ? Text(t.companionRemote.connectedTo(name: companionRemote.connectedDevice?.name ?? '')) : Text(t.companionRemote.controlThisDevice), trailing: companionRemote.isConnected ? const AppIcon(Symbols.check_circle_rounded, fill: 1, color: Colors.green) : const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => RemoteSessionDialog.show(context), ) else ListTile( leading: const AppIcon(Symbols.phone_android_rounded, fill: 1), title: Text(t.companionRemote.remoteControl), subtitle: companionRemote.isConnected ? Text(t.companionRemote.connectedTo(name: companionRemote.connectedDevice?.name ?? '')) : Text(t.companionRemote.controlDesktop), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () { Navigator.push(context, MaterialPageRoute(builder: (context) => const MobileRemoteScreen())); }, ), ], ), ); }, ); } Widget _buildAdvancedSection() { return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( t.settings.advanced, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), SwitchListTile( focusNode: _focusTracker.get(_kCrashReporting), secondary: const AppIcon(Symbols.monitoring_rounded, fill: 1), title: Text(t.settings.crashReporting), subtitle: Text(t.settings.crashReportingDescription), value: _crashReporting, onChanged: (value) async { setState(() { _crashReporting = value; }); await _settingsService.setCrashReporting(value); }, ), SwitchListTile( focusNode: _focusTracker.get(_kDebugLogging), secondary: const AppIcon(Symbols.bug_report_rounded, fill: 1), title: Text(t.settings.debugLogging), subtitle: Text(t.settings.debugLoggingDescription), value: _enableDebugLogging, onChanged: (value) async { setState(() { _enableDebugLogging = value; }); await _settingsService.setEnableDebugLogging(value); }, ), ListTile( focusNode: _focusTracker.get(_kViewLogs), leading: const AppIcon(Symbols.article_rounded, fill: 1), title: Text(t.settings.viewLogs), subtitle: Text(t.settings.viewLogsDescription), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () { Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen())); }, ), ListTile( focusNode: _focusTracker.get(_kClearCache), leading: const AppIcon(Symbols.cleaning_services_rounded, fill: 1), title: Text(t.settings.clearCache), subtitle: Text(t.settings.clearCacheDescription), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), 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), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showResetSettingsDialog(), ), if (kDebugMode) ListTile( leading: const AppIcon(Symbols.error_rounded, fill: 1), title: const Text('Test Sentry'), subtitle: const Text('Send a test error'), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () { throw Exception("Example exception"); }, ), if (kDebugMode) ListTile( leading: const AppIcon(Symbols.timer_rounded, fill: 1), title: const Text('Test ANR'), subtitle: const Text('Block the main thread for 10 seconds'), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () { showSnackBar(context, 'Blocking main thread...'); final end = DateTime.now().add(const Duration(seconds: 10)); while (DateTime.now().isBefore(end)) {} }, ), ], ), ); } Widget _buildUpdateSection() { // Native updater: simple tile that triggers Sparkle/WinSparkle native dialog if (UpdateService.useNativeUpdater) { return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( t.settings.updates, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), ListTile( focusNode: _focusTracker.get(_kCheckForUpdates), leading: const AppIcon(Symbols.system_update_rounded, fill: 1), title: Text(t.settings.checkForUpdates), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => UpdateService.checkForUpdatesNative(inBackground: false), ), ], ), ); } final hasUpdate = _updateInfo != null && _updateInfo!['hasUpdate'] == true; return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( t.settings.updates, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), ListTile( focusNode: _focusTracker.get(_kCheckForUpdates), leading: AppIcon( hasUpdate ? Symbols.system_update_rounded : Symbols.check_circle_rounded, fill: 1, color: hasUpdate ? Colors.orange : null, ), title: Text(hasUpdate ? t.settings.updateAvailable : t.settings.checkForUpdates), subtitle: hasUpdate ? Text(t.update.versionAvailable(version: _updateInfo!['latestVersion'])) : null, trailing: _isCheckingForUpdate ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) : const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: _isCheckingForUpdate ? null : () { if (hasUpdate) { _showUpdateDialog(); } else { _checkForUpdates(); } }, ), ], ), ); } 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), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () { Navigator.push(context, MaterialPageRoute(builder: (context) => const AboutScreen())); }, ), ); } void _showThemeDialog(ThemeProvider themeProvider) { showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text(t.settings.theme), content: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: AppIcon( themeProvider.themeMode == settings.ThemeMode.system ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, fill: 1, ), title: Text(t.settings.systemTheme), subtitle: Text(t.settings.systemThemeDescription), onTap: () { themeProvider.setThemeMode(settings.ThemeMode.system); Navigator.pop(context); }, ), ListTile( leading: AppIcon( themeProvider.themeMode == settings.ThemeMode.light ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, fill: 1, ), title: Text(t.settings.lightTheme), onTap: () { themeProvider.setThemeMode(settings.ThemeMode.light); Navigator.pop(context); }, ), ListTile( leading: AppIcon( themeProvider.themeMode == settings.ThemeMode.dark ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, fill: 1, ), title: Text(t.settings.darkTheme), onTap: () { themeProvider.setThemeMode(settings.ThemeMode.dark); Navigator.pop(context); }, ), ListTile( leading: AppIcon( themeProvider.themeMode == settings.ThemeMode.oled ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, fill: 1, ), title: Text(t.settings.oledTheme), subtitle: Text(t.settings.oledThemeDescription), onTap: () { themeProvider.setThemeMode(settings.ThemeMode.oled); Navigator.pop(context); }, ), ], ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], ); }, ); } void _showBufferSizeDialog() { final options = [0, 64, 128, 256, 512, 1024]; showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text(t.settings.bufferSize), content: Column( mainAxisSize: MainAxisSize.min, children: options.map((size) { return ListTile( leading: AppIcon( _bufferSize == size ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, fill: 1, ), title: Text(size == 0 ? t.settings.bufferSizeAuto : '${size}MB'), onTap: () async { setState(() { _bufferSize = size; _settingsService.setBufferSize(size); }); Navigator.pop(context); if (Platform.isAndroid && size > 0) { final heapMB = await PlayerAndroid.getHeapSize(); if (heapMB > 0 && size > heapMB ~/ 4 && mounted) { showAppSnackBar(this.context, t.settings.bufferSizeWarning(heap: heapMB.toString(), size: size.toString())); } } }, ); }).toList(), ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], ); }, ); } /// Generic numeric input dialog to avoid duplication across settings. /// On TV, uses a spinner widget with +/- buttons for D-pad navigation. /// On other platforms, uses a TextField with focus management. void _showNumericInputDialog({ required String title, required String labelText, required String suffixText, required int min, required int max, required int currentValue, required Future Function(int value) onSave, }) { final useDpadControls = InputModeTracker.isKeyboardMode(context); if (useDpadControls) { _showNumericInputDialogTV( title: title, suffixText: suffixText, min: min, max: max, currentValue: currentValue, onSave: onSave, ); } else { _showNumericInputDialogStandard( title: title, labelText: labelText, suffixText: suffixText, min: min, max: max, currentValue: currentValue, onSave: onSave, ); } } /// TV-specific numeric input dialog with spinner widget. void _showNumericInputDialogTV({ required String title, required String suffixText, required int min, required int max, required int currentValue, required Future Function(int value) onSave, }) { int spinnerValue = currentValue; final saveFocusNode = FocusNode(); showDialog( context: context, builder: (BuildContext dialogContext) { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( title: Text(title), content: Column( mainAxisSize: MainAxisSize.min, children: [ TvNumberSpinner( value: spinnerValue, min: min, max: max, suffix: suffixText, autofocus: true, onChanged: (value) { setDialogState(() { spinnerValue = value; }); }, onConfirm: () => saveFocusNode.requestFocus(), onCancel: () => Navigator.pop(dialogContext), ), const SizedBox(height: 8), Text( t.settings.durationHint(min: min, max: max), style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), actions: [ TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), TextButton( focusNode: saveFocusNode, onPressed: () async { await onSave(spinnerValue); if (dialogContext.mounted) { Navigator.pop(dialogContext); } }, child: Text(t.common.save), ), ], ); }, ); }, ).then((_) => saveFocusNode.dispose()); } /// Standard numeric input dialog with TextField for non-TV platforms. void _showNumericInputDialogStandard({ required String title, required String labelText, required String suffixText, required int min, required int max, required int currentValue, required Future Function(int value) onSave, }) { final controller = TextEditingController(text: currentValue.toString()); String? errorText; final saveFocusNode = FocusNode(); showDialog( context: context, builder: (BuildContext dialogContext) { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( title: Text(title), content: TextField( controller: controller, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: labelText, hintText: t.settings.durationHint(min: min, max: max), errorText: errorText, suffixText: suffixText, ), autofocus: true, textInputAction: TextInputAction.done, onEditingComplete: () { // Move focus to Save button when keyboard checkmark is pressed saveFocusNode.requestFocus(); }, onChanged: (value) { final parsed = int.tryParse(value); setDialogState(() { if (parsed == null) { errorText = t.settings.validationErrorEnterNumber; } else if (parsed < min || parsed > max) { errorText = t.settings.validationErrorDuration(min: min, max: max, unit: labelText.toLowerCase()); } else { errorText = null; } }); }, ), actions: [ TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), TextButton( focusNode: saveFocusNode, onPressed: () async { final parsed = int.tryParse(controller.text); if (parsed != null && parsed >= min && parsed <= max) { await onSave(parsed); if (dialogContext.mounted) { Navigator.pop(dialogContext); } } }, child: Text(t.common.save), ), ], ); }, ); }, ).then((_) { // Clean up focus node when dialog is dismissed saveFocusNode.dispose(); }); } void _showSeekTimeSmallDialog() { _showNumericInputDialog( title: t.settings.smallSkipDuration, labelText: t.settings.secondsLabel, suffixText: t.settings.secondsShort, min: 1, max: 120, currentValue: _seekTimeSmall, onSave: (value) async { setState(() { _seekTimeSmall = value; _settingsService.setSeekTimeSmall(value); }); await _keyboardService?.refreshFromStorage(); }, ); } void _showSeekTimeLargeDialog() { _showNumericInputDialog( title: t.settings.largeSkipDuration, labelText: t.settings.secondsLabel, suffixText: t.settings.secondsShort, min: 1, max: 120, currentValue: _seekTimeLarge, onSave: (value) async { setState(() { _seekTimeLarge = value; _settingsService.setSeekTimeLarge(value); }); await _keyboardService?.refreshFromStorage(); }, ); } void _showSleepTimerDurationDialog() { _showNumericInputDialog( title: t.settings.defaultSleepTimer, labelText: t.settings.minutesLabel, suffixText: t.settings.minutesShort, min: 5, max: 240, currentValue: _sleepTimerDuration, onSave: (value) async { setState(() => _sleepTimerDuration = value); await _settingsService.setSleepTimerDuration(value); }, ); } void _showAutoSkipDelayDialog() { _showNumericInputDialog( title: t.settings.autoSkipDelay, labelText: t.settings.secondsLabel, suffixText: t.settings.secondsShort, min: 1, max: 30, currentValue: _autoSkipDelay, onSave: (value) async { setState(() => _autoSkipDelay = value); await _settingsService.setAutoSkipDelay(value); }, ); } void _showMaxVolumeDialog() { _showNumericInputDialog( title: t.settings.maxVolume, labelText: t.settings.maxVolumeDescription, suffixText: '%', min: 100, max: 300, currentValue: _maxVolume, onSave: (value) async { setState(() => _maxVolume = value); await _settingsService.setMaxVolume(value); }, ); } void _showPlayerBackendDialog() { showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text(t.settings.playerBackend), content: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: AppIcon( _useExoPlayer ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, fill: 1, ), title: Text(t.settings.exoPlayer), subtitle: Text(t.settings.exoPlayerDescription), onTap: () async { setState(() { _useExoPlayer = true; }); await _settingsService.setUseExoPlayer(true); if (context.mounted) Navigator.pop(context); }, ), ListTile( leading: AppIcon( !_useExoPlayer ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, fill: 1, ), title: Text(t.settings.mpv), subtitle: Text(t.settings.mpvDescription), onTap: () async { setState(() { _useExoPlayer = false; }); await _settingsService.setUseExoPlayer(false); if (context.mounted) Navigator.pop(context); }, ), ], ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], ); }, ); } void _showKeyboardShortcutsDialog() { if (_keyboardService == null) return; Navigator.push( context, MaterialPageRoute(builder: (context) => _KeyboardShortcutsScreen(keyboardService: _keyboardService!)), ); } void _showClearCacheDialog() { showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text(t.settings.clearCache), content: Text(t.settings.clearCacheDescription), actions: [ TextButton( autofocus: true, onPressed: () => Navigator.pop(context), child: Text(t.common.cancel), ), TextButton( onPressed: () async { final navigator = Navigator.of(context); await _settingsService.clearCache(); if (mounted) { navigator.pop(); showSuccessSnackBar(this.context, t.settings.clearCacheSuccess); } }, child: Text(t.common.clear), ), ], ); }, ); } void _showResetSettingsDialog() { showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text(t.settings.resetSettings), content: Text(t.settings.resetSettingsDescription), actions: [ TextButton( autofocus: true, onPressed: () => Navigator.pop(context), child: Text(t.common.cancel), ), TextButton( onPressed: () async { final navigator = Navigator.of(context); await _settingsService.resetAllSettings(); await _keyboardService?.resetToDefaults(); if (mounted) { navigator.pop(); showSuccessSnackBar(this.context, t.settings.resetSettingsSuccess); _loadSettings(); } }, child: Text(t.common.reset), ), ], ); }, ); } String _getLanguageDisplayName(AppLocale locale) { switch (locale) { case AppLocale.en: return 'English'; case AppLocale.sv: return 'Svenska'; case AppLocale.fr: return 'Français'; case AppLocale.it: return 'Italiano'; case AppLocale.nl: return 'Nederlands'; case AppLocale.de: return 'Deutsch'; case AppLocale.zh: return '中文'; case AppLocale.ko: return '한국어'; case AppLocale.es: return 'Español'; case AppLocale.pt: return 'Português'; case AppLocale.ja: return '日本語'; case AppLocale.ru: return 'Русский'; case AppLocale.pl: return 'Polski'; case AppLocale.da: return 'Dansk'; case AppLocale.nb: return 'Norsk bokmål'; } } void _showLanguageDialog() { showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text(t.settings.language), content: Column( mainAxisSize: MainAxisSize.min, children: AppLocale.values.map((locale) { final isSelected = LocaleSettings.currentLocale == locale; return ListTile( title: Text(_getLanguageDisplayName(locale)), leading: AppIcon( isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, fill: 1, color: isSelected ? Theme.of(context).colorScheme.primary : null, ), tileColor: isSelected ? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3) : null, onTap: () async { // Save the locale to settings await _settingsService.setAppLocale(locale); // Set the locale immediately LocaleSettings.setLocale(locale); // Close dialog if (context.mounted) { Navigator.pop(context); } // Trigger app-wide rebuild by restarting the app if (context.mounted) { _restartApp(); } }, ); }).toList(), ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], ); }, ); } void _restartApp() { // Navigate to the root and remove all previous routes Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false); } Future _checkForUpdates() async { setState(() { _isCheckingForUpdate = true; }); try { final updateInfo = await UpdateService.checkForUpdates(); if (mounted) { setState(() { _updateInfo = updateInfo; _isCheckingForUpdate = false; }); if (updateInfo == null || updateInfo['hasUpdate'] != true) { // Show "no updates" message showAppSnackBar(context, t.update.latestVersion); } } } catch (e) { if (mounted) { setState(() { _isCheckingForUpdate = false; }); showErrorSnackBar(context, t.update.checkFailed); } } } void _showUpdateDialog() { if (_updateInfo == null) return; showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text(t.settings.updateAvailable), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( t.update.versionAvailable(version: _updateInfo!['latestVersion']), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text( t.update.currentVersion(version: _updateInfo!['currentVersion']), style: Theme.of(context).textTheme.bodySmall, ), ], ), actions: [ TextButton( autofocus: true, onPressed: () => Navigator.pop(context), child: Text(t.common.close), ), FilledButton( onPressed: () async { final url = Uri.parse(_updateInfo!['releaseUrl']); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } if (context.mounted) Navigator.pop(context); }, child: Text(t.update.viewRelease), ), ], ); }, ); } /// Generic option selection dialog for settings with SettingsProvider void _showOptionSelectionDialog({ required String title, required List<_DialogOption> options, required T Function(SettingsProvider) getCurrentValue, required Future Function(T value, SettingsProvider provider) onSelect, }) { final settingsProvider = context.read(); showDialog( context: context, builder: (BuildContext context) { return Consumer( builder: (context, provider, child) { final currentValue = getCurrentValue(provider); return AlertDialog( title: Text(title), content: Column( mainAxisSize: MainAxisSize.min, children: options.map((option) { return ListTile( leading: AppIcon( currentValue == option.value ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, fill: 1, ), title: Text(option.title), subtitle: option.subtitle != null ? Text(option.subtitle!) : null, onTap: () async { await onSelect(option.value, settingsProvider); if (context.mounted) Navigator.pop(context); }, ); }).toList(), ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], ); }, ); }, ); } void _showLibraryDensityDialog() { _showOptionSelectionDialog( title: t.settings.libraryDensity, options: [ _DialogOption( value: settings.LibraryDensity.compact, title: t.settings.compact, subtitle: t.settings.compactDescription, ), _DialogOption( value: settings.LibraryDensity.normal, title: t.settings.normal, subtitle: t.settings.normalDescription, ), _DialogOption( value: settings.LibraryDensity.comfortable, title: t.settings.comfortable, subtitle: t.settings.comfortableDescription, ), ], getCurrentValue: (p) => p.libraryDensity, onSelect: (value, provider) => provider.setLibraryDensity(value), ); } void _showViewModeDialog() { _showOptionSelectionDialog( title: t.settings.viewMode, options: [ _DialogOption( value: settings.ViewMode.grid, title: t.settings.gridView, subtitle: t.settings.gridViewDescription, ), _DialogOption( value: settings.ViewMode.list, title: t.settings.listView, subtitle: t.settings.listViewDescription, ), ], getCurrentValue: (p) => p.viewMode, onSelect: (value, provider) => provider.setViewMode(value), ); } void _showEpisodePosterModeDialog() { _showOptionSelectionDialog( title: t.settings.episodePosterMode, options: [ _DialogOption( value: settings.EpisodePosterMode.seriesPoster, title: t.settings.seriesPoster, subtitle: t.settings.seriesPosterDescription, ), _DialogOption( value: settings.EpisodePosterMode.seasonPoster, title: t.settings.seasonPoster, subtitle: t.settings.seasonPosterDescription, ), _DialogOption( value: settings.EpisodePosterMode.episodeThumbnail, title: t.settings.episodeThumbnail, subtitle: t.settings.episodeThumbnailDescription, ), ], getCurrentValue: (p) => p.episodePosterMode, onSelect: (value, provider) => provider.setEpisodePosterMode(value), ); } } class _KeyboardShortcutsScreen extends StatefulWidget { final KeyboardShortcutsService keyboardService; const _KeyboardShortcutsScreen({required this.keyboardService}); @override State<_KeyboardShortcutsScreen> createState() => _KeyboardShortcutsScreenState(); } class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { Map _hotkeys = {}; bool _isLoading = true; @override void initState() { super.initState(); _loadHotkeys(); } Future _loadHotkeys() async { await widget.keyboardService.refreshFromStorage(); if (!mounted) return; setState(() { _hotkeys = widget.keyboardService.hotkeys; _isLoading = false; }); } @override Widget build(BuildContext context) { 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 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), ), ), ], ); } void _editHotkey(String action, HotKey currentHotkey) { showDialog( context: context, builder: (BuildContext context) { return HotKeyRecorderWidget( actionName: widget.keyboardService.getActionDisplayName(action), currentHotKey: currentHotkey, onHotKeyRecorded: (newHotkey) async { final navigator = Navigator.of(context); // Check for conflicts final existingAction = widget.keyboardService.getActionForHotkey(newHotkey); if (existingAction != null && existingAction != action) { navigator.pop(); showErrorSnackBar( context, t.settings.shortcutAlreadyAssigned(action: widget.keyboardService.getActionDisplayName(existingAction)), ); return; } // Save the new hotkey await widget.keyboardService.setHotkey(action, newHotkey); if (mounted) { // Update UI directly instead of reloading from storage setState(() { _hotkeys[action] = newHotkey; }); navigator.pop(); showSuccessSnackBar( this.context, t.settings.shortcutUpdated(action: widget.keyboardService.getActionDisplayName(action)), ); } }, onCancel: () => Navigator.pop(context), ); }, ); } }