diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4a651a5e..c358ce35 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -3,11 +3,8 @@ import 'package:provider/provider.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import '../providers/theme_provider.dart'; import '../providers/settings_provider.dart'; -import '../providers/plex_client_provider.dart'; -import '../providers/hidden_libraries_provider.dart'; import '../services/settings_service.dart' as settings; import '../services/keyboard_shortcuts_service.dart'; -import '../models/plex_library.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/hotkey_recorder_widget.dart'; import 'about_screen.dart'; @@ -27,6 +24,8 @@ class _SettingsScreenState extends State { bool _enableDebugLogging = false; bool _enableHardwareDecoding = true; int _bufferSize = 128; + int _seekTimeSmall = 10; + int _seekTimeLarge = 30; @override void initState() { @@ -42,6 +41,8 @@ class _SettingsScreenState extends State { _enableDebugLogging = _settingsService.getEnableDebugLogging(); _enableHardwareDecoding = _settingsService.getEnableHardwareDecoding(); _bufferSize = _settingsService.getBufferSize(); + _seekTimeSmall = _settingsService.getSeekTimeSmall(); + _seekTimeLarge = _settingsService.getSeekTimeLarge(); _isLoading = false; }); } @@ -168,6 +169,20 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _showBufferSizeDialog(), ), + ListTile( + leading: const Icon(Icons.replay_10), + title: const Text('Small Skip Duration'), + subtitle: Text('$_seekTimeSmall seconds'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showSeekTimeSmallDialog(), + ), + ListTile( + leading: const Icon(Icons.replay_30), + title: const Text('Large Skip Duration'), + subtitle: Text('$_seekTimeLarge seconds'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showSeekTimeLargeDialog(), + ), ], ), ); @@ -359,6 +374,134 @@ class _SettingsScreenState extends State { ); } + void _showSeekTimeSmallDialog() { + final controller = TextEditingController(text: _seekTimeSmall.toString()); + String? errorText; + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: const Text('Small Skip Duration'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Seconds', + hintText: 'Enter duration (1-120)', + errorText: errorText, + suffixText: 's', + ), + autofocus: true, + onChanged: (value) { + final parsed = int.tryParse(value); + setDialogState(() { + if (parsed == null) { + errorText = 'Please enter a valid number'; + } else if (parsed < 1 || parsed > 120) { + errorText = 'Duration must be between 1 and 120 seconds'; + } else { + errorText = null; + } + }); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + final parsed = int.tryParse(controller.text); + if (parsed != null && parsed >= 1 && parsed <= 120) { + setState(() { + _seekTimeSmall = parsed; + _settingsService.setSeekTimeSmall(parsed); + }); + // Reload keyboard shortcuts service to use new settings + await _keyboardService.refreshFromStorage(); + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } + } + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + } + + void _showSeekTimeLargeDialog() { + final controller = TextEditingController(text: _seekTimeLarge.toString()); + String? errorText; + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: const Text('Large Skip Duration'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Seconds', + hintText: 'Enter duration (1-120)', + errorText: errorText, + suffixText: 's', + ), + autofocus: true, + onChanged: (value) { + final parsed = int.tryParse(value); + setDialogState(() { + if (parsed == null) { + errorText = 'Please enter a valid number'; + } else if (parsed < 1 || parsed > 120) { + errorText = 'Duration must be between 1 and 120 seconds'; + } else { + errorText = null; + } + }); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + final parsed = int.tryParse(controller.text); + if (parsed != null && parsed >= 1 && parsed <= 120) { + setState(() { + _seekTimeLarge = parsed; + _settingsService.setSeekTimeLarge(parsed); + }); + // Reload keyboard shortcuts service to use new settings + await _keyboardService.refreshFromStorage(); + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } + } + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + } + void _showKeyboardShortcutsDialog() { Navigator.push( context, diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index ba33ece0..30bd9a56 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -10,6 +10,8 @@ class KeyboardShortcutsService { Map _shortcuts = {}; // Legacy string shortcuts for backward compatibility Map _hotkeys = {}; // New HotKey objects + int _seekTimeSmall = 10; // Default, loaded from settings + int _seekTimeLarge = 30; // Default, loaded from settings KeyboardShortcutsService._(); @@ -28,6 +30,8 @@ class KeyboardShortcutsService { _shortcuts = _settingsService .getKeyboardShortcuts(); // Keep for legacy compatibility _hotkeys = await _settingsService.getKeyboardHotkeys(); // Primary method + _seekTimeSmall = _settingsService.getSeekTimeSmall(); + _seekTimeLarge = _settingsService.getSeekTimeLarge(); } Map get shortcuts => Map.from(_shortcuts); @@ -61,6 +65,8 @@ class KeyboardShortcutsService { Future refreshFromStorage() async { _hotkeys = await _settingsService.getKeyboardHotkeys(); + _seekTimeSmall = _settingsService.getSeekTimeSmall(); + _seekTimeLarge = _settingsService.getSeekTimeLarge(); } Future resetToDefaults() async { @@ -262,16 +268,16 @@ class KeyboardShortcutsService { player.setVolume(newVolume); break; case 'seek_forward': - _seekWithClamping(player, const Duration(seconds: 10)); + _seekWithClamping(player, Duration(seconds: _seekTimeSmall)); break; case 'seek_backward': - _seekWithClamping(player, const Duration(seconds: -10)); + _seekWithClamping(player, Duration(seconds: -_seekTimeSmall)); break; case 'seek_forward_large': - _seekWithClamping(player, const Duration(seconds: 30)); + _seekWithClamping(player, Duration(seconds: _seekTimeLarge)); break; case 'seek_backward_large': - _seekWithClamping(player, const Duration(seconds: -30)); + _seekWithClamping(player, Duration(seconds: -_seekTimeLarge)); break; case 'fullscreen_toggle': onToggleFullscreen?.call(); @@ -318,13 +324,13 @@ class KeyboardShortcutsService { case 'volume_down': return 'Volume Down'; case 'seek_forward': - return 'Seek Forward'; + return 'Seek Forward (${_seekTimeSmall}s)'; case 'seek_backward': - return 'Seek Backward'; + return 'Seek Backward (${_seekTimeSmall}s)'; case 'seek_forward_large': - return 'Seek Forward (Large)'; + return 'Seek Forward (${_seekTimeLarge}s)'; case 'seek_backward_large': - return 'Seek Backward (Large)'; + return 'Seek Backward (${_seekTimeLarge}s)'; case 'fullscreen_toggle': return 'Toggle Fullscreen'; case 'mute_toggle': diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index ad28b843..df43f2fa 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -18,6 +18,8 @@ class SettingsService { static const String _keyPreferredAudioCodec = 'preferred_audio_codec'; static const String _keyLibraryDensity = 'library_density'; static const String _keyUseSeasonPoster = 'use_season_poster'; + static const String _keySeekTimeSmall = 'seek_time_small'; + static const String _keySeekTimeLarge = 'seek_time_large'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -117,6 +119,24 @@ class SettingsService { return _prefs.getBool(_keyUseSeasonPoster) ?? false; // Default: false (use series poster) } + // Seek Time Small (in seconds) + Future setSeekTimeSmall(int seconds) async { + await _prefs.setInt(_keySeekTimeSmall, seconds); + } + + int getSeekTimeSmall() { + return _prefs.getInt(_keySeekTimeSmall) ?? 10; // Default: 10 seconds + } + + // Seek Time Large (in seconds) + Future setSeekTimeLarge(int seconds) async { + await _prefs.setInt(_keySeekTimeLarge, seconds); + } + + int getSeekTimeLarge() { + return _prefs.getInt(_keySeekTimeLarge) ?? 30; // Default: 30 seconds + } + // Keyboard Shortcuts (Legacy String-based) Map getDefaultKeyboardShortcuts() { return { @@ -581,6 +601,8 @@ class SettingsService { _prefs.remove(_keyPreferredAudioCodec), _prefs.remove(_keyLibraryDensity), _prefs.remove(_keyUseSeasonPoster), + _prefs.remove(_keySeekTimeSmall), + _prefs.remove(_keySeekTimeLarge), ]); } @@ -605,6 +627,8 @@ class SettingsService { 'preferredAudioCodec': getPreferredAudioCodec(), 'libraryDensity': getLibraryDensity().name, 'useSeasonPoster': getUseSeasonPoster(), + 'seekTimeSmall': getSeekTimeSmall(), + 'seekTimeLarge': getSeekTimeLarge(), 'keyboardShortcuts': getKeyboardShortcuts(), 'keyboardHotkeys': hotkeys.map( (key, value) => MapEntry(key, _serializeHotKey(value)), diff --git a/lib/widgets/plex_video_controls.dart b/lib/widgets/plex_video_controls.dart index 8a5c58d9..a10db77c 100644 --- a/lib/widgets/plex_video_controls.dart +++ b/lib/widgets/plex_video_controls.dart @@ -10,6 +10,7 @@ import '../models/plex_media_info.dart'; import '../providers/plex_client_provider.dart'; import '../services/fullscreen_state_manager.dart'; import '../services/keyboard_shortcuts_service.dart'; +import '../services/settings_service.dart'; import '../utils/desktop_window_padding.dart'; import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; @@ -49,7 +50,7 @@ class PlexVideoControls extends StatefulWidget { } class _PlexVideoControlsState extends State - with WindowListener { + with WindowListener, WidgetsBindingObserver { bool _showControls = true; List _chapters = []; bool _chaptersLoaded = false; @@ -57,14 +58,19 @@ class _PlexVideoControlsState extends State bool _isFullscreen = false; late final FocusNode _focusNode; KeyboardShortcutsService? _keyboardService; + int _seekTimeSmall = 10; // Default, loaded from settings + int _seekTimeLarge = 30; // Default, loaded from settings @override void initState() { super.initState(); _focusNode = FocusNode(); _loadChapters(); + _loadSeekTimes(); _startHideTimer(); _initKeyboardService(); + // Add lifecycle observer to reload settings when app resumes + WidgetsBinding.instance.addObserver(this); // Add window listener for tracking fullscreen state (for button icon) if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { windowManager.addListener(this); @@ -75,6 +81,16 @@ class _PlexVideoControlsState extends State _keyboardService = await KeyboardShortcutsService.getInstance(); } + Future _loadSeekTimes() async { + final settingsService = await SettingsService.getInstance(); + if (mounted) { + setState(() { + _seekTimeSmall = settingsService.getSeekTimeSmall(); + _seekTimeLarge = settingsService.getSeekTimeLarge(); + }); + } + } + void _toggleSubtitles() { // Toggle subtitle visibility - this would need to be implemented based on your subtitle system // For now, this is a placeholder @@ -108,6 +124,8 @@ class _PlexVideoControlsState extends State void dispose() { _hideTimer?.cancel(); _focusNode.dispose(); + // Remove lifecycle observer + WidgetsBinding.instance.removeObserver(this); // Remove window listener if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { windowManager.removeListener(this); @@ -115,6 +133,14 @@ class _PlexVideoControlsState extends State super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + // Reload seek times when app resumes (e.g., returning from settings) + _loadSeekTimes(); + } + } + @override void onWindowEnterFullScreen() { if (mounted) { @@ -265,8 +291,8 @@ class _PlexVideoControlsState extends State void _seekToPreviousChapter() { if (_chapters.isEmpty) { - // No chapters - seek backward 10 seconds - _seekWithClamping(const Duration(seconds: -10)); + // No chapters - seek backward by configured amount + _seekWithClamping(Duration(seconds: -_seekTimeSmall)); return; } @@ -288,8 +314,8 @@ class _PlexVideoControlsState extends State void _seekToNextChapter() { if (_chapters.isEmpty) { - // No chapters - seek forward 10 seconds - _seekWithClamping(const Duration(seconds: 10)); + // No chapters - seek forward by configured amount + _seekWithClamping(Duration(seconds: _seekTimeSmall)); return; } @@ -320,6 +346,38 @@ class _PlexVideoControlsState extends State widget.player.seek(clampedPosition); } + /// Get the replay icon based on the duration + /// Returns numbered icons (replay_5, replay_10, replay_30) when available, + /// otherwise returns generic replay icon + IconData _getReplayIcon(int seconds) { + switch (seconds) { + case 5: + return Icons.replay_5; + case 10: + return Icons.replay_10; + case 30: + return Icons.replay_30; + default: + return Icons.replay; // Generic icon for custom durations + } + } + + /// Get the forward icon based on the duration + /// Returns numbered icons (forward_5, forward_10, forward_30) when available, + /// otherwise returns generic forward icon + IconData _getForwardIcon(int seconds) { + switch (seconds) { + case 5: + return Icons.forward_5; + case 10: + return Icons.forward_10; + case 30: + return Icons.forward_30; + default: + return Icons.forward; // Generic icon for custom durations + } + } + Future _toggleFullscreen() async { if (!PlatformDetector.isMobile(context)) { // Query actual window state to determine what action to take @@ -560,14 +618,14 @@ class _PlexVideoControlsState extends State shape: BoxShape.circle, ), child: IconButton( - icon: const Icon( - Icons.replay_10, + icon: Icon( + _getReplayIcon(_seekTimeSmall), color: Colors.white, size: 48, ), iconSize: 48, onPressed: () { - _seekWithClamping(const Duration(seconds: -10)); + _seekWithClamping(Duration(seconds: -_seekTimeSmall)); }, ), ), @@ -602,14 +660,14 @@ class _PlexVideoControlsState extends State shape: BoxShape.circle, ), child: IconButton( - icon: const Icon( - Icons.forward_10, + icon: Icon( + _getForwardIcon(_seekTimeSmall), color: Colors.white, size: 48, ), iconSize: 48, onPressed: () { - _seekWithClamping(const Duration(seconds: 10)); + _seekWithClamping(Duration(seconds: _seekTimeSmall)); }, ), ), @@ -831,10 +889,10 @@ class _PlexVideoControlsState extends State ), onPressed: widget.onPrevious, ), - // Previous chapter (or -10s if no chapters) + // Previous chapter (or skip backward if no chapters) IconButton( icon: Icon( - _chapters.isEmpty ? Icons.replay_10 : Icons.fast_rewind, + _chapters.isEmpty ? _getReplayIcon(_seekTimeSmall) : Icons.fast_rewind, color: Colors.white, ), onPressed: _seekToPreviousChapter, @@ -863,10 +921,10 @@ class _PlexVideoControlsState extends State ); }, ), - // Next chapter (or +10s if no chapters) + // Next chapter (or skip forward if no chapters) IconButton( icon: Icon( - _chapters.isEmpty ? Icons.forward_10 : Icons.fast_forward, + _chapters.isEmpty ? _getForwardIcon(_seekTimeSmall) : Icons.fast_forward, color: Colors.white, ), onPressed: _seekToNextChapter,