Files
plezy/lib/screens/settings/settings_screen.dart
T

2278 lines
86 KiB
Dart

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/dpad_navigator.dart';
import '../../focus/focus_memory_tracker.dart';
import '../../focus/input_mode_tracker.dart';
import '../../i18n/strings.g.dart';
import '../../utils/focus_utils.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/focusable_list_tile.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/overlay_sheet.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<T> {
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<SettingsScreen> createState() => _SettingsScreenState();
}
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 _kShowUnwatchedCount = 'show_unwatched_count';
static const _kHideSpoilers = 'hide_spoilers';
static const _kShowNavBarLabels = 'show_nav_bar_labels';
static const _kLiveTvDefaultFavorites = 'live_tv_default_favorites';
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 _kRewindOnResume = 'rewind_on_resume';
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 _kIntroPattern = 'intro_pattern';
static const _kCreditsPattern = 'credits_pattern';
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 _rewindOnResume = 0;
int _sleepTimerDuration = 30;
bool _rememberTrackSelections = true;
bool _clickVideoTogglesPlayback = false;
bool _autoSkipIntro = false;
bool _autoSkipCredits = false;
int _autoSkipDelay = 5;
String _introPattern = settings.SettingsService.defaultIntroPattern;
String _creditsPattern = settings.SettingsService.defaultCreditsPattern;
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<String, dynamic>? _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<void> _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();
_rewindOnResume = _settingsService.getRewindOnResume();
_sleepTimerDuration = _settingsService.getSleepTimerDuration();
_rememberTrackSelections = _settingsService.getRememberTrackSelections();
_clickVideoTogglesPlayback = _settingsService.getClickVideoTogglesPlayback();
_autoSkipIntro = _settingsService.getAutoSkipIntro();
_autoSkipCredits = _settingsService.getAutoSkipCredits();
_autoSkipDelay = _settingsService.getAutoSkipDelay();
_introPattern = _settingsService.getIntroPattern();
_creditsPattern = _settingsService.getCreditsPattern();
_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(
primary: false,
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<ThemeProvider>(
builder: (context, themeProvider, child) {
return Builder(
builder: (tileContext) => ListTile(
focusNode: _focusTracker.get(_kTheme),
leading: AppIcon(themeProvider.themeModeIcon, fill: 1),
title: Text(t.settings.theme),
subtitle: Text(themeProvider.themeModeDisplayName),
trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final value = await _showSettingsMenu<settings.ThemeMode>(
tileContext: tileContext,
title: t.settings.theme,
options: [
_DialogOption(
value: settings.ThemeMode.system,
title: t.settings.systemTheme,
subtitle: t.settings.systemThemeDescription,
),
_DialogOption(value: settings.ThemeMode.light, title: t.settings.lightTheme),
_DialogOption(value: settings.ThemeMode.dark, title: t.settings.darkTheme),
_DialogOption(
value: settings.ThemeMode.oled,
title: t.settings.oledTheme,
subtitle: t.settings.oledThemeDescription,
),
],
currentValue: themeProvider.themeMode,
);
if (value != null) {
themeProvider.setThemeMode(value);
}
},
),
);
},
),
Builder(
builder: (tileContext) => 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.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final value = await _showSettingsMenu<AppLocale>(
tileContext: tileContext,
title: t.settings.language,
options: AppLocale.values
.map((locale) => _DialogOption(value: locale, title: _getLanguageDisplayName(locale)))
.toList(),
currentValue: LocaleSettings.currentLocale,
);
if (value != null) {
await _settingsService.setAppLocale(value);
LocaleSettings.setLocale(value);
_restartApp();
}
},
),
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return Builder(
builder: (tileContext) => 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.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final value = await _showSettingsMenu<settings.LibraryDensity>(
tileContext: tileContext,
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,
),
],
currentValue: settingsProvider.libraryDensity,
);
if (value != null) {
settingsProvider.setLibraryDensity(value);
}
},
),
);
},
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return Builder(
builder: (tileContext) => 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.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final value = await _showSettingsMenu<settings.ViewMode>(
tileContext: tileContext,
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,
),
],
currentValue: settingsProvider.viewMode,
);
if (value != null) {
settingsProvider.setViewMode(value);
}
},
),
);
},
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return Builder(
builder: (tileContext) => 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.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final value = await _showSettingsMenu<settings.EpisodePosterMode>(
tileContext: tileContext,
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,
),
],
currentValue: settingsProvider.episodePosterMode,
);
if (value != null) {
settingsProvider.setEpisodePosterMode(value);
}
},
),
);
},
),
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),
value: settingsProvider.showHeroSection,
onChanged: (value) async {
await settingsProvider.setShowHeroSection(value);
},
);
},
),
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),
value: settingsProvider.useGlobalHubs,
onChanged: (value) async {
await settingsProvider.setUseGlobalHubs(value);
},
);
},
),
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),
value: settingsProvider.showServerNameOnHubs,
onChanged: (value) async {
await settingsProvider.setShowServerNameOnHubs(value);
},
);
},
),
if (PlatformDetector.shouldUseSideNavigation(context))
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),
value: settingsProvider.alwaysKeepSidebarOpen,
onChanged: (value) async {
await settingsProvider.setAlwaysKeepSidebarOpen(value);
},
);
},
),
if (!PlatformDetector.shouldUseSideNavigation(context))
Consumer<SettingsProvider>(
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<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SwitchListTile(
focusNode: _focusTracker.get(_kLiveTvDefaultFavorites),
secondary: const AppIcon(Symbols.star_rounded, fill: 1),
title: Text(t.settings.liveTvDefaultFavorites),
subtitle: Text(t.settings.liveTvDefaultFavoritesDescription),
value: settingsProvider.liveTvDefaultFavorites,
onChanged: (value) async {
await settingsProvider.setLiveTvDefaultFavorites(value);
},
);
},
),
Consumer<SettingsProvider>(
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<SettingsProvider>(
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<UserProfileProvider>(
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)
Builder(
builder: (tileContext) => 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.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final value = await _showSettingsMenu<bool>(
tileContext: tileContext,
title: t.settings.playerBackend,
options: [
_DialogOption(
value: true,
title: t.settings.exoPlayer,
subtitle: t.settings.exoPlayerDescription,
),
_DialogOption(value: false, title: t.settings.mpv, subtitle: t.settings.mpvDescription),
],
currentValue: _useExoPlayer,
);
if (value != null) {
setState(() => _useExoPlayer = value);
await _settingsService.setUseExoPlayer(value);
}
},
),
),
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 && !PlatformDetector.isTV()) || 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);
},
),
Builder(
builder: (tileContext) => 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.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final bufferOptions = [0, 64, 128, 256, 512, 1024];
final value = await _showSettingsMenu<int>(
tileContext: tileContext,
title: t.settings.bufferSize,
options: bufferOptions
.map(
(size) =>
_DialogOption(value: size, title: size == 0 ? t.settings.bufferSizeAuto : '${size}MB'),
)
.toList(),
currentValue: _bufferSize,
);
if (value != null) {
setState(() {
_bufferSize = value;
_settingsService.setBufferSize(value);
});
if (Platform.isAndroid && value > 0) {
final heapMB = await PlayerAndroid.getHeapSize();
if (heapMB > 0 && value > heapMB ~/ 4 && mounted) {
showAppSnackBar(
context,
t.settings.bufferSizeWarning(heap: heapMB.toString(), size: value.toString()),
);
}
}
}
},
),
),
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(_kRewindOnResume),
leading: const AppIcon(Symbols.replay_rounded, fill: 1),
title: Text(t.settings.rewindOnResume),
subtitle: Text(t.settings.secondsUnit(seconds: _rewindOnResume.toString())),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showRewindOnResumeDialog(),
),
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(),
),
ListTile(
focusNode: _focusTracker.get(_kIntroPattern),
leading: const AppIcon(Symbols.match_case_rounded, fill: 1),
title: Text(t.settings.introPattern),
subtitle: Text(t.settings.introPatternDescription),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showTextInputDialog(
title: t.settings.introPattern,
currentValue: _introPattern,
defaultValue: settings.SettingsService.defaultIntroPattern,
onSave: (value) async {
setState(() => _introPattern = value);
await _settingsService.setIntroPattern(value);
},
),
),
ListTile(
focusNode: _focusTracker.get(_kCreditsPattern),
leading: const AppIcon(Symbols.match_case_rounded, fill: 1),
title: Text(t.settings.creditsPattern),
subtitle: Text(t.settings.creditsPatternDescription),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showTextInputDialog(
title: t.settings.creditsPattern,
currentValue: _creditsPattern,
defaultValue: settings.SettingsService.defaultCreditsPattern,
onSave: (value) async {
setState(() => _creditsPattern = value);
await _settingsService.setCreditsPattern(value);
},
),
),
],
),
);
}
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<String>(
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<void> _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<String>(
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<void> _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 if (PlatformDetector.isTV()) {
if (mounted) {
showErrorSnackBar(context, t.settings.downloadLocationSelectError);
}
return;
}
} 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<void> _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<CompanionRemoteProvider>(
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()));
},
),
);
}
/// Shows a platform-adaptive settings menu: popup dropdown on desktop/TV,
/// bottom sheet on mobile.
Future<T?> _showSettingsMenu<T>({
required BuildContext tileContext,
required String title,
required List<_DialogOption<T>> options,
required T currentValue,
}) {
final useBottomSheet = Platform.isIOS || Platform.isAndroid;
final focusFirstItem = InputModeTracker.isKeyboardMode(context);
if (useBottomSheet) {
return _showSettingsMenuSheet<T>(
title: title,
options: options,
currentValue: currentValue,
focusFirstItem: focusFirstItem,
);
} else {
return _showSettingsMenuPopup<T>(
tileContext: tileContext,
options: options,
currentValue: currentValue,
focusFirstItem: focusFirstItem,
);
}
}
/// Mobile path: bottom sheet via OverlaySheetController.showAdaptive
Future<T?> _showSettingsMenuSheet<T>({
required String title,
required List<_DialogOption<T>> options,
required T currentValue,
required bool focusFirstItem,
}) {
return OverlaySheetController.showAdaptive<T>(
context,
showDragHandle: true,
builder: (context) => _SettingsMenuSheet<T>(
title: title,
options: options,
currentValue: currentValue,
focusFirstItem: focusFirstItem,
),
);
}
/// Desktop/TV path: positioned popup dropdown
Future<T?> _showSettingsMenuPopup<T>({
required BuildContext tileContext,
required List<_DialogOption<T>> options,
required T currentValue,
required bool focusFirstItem,
}) {
final renderBox = tileContext.findRenderObject() as RenderBox;
final position = renderBox.localToGlobal(Offset.zero);
final tileSize = renderBox.size;
return showDialog<T>(
context: context,
barrierColor: Colors.transparent,
builder: (context) => _SettingsMenuPopup<T>(
position: Offset(position.dx + tileSize.width, position.dy + tileSize.height),
options: options,
currentValue: currentValue,
focusFirstItem: focusFirstItem,
),
);
}
/// 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<void> 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<void> 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<void> 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 _showRewindOnResumeDialog() {
_showNumericInputDialog(
title: t.settings.rewindOnResume,
labelText: t.settings.secondsLabel,
suffixText: t.settings.secondsShort,
min: 0,
max: 10,
currentValue: _rewindOnResume,
onSave: (value) async {
setState(() {
_rewindOnResume = value;
_settingsService.setRewindOnResume(value);
});
},
);
}
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 _showTextInputDialog({
required String title,
required String currentValue,
required String defaultValue,
required Future<void> Function(String value) onSave,
}) {
final controller = TextEditingController(text: currentValue);
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,
decoration: InputDecoration(
labelText: 'Regex',
errorText: errorText,
),
autofocus: true,
textInputAction: TextInputAction.done,
onEditingComplete: () => saveFocusNode.requestFocus(),
onChanged: (value) {
setDialogState(() {
try {
RegExp(value, caseSensitive: false);
errorText = null;
} catch (_) {
errorText = t.settings.invalidRegex;
}
});
},
),
actions: [
TextButton(
onPressed: () {
controller.text = defaultValue;
setDialogState(() => errorText = null);
},
child: Text(t.settings.resetToDefault),
),
TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)),
TextButton(
focusNode: saveFocusNode,
onPressed: () async {
if (errorText != null) return;
await onSave(controller.text);
if (dialogContext.mounted) Navigator.pop(dialogContext);
},
child: Text(t.common.save),
),
],
);
},
);
},
).then((_) => saveFocusNode.dispose());
}
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 _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 _restartApp() {
// Navigate to the root and remove all previous routes
Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false);
}
Future<void> _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),
),
],
);
},
);
}
}
/// Bottom sheet content for settings menu (mobile)
class _SettingsMenuSheet<T> extends StatefulWidget {
final String title;
final List<_DialogOption<T>> options;
final T currentValue;
final bool focusFirstItem;
const _SettingsMenuSheet({
required this.title,
required this.options,
required this.currentValue,
this.focusFirstItem = false,
});
@override
State<_SettingsMenuSheet<T>> createState() => _SettingsMenuSheetState<T>();
}
class _SettingsMenuSheetState<T> extends State<_SettingsMenuSheet<T>> {
late final FocusNode _initialFocusNode;
@override
void initState() {
super.initState();
_initialFocusNode = FocusNode(debugLabel: 'SettingsMenuSheetInitialFocus');
}
@override
void dispose() {
_initialFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Text(
widget.title,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...widget.options.asMap().entries.map((entry) {
final index = entry.key;
final option = entry.value;
final isSelected = widget.currentValue == option.value;
return FocusableListTile(
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
dense: true,
leading: AppIcon(
isSelected ? 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: () => OverlaySheetController.closeAdaptive(context, option.value),
);
}),
],
),
),
),
],
);
}
}
/// Positioned popup menu for settings menu (desktop/TV)
class _SettingsMenuPopup<T> extends StatefulWidget {
final Offset position;
final List<_DialogOption<T>> options;
final T currentValue;
final bool focusFirstItem;
const _SettingsMenuPopup({
required this.position,
required this.options,
required this.currentValue,
this.focusFirstItem = false,
});
@override
State<_SettingsMenuPopup<T>> createState() => _SettingsMenuPopupState<T>();
}
class _SettingsMenuPopupState<T> extends State<_SettingsMenuPopup<T>> {
late final FocusNode _initialFocusNode;
@override
void initState() {
super.initState();
_initialFocusNode = FocusNode(debugLabel: 'SettingsMenuPopupInitialFocus');
if (widget.focusFirstItem) {
FocusUtils.requestFocusAfterBuild(this, _initialFocusNode);
}
}
@override
void dispose() {
_initialFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
const menuWidth = 280.0;
const edgePadding = 8.0;
// Right-align: position.dx is the tile's right edge
final left = (widget.position.dx - menuWidth).clamp(edgePadding, screenSize.width - menuWidth - edgePadding);
final estimatedHeight = widget.options.length * 56.0 + 16;
final spaceBelow = screenSize.height - widget.position.dy - edgePadding;
final spaceAbove = widget.position.dy - edgePadding;
final double top;
final double maxHeight;
if (estimatedHeight <= spaceBelow) {
top = widget.position.dy;
maxHeight = spaceBelow;
} else if (spaceAbove > spaceBelow) {
final menuHeight = estimatedHeight.clamp(0.0, spaceAbove);
top = widget.position.dy - menuHeight;
maxHeight = menuHeight;
} else {
top = widget.position.dy;
maxHeight = spaceBelow;
}
return FocusScope(
autofocus: false,
child: Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (node, event) {
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: () => Navigator.pop(context),
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
),
Positioned(
left: left,
top: top,
child: Material(
elevation: 8,
color: Color.alphaBlend(
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08),
Theme.of(context).colorScheme.surface,
),
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: menuWidth, maxWidth: menuWidth, maxHeight: maxHeight),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.options.asMap().entries.map((entry) {
final index = entry.key;
final option = entry.value;
final isSelected = widget.currentValue == option.value;
return FocusableListTile(
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
dense: true,
leading: AppIcon(
isSelected ? 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: () => Navigator.pop(context, option.value),
);
}).toList(),
),
),
),
),
),
],
),
),
);
}
}
class _KeyboardShortcutsScreen extends StatefulWidget {
final KeyboardShortcutsService keyboardService;
const _KeyboardShortcutsScreen({required this.keyboardService});
@override
State<_KeyboardShortcutsScreen> createState() => _KeyboardShortcutsScreenState();
}
class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
Map<String, HotKey> _hotkeys = {};
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadHotkeys();
}
Future<void> _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),
);
},
);
}
}