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 <noreply@anthropic.com>
This commit is contained in:
Dustin-Mike Jens Hähnel
2026-08-16 20:06:08 +02:00
co-authored by Claude Opus 4.8
parent 78f4b55e68
commit 51c2229148
4 changed files with 271 additions and 0 deletions
+4
View File
@@ -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<void> stop() async {
sleepTimer.cancel();
await _player.stop();
await super.stop();
}
+75
View File
@@ -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<MediaItem?>(
@@ -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<Duration?>(
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<Duration?>(
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),
);
},
);
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'dart:async';
import 'package:flutter/material.dart';
class SleepTimer {
final VoidCallback onElapsed;
final DateTime Function() now;
late final ValueNotifier<Duration?> 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;
}
}