Merge branch 'sleep-timer-47'

This commit is contained in:
edde746
2025-11-05 13:29:45 +01:00
4 changed files with 434 additions and 20 deletions
+74 -3
View File
@@ -29,6 +29,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
_bufferSize = _settingsService.getBufferSize();
_seekTimeSmall = _settingsService.getSeekTimeSmall();
_seekTimeLarge = _settingsService.getSeekTimeLarge();
_sleepTimerDuration = _settingsService.getSleepTimerDuration();
_isLoading = false;
});
}
@@ -219,6 +221,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
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(),
),
],
),
);
@@ -284,9 +293,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const LogsScreen(),
),
MaterialPageRoute(builder: (context) => const LogsScreen()),
);
},
),
@@ -599,6 +606,70 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
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,
+11
View File
@@ -26,6 +26,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;
@@ -169,6 +170,15 @@ class SettingsService {
return _prefs.getInt(_keySeekTimeLarge) ?? 30; // Default: 30 seconds
}
// Sleep Timer Duration (in minutes)
Future<void> 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<String, String> getDefaultKeyboardShortcuts() {
return {
@@ -682,6 +692,7 @@ class SettingsService {
_prefs.remove(_keySeekTimeSmall),
_prefs.remove(_keySeekTimeLarge),
_prefs.remove(_keyMediaVersionPreferences),
_prefs.remove(_keySleepTimerDuration),
]);
}
+105
View File
@@ -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();
}
}
+244 -17
View File
@@ -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<PlexVideoControls>
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<PlexVideoControls>
// 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<PlexVideoControls>
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<PlexVideoControls>
// 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<PlexVideoControls>
// 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,209 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
);
}
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(
Icons.timer,
color: Colors.white70,
),
title: Text(
label,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.normal,
),
),
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 +2146,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
// 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 +2156,13 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
Navigator.pushReplacement(
context,
PageRouteBuilder<bool>(
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 +2170,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
} 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')));
}
}
}