Merge pull request 'Add sleep timer feature' (#1) from feature/sleep-timer into main

Reviewed-on: dustin/Mello#1
This commit was merged in pull request #1.
This commit is contained in:
2026-08-16 18:20:50 +00:00
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:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'sleep_timer.dart';
/// Kern der Wiedergabe: kapselt just_audio hinter audio_service, /// Kern der Wiedergabe: kapselt just_audio hinter audio_service,
/// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren. /// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren.
class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
final AudioPlayer _player = AudioPlayer(); final AudioPlayer _player = AudioPlayer();
late final SleepTimer sleepTimer;
MeloAudioHandler() { MeloAudioHandler() {
sleepTimer = SleepTimer(onElapsed: pause);
// just_audio-Events → audio_service PlaybackState // just_audio-Events → audio_service PlaybackState
_player.playbackEventStream.map(_transformEvent).pipe(playbackState); _player.playbackEventStream.map(_transformEvent).pipe(playbackState);
@@ -46,6 +49,7 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
@override @override
Future<void> stop() async { Future<void> stop() async {
sleepTimer.cancel();
await _player.stop(); await _player.stop();
await super.stop(); await super.stop();
} }
+75
View File
@@ -21,6 +21,9 @@ class NowPlayingScreen extends StatelessWidget {
icon: const Icon(Icons.keyboard_arrow_down), icon: const Icon(Icons.keyboard_arrow_down),
onPressed: () => Navigator.of(context).maybePop(), onPressed: () => Navigator.of(context).maybePop(),
), ),
actions: [
_SleepTimerButton(handler: handler),
],
), ),
body: SafeArea( body: SafeArea(
child: StreamBuilder<MediaItem?>( 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;
}
}
+118
View File
@@ -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));
});
});
}