From e30d20e6d14c5dd21d60ab9dd04416e59c25a5c7 Mon Sep 17 00:00:00 2001 From: Tazio Date: Mon, 1 Dec 2025 10:50:48 +0100 Subject: [PATCH] feat: auto skip --- lib/screens/settings_screen.dart | 120 ++++++++++- lib/services/settings_service.dart | 39 +++- .../video_controls/video_controls.dart | 190 +++++++++++++++--- 3 files changed, 314 insertions(+), 35 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index a3d8cef0..4f815802 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -37,6 +37,9 @@ class _SettingsScreenState extends State { int _seekTimeLarge = 30; int _sleepTimerDuration = 30; bool _rememberTrackSelections = true; + bool _autoSkipIntro = true; + bool _autoSkipCredits = true; + int _autoSkipDelay = 5; // Update checking state bool _isCheckingForUpdate = false; @@ -60,6 +63,9 @@ class _SettingsScreenState extends State { _seekTimeLarge = _settingsService.getSeekTimeLarge(); _sleepTimerDuration = _settingsService.getSleepTimerDuration(); _rememberTrackSelections = _settingsService.getRememberTrackSelections(); + _autoSkipIntro = _settingsService.getAutoSkipIntro(); + _autoSkipCredits = _settingsService.getAutoSkipCredits(); + _autoSkipDelay = _settingsService.getAutoSkipDelay(); _isLoading = false; }); } @@ -291,6 +297,48 @@ class _SettingsScreenState extends State { await _settingsService.setRememberTrackSelections(value); }, ), + const Divider(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text( + 'Auto Skip', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + SwitchListTile( + secondary: const Icon(Icons.fast_forward), + title: const Text('Auto Skip Intro'), + subtitle: const Text('Automatically skip intro markers after a few seconds'), + value: _autoSkipIntro, + onChanged: (value) async { + setState(() { + _autoSkipIntro = value; + }); + await _settingsService.setAutoSkipIntro(value); + }, + ), + SwitchListTile( + secondary: const Icon(Icons.skip_next), + title: const Text('Auto Skip Credits'), + subtitle: const Text('Automatically skip credits and play next episode'), + value: _autoSkipCredits, + onChanged: (value) async { + setState(() { + _autoSkipCredits = value; + }); + await _settingsService.setAutoSkipCredits(value); + }, + ), + ListTile( + leading: const Icon(Icons.timer), + title: const Text('Auto Skip Delay'), + subtitle: Text('Wait $_autoSkipDelay seconds before auto-skipping'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showAutoSkipDelayDialog(), + ), ], ), ); @@ -703,7 +751,7 @@ class _SettingsScreenState extends State { keyboardType: TextInputType.number, decoration: InputDecoration( labelText: t.settings.minutesLabel, - hintText: t.settings.durationHint(min: 5, max: 180), + hintText: t.settings.durationHint(min: 5, max: 240), errorText: errorText, suffixText: t.settings.minutesShort, ), @@ -713,10 +761,10 @@ class _SettingsScreenState extends State { setDialogState(() { if (parsed == null) { errorText = t.settings.validationErrorEnterNumber; - } else if (parsed < 5 || parsed > 180) { + } else if (parsed < 5 || parsed > 240) { errorText = t.settings.validationErrorDuration( min: 5, - max: 180, + max: 240, unit: t.settings.minutesLabel.toLowerCase(), ); } else { @@ -733,11 +781,73 @@ class _SettingsScreenState extends State { TextButton( onPressed: () async { final parsed = int.tryParse(controller.text); - if (parsed != null && parsed >= 5 && parsed <= 180) { + if (parsed != null && parsed >= 5 && parsed <= 240) { setState(() { _sleepTimerDuration = parsed; - _settingsService.setSleepTimerDuration(parsed); }); + await _settingsService.setSleepTimerDuration(parsed); + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } + } + }, + child: Text(t.common.save), + ), + ], + ); + }, + ); + }, + ); + } + + void _showAutoSkipDelayDialog() { + final controller = TextEditingController(text: _autoSkipDelay.toString()); + String? errorText; + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: const Text('Auto Skip Delay'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Seconds', + hintText: 'Enter delay between 1-30 seconds', + 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 > 30) { + errorText = 'Delay must be between 1 and 30 seconds'; + } else { + errorText = null; + } + }); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () async { + final parsed = int.tryParse(controller.text); + if (parsed != null && parsed >= 1 && parsed <= 30) { + setState(() { + _autoSkipDelay = parsed; + }); + await _settingsService.setAutoSkipDelay(parsed); if (dialogContext.mounted) { Navigator.pop(dialogContext); } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 422bb120..5d52f291 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -41,6 +41,9 @@ class SettingsService { 'subtitle_background_opacity'; static const String _keyAppLocale = 'app_locale'; static const String _keyRememberTrackSelections = 'remember_track_selections'; + static const String _keyAutoSkipIntro = 'auto_skip_intro'; + static const String _keyAutoSkipCredits = 'auto_skip_credits'; + static const String _keyAutoSkipDelay = 'auto_skip_delay'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -805,12 +808,39 @@ class SettingsService { // Track Selection Settings /// Remember Track Selections - Save per-media audio/subtitle language preferences - Future setRememberTrackSelections(bool enabled) async { - await _prefs.setBool(_keyRememberTrackSelections, enabled); + Future setRememberTrackSelections(bool value) async { + await _prefs.setBool(_keyRememberTrackSelections, value); } bool getRememberTrackSelections() { - return _prefs.getBool(_keyRememberTrackSelections) ?? true; // Default: true + return _prefs.getBool(_keyRememberTrackSelections) ?? true; + } + + // Auto Skip Intro + Future setAutoSkipIntro(bool value) async { + await _prefs.setBool(_keyAutoSkipIntro, value); + } + + bool getAutoSkipIntro() { + return _prefs.getBool(_keyAutoSkipIntro) ?? true; // Default: enabled + } + + // Auto Skip Credits + Future setAutoSkipCredits(bool value) async { + await _prefs.setBool(_keyAutoSkipCredits, value); + } + + bool getAutoSkipCredits() { + return _prefs.getBool(_keyAutoSkipCredits) ?? true; // Default: enabled + } + + // Auto Skip Delay (in seconds) + Future setAutoSkipDelay(int seconds) async { + await _prefs.setInt(_keyAutoSkipDelay, seconds); + } + + int getAutoSkipDelay() { + return _prefs.getInt(_keyAutoSkipDelay) ?? 5; // Default: 5 seconds } // Reset all settings to defaults @@ -875,6 +905,9 @@ class SettingsService { (key, value) => MapEntry(key, _serializeHotKey(value)), ), 'rememberTrackSelections': getRememberTrackSelections(), + 'autoSkipIntro': getAutoSkipIntro(), + 'autoSkipCredits': getAutoSkipCredits(), + 'autoSkipDelay': getAutoSkipDelay(), }; } } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index b8216faa..6b070bd3 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -114,6 +114,12 @@ class _PlexVideoControlsState extends State bool _markersLoaded = false; // Playback state subscription for auto-hide timer StreamSubscription? _playingSubscription; + // Auto-skip state + bool _autoSkipIntro = true; + bool _autoSkipCredits = true; + int _autoSkipDelay = 5; + Timer? _autoSkipTimer; + double _autoSkipProgress = 0.0; @override void initState() { @@ -163,6 +169,13 @@ class _PlexVideoControlsState extends State setState(() { _currentMarker = foundMarker; }); + + // Start auto-skip timer for new marker + if (foundMarker != null) { + _startAutoSkipTimer(foundMarker); + } else { + _cancelAutoSkipTimer(); + } } } }); @@ -183,6 +196,74 @@ class _PlexVideoControlsState extends State if (_currentMarker != null) { widget.player.seek(_currentMarker!.endTime); } + _cancelAutoSkipTimer(); + } + + void _startAutoSkipTimer(PlexMarker marker) { + _cancelAutoSkipTimer(); + + final shouldAutoSkip = (marker.isCredits && _autoSkipCredits) || + (!marker.isCredits && _autoSkipIntro); + + if (!shouldAutoSkip || _autoSkipDelay <= 0) return; + + _autoSkipProgress = 0.0; + const tickDuration = Duration(milliseconds: 50); + final totalTicks = (_autoSkipDelay * 1000) / tickDuration.inMilliseconds; + + if (totalTicks <= 0) return; + + _autoSkipTimer = Timer.periodic(tickDuration, (timer) { + if (!mounted || _currentMarker != marker) { + timer.cancel(); + return; + } + + setState(() { + _autoSkipProgress = (timer.tick / totalTicks).clamp(0.0, 1.0); + }); + + if (timer.tick >= totalTicks) { + timer.cancel(); + try { + _performAutoSkip(); + } catch (e) { + // Handle any errors during skip gracefully + } + } + }); + } + + void _cancelAutoSkipTimer() { + _autoSkipTimer?.cancel(); + _autoSkipTimer = null; + if (mounted) { + setState(() { + _autoSkipProgress = 0.0; + }); + } + } + + /// Perform the appropriate skip action based on marker type and next episode availability + void _performAutoSkip() { + if (_currentMarker == null) return; + + final isCredits = _currentMarker!.isCredits; + final hasNextEpisode = widget.onNext != null; + final showNextEpisode = isCredits && hasNextEpisode; + + if (showNextEpisode) { + widget.onNext?.call(); + } else { + _skipMarker(); + } + } + + /// Check if auto-skip should be active for the current marker + bool _shouldShowAutoSkip() { + if (_currentMarker == null) return false; + return (_currentMarker!.isCredits && _autoSkipCredits) || + (!_currentMarker!.isCredits && _autoSkipIntro); } Future _loadSeekTimes() async { @@ -193,6 +274,9 @@ class _PlexVideoControlsState extends State _audioSyncOffset = settingsService.getAudioSyncOffset(); _subtitleSyncOffset = settingsService.getSubtitleSyncOffset(); _isRotationLocked = settingsService.getRotationLocked(); + _autoSkipIntro = settingsService.getAutoSkipIntro(); + _autoSkipCredits = settingsService.getAutoSkipCredits(); + _autoSkipDelay = settingsService.getAutoSkipDelay(); }); // Apply rotation lock setting @@ -240,6 +324,7 @@ class _PlexVideoControlsState extends State void dispose() { _hideTimer?.cancel(); _feedbackTimer?.cancel(); + _autoSkipTimer?.cancel(); _seekThrottle.cancel(); _playingSubscription?.cancel(); _focusNode.dispose(); @@ -329,6 +414,8 @@ class _PlexVideoControlsState extends State }); if (_showControls) { _startHideTimer(); + // Cancel auto-skip when user manually shows controls + _cancelAutoSkipTimer(); } // On macOS, hide/show traffic lights with controls @@ -804,9 +891,20 @@ class _PlexVideoControlsState extends State // Show "Next Episode" for credits when next episode is available final bool showNextEpisode = isCredits && hasNextEpisode; - final String buttonText = showNextEpisode + final String baseButtonText = showNextEpisode ? 'Next Episode' : (isCredits ? 'Skip Credits' : 'Skip Intro'); + + final isAutoSkipActive = _autoSkipTimer?.isActive ?? false; + final shouldShowAutoSkip = _shouldShowAutoSkip(); + + final int remainingSeconds = isAutoSkipActive && shouldShowAutoSkip + ? (_autoSkipDelay - (_autoSkipProgress * _autoSkipDelay)).ceil().clamp(0, _autoSkipDelay) + : 0; + + final String buttonText = isAutoSkipActive && shouldShowAutoSkip && remainingSeconds > 0 + ? '$baseButtonText ($remainingSeconds)' + : baseButtonText; final IconData buttonIcon = showNextEpisode ? Icons.skip_next : Icons.fast_forward; @@ -814,36 +912,74 @@ class _PlexVideoControlsState extends State return Material( color: Colors.transparent, child: InkWell( - onTap: showNextEpisode ? widget.onNext : _skipMarker, + onTap: () { + if (isAutoSkipActive) { + _cancelAutoSkipTimer(); + } + // Always perform the skip action when tapped + _performAutoSkip(); + }, borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 8, - offset: const Offset(0, 2), + child: Stack( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - buttonText, - style: const TextStyle( - color: Colors.black, - fontSize: 16, - fontWeight: FontWeight.w600, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + buttonText, + style: const TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Icon(buttonIcon, color: Colors.black, size: 20), + ], + ), + ), + // Progress indicator overlay + if (isAutoSkipActive && shouldShowAutoSkip) + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Row( + children: [ + Expanded( + flex: (_autoSkipProgress * 100).round(), + child: Container( + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + Expanded( + flex: ((1.0 - _autoSkipProgress) * 100).round(), + child: Container( + decoration: const BoxDecoration( + color: Colors.transparent, + ), + ), + ), + ], + ), ), ), - const SizedBox(width: 8), - Icon(buttonIcon, color: Colors.black, size: 20), - ], - ), + ], ), ), );