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:
co-authored by
Claude Opus 4.8
parent
78f4b55e68
commit
51c2229148
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user