From 00da8a23e304566f4c9a81d926e5fb5527fbe7de Mon Sep 17 00:00:00 2001 From: Sam Matthews <75203773+TheLidlMan@users.noreply.github.com> Date: Wed, 5 Nov 2025 11:28:51 +0000 Subject: [PATCH] feat: sleep timer - default timer values in settings - extend timer function - pauses playback when done and shows popup --- lib/screens/settings_screen.dart | 77 ++++++- lib/services/settings_service.dart | 11 + lib/services/sleep_timer_service.dart | 105 +++++++++ lib/widgets/plex_video_controls.dart | 295 ++++++++++++++++++++++++-- 4 files changed, 468 insertions(+), 20 deletions(-) create mode 100644 lib/services/sleep_timer_service.dart diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7e7bd8cf..43ed0838 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -29,6 +29,7 @@ class _SettingsScreenState extends State { int _bufferSize = 128; int _seekTimeSmall = 10; int _seekTimeLarge = 30; + int _sleepTimerDuration = 30; // Update checking state bool _isCheckingForUpdate = false; @@ -50,6 +51,7 @@ class _SettingsScreenState extends State { _bufferSize = _settingsService.getBufferSize(); _seekTimeSmall = _settingsService.getSeekTimeSmall(); _seekTimeLarge = _settingsService.getSeekTimeLarge(); + _sleepTimerDuration = _settingsService.getSleepTimerDuration(); _isLoading = false; }); } @@ -208,6 +210,13 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _showSeekTimeLargeDialog(), ), + ListTile( + leading: const Icon(Icons.bedtime), + title: const Text('Default Sleep Timer'), + subtitle: Text('$_sleepTimerDuration minutes'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showSleepTimerDurationDialog(), + ), ], ), ); @@ -273,9 +282,7 @@ class _SettingsScreenState extends State { onTap: () { Navigator.push( context, - MaterialPageRoute( - builder: (context) => const LogsScreen(), - ), + MaterialPageRoute(builder: (context) => const LogsScreen()), ); }, ), @@ -588,6 +595,70 @@ class _SettingsScreenState extends State { ); } + void _showSleepTimerDurationDialog() { + final controller = TextEditingController( + text: _sleepTimerDuration.toString(), + ); + String? errorText; + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: const Text('Default Sleep Timer'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Minutes', + hintText: 'Enter duration (5-180)', + errorText: errorText, + suffixText: 'min', + ), + autofocus: true, + onChanged: (value) { + final parsed = int.tryParse(value); + setDialogState(() { + if (parsed == null) { + errorText = 'Please enter a valid number'; + } else if (parsed < 5 || parsed > 180) { + errorText = 'Duration must be between 5 and 180 minutes'; + } 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 >= 5 && parsed <= 180) { + setState(() { + _sleepTimerDuration = parsed; + _settingsService.setSleepTimerDuration(parsed); + }); + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } + } + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + } + void _showKeyboardShortcutsDialog() { Navigator.push( context, diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 1c35f8e5..0715f8dc 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -23,6 +23,7 @@ class SettingsService { static const String _keySeekTimeLarge = 'seek_time_large'; static const String _keyMediaVersionPreferences = 'media_version_preferences'; static const String _keyShowHeroSection = 'show_hero_section'; + static const String _keySleepTimerDuration = 'sleep_timer_duration'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -153,6 +154,15 @@ class SettingsService { return _prefs.getInt(_keySeekTimeLarge) ?? 30; // Default: 30 seconds } + // Sleep Timer Duration (in minutes) + Future setSleepTimerDuration(int minutes) async { + await _prefs.setInt(_keySleepTimerDuration, minutes); + } + + int getSleepTimerDuration() { + return _prefs.getInt(_keySleepTimerDuration) ?? 30; // Default: 30 minutes + } + // Keyboard Shortcuts (Legacy String-based) Map getDefaultKeyboardShortcuts() { return { @@ -665,6 +675,7 @@ class SettingsService { _prefs.remove(_keySeekTimeSmall), _prefs.remove(_keySeekTimeLarge), _prefs.remove(_keyMediaVersionPreferences), + _prefs.remove(_keySleepTimerDuration), ]); } diff --git a/lib/services/sleep_timer_service.dart b/lib/services/sleep_timer_service.dart new file mode 100644 index 00000000..727e9aa9 --- /dev/null +++ b/lib/services/sleep_timer_service.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import '../utils/app_logger.dart'; + +/// Service to manage sleep timer functionality +/// Allows setting a timer to pause/stop playback after a specified duration +class SleepTimerService extends ChangeNotifier { + static final SleepTimerService _instance = SleepTimerService._internal(); + factory SleepTimerService() => _instance; + SleepTimerService._internal(); + + Timer? _timer; + DateTime? _endTime; + Duration? _duration; + VoidCallback? _onTimerComplete; + + /// Whether a timer is currently active + bool get isActive => _timer != null && _timer!.isActive; + + /// The time when the timer will complete + DateTime? get endTime => _endTime; + + /// The original duration of the timer + Duration? get duration => _duration; + + /// Remaining time on the timer + Duration? get remainingTime { + if (_endTime == null) return null; + final remaining = _endTime!.difference(DateTime.now()); + return remaining.isNegative ? Duration.zero : remaining; + } + + /// Start a sleep timer with the specified duration + /// [duration] - How long until the timer completes + /// [onComplete] - Callback to execute when timer completes + void startTimer(Duration duration, VoidCallback onComplete) { + // Cancel any existing timer + cancelTimer(); + + _duration = duration; + _endTime = DateTime.now().add(duration); + _onTimerComplete = onComplete; + + appLogger.d('Sleep timer started: ${duration.inMinutes} minutes'); + + // Create a periodic timer to update remaining time + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + final remaining = remainingTime; + + if (remaining == null || remaining.inSeconds <= 0) { + appLogger.d('Sleep timer completed'); + _executeCallback(); + cancelTimer(); + } else { + // Notify listeners to update UI + notifyListeners(); + } + }); + + notifyListeners(); + } + + /// Cancel the active timer + void cancelTimer() { + if (_timer != null) { + appLogger.d('Sleep timer cancelled'); + _timer?.cancel(); + _timer = null; + _endTime = null; + _duration = null; + _onTimerComplete = null; + notifyListeners(); + } + } + + /// Extend the current timer by the specified duration + void extendTimer(Duration additionalTime) { + if (_endTime != null) { + _endTime = _endTime!.add(additionalTime); + _duration = _duration != null + ? _duration! + additionalTime + : additionalTime; + appLogger.d( + 'Sleep timer extended by ${additionalTime.inMinutes} minutes', + ); + notifyListeners(); + } + } + + void _executeCallback() { + if (_onTimerComplete != null) { + try { + _onTimerComplete!(); + } catch (e) { + appLogger.e('Error executing sleep timer callback', error: e); + } + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} diff --git a/lib/widgets/plex_video_controls.dart b/lib/widgets/plex_video_controls.dart index e0d3a1cf..9bad972a 100644 --- a/lib/widgets/plex_video_controls.dart +++ b/lib/widgets/plex_video_controls.dart @@ -12,6 +12,7 @@ import '../providers/plex_client_provider.dart'; import '../services/fullscreen_state_manager.dart'; import '../services/keyboard_shortcuts_service.dart'; import '../services/settings_service.dart'; +import '../services/sleep_timer_service.dart'; import '../utils/desktop_window_padding.dart'; import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; @@ -312,6 +313,22 @@ class _PlexVideoControlsState extends State return Row( mainAxisSize: MainAxisSize.min, children: [ + // Sleep Timer button + ListenableBuilder( + listenable: SleepTimerService(), + builder: (context, _) { + final sleepTimer = SleepTimerService(); + return IconButton( + icon: Icon( + sleepTimer.isActive + ? Icons.bedtime + : Icons.bedtime_outlined, + color: sleepTimer.isActive ? Colors.amber : Colors.white, + ), + onPressed: _showSleepTimerBottomSheet, + ); + }, + ), IconButton( icon: const Icon(Icons.speed, color: Colors.white), onPressed: _showPlaybackSpeedBottomSheet, @@ -403,8 +420,8 @@ class _PlexVideoControlsState extends State // Clamp between 0 and video duration final clampedPosition = newPosition.isNegative - ? Duration.zero - : (newPosition > duration ? duration : newPosition); + ? Duration.zero + : (newPosition > duration ? duration : newPosition); widget.player.seek(clampedPosition); } @@ -650,8 +667,10 @@ class _PlexVideoControlsState extends State builder: (context, constraints) { final height = constraints.maxHeight; final width = constraints.maxWidth; - final topExclude = height * 0.15; // Exclude top 15% (top bar) - final bottomExclude = height * 0.15; // Exclude bottom 15% (seek slider) + final topExclude = + height * 0.15; // Exclude top 15% (top bar) + final bottomExclude = + height * 0.15; // Exclude bottom 15% (seek slider) final leftZoneWidth = width * 0.35; // Left 35% return Stack( @@ -1075,7 +1094,9 @@ class _PlexVideoControlsState extends State // Previous chapter (or skip backward if no chapters) IconButton( icon: Icon( - _chapters.isEmpty ? _getReplayIcon(_seekTimeSmall) : Icons.fast_rewind, + _chapters.isEmpty + ? _getReplayIcon(_seekTimeSmall) + : Icons.fast_rewind, color: Colors.white, ), onPressed: _seekToPreviousChapter, @@ -1107,7 +1128,9 @@ class _PlexVideoControlsState extends State // Next chapter (or skip forward if no chapters) IconButton( icon: Icon( - _chapters.isEmpty ? _getForwardIcon(_seekTimeSmall) : Icons.fast_forward, + _chapters.isEmpty + ? _getForwardIcon(_seekTimeSmall) + : Icons.fast_forward, color: Colors.white, ), onPressed: _seekToNextChapter, @@ -1755,6 +1778,243 @@ class _PlexVideoControlsState extends State ); } + void _showSleepTimerBottomSheet() async { + final sleepTimer = SleepTimerService(); + final settingsService = await SettingsService.getInstance(); + final defaultDuration = settingsService.getSleepTimerDuration(); + + if (!mounted) return; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: _getBottomSheetConstraints(), + builder: (context) { + return ListenableBuilder( + listenable: sleepTimer, + builder: (context, _) { + final durations = [5, 10, 15, 30, 45, 60, 90, 120]; + // Add default duration if not in list + if (!durations.contains(defaultDuration)) { + durations.add(defaultDuration); + durations.sort(); + } + final remainingTime = sleepTimer.remainingTime; + + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon( + sleepTimer.isActive + ? Icons.bedtime + : Icons.bedtime_outlined, + color: sleepTimer.isActive + ? Colors.amber + : Colors.white, + ), + const SizedBox(width: 12), + const Text( + 'Sleep Timer', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + + // Show current timer status if active + if (sleepTimer.isActive && remainingTime != null) ...[ + Container( + padding: const EdgeInsets.all(16), + color: Colors.amber.withValues(alpha: 0.1), + child: Column( + children: [ + const Text( + 'Timer Active', + style: TextStyle( + color: Colors.amber, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Playback will pause in ${_formatSleepTimerDuration(remainingTime)}', + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.add), + label: const Text('+15 min'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide( + color: Colors.white54, + ), + ), + onPressed: () { + sleepTimer.extendTimer( + const Duration(minutes: 15), + ); + }, + ), + const SizedBox(width: 12), + FilledButton.icon( + icon: const Icon(Icons.cancel), + label: const Text('Cancel'), + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + ), + onPressed: () { + sleepTimer.cancelTimer(); + Navigator.pop(context); + }, + ), + ], + ), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + ], + + // Duration selection list + Expanded( + child: ListView.builder( + itemCount: durations.length, + itemBuilder: (context, index) { + final minutes = durations[index]; + final isDefault = minutes == defaultDuration; + final label = minutes < 60 + ? '$minutes minutes' + : '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}'; + + return ListTile( + leading: Icon( + isDefault ? Icons.star : Icons.timer, + color: isDefault ? Colors.amber : Colors.white70, + ), + title: Row( + children: [ + Text( + label, + style: TextStyle( + color: Colors.white, + fontWeight: isDefault + ? FontWeight.bold + : FontWeight.normal, + ), + ), + if (isDefault) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.amber.withValues( + alpha: 0.2, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.amber.withValues( + alpha: 0.5, + ), + ), + ), + child: const Text( + 'DEFAULT', + style: TextStyle( + color: Colors.amber, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + onTap: () { + sleepTimer.startTimer( + Duration(minutes: minutes), + () { + // Pause playback when timer completes + widget.player.pause(); + + // Show a snackbar notification + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Sleep timer completed - playback paused', + ), + duration: Duration(seconds: 3), + ), + ); + } + }, + ); + Navigator.pop(context); + + // Show confirmation snackbar + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Sleep timer set for $label'), + duration: const Duration(seconds: 2), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + String _formatSleepTimerDuration(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m ${seconds}s'; + } else if (minutes > 0) { + return '${minutes}m ${seconds}s'; + } else { + return '${seconds}s'; + } + } + void _showPlaybackSpeedBottomSheet() { showModalBottomSheet( context: context, @@ -1920,8 +2180,8 @@ class _PlexVideoControlsState extends State // Save the preference final settingsService = await SettingsService.getInstance(); - final seriesKey = widget.metadata.grandparentRatingKey ?? - widget.metadata.ratingKey; + final seriesKey = + widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey; await settingsService.setMediaVersionPreference(seriesKey, newMediaIndex); // Navigate to new player screen with the selected version @@ -1930,12 +2190,13 @@ class _PlexVideoControlsState extends State Navigator.pushReplacement( context, PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( - metadata: widget.metadata.copyWith( - viewOffset: currentPosition.inMilliseconds, - ), - selectedMediaIndex: newMediaIndex, - ), + pageBuilder: (context, animation, secondaryAnimation) => + VideoPlayerScreen( + metadata: widget.metadata.copyWith( + viewOffset: currentPosition.inMilliseconds, + ), + selectedMediaIndex: newMediaIndex, + ), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, ), @@ -1943,9 +2204,9 @@ class _PlexVideoControlsState extends State } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error switching version: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error switching version: $e'))); } } }