feat: customizable seek durations
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user