From 29a86b0dc36e9d4a7d1dcd3172d0ee6c543bd8aa Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 30 Aug 2026 17:17:09 +0700 Subject: [PATCH] feat(player): add sleep timer --- lib/l10n/arb/app_en.arb | 39 ++++++++++ lib/providers/music_player_provider.dart | 6 ++ lib/screens/now_playing_screen.dart | 75 ++++++++++++++++++++ lib/services/music_player_service.dart | 25 +++++++ test/now_playing_lyrics_transition_test.dart | 19 +++++ 5 files changed, 164 insertions(+) diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 3d49625e..16ef9292 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index 4060ae5d..98d6f13e 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -69,6 +69,8 @@ class MusicPlayerController { bool get isAvailable => _handler != null; + DateTime? get sleepTimerEndsAt => _handler?.sleepTimerEndsAt; + Future ensureInitialized() async { try { return await initMusicPlayer(); @@ -118,6 +120,10 @@ class MusicPlayerController { Future next() async => _handler?.skipToNext(); Future previous() async => _handler?.skipToPrevious(); + void setSleepTimer(Duration duration) => _handler?.setSleepTimer(duration); + + void cancelSleepTimer() => _handler?.cancelSleepTimer(); + Future togglePlayPause(bool isPlaying) async { final handler = _handler; if (handler == null) return; diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 90ef1ff9..d8939d12 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -735,6 +735,15 @@ class _NowPlayingScreenState extends ConsumerState { 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( context: context, useRootNavigator: true, @@ -756,6 +765,12 @@ class _NowPlayingScreenState extends ConsumerState { 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 { case 'details': _showDetailsSheet(colorScheme); break; + case 'sleepTimer': + await _showSleepTimerSheet(controller, colorScheme); + break; case 'external': await _openExternally(source); break; } } + Future _showSleepTimerSheet( + MusicPlayerController controller, + ColorScheme colorScheme, + ) async { + const durations = [15, 30, 45, 60]; + final isActive = controller.sleepTimerEndsAt != null; + final selection = await showAppBottomSheet( + 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 _goToCurrentAlbum({ required MediaItem mediaItem, required String source, diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 39520310..2e835346 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -277,6 +277,8 @@ class MusicPlayerHandler extends BaseAudioHandler bool _initialized = false; bool _sourceReady = false; Future? _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 _activateAudioSession() async { try { final session = _audioSession ?? await AudioSession.instance; @@ -1215,6 +1238,7 @@ class MusicPlayerHandler extends BaseAudioHandler @override Future stop() async { + cancelSleepTimer(); _playRequestGeneration++; _switchingGeneration = 0; _userPaused = true; @@ -1362,6 +1386,7 @@ class MusicPlayerHandler extends BaseAudioHandler Future dispose() async { _disposed = true; + cancelSleepTimer(); _playRequestGeneration++; for (final sub in _subscriptions) { await sub.cancel(); diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 56a1d096..3418f493 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -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); + }); }