feat: customizable seek durations

This commit is contained in:
edde746
2025-11-04 08:26:05 +01:00
parent 5126b67c9c
commit 770cb6b744
4 changed files with 257 additions and 26 deletions
+146 -3
View File
@@ -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<SettingsScreen> {
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<SettingsScreen> {
_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<SettingsScreen> {
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<SettingsScreen> {
);
}
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,
+14 -8
View File
@@ -10,6 +10,8 @@ class KeyboardShortcutsService {
Map<String, String> _shortcuts =
{}; // Legacy string shortcuts for backward compatibility
Map<String, HotKey> _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<String, String> get shortcuts => Map.from(_shortcuts);
@@ -61,6 +65,8 @@ class KeyboardShortcutsService {
Future<void> refreshFromStorage() async {
_hotkeys = await _settingsService.getKeyboardHotkeys();
_seekTimeSmall = _settingsService.getSeekTimeSmall();
_seekTimeLarge = _settingsService.getSeekTimeLarge();
}
Future<void> 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':
+24
View File
@@ -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<void> 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<void> 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<String, String> 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)),
+73 -15
View File
@@ -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<PlexVideoControls>
with WindowListener {
with WindowListener, WidgetsBindingObserver {
bool _showControls = true;
List<PlexChapter> _chapters = [];
bool _chaptersLoaded = false;
@@ -57,14 +58,19 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
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<PlexVideoControls>
_keyboardService = await KeyboardShortcutsService.getInstance();
}
Future<void> _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<PlexVideoControls>
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<PlexVideoControls>
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<PlexVideoControls>
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<PlexVideoControls>
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<PlexVideoControls>
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<void> _toggleFullscreen() async {
if (!PlatformDetector.isMobile(context)) {
// Query actual window state to determine what action to take
@@ -560,14 +618,14 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
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<PlexVideoControls>
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<PlexVideoControls>
),
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<PlexVideoControls>
);
},
),
// 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,