mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
feat(player): add sleep timer
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user