feat(player): add sleep timer

This commit is contained in:
zarzet
2026-08-30 17:23:16 +07:00
parent a926a2825b
commit 29a86b0dc3
5 changed files with 164 additions and 0 deletions
+39
View File
@@ -6053,6 +6053,45 @@
"@nowPlayingDetails": {
"description": "Menu item and section title for track metadata details"
},
"nowPlayingSleepTimer": "Sleep timer",
"@nowPlayingSleepTimer": {
"description": "Menu item and sheet title for the playback sleep timer"
},
"nowPlayingSleepTimerActive": "Ends at {time}",
"@nowPlayingSleepTimerActive": {
"description": "Active sleep timer deadline shown in the player menu",
"placeholders": {
"time": {
"type": "String"
}
}
},
"nowPlayingSleepTimerMinutes": "{minutes, plural, =1{1 minute} other{{minutes} minutes}}",
"@nowPlayingSleepTimerMinutes": {
"description": "Duration choice for the playback sleep timer",
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"nowPlayingSleepTimerOff": "Turn off sleep timer",
"@nowPlayingSleepTimerOff": {
"description": "Action to cancel the active playback sleep timer"
},
"nowPlayingSleepTimerSet": "Sleep timer set for {duration}",
"@nowPlayingSleepTimerSet": {
"description": "Confirmation after setting the playback sleep timer",
"placeholders": {
"duration": {
"type": "String"
}
}
},
"nowPlayingSleepTimerCancelled": "Sleep timer turned off",
"@nowPlayingSleepTimerCancelled": {
"description": "Confirmation after cancelling the playback sleep timer"
},
"nowPlayingOpenInExternalPlayer": "Open in external player",
"@nowPlayingOpenInExternalPlayer": {
"description": "Menu item to open the current track in an external player"
+6
View File
@@ -69,6 +69,8 @@ class MusicPlayerController {
bool get isAvailable => _handler != null;
DateTime? get sleepTimerEndsAt => _handler?.sleepTimerEndsAt;
Future<MusicPlayerHandler?> ensureInitialized() async {
try {
return await initMusicPlayer();
@@ -118,6 +120,10 @@ class MusicPlayerController {
Future<void> next() async => _handler?.skipToNext();
Future<void> previous() async => _handler?.skipToPrevious();
void setSleepTimer(Duration duration) => _handler?.setSleepTimer(duration);
void cancelSleepTimer() => _handler?.cancelSleepTimer();
Future<void> togglePlayPause(bool isPlaying) async {
final handler = _handler;
if (handler == null) return;
+75
View File
@@ -735,6 +735,15 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
required String source,
required ColorScheme colorScheme,
}) async {
final controller = ref.read(musicPlayerControllerProvider);
final sleepTimerEndsAt = controller.sleepTimerEndsAt;
final sleepTimerSubtitle = sleepTimerEndsAt == null
? null
: context.l10n.nowPlayingSleepTimerActive(
MaterialLocalizations.of(
context,
).formatTimeOfDay(TimeOfDay.fromDateTime(sleepTimerEndsAt)),
);
final action = await showAppBottomSheet<String>(
context: context,
useRootNavigator: true,
@@ -756,6 +765,12 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
title: sheetContext.l10n.nowPlayingDetails,
onTap: () => Navigator.of(sheetContext).pop('details'),
),
SettingsItem(
icon: Icons.bedtime_outlined,
title: sheetContext.l10n.nowPlayingSleepTimer,
subtitle: sleepTimerSubtitle,
onTap: () => Navigator.of(sheetContext).pop('sleepTimer'),
),
SettingsItem(
icon: Icons.open_in_new,
title: sheetContext.l10n.nowPlayingOpenInExternalPlayer,
@@ -779,12 +794,72 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
case 'details':
_showDetailsSheet(colorScheme);
break;
case 'sleepTimer':
await _showSleepTimerSheet(controller, colorScheme);
break;
case 'external':
await _openExternally(source);
break;
}
}
Future<void> _showSleepTimerSheet(
MusicPlayerController controller,
ColorScheme colorScheme,
) async {
const durations = [15, 30, 45, 60];
final isActive = controller.sleepTimerEndsAt != null;
final selection = await showAppBottomSheet<String>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
title: context.l10n.nowPlayingSleepTimer,
builder: (sheetContext) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: SettingsGroup(
children: [
for (final minutes in durations)
SettingsItem(
icon: Icons.timer_outlined,
title: sheetContext.l10n.nowPlayingSleepTimerMinutes(minutes),
showDivider: isActive || minutes != durations.last,
onTap: () => Navigator.of(sheetContext).pop('$minutes'),
),
if (isActive)
SettingsItem(
icon: Icons.timer_off_outlined,
title: sheetContext.l10n.nowPlayingSleepTimerOff,
showDivider: false,
onTap: () => Navigator.of(sheetContext).pop('off'),
),
],
),
),
);
if (!mounted || selection == null) return;
if (selection == 'off') {
controller.cancelSleepTimer();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.nowPlayingSleepTimerCancelled)),
);
return;
}
final minutes = int.tryParse(selection);
if (minutes == null) return;
controller.setSleepTimer(Duration(minutes: minutes));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.nowPlayingSleepTimerSet(
context.l10n.nowPlayingSleepTimerMinutes(minutes),
),
),
),
);
}
Future<void> _goToCurrentAlbum({
required MediaItem mediaItem,
required String source,
+25
View File
@@ -277,6 +277,8 @@ class MusicPlayerHandler extends BaseAudioHandler
bool _initialized = false;
bool _sourceReady = false;
Future<void>? _activePlayOperation;
Timer? _sleepTimer;
DateTime? _sleepTimerEndsAt;
bool _shuffle = false;
AudioServiceRepeatMode _repeatMode = AudioServiceRepeatMode.none;
@@ -305,6 +307,8 @@ class MusicPlayerHandler extends BaseAudioHandler
static const Duration _positionPersistInterval = Duration(seconds: 10);
static const int _maxResolvedPathCacheEntries = 64;
DateTime? get sleepTimerEndsAt => _sleepTimerEndsAt;
MusicPlayerHandler() {
_activeMusicPlayerHandler = this;
_init();
@@ -423,6 +427,25 @@ class MusicPlayerHandler extends BaseAudioHandler
await _persistSession(position: await _currentPositionForPersist());
}
void setSleepTimer(Duration duration) {
if (duration <= Duration.zero) return;
_sleepTimer?.cancel();
_sleepTimerEndsAt = DateTime.now().add(duration);
_sleepTimer = Timer(duration, () {
_sleepTimer = null;
_sleepTimerEndsAt = null;
if (_disposed) return;
_log.i('Sleep timer elapsed; pausing playback');
unawaited(pause());
});
}
void cancelSleepTimer() {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepTimerEndsAt = null;
}
Future<void> _activateAudioSession() async {
try {
final session = _audioSession ?? await AudioSession.instance;
@@ -1215,6 +1238,7 @@ class MusicPlayerHandler extends BaseAudioHandler
@override
Future<void> stop() async {
cancelSleepTimer();
_playRequestGeneration++;
_switchingGeneration = 0;
_userPaused = true;
@@ -1362,6 +1386,7 @@ class MusicPlayerHandler extends BaseAudioHandler
Future<void> dispose() async {
_disposed = true;
cancelSleepTimer();
_playRequestGeneration++;
for (final sub in _subscriptions) {
await sub.cancel();
@@ -120,4 +120,23 @@ void main() {
expect(find.text('Go to Album'), findsOneWidget);
expect(find.byIcon(Icons.album_outlined), findsOneWidget);
});
testWidgets('Now Playing menu exposes sleep timer duration choices', (
tester,
) async {
await pumpNowPlaying(tester);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
await tester.tap(find.text('Sleep timer'));
await tester.pumpAndSettle();
expect(find.text('15 minutes'), findsOneWidget);
expect(find.text('30 minutes'), findsOneWidget);
expect(find.text('45 minutes'), findsOneWidget);
expect(find.text('60 minutes'), findsOneWidget);
expect(find.text('Turn off sleep timer'), findsNothing);
});
}