feat: auto skip

This commit is contained in:
Tazio
2025-12-01 10:50:48 +01:00
parent 055765ead7
commit e30d20e6d1
3 changed files with 314 additions and 35 deletions
+115 -5
View File
@@ -37,6 +37,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
_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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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);
}
+36 -3
View File
@@ -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<void> setRememberTrackSelections(bool enabled) async {
await _prefs.setBool(_keyRememberTrackSelections, enabled);
Future<void> 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<void> setAutoSkipIntro(bool value) async {
await _prefs.setBool(_keyAutoSkipIntro, value);
}
bool getAutoSkipIntro() {
return _prefs.getBool(_keyAutoSkipIntro) ?? true; // Default: enabled
}
// Auto Skip Credits
Future<void> setAutoSkipCredits(bool value) async {
await _prefs.setBool(_keyAutoSkipCredits, value);
}
bool getAutoSkipCredits() {
return _prefs.getBool(_keyAutoSkipCredits) ?? true; // Default: enabled
}
// Auto Skip Delay (in seconds)
Future<void> 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(),
};
}
}
+163 -27
View File
@@ -114,6 +114,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
bool _markersLoaded = false;
// Playback state subscription for auto-hide timer
StreamSubscription<bool>? _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<PlexVideoControls>
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<PlexVideoControls>
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<void> _loadSeekTimes() async {
@@ -193,6 +274,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
_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<PlexVideoControls>
void dispose() {
_hideTimer?.cancel();
_feedbackTimer?.cancel();
_autoSkipTimer?.cancel();
_seekThrottle.cancel();
_playingSubscription?.cancel();
_focusNode.dispose();
@@ -329,6 +414,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
});
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<PlexVideoControls>
// 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<PlexVideoControls>
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),
],
),
],
),
),
);