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: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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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