From 51c2229148f02545ce98ddf19f51d6921a35ce82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dustin-Mike=20Jens=20H=C3=A4hnel?= Date: Sun, 16 Aug 2026 20:06:08 +0200 Subject: [PATCH] Add sleep timer feature Timer stops playback (pause) after 5/10/15/30/60 min. Wall-clock deadline is self-correcting against OS throttling; 1Hz tick drives the countdown UI. Cancelled in stop() so no timer resurrects a paused player after teardown. - sleep_timer.dart: isolated time logic, injectable clock for testing - audio_handler.dart: sleepTimer field + cancel() in stop() - now_playing_screen.dart: AppBar button + bottom sheet - 10 unit tests via mocked clock Co-Authored-By: Claude Opus 4.8 --- lib/player/audio_handler.dart | 4 + lib/player/now_playing_screen.dart | 75 ++++++++++++++++++ lib/player/sleep_timer.dart | 74 ++++++++++++++++++ test/sleep_timer_test.dart | 118 +++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 lib/player/sleep_timer.dart create mode 100644 test/sleep_timer_test.dart diff --git a/lib/player/audio_handler.dart b/lib/player/audio_handler.dart index 8ca3506..040272b 100644 --- a/lib/player/audio_handler.dart +++ b/lib/player/audio_handler.dart @@ -1,12 +1,15 @@ import 'package:audio_service/audio_service.dart'; import 'package:just_audio/just_audio.dart'; +import 'sleep_timer.dart'; /// Kern der Wiedergabe: kapselt just_audio hinter audio_service, /// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren. class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final AudioPlayer _player = AudioPlayer(); + late final SleepTimer sleepTimer; MeloAudioHandler() { + sleepTimer = SleepTimer(onElapsed: pause); // just_audio-Events → audio_service PlaybackState _player.playbackEventStream.map(_transformEvent).pipe(playbackState); @@ -46,6 +49,7 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { @override Future stop() async { + sleepTimer.cancel(); await _player.stop(); await super.stop(); } diff --git a/lib/player/now_playing_screen.dart b/lib/player/now_playing_screen.dart index b89cde6..20c404d 100644 --- a/lib/player/now_playing_screen.dart +++ b/lib/player/now_playing_screen.dart @@ -21,6 +21,9 @@ class NowPlayingScreen extends StatelessWidget { icon: const Icon(Icons.keyboard_arrow_down), onPressed: () => Navigator.of(context).maybePop(), ), + actions: [ + _SleepTimerButton(handler: handler), + ], ), body: SafeArea( child: StreamBuilder( @@ -197,3 +200,75 @@ class _Controls extends StatelessWidget { ); } } + +class _SleepTimerButton extends StatelessWidget { + const _SleepTimerButton({required this.handler}); + final MeloAudioHandler handler; + + void _showSleepOptions(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (ctx) => Container( + color: Theme.of(ctx).scaffoldBackgroundColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Sleep-Timer', + style: Theme.of(ctx).textTheme.titleLarge, + ), + ), + ...const [5, 10, 15, 30, 60].map( + (minutes) => ListTile( + title: Text('$minutes Minuten'), + onTap: () { + handler.sleepTimer.start(Duration(minutes: minutes)); + Navigator.pop(ctx); + }, + ), + ), + ValueListenableBuilder( + valueListenable: handler.sleepTimer.remaining, + builder: (_, remaining, _) => remaining == null + ? const SizedBox.shrink() + : ListTile( + title: const Text('Timer beenden'), + leading: const Icon(Icons.close), + onTap: () { + handler.sleepTimer.cancel(); + Navigator.pop(ctx); + }, + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: handler.sleepTimer.remaining, + builder: (context, remaining, _) { + if (remaining == null) { + return IconButton( + tooltip: 'Sleep-Timer', + icon: const Icon(Icons.bedtime_outlined), + onPressed: () => _showSleepOptions(context), + ); + } + final minutes = remaining.inMinutes; + final seconds = (remaining.inSeconds % 60).toString().padLeft(2, '0'); + return IconButton( + tooltip: 'Sleep-Timer: noch $minutes:$seconds', + icon: Icon(Icons.bedtime, color: MeloTheme.red), + onPressed: () => _showSleepOptions(context), + ); + }, + ); + } +} diff --git a/lib/player/sleep_timer.dart b/lib/player/sleep_timer.dart new file mode 100644 index 0000000..3441e06 --- /dev/null +++ b/lib/player/sleep_timer.dart @@ -0,0 +1,74 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +class SleepTimer { + final VoidCallback onElapsed; + final DateTime Function() now; + + late final ValueNotifier remaining = ValueNotifier(null); + Timer? _timer; + DateTime? _deadline; + bool _firedThisSession = false; + + SleepTimer({ + required this.onElapsed, + DateTime Function()? now, + }) : now = now ?? DateTime.now; + + void start(Duration duration) { + if (duration <= Duration.zero) { + cancel(); + return; + } + + cancel(); // Replace any running timer + _firedThisSession = false; + // ponytail: Wall-Clock-Deadline, selbstkorrigierend gegen OS-Throttling. + // Nachteil: manuelle Systemuhr-Änderung feuert früh/spät — akzeptiert. + _deadline = now().add(duration); + _updateRemaining(); + + // Start periodic updates (1Hz for UI) + _timer = Timer.periodic(const Duration(seconds: 1), (_) => tick()); + } + + void tick() { + _updateRemaining(); + _checkElapsed(); + } + + void _checkElapsed() { + if (_deadline == null || _firedThisSession) return; + + if (!now().isBefore(_deadline!)) { + _firedThisSession = true; + _deadline = null; + _timer?.cancel(); + _timer = null; + remaining.value = null; + onElapsed(); + } + } + + void _updateRemaining() { + if (_deadline == null) { + remaining.value = null; + return; + } + + final diff = _deadline!.difference(now()); + if (diff.isNegative) { + remaining.value = null; + } else { + remaining.value = diff; + } + } + + void cancel() { + _timer?.cancel(); + _timer = null; + _deadline = null; + _firedThisSession = false; + remaining.value = null; + } +} diff --git a/test/sleep_timer_test.dart b/test/sleep_timer_test.dart new file mode 100644 index 0000000..eb6797e --- /dev/null +++ b/test/sleep_timer_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/player/sleep_timer.dart'; + +void main() { + group('SleepTimer with mocked clock', () { + late DateTime currentTime; + late SleepTimer sleepTimer; + late int elapsedCount; + + setUp(() { + currentTime = DateTime(2024, 1, 1, 12, 0, 0); + elapsedCount = 0; + sleepTimer = SleepTimer( + onElapsed: () => elapsedCount++, + now: () => currentTime, + ); + }); + + tearDown(() { + sleepTimer.cancel(); + }); + + test('remaining is null when inactive', () { + expect(sleepTimer.remaining.value, isNull); + }); + + test('start sets remaining to duration', () { + sleepTimer.start(const Duration(minutes: 5)); + expect(sleepTimer.remaining.value, isNotNull); + expect(sleepTimer.remaining.value!.inMinutes, equals(5)); + }); + + test('remaining decrements as time passes', () { + sleepTimer.start(const Duration(minutes: 1)); + expect(sleepTimer.remaining.value!.inSeconds, equals(60)); + + // Simulate 30 seconds passing + currentTime = currentTime.add(const Duration(seconds: 30)); + sleepTimer.tick(); // Manually call tick to simulate timer + + expect(sleepTimer.remaining.value!.inSeconds, equals(30)); + }); + + test('fires callback when timer elapses', () { + sleepTimer.start(const Duration(minutes: 5)); + expect(elapsedCount, equals(0)); + + // Simulate 5 minutes passing + currentTime = currentTime.add(const Duration(minutes: 5)); + sleepTimer.tick(); + + expect(elapsedCount, equals(1), reason: 'onElapsed should fire once'); + expect(sleepTimer.remaining.value, isNull); + }); + + test('cancel stops timer and clears remaining', () { + sleepTimer.start(const Duration(minutes: 5)); + expect(sleepTimer.remaining.value, isNotNull); + + sleepTimer.cancel(); + + expect(sleepTimer.remaining.value, isNull); + expect(elapsedCount, equals(0), reason: 'Should not fire after cancel'); + }); + + test('does not fire if duration is zero', () { + sleepTimer.start(Duration.zero); + + expect(elapsedCount, equals(0)); + expect(sleepTimer.remaining.value, isNull); + }); + + test('does not fire if duration is negative', () { + sleepTimer.start(const Duration(seconds: -5)); + + expect(elapsedCount, equals(0)); + expect(sleepTimer.remaining.value, isNull); + }); + + test('start while running replaces old timer', () { + sleepTimer.start(const Duration(minutes: 10)); + final firstDuration = sleepTimer.remaining.value!; + + sleepTimer.start(const Duration(minutes: 5)); + final secondDuration = sleepTimer.remaining.value!; + + expect(secondDuration < firstDuration, isTrue); + expect(elapsedCount, equals(0)); + }); + + test('fires exactly once when elapsed', () { + sleepTimer.start(const Duration(minutes: 5)); + + // Advance time past deadline + currentTime = currentTime.add(const Duration(minutes: 6)); + sleepTimer.tick(); + + expect(elapsedCount, equals(1)); + + // Even if we call tick again, should not fire twice + sleepTimer.tick(); + expect(elapsedCount, equals(1), reason: 'Should fire only once'); + }); + + test('does not fire if cancelled before elapse', () { + sleepTimer.start(const Duration(minutes: 5)); + + currentTime = currentTime.add(const Duration(minutes: 3)); + sleepTimer.cancel(); + + currentTime = currentTime.add(const Duration(minutes: 3)); + sleepTimer.tick(); + + expect(elapsedCount, equals(0)); + }); + }); +} -- 2.54.0