From b98188d7fbc13e6e79b2ab2f3682bb4e864bc70e Mon Sep 17 00:00:00 2001 From: Dr-Blank <64108942+Dr-Blank@users.noreply.github.com> Date: Thu, 6 Jun 2024 15:35:30 -0400 Subject: [PATCH] sleeptimer --- .../player/providers/audiobook_player.dart | 24 +- ...rovider.g.dart => audiobook_player.g.dart} | 19 +- .../player/view/player_when_expanded.dart | 386 +++---------- .../widgets/audiobook_player_seek_button.dart | 32 ++ .../audiobook_player_seek_chapter_button.dart | 79 +++ .../widgets/player_speed_adjust_button.dart | 35 ++ .../player/view/widgets/speed_selector.dart | 205 +++++++ .../sleep_timer/core/sleep_timer.dart | 89 +++ .../providers/sleep_timer_provider.dart | 19 + .../providers/sleep_timer_provider.g.dart | 24 + lib/main.dart | 40 +- lib/settings/app_settings_provider.dart | 4 +- lib/settings/app_settings_provider.g.dart | 6 +- lib/settings/models/app_settings.dart | 49 +- lib/settings/models/app_settings.freezed.dart | 538 +++++++++++++++++- lib/settings/models/app_settings.g.dart | 75 ++- pubspec.yaml | 1 + 17 files changed, 1262 insertions(+), 363 deletions(-) rename lib/features/player/providers/{audiobook_player_provider.g.dart => audiobook_player.g.dart} (56%) create mode 100644 lib/features/player/view/widgets/audiobook_player_seek_button.dart create mode 100644 lib/features/player/view/widgets/audiobook_player_seek_chapter_button.dart create mode 100644 lib/features/player/view/widgets/player_speed_adjust_button.dart create mode 100644 lib/features/player/view/widgets/speed_selector.dart create mode 100644 lib/features/sleep_timer/core/sleep_timer.dart create mode 100644 lib/features/sleep_timer/providers/sleep_timer_provider.dart create mode 100644 lib/features/sleep_timer/providers/sleep_timer_provider.g.dart diff --git a/lib/features/player/providers/audiobook_player.dart b/lib/features/player/providers/audiobook_player.dart index 2fb0627..e4de66d 100644 --- a/lib/features/player/providers/audiobook_player.dart +++ b/lib/features/player/providers/audiobook_player.dart @@ -1,8 +1,9 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:whispering_pages/api/api_provider.dart'; -import 'package:whispering_pages/features/player/core/audiobook_player.dart' as abp; +import 'package:whispering_pages/features/player/core/audiobook_player.dart' + as abp; -part 'audiobook_player_provider.g.dart'; +part 'audiobook_player.g.dart'; // @Riverpod(keepAlive: true) // abp.AudiobookPlayer audiobookPlayer( @@ -19,12 +20,23 @@ part 'audiobook_player_provider.g.dart'; const playerId = 'audiobook_player'; @Riverpod(keepAlive: true) -class AudiobookPlayer extends _$AudiobookPlayer { +class SimpleAudiobookPlayer extends _$SimpleAudiobookPlayer { @override abp.AudiobookPlayer build() { final api = ref.watch(authenticatedApiProvider); - final player = - abp.AudiobookPlayer(api.token!, api.baseUrl); + final player = abp.AudiobookPlayer(api.token!, api.baseUrl); + + ref.onDispose(player.dispose); + + return player; + } +} + +@Riverpod(keepAlive: true) +class AudiobookPlayer extends _$AudiobookPlayer { + @override + abp.AudiobookPlayer build() { + final player = ref.watch(simpleAudiobookPlayerProvider); ref.onDispose(player.dispose); @@ -40,7 +52,7 @@ class AudiobookPlayer extends _$AudiobookPlayer { ref.notifyListeners(); } -Future setSpeed(double speed) async { + Future setSpeed(double speed) async { await state.setSpeed(speed); notifyListeners(); } diff --git a/lib/features/player/providers/audiobook_player_provider.g.dart b/lib/features/player/providers/audiobook_player.g.dart similarity index 56% rename from lib/features/player/providers/audiobook_player_provider.g.dart rename to lib/features/player/providers/audiobook_player.g.dart index c74f23c..9787ee3 100644 --- a/lib/features/player/providers/audiobook_player_provider.g.dart +++ b/lib/features/player/providers/audiobook_player.g.dart @@ -6,7 +6,24 @@ part of 'audiobook_player.dart'; // RiverpodGenerator // ************************************************************************** -String _$audiobookPlayerHash() => r'a636d5e8e73dc6bbf7b3f47f83884bb3af3b9370'; +String _$simpleAudiobookPlayerHash() => + r'b65e6d779476a2c1fa38f617771bf997acb4f5b8'; + +/// See also [SimpleAudiobookPlayer]. +@ProviderFor(SimpleAudiobookPlayer) +final simpleAudiobookPlayerProvider = + NotifierProvider.internal( + SimpleAudiobookPlayer.new, + name: r'simpleAudiobookPlayerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$simpleAudiobookPlayerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SimpleAudiobookPlayer = Notifier; +String _$audiobookPlayerHash() => r'38042d0c93034e6907677fdb614a9af1b9d636af'; /// See also [AudiobookPlayer]. @ProviderFor(AudiobookPlayer) diff --git a/lib/features/player/view/player_when_expanded.dart b/lib/features/player/view/player_when_expanded.dart index 82e570e..2fb9cfe 100644 --- a/lib/features/player/view/player_when_expanded.dart +++ b/lib/features/player/view/player_when_expanded.dart @@ -1,15 +1,19 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:list_wheel_scroll_view_nls/list_wheel_scroll_view_nls.dart'; -import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:whispering_pages/constants/sizes.dart'; -import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; import 'package:whispering_pages/features/player/providers/currently_playing_provider.dart'; import 'package:whispering_pages/features/player/view/audiobook_player.dart'; -import 'package:whispering_pages/settings/app_settings_provider.dart'; +import 'package:whispering_pages/features/sleep_timer/core/sleep_timer.dart'; +import 'package:whispering_pages/features/sleep_timer/providers/sleep_timer_provider.dart'; import 'package:whispering_pages/shared/extensions/inverse_lerp.dart'; +import 'widgets/audiobook_player_seek_button.dart'; +import 'widgets/audiobook_player_seek_chapter_button.dart'; +import 'widgets/player_speed_adjust_button.dart'; + class PlayerWhenExpanded extends HookConsumerWidget { const PlayerWhenExpanded({ super.key, @@ -127,7 +131,7 @@ class PlayerWhenExpanded extends HookConsumerWidget { style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Theme.of(context) .colorScheme - .onBackground + .onSurface .withOpacity(0.7), ), maxLines: 1, @@ -193,10 +197,7 @@ class PlayerWhenExpanded extends HookConsumerWidget { // speed control const PlayerSpeedAdjustButton(), // sleep timer - IconButton( - icon: const Icon(Icons.timer), - onPressed: () {}, - ), + const SleepTimerButton(), // chapter list IconButton( icon: const Icon(Icons.menu_book_rounded), @@ -216,334 +217,85 @@ class PlayerWhenExpanded extends HookConsumerWidget { } } -class PlayerSpeedAdjustButton extends HookConsumerWidget { - const PlayerSpeedAdjustButton({ +class SleepTimerButton extends HookConsumerWidget { + const SleepTimerButton({ super.key, }); @override Widget build(BuildContext context, WidgetRef ref) { - final player = ref.watch(audiobookPlayerProvider); - final notifier = ref.watch(audiobookPlayerProvider.notifier); - return TextButton( - child: Text('${player.speed}x'), - onPressed: () { - showModalBottomSheet( - context: context, - barrierLabel: 'Select Speed', - constraints: const BoxConstraints( - maxHeight: 225, - ), - builder: (context) { - return SpeedSelector( - onSpeedSelected: (speed) { - notifier.setSpeed(speed); - }, - ); - }, - ); - }, - ); + final sleepTimer = ref.watch(sleepTimerProvider); + // if sleep timer is not active, show the button with the sleep timer icon + // if the sleep timer is active, show the remaining time in a pill shaped container + return sleepTimer == null + ? IconButton( + color: sleepTimer != null + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, + icon: const Icon(Icons.timer_rounded), + onPressed: () {}, + ) + : RemainingSleepTimeDisplay( + timer: sleepTimer, + ); } } -class SpeedSelector extends HookConsumerWidget { - const SpeedSelector({ +class RemainingSleepTimeDisplay extends HookConsumerWidget { + const RemainingSleepTimeDisplay({ super.key, - required this.onSpeedSelected, + required this.timer, }); - final void Function(double speed) onSpeedSelected; + final SleepTimer timer; @override Widget build(BuildContext context, WidgetRef ref) { - final appSettings = ref.watch(appSettingsProvider); - final speeds = appSettings.playerSettings.speedOptions; - final currentSpeed = ref.watch(audiobookPlayerProvider).speed; - final speedState = useState(currentSpeed); - - // hook the onSpeedSelected function to the state - useEffect( - () { - onSpeedSelected(speedState.value); - return null; - }, - [speedState.value], - ); - - // the speed options - const minSpeed = 0.1; - const maxSpeed = 4.0; - const speedIncrement = 0.05; - final availableSpeeds = ((maxSpeed - minSpeed) / speedIncrement).ceil(); - final availableSpeedsList = List.generate( - availableSpeeds, - (index) { - // need to round to 2 decimal place to avoid floating point errors - return double.parse( - (minSpeed + index * speedIncrement).toStringAsFixed(2), - ); - }, - ); - - final scrollController = FixedExtentScrollController( - initialItem: availableSpeedsList.indexOf(currentSpeed), - ); - const double itemExtent = 25; - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Center( - child: Text( - 'Playback Speed: ${speedState.value}x', - style: Theme.of(context).textTheme.titleLarge, - ), - ), + final remainingTime = useStream(timer.remainingTimeStream).data; + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Text( + timer.timer == null + ? timer.duration.formatSingleLargestUnit() + : remainingTime?.formatSingleLargestUnit() ?? '', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onPrimary, ), - ), - Flexible( - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // a minus button to decrease the speed - IconButton.filledTonal( - icon: const Icon(Icons.remove), - onPressed: () { - // animate to index - 1 - final index = availableSpeedsList.indexOf(speedState.value); - if (index > 0) { - scrollController.animateToItem( - index - 1, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }, - ), - Expanded( - child: ListWheelScrollViewX( - controller: scrollController, - scrollDirection: Axis.horizontal, - itemExtent: itemExtent, - diameterRatio: 1.5, squeeze: 1.2, - // useMagnifier: true, - // magnification: 1.5, - physics: const FixedExtentScrollPhysics(), - children: availableSpeedsList - .map( - (speed) => Column( - children: [ - // a vertical line - Container( - height: itemExtent * 2, - // thick if multiple of 1, thin if multiple of 0.5 and transparent if multiple of 0.05 - width: speed % 0.5 == 0 - ? 3 - : speed % 0.25 == 0 - ? 2 - : 0.5, - color: - Theme.of(context).colorScheme.onBackground, - ), - // the speed text but only at .5 increments of speed - if (speed % 0.25 == 0) - Text( - speed.toString(), - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onBackground, - ), - ), - ], - ), - ) - .toList(), - onSelectedItemChanged: (index) { - speedState.value = availableSpeedsList[index]; - // onSpeedSelected(availableSpeedsList[index]); - // call after 500ms to avoid the scrollview from scrolling to the selected speed - // Future.delayed( - // const Duration(milliseconds: 100), - // () => onSpeedSelected(availableSpeedsList[index]), - // ); - }, - ), - ), - // a plus button to increase the speed - IconButton.filledTonal( - icon: const Icon(Icons.add), - onPressed: () { - // animate to index + 1 - final index = availableSpeedsList.indexOf(speedState.value); - if (index < availableSpeedsList.length - 1) { - scrollController.animateToItem( - index + 1, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }, - ), - ], - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: speeds - .map( - (speed) => Flexible( - // the text button should be highlighted if the speed is selected - child: TextButton( - style: speed == speedState.value - ? TextButton.styleFrom( - backgroundColor: Theme.of(context) - .colorScheme - .primaryContainer, - foregroundColor: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ) - : null, - onPressed: () async { - // animate the wheel to the selected speed - var index = availableSpeedsList.indexOf(speed); - // if the speed is not in the list - if (index == -1) { - // find the nearest speed - final nearestSpeed = availableSpeedsList.firstWhere( - (element) => element > speed, - orElse: () => availableSpeedsList.last, - ); - index = availableSpeedsList.indexOf(nearestSpeed); - } - await scrollController.animateToItem( - index, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - - // call the onSpeedSelected function - speedState.value = speed; - }, - child: Text('$speed'), - ), - ), - ) - .toList(), - ), - const SizedBox( - height: 8, - ), - ], ), ); } } -class AudiobookPlayerSeekButton extends HookConsumerWidget { - const AudiobookPlayerSeekButton({ - super.key, - required this.isForward, - }); - - /// if true, the button seeks forward, else it seeks backwards - final bool isForward; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final player = ref.watch(audiobookPlayerProvider); - return IconButton( - icon: Icon( - isForward ? Icons.forward_30 : Icons.replay_30, - size: AppElementSizes.iconSizeSmall, - ), - onPressed: () { - if (isForward) { - player.seek(player.positionInBook + const Duration(seconds: 30)); - } else { - player.seek(player.positionInBook - const Duration(seconds: 30)); - } - }, - ); - } -} - -class AudiobookPlayerSeekChapterButton extends HookConsumerWidget { - const AudiobookPlayerSeekChapterButton({ - super.key, - required this.isForward, - }); - - /// if true, the button seeks forward, else it seeks backwards - final bool isForward; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final player = ref.watch(audiobookPlayerProvider); - - // add a small offset so the display does not show the previous chapter for a split second - const offset = Duration(milliseconds: 10); - - /// time into the current chapter to determine if we should go to the previous chapter or the start of the current chapter - const doNotSeekBackIfLessThan = Duration(seconds: 5); - - /// seek forward to the next chapter - void seekForward() { - final index = player.book!.chapters.indexOf(player.currentChapter!); - if (index < player.book!.chapters.length - 1) { - player.seek( - player.book!.chapters[index + 1].start + offset, - ); - } else { - player.seek(player.currentChapter!.end); - } +extension DurationFormat on Duration { + /// will return a number followed by h, m, or s depending on the duration + /// only the largest unit will be shown + String formatSingleLargestUnit() { + if (inHours > 0) { + return '${inHours}h'; + } else if (inMinutes > 0) { + return '${inMinutes}m'; + } else { + return '${inSeconds}s'; } - - /// seek backward to the previous chapter or the start of the current chapter - void seekBackward() { - final currentPlayingChapterIndex = - player.book!.chapters.indexOf(player.currentChapter!); - final chapterPosition = - player.positionInBook - player.currentChapter!.start; - BookChapter chapterToSeekTo; - // if player position is less than 5 seconds into the chapter, go to the previous chapter - if (chapterPosition < doNotSeekBackIfLessThan && - currentPlayingChapterIndex > 0) { - chapterToSeekTo = player.book!.chapters[currentPlayingChapterIndex - 1]; - } else { - chapterToSeekTo = player.currentChapter!; - } - player.seek( - chapterToSeekTo.start + offset, - ); - } - - return IconButton( - icon: Icon( - isForward ? Icons.skip_next : Icons.skip_previous, - size: AppElementSizes.iconSizeSmall, - ), - onPressed: () { - if (player.book == null) { - return; - } - // if chapter does not exist, go to the start or end of the book - if (player.currentChapter == null) { - player.seek(isForward ? player.book!.duration : Duration.zero); - return; - } - if (isForward) { - seekForward(); - } else { - seekBackward(); - } - }, - ); } } + +void useInterval(VoidCallback callback, Duration delay) { + final savedCallback = useRef(callback); + savedCallback.value = callback; + + useEffect( + () { + final timer = Timer.periodic(delay, (_) => savedCallback.value()); + return timer.cancel; + }, + [delay], + ); +} diff --git a/lib/features/player/view/widgets/audiobook_player_seek_button.dart b/lib/features/player/view/widgets/audiobook_player_seek_button.dart new file mode 100644 index 0000000..bdb7818 --- /dev/null +++ b/lib/features/player/view/widgets/audiobook_player_seek_button.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:whispering_pages/constants/sizes.dart'; +import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; + +class AudiobookPlayerSeekButton extends HookConsumerWidget { + const AudiobookPlayerSeekButton({ + super.key, + required this.isForward, + }); + + /// if true, the button seeks forward, else it seeks backwards + final bool isForward; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = ref.watch(audiobookPlayerProvider); + return IconButton( + icon: Icon( + isForward ? Icons.forward_30 : Icons.replay_30, + size: AppElementSizes.iconSizeSmall, + ), + onPressed: () { + if (isForward) { + player.seek(player.positionInBook + const Duration(seconds: 30)); + } else { + player.seek(player.positionInBook - const Duration(seconds: 30)); + } + }, + ); + } +} diff --git a/lib/features/player/view/widgets/audiobook_player_seek_chapter_button.dart b/lib/features/player/view/widgets/audiobook_player_seek_chapter_button.dart new file mode 100644 index 0000000..8f2c57a --- /dev/null +++ b/lib/features/player/view/widgets/audiobook_player_seek_chapter_button.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; +import 'package:whispering_pages/constants/sizes.dart'; +import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; + +class AudiobookPlayerSeekChapterButton extends HookConsumerWidget { + const AudiobookPlayerSeekChapterButton({ + super.key, + required this.isForward, + }); + + /// if true, the button seeks forward, else it seeks backwards + final bool isForward; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = ref.watch(audiobookPlayerProvider); + + // add a small offset so the display does not show the previous chapter for a split second + const offset = Duration(milliseconds: 10); + + /// time into the current chapter to determine if we should go to the previous chapter or the start of the current chapter + const doNotSeekBackIfLessThan = Duration(seconds: 5); + + /// seek forward to the next chapter + void seekForward() { + final index = player.book!.chapters.indexOf(player.currentChapter!); + if (index < player.book!.chapters.length - 1) { + player.seek( + player.book!.chapters[index + 1].start + offset, + ); + } else { + player.seek(player.currentChapter!.end); + } + } + + /// seek backward to the previous chapter or the start of the current chapter + void seekBackward() { + final currentPlayingChapterIndex = + player.book!.chapters.indexOf(player.currentChapter!); + final chapterPosition = + player.positionInBook - player.currentChapter!.start; + BookChapter chapterToSeekTo; + // if player position is less than 5 seconds into the chapter, go to the previous chapter + if (chapterPosition < doNotSeekBackIfLessThan && + currentPlayingChapterIndex > 0) { + chapterToSeekTo = player.book!.chapters[currentPlayingChapterIndex - 1]; + } else { + chapterToSeekTo = player.currentChapter!; + } + player.seek( + chapterToSeekTo.start + offset, + ); + } + + return IconButton( + icon: Icon( + isForward ? Icons.skip_next : Icons.skip_previous, + size: AppElementSizes.iconSizeSmall, + ), + onPressed: () { + if (player.book == null) { + return; + } + // if chapter does not exist, go to the start or end of the book + if (player.currentChapter == null) { + player.seek(isForward ? player.book!.duration : Duration.zero); + return; + } + if (isForward) { + seekForward(); + } else { + seekBackward(); + } + }, + ); + } +} diff --git a/lib/features/player/view/widgets/player_speed_adjust_button.dart b/lib/features/player/view/widgets/player_speed_adjust_button.dart new file mode 100644 index 0000000..3248967 --- /dev/null +++ b/lib/features/player/view/widgets/player_speed_adjust_button.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; +import 'package:whispering_pages/features/player/view/widgets/speed_selector.dart'; + +class PlayerSpeedAdjustButton extends HookConsumerWidget { + const PlayerSpeedAdjustButton({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = ref.watch(audiobookPlayerProvider); + final notifier = ref.watch(audiobookPlayerProvider.notifier); + return TextButton( + child: Text('${player.speed}x'), + onPressed: () { + showModalBottomSheet( + context: context, + barrierLabel: 'Select Speed', + constraints: const BoxConstraints( + maxHeight: 225, + ), + builder: (context) { + return SpeedSelector( + onSpeedSelected: (speed) { + notifier.setSpeed(speed); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/player/view/widgets/speed_selector.dart b/lib/features/player/view/widgets/speed_selector.dart new file mode 100644 index 0000000..782ed57 --- /dev/null +++ b/lib/features/player/view/widgets/speed_selector.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:list_wheel_scroll_view_nls/list_wheel_scroll_view_nls.dart'; +import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; +import 'package:whispering_pages/settings/app_settings_provider.dart'; + +class SpeedSelector extends HookConsumerWidget { + const SpeedSelector({ + super.key, + required this.onSpeedSelected, + }); + + final void Function(double speed) onSpeedSelected; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appSettings = ref.watch(appSettingsProvider); + final speeds = appSettings.playerSettings.speedOptions; + final currentSpeed = ref.watch(audiobookPlayerProvider).speed; + final speedState = useState(currentSpeed); + + // hook the onSpeedSelected function to the state + useEffect( + () { + onSpeedSelected(speedState.value); + return null; + }, + [speedState.value], + ); + + // the speed options + const minSpeed = 0.1; + const maxSpeed = 4.0; + const speedIncrement = 0.05; + final availableSpeeds = ((maxSpeed - minSpeed) / speedIncrement).ceil(); + final availableSpeedsList = List.generate( + availableSpeeds, + (index) { + // need to round to 2 decimal place to avoid floating point errors + return double.parse( + (minSpeed + index * speedIncrement).toStringAsFixed(2), + ); + }, + ); + + final scrollController = FixedExtentScrollController( + initialItem: availableSpeedsList.indexOf(currentSpeed), + ); + const double itemExtent = 25; + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: Text( + 'Playback Speed: ${speedState.value}x', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + ), + Flexible( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // a minus button to decrease the speed + IconButton.filledTonal( + icon: const Icon(Icons.remove), + onPressed: () { + // animate to index - 1 + final index = availableSpeedsList.indexOf(speedState.value); + if (index > 0) { + scrollController.animateToItem( + index - 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + ), + Expanded( + child: ListWheelScrollViewX( + controller: scrollController, + scrollDirection: Axis.horizontal, + itemExtent: itemExtent, + diameterRatio: 1.5, squeeze: 1.2, + // useMagnifier: true, + // magnification: 1.5, + physics: const FixedExtentScrollPhysics(), + children: availableSpeedsList + .map( + (speed) => Column( + children: [ + // a vertical line + Container( + height: itemExtent * 2, + // thick if multiple of 1, thin if multiple of 0.5 and transparent if multiple of 0.05 + width: speed % 0.5 == 0 + ? 3 + : speed % 0.25 == 0 + ? 2 + : 0.5, + color: + Theme.of(context).colorScheme.onBackground, + ), + // the speed text but only at .5 increments of speed + if (speed % 0.25 == 0) + Text( + speed.toString(), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onBackground, + ), + ), + ], + ), + ) + .toList(), + onSelectedItemChanged: (index) { + speedState.value = availableSpeedsList[index]; + // onSpeedSelected(availableSpeedsList[index]); + // call after 500ms to avoid the scrollview from scrolling to the selected speed + // Future.delayed( + // const Duration(milliseconds: 100), + // () => onSpeedSelected(availableSpeedsList[index]), + // ); + }, + ), + ), + // a plus button to increase the speed + IconButton.filledTonal( + icon: const Icon(Icons.add), + onPressed: () { + // animate to index + 1 + final index = availableSpeedsList.indexOf(speedState.value); + if (index < availableSpeedsList.length - 1) { + scrollController.animateToItem( + index + 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + ), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: speeds + .map( + (speed) => Flexible( + // the text button should be highlighted if the speed is selected + child: TextButton( + style: speed == speedState.value + ? TextButton.styleFrom( + backgroundColor: Theme.of(context) + .colorScheme + .primaryContainer, + foregroundColor: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ) + : null, + onPressed: () async { + // animate the wheel to the selected speed + var index = availableSpeedsList.indexOf(speed); + // if the speed is not in the list + if (index == -1) { + // find the nearest speed + final nearestSpeed = availableSpeedsList.firstWhere( + (element) => element > speed, + orElse: () => availableSpeedsList.last, + ); + index = availableSpeedsList.indexOf(nearestSpeed); + } + await scrollController.animateToItem( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + + // call the onSpeedSelected function + speedState.value = speed; + }, + child: Text('$speed'), + ), + ), + ) + .toList(), + ), + const SizedBox( + height: 8, + ), + ], + ), + ); + } +} diff --git a/lib/features/sleep_timer/core/sleep_timer.dart b/lib/features/sleep_timer/core/sleep_timer.dart new file mode 100644 index 0000000..7e85555 --- /dev/null +++ b/lib/features/sleep_timer/core/sleep_timer.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:whispering_pages/features/player/core/audiobook_player.dart'; + +/// this timer pauses the music player after a certain duration +/// +/// watches the state of the music player and pauses it when the timer is up +/// timer is cancelled when the music player is paused or stopped +class SleepTimer { + /// The duration after which the music player will be paused + final Duration duration; + + /// The player to be paused + final AudiobookPlayer player; + + /// The timer that will pause the player + Timer? timer; + + /// for internal use only + /// when the timer was started + DateTime? startedAt; + + SleepTimer({required this.duration, required this.player}) { + player.playbackEventStream.listen((event) { + if (event.processingState == ProcessingState.completed || + event.processingState == ProcessingState.idle) { + reset(); + } + }); + + /// pause the player when the timer is up + player.playerStateStream.listen((state) { + if (state.playing && timer == null) { + startTimer(); + } else if (!state.playing) { + reset(); + } + }); + debugPrint('SleepTimer created with duration: $duration'); + } + + /// resets the timer + void reset() { + if (timer != null) { + timer!.cancel(); + debugPrint( + 'SleepTimer cancelled timer, remaining time: $remainingTime, duration: $duration', + ); + timer = null; + } + } + + /// starts the timer + void startTimer() { + reset(); + timer = Timer(duration, () { + player.pause(); + reset(); + debugPrint('SleepTimer paused player after $duration'); + }); + startedAt = DateTime.now(); + debugPrint('SleepTimer started for $duration at $startedAt'); + } + + Duration get remainingTime { + if (timer == null) { + return Duration.zero; + } + final elapsed = DateTime.now().difference(startedAt!); + return duration - elapsed; + } + + /// a stream that emits the remaining time every second + Stream get remainingTimeStream async* { + while (timer != null) { + yield remainingTime; + await Future.delayed(0.5.seconds); + } + } + + /// dispose the timer + void dispose() { + reset(); + debugPrint('SleepTimer disposed'); + } +} diff --git a/lib/features/sleep_timer/providers/sleep_timer_provider.dart b/lib/features/sleep_timer/providers/sleep_timer_provider.dart new file mode 100644 index 0000000..e173334 --- /dev/null +++ b/lib/features/sleep_timer/providers/sleep_timer_provider.dart @@ -0,0 +1,19 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; +import 'package:whispering_pages/features/sleep_timer/core/sleep_timer.dart'; +import 'package:whispering_pages/settings/app_settings_provider.dart'; + +part 'sleep_timer_provider.g.dart'; + +@Riverpod(keepAlive: true) +SleepTimer? sleepTimer(SleepTimerRef ref) { + final appSettings = ref.watch(appSettingsProvider); + final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings; + var sleepTimer = SleepTimer( + // duration: sleepTimerSettings.defaultDuration, + duration: const Duration(seconds: 5), + player: ref.watch(simpleAudiobookPlayerProvider), + ); + ref.onDispose(sleepTimer.dispose); + return sleepTimer; +} diff --git a/lib/features/sleep_timer/providers/sleep_timer_provider.g.dart b/lib/features/sleep_timer/providers/sleep_timer_provider.g.dart new file mode 100644 index 0000000..a08cede --- /dev/null +++ b/lib/features/sleep_timer/providers/sleep_timer_provider.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sleep_timer_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$sleepTimerHash() => r'79646b12412f3300166db29328664a5e58e405bd'; + +/// See also [sleepTimer]. +@ProviderFor(sleepTimer) +final sleepTimerProvider = Provider.internal( + sleepTimer, + name: r'sleepTimerProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$sleepTimerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef SleepTimerRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/main.dart b/lib/main.dart index 97bcec7..444d17d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,8 @@ import 'package:just_audio_media_kit/just_audio_media_kit.dart' show JustAudioMediaKit; import 'package:whispering_pages/api/server_provider.dart'; import 'package:whispering_pages/db/storage.dart'; +import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; +import 'package:whispering_pages/features/sleep_timer/providers/sleep_timer_provider.dart'; import 'package:whispering_pages/router/router.dart'; import 'package:whispering_pages/settings/api_settings_provider.dart'; import 'package:whispering_pages/settings/app_settings_provider.dart'; @@ -56,14 +58,36 @@ class MyApp extends ConsumerWidget { routerConfig.goNamed(Routes.onboarding.name); } - return MaterialApp.router( - // debugShowCheckedModeBanner: false, - theme: lightTheme, - darkTheme: darkTheme, - themeMode: ref.watch(appSettingsProvider).isDarkMode - ? ThemeMode.dark - : ThemeMode.light, - routerConfig: routerConfig, + return _EagerInitialization( + child: MaterialApp.router( + // debugShowCheckedModeBanner: false, + theme: lightTheme, + darkTheme: darkTheme, + themeMode: ref.watch(appSettingsProvider).isDarkMode + ? ThemeMode.dark + : ThemeMode.light, + routerConfig: routerConfig, + ), ); } } + +// https://riverpod.dev/docs/essentials/eager_initialization +// Eagerly initialize providers by watching them. +class _EagerInitialization extends ConsumerWidget { + const _EagerInitialization({required this.child}); + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Eagerly initialize providers by watching them. + // By using "watch", the provider will stay alive and not be disposed. + try { + ref.watch(simpleAudiobookPlayerProvider); + ref.watch(sleepTimerProvider); + } catch (e) { + debugPrintStack(stackTrace: StackTrace.current, label: e.toString()); + } + return child; + } +} diff --git a/lib/settings/app_settings_provider.dart b/lib/settings/app_settings_provider.dart index ccfe9df..cdcf8bd 100644 --- a/lib/settings/app_settings_provider.dart +++ b/lib/settings/app_settings_provider.dart @@ -2,14 +2,14 @@ import 'package:flutter/material.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:whispering_pages/settings/models/app_settings.dart' as model; import 'package:whispering_pages/db/available_boxes.dart'; +import 'package:whispering_pages/settings/models/app_settings.dart' as model; part 'app_settings_provider.g.dart'; final _box = AvailableHiveBoxes.userPrefsBox; -@riverpod +@Riverpod(keepAlive: true) class AppSettings extends _$AppSettings { @override model.AppSettings build() { diff --git a/lib/settings/app_settings_provider.g.dart b/lib/settings/app_settings_provider.g.dart index b8de14e..e903f2e 100644 --- a/lib/settings/app_settings_provider.g.dart +++ b/lib/settings/app_settings_provider.g.dart @@ -6,12 +6,12 @@ part of 'app_settings_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$appSettingsHash() => r'da2cd1bb0da6e136e906bc61f29da89d0c5f53fb'; +String _$appSettingsHash() => r'6716bc568850ffd373fd8572c5781beefafbb9ee'; /// See also [AppSettings]. @ProviderFor(AppSettings) final appSettingsProvider = - AutoDisposeNotifierProvider.internal( + NotifierProvider.internal( AppSettings.new, name: r'appSettingsProvider', debugGetCreateSourceHash: @@ -20,6 +20,6 @@ final appSettingsProvider = allTransitiveDependencies: null, ); -typedef _$AppSettings = AutoDisposeNotifier; +typedef _$AppSettings = Notifier; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/settings/models/app_settings.dart b/lib/settings/models/app_settings.dart index 84d9141..48464e4 100644 --- a/lib/settings/models/app_settings.dart +++ b/lib/settings/models/app_settings.dart @@ -30,7 +30,7 @@ class PlayerSettings with _$PlayerSettings { @Default(1) double preferredDefaultVolume, @Default(1) double preferredDefaultSpeed, @Default([0.75, 1, 1.25, 1.5, 1.75, 2]) List speedOptions, - @Default(Duration(minutes: 15)) Duration sleepTimer, + @Default(SleepTimerSettings()) SleepTimerSettings sleepTimerSettings, }) = _PlayerSettings; factory PlayerSettings.fromJson(Map json) => @@ -57,3 +57,50 @@ class MinimizedPlayerSettings with _$MinimizedPlayerSettings { factory MinimizedPlayerSettings.fromJson(Map json) => _$MinimizedPlayerSettingsFromJson(json); } + +enum SleepTimerShakeSenseMode { never, always, nearEnds } + +@freezed +class SleepTimerSettings with _$SleepTimerSettings { + const factory SleepTimerSettings({ + @Default(Duration(minutes: 15)) Duration defaultDuration, + @Default(SleepTimerShakeSenseMode.always) + SleepTimerShakeSenseMode shakeSenseMode, + + /// the duration in which the shake is detected before the end of the timer and after the timer ends + /// only used if [shakeSenseMode] is [SleepTimerShakeSenseMode.nearEnds] + @Default(Duration(seconds: 30)) Duration shakeSenseDuration, + @Default(true) bool vibrateWhenReset, + @Default(false) bool beepWhenReset, + @Default(false) bool fadeOutAudio, + @Default(0.5) double shakeDetectThreshold, + + /// if true, the player will automatically rewind the audio when the sleep timer is stopped + @Default(false) bool autoRewindWhenStopped, + + /// the key is the duration in minutes + @Default({ + 5: Duration(seconds: 10), + 15: Duration(seconds: 30), + 45: Duration(seconds: 45), + 60: Duration(minutes: 1), + 120: Duration(minutes: 2), + }) + Map autoRewindDurations, + + /// auto turn on timer settings + @Default(false) bool autoTurnOnTimer, + + /// always auto turn on timer settings or during specific times + @Default(true) bool alwaysAutoTurnOnTimer, + + /// auto timer settings, only used if [alwaysAutoTurnOnTimer] is false + /// + /// duration is the time from 00:00 + @Default(Duration(hours: 22, minutes: 0)) Duration autoTurnOnTime, + @Default(Duration(hours: 6, minutes: 0)) Duration autoTurnOffTime, + }) = _SleepTimerSettings; + + factory SleepTimerSettings.fromJson(Map json) => + _$SleepTimerSettingsFromJson(json); +} diff --git a/lib/settings/models/app_settings.freezed.dart b/lib/settings/models/app_settings.freezed.dart index 6643f4c..6f352f8 100644 --- a/lib/settings/models/app_settings.freezed.dart +++ b/lib/settings/models/app_settings.freezed.dart @@ -229,7 +229,8 @@ mixin _$PlayerSettings { double get preferredDefaultVolume => throw _privateConstructorUsedError; double get preferredDefaultSpeed => throw _privateConstructorUsedError; List get speedOptions => throw _privateConstructorUsedError; - Duration get sleepTimer => throw _privateConstructorUsedError; + SleepTimerSettings get sleepTimerSettings => + throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -249,10 +250,11 @@ abstract class $PlayerSettingsCopyWith<$Res> { double preferredDefaultVolume, double preferredDefaultSpeed, List speedOptions, - Duration sleepTimer}); + SleepTimerSettings sleepTimerSettings}); $MinimizedPlayerSettingsCopyWith<$Res> get miniPlayerSettings; $ExpandedPlayerSettingsCopyWith<$Res> get expandedPlayerSettings; + $SleepTimerSettingsCopyWith<$Res> get sleepTimerSettings; } /// @nodoc @@ -273,7 +275,7 @@ class _$PlayerSettingsCopyWithImpl<$Res, $Val extends PlayerSettings> Object? preferredDefaultVolume = null, Object? preferredDefaultSpeed = null, Object? speedOptions = null, - Object? sleepTimer = null, + Object? sleepTimerSettings = null, }) { return _then(_value.copyWith( miniPlayerSettings: null == miniPlayerSettings @@ -296,10 +298,10 @@ class _$PlayerSettingsCopyWithImpl<$Res, $Val extends PlayerSettings> ? _value.speedOptions : speedOptions // ignore: cast_nullable_to_non_nullable as List, - sleepTimer: null == sleepTimer - ? _value.sleepTimer - : sleepTimer // ignore: cast_nullable_to_non_nullable - as Duration, + sleepTimerSettings: null == sleepTimerSettings + ? _value.sleepTimerSettings + : sleepTimerSettings // ignore: cast_nullable_to_non_nullable + as SleepTimerSettings, ) as $Val); } @@ -320,6 +322,15 @@ class _$PlayerSettingsCopyWithImpl<$Res, $Val extends PlayerSettings> return _then(_value.copyWith(expandedPlayerSettings: value) as $Val); }); } + + @override + @pragma('vm:prefer-inline') + $SleepTimerSettingsCopyWith<$Res> get sleepTimerSettings { + return $SleepTimerSettingsCopyWith<$Res>(_value.sleepTimerSettings, + (value) { + return _then(_value.copyWith(sleepTimerSettings: value) as $Val); + }); + } } /// @nodoc @@ -336,12 +347,14 @@ abstract class _$$PlayerSettingsImplCopyWith<$Res> double preferredDefaultVolume, double preferredDefaultSpeed, List speedOptions, - Duration sleepTimer}); + SleepTimerSettings sleepTimerSettings}); @override $MinimizedPlayerSettingsCopyWith<$Res> get miniPlayerSettings; @override $ExpandedPlayerSettingsCopyWith<$Res> get expandedPlayerSettings; + @override + $SleepTimerSettingsCopyWith<$Res> get sleepTimerSettings; } /// @nodoc @@ -360,7 +373,7 @@ class __$$PlayerSettingsImplCopyWithImpl<$Res> Object? preferredDefaultVolume = null, Object? preferredDefaultSpeed = null, Object? speedOptions = null, - Object? sleepTimer = null, + Object? sleepTimerSettings = null, }) { return _then(_$PlayerSettingsImpl( miniPlayerSettings: null == miniPlayerSettings @@ -383,10 +396,10 @@ class __$$PlayerSettingsImplCopyWithImpl<$Res> ? _value._speedOptions : speedOptions // ignore: cast_nullable_to_non_nullable as List, - sleepTimer: null == sleepTimer - ? _value.sleepTimer - : sleepTimer // ignore: cast_nullable_to_non_nullable - as Duration, + sleepTimerSettings: null == sleepTimerSettings + ? _value.sleepTimerSettings + : sleepTimerSettings // ignore: cast_nullable_to_non_nullable + as SleepTimerSettings, )); } } @@ -400,7 +413,7 @@ class _$PlayerSettingsImpl implements _PlayerSettings { this.preferredDefaultVolume = 1, this.preferredDefaultSpeed = 1, final List speedOptions = const [0.75, 1, 1.25, 1.5, 1.75, 2], - this.sleepTimer = const Duration(minutes: 15)}) + this.sleepTimerSettings = const SleepTimerSettings()}) : _speedOptions = speedOptions; factory _$PlayerSettingsImpl.fromJson(Map json) => @@ -429,11 +442,11 @@ class _$PlayerSettingsImpl implements _PlayerSettings { @override @JsonKey() - final Duration sleepTimer; + final SleepTimerSettings sleepTimerSettings; @override String toString() { - return 'PlayerSettings(miniPlayerSettings: $miniPlayerSettings, expandedPlayerSettings: $expandedPlayerSettings, preferredDefaultVolume: $preferredDefaultVolume, preferredDefaultSpeed: $preferredDefaultSpeed, speedOptions: $speedOptions, sleepTimer: $sleepTimer)'; + return 'PlayerSettings(miniPlayerSettings: $miniPlayerSettings, expandedPlayerSettings: $expandedPlayerSettings, preferredDefaultVolume: $preferredDefaultVolume, preferredDefaultSpeed: $preferredDefaultSpeed, speedOptions: $speedOptions, sleepTimerSettings: $sleepTimerSettings)'; } @override @@ -451,8 +464,8 @@ class _$PlayerSettingsImpl implements _PlayerSettings { other.preferredDefaultSpeed == preferredDefaultSpeed) && const DeepCollectionEquality() .equals(other._speedOptions, _speedOptions) && - (identical(other.sleepTimer, sleepTimer) || - other.sleepTimer == sleepTimer)); + (identical(other.sleepTimerSettings, sleepTimerSettings) || + other.sleepTimerSettings == sleepTimerSettings)); } @JsonKey(ignore: true) @@ -464,7 +477,7 @@ class _$PlayerSettingsImpl implements _PlayerSettings { preferredDefaultVolume, preferredDefaultSpeed, const DeepCollectionEquality().hash(_speedOptions), - sleepTimer); + sleepTimerSettings); @JsonKey(ignore: true) @override @@ -488,7 +501,7 @@ abstract class _PlayerSettings implements PlayerSettings { final double preferredDefaultVolume, final double preferredDefaultSpeed, final List speedOptions, - final Duration sleepTimer}) = _$PlayerSettingsImpl; + final SleepTimerSettings sleepTimerSettings}) = _$PlayerSettingsImpl; factory _PlayerSettings.fromJson(Map json) = _$PlayerSettingsImpl.fromJson; @@ -504,7 +517,7 @@ abstract class _PlayerSettings implements PlayerSettings { @override List get speedOptions; @override - Duration get sleepTimer; + SleepTimerSettings get sleepTimerSettings; @override @JsonKey(ignore: true) _$$PlayerSettingsImplCopyWith<_$PlayerSettingsImpl> get copyWith => @@ -821,3 +834,486 @@ abstract class _MinimizedPlayerSettings implements MinimizedPlayerSettings { _$$MinimizedPlayerSettingsImplCopyWith<_$MinimizedPlayerSettingsImpl> get copyWith => throw _privateConstructorUsedError; } + +SleepTimerSettings _$SleepTimerSettingsFromJson(Map json) { + return _SleepTimerSettings.fromJson(json); +} + +/// @nodoc +mixin _$SleepTimerSettings { + Duration get defaultDuration => throw _privateConstructorUsedError; + SleepTimerShakeSenseMode get shakeSenseMode => + throw _privateConstructorUsedError; + + /// the duration in which the shake is detected before the end of the timer and after the timer ends + /// only used if [shakeSenseMode] is [SleepTimerShakeSenseMode.nearEnds] + Duration get shakeSenseDuration => throw _privateConstructorUsedError; + bool get vibrateWhenReset => throw _privateConstructorUsedError; + bool get beepWhenReset => throw _privateConstructorUsedError; + bool get fadeOutAudio => throw _privateConstructorUsedError; + double get shakeDetectThreshold => throw _privateConstructorUsedError; + + /// if true, the player will automatically rewind the audio when the sleep timer is stopped + bool get autoRewindWhenStopped => throw _privateConstructorUsedError; + + /// the key is the duration in minutes + Map get autoRewindDurations => + throw _privateConstructorUsedError; + + /// auto turn on timer settings + bool get autoTurnOnTimer => throw _privateConstructorUsedError; + + /// always auto turn on timer settings or during specific times + bool get alwaysAutoTurnOnTimer => throw _privateConstructorUsedError; + + /// auto timer settings, only used if [alwaysAutoTurnOnTimer] is false + /// + /// duration is the time from 00:00 + Duration get autoTurnOnTime => throw _privateConstructorUsedError; + Duration get autoTurnOffTime => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $SleepTimerSettingsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SleepTimerSettingsCopyWith<$Res> { + factory $SleepTimerSettingsCopyWith( + SleepTimerSettings value, $Res Function(SleepTimerSettings) then) = + _$SleepTimerSettingsCopyWithImpl<$Res, SleepTimerSettings>; + @useResult + $Res call( + {Duration defaultDuration, + SleepTimerShakeSenseMode shakeSenseMode, + Duration shakeSenseDuration, + bool vibrateWhenReset, + bool beepWhenReset, + bool fadeOutAudio, + double shakeDetectThreshold, + bool autoRewindWhenStopped, + Map autoRewindDurations, + bool autoTurnOnTimer, + bool alwaysAutoTurnOnTimer, + Duration autoTurnOnTime, + Duration autoTurnOffTime}); +} + +/// @nodoc +class _$SleepTimerSettingsCopyWithImpl<$Res, $Val extends SleepTimerSettings> + implements $SleepTimerSettingsCopyWith<$Res> { + _$SleepTimerSettingsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? defaultDuration = null, + Object? shakeSenseMode = null, + Object? shakeSenseDuration = null, + Object? vibrateWhenReset = null, + Object? beepWhenReset = null, + Object? fadeOutAudio = null, + Object? shakeDetectThreshold = null, + Object? autoRewindWhenStopped = null, + Object? autoRewindDurations = null, + Object? autoTurnOnTimer = null, + Object? alwaysAutoTurnOnTimer = null, + Object? autoTurnOnTime = null, + Object? autoTurnOffTime = null, + }) { + return _then(_value.copyWith( + defaultDuration: null == defaultDuration + ? _value.defaultDuration + : defaultDuration // ignore: cast_nullable_to_non_nullable + as Duration, + shakeSenseMode: null == shakeSenseMode + ? _value.shakeSenseMode + : shakeSenseMode // ignore: cast_nullable_to_non_nullable + as SleepTimerShakeSenseMode, + shakeSenseDuration: null == shakeSenseDuration + ? _value.shakeSenseDuration + : shakeSenseDuration // ignore: cast_nullable_to_non_nullable + as Duration, + vibrateWhenReset: null == vibrateWhenReset + ? _value.vibrateWhenReset + : vibrateWhenReset // ignore: cast_nullable_to_non_nullable + as bool, + beepWhenReset: null == beepWhenReset + ? _value.beepWhenReset + : beepWhenReset // ignore: cast_nullable_to_non_nullable + as bool, + fadeOutAudio: null == fadeOutAudio + ? _value.fadeOutAudio + : fadeOutAudio // ignore: cast_nullable_to_non_nullable + as bool, + shakeDetectThreshold: null == shakeDetectThreshold + ? _value.shakeDetectThreshold + : shakeDetectThreshold // ignore: cast_nullable_to_non_nullable + as double, + autoRewindWhenStopped: null == autoRewindWhenStopped + ? _value.autoRewindWhenStopped + : autoRewindWhenStopped // ignore: cast_nullable_to_non_nullable + as bool, + autoRewindDurations: null == autoRewindDurations + ? _value.autoRewindDurations + : autoRewindDurations // ignore: cast_nullable_to_non_nullable + as Map, + autoTurnOnTimer: null == autoTurnOnTimer + ? _value.autoTurnOnTimer + : autoTurnOnTimer // ignore: cast_nullable_to_non_nullable + as bool, + alwaysAutoTurnOnTimer: null == alwaysAutoTurnOnTimer + ? _value.alwaysAutoTurnOnTimer + : alwaysAutoTurnOnTimer // ignore: cast_nullable_to_non_nullable + as bool, + autoTurnOnTime: null == autoTurnOnTime + ? _value.autoTurnOnTime + : autoTurnOnTime // ignore: cast_nullable_to_non_nullable + as Duration, + autoTurnOffTime: null == autoTurnOffTime + ? _value.autoTurnOffTime + : autoTurnOffTime // ignore: cast_nullable_to_non_nullable + as Duration, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SleepTimerSettingsImplCopyWith<$Res> + implements $SleepTimerSettingsCopyWith<$Res> { + factory _$$SleepTimerSettingsImplCopyWith(_$SleepTimerSettingsImpl value, + $Res Function(_$SleepTimerSettingsImpl) then) = + __$$SleepTimerSettingsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {Duration defaultDuration, + SleepTimerShakeSenseMode shakeSenseMode, + Duration shakeSenseDuration, + bool vibrateWhenReset, + bool beepWhenReset, + bool fadeOutAudio, + double shakeDetectThreshold, + bool autoRewindWhenStopped, + Map autoRewindDurations, + bool autoTurnOnTimer, + bool alwaysAutoTurnOnTimer, + Duration autoTurnOnTime, + Duration autoTurnOffTime}); +} + +/// @nodoc +class __$$SleepTimerSettingsImplCopyWithImpl<$Res> + extends _$SleepTimerSettingsCopyWithImpl<$Res, _$SleepTimerSettingsImpl> + implements _$$SleepTimerSettingsImplCopyWith<$Res> { + __$$SleepTimerSettingsImplCopyWithImpl(_$SleepTimerSettingsImpl _value, + $Res Function(_$SleepTimerSettingsImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? defaultDuration = null, + Object? shakeSenseMode = null, + Object? shakeSenseDuration = null, + Object? vibrateWhenReset = null, + Object? beepWhenReset = null, + Object? fadeOutAudio = null, + Object? shakeDetectThreshold = null, + Object? autoRewindWhenStopped = null, + Object? autoRewindDurations = null, + Object? autoTurnOnTimer = null, + Object? alwaysAutoTurnOnTimer = null, + Object? autoTurnOnTime = null, + Object? autoTurnOffTime = null, + }) { + return _then(_$SleepTimerSettingsImpl( + defaultDuration: null == defaultDuration + ? _value.defaultDuration + : defaultDuration // ignore: cast_nullable_to_non_nullable + as Duration, + shakeSenseMode: null == shakeSenseMode + ? _value.shakeSenseMode + : shakeSenseMode // ignore: cast_nullable_to_non_nullable + as SleepTimerShakeSenseMode, + shakeSenseDuration: null == shakeSenseDuration + ? _value.shakeSenseDuration + : shakeSenseDuration // ignore: cast_nullable_to_non_nullable + as Duration, + vibrateWhenReset: null == vibrateWhenReset + ? _value.vibrateWhenReset + : vibrateWhenReset // ignore: cast_nullable_to_non_nullable + as bool, + beepWhenReset: null == beepWhenReset + ? _value.beepWhenReset + : beepWhenReset // ignore: cast_nullable_to_non_nullable + as bool, + fadeOutAudio: null == fadeOutAudio + ? _value.fadeOutAudio + : fadeOutAudio // ignore: cast_nullable_to_non_nullable + as bool, + shakeDetectThreshold: null == shakeDetectThreshold + ? _value.shakeDetectThreshold + : shakeDetectThreshold // ignore: cast_nullable_to_non_nullable + as double, + autoRewindWhenStopped: null == autoRewindWhenStopped + ? _value.autoRewindWhenStopped + : autoRewindWhenStopped // ignore: cast_nullable_to_non_nullable + as bool, + autoRewindDurations: null == autoRewindDurations + ? _value._autoRewindDurations + : autoRewindDurations // ignore: cast_nullable_to_non_nullable + as Map, + autoTurnOnTimer: null == autoTurnOnTimer + ? _value.autoTurnOnTimer + : autoTurnOnTimer // ignore: cast_nullable_to_non_nullable + as bool, + alwaysAutoTurnOnTimer: null == alwaysAutoTurnOnTimer + ? _value.alwaysAutoTurnOnTimer + : alwaysAutoTurnOnTimer // ignore: cast_nullable_to_non_nullable + as bool, + autoTurnOnTime: null == autoTurnOnTime + ? _value.autoTurnOnTime + : autoTurnOnTime // ignore: cast_nullable_to_non_nullable + as Duration, + autoTurnOffTime: null == autoTurnOffTime + ? _value.autoTurnOffTime + : autoTurnOffTime // ignore: cast_nullable_to_non_nullable + as Duration, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SleepTimerSettingsImpl implements _SleepTimerSettings { + const _$SleepTimerSettingsImpl( + {this.defaultDuration = const Duration(minutes: 15), + this.shakeSenseMode = SleepTimerShakeSenseMode.always, + this.shakeSenseDuration = const Duration(seconds: 30), + this.vibrateWhenReset = true, + this.beepWhenReset = false, + this.fadeOutAudio = false, + this.shakeDetectThreshold = 0.5, + this.autoRewindWhenStopped = false, + final Map autoRewindDurations = const { + 5: Duration(seconds: 10), + 15: Duration(seconds: 30), + 45: Duration(seconds: 45), + 60: Duration(minutes: 1), + 120: Duration(minutes: 2) + }, + this.autoTurnOnTimer = false, + this.alwaysAutoTurnOnTimer = true, + this.autoTurnOnTime = const Duration(hours: 22, minutes: 0), + this.autoTurnOffTime = const Duration(hours: 6, minutes: 0)}) + : _autoRewindDurations = autoRewindDurations; + + factory _$SleepTimerSettingsImpl.fromJson(Map json) => + _$$SleepTimerSettingsImplFromJson(json); + + @override + @JsonKey() + final Duration defaultDuration; + @override + @JsonKey() + final SleepTimerShakeSenseMode shakeSenseMode; + + /// the duration in which the shake is detected before the end of the timer and after the timer ends + /// only used if [shakeSenseMode] is [SleepTimerShakeSenseMode.nearEnds] + @override + @JsonKey() + final Duration shakeSenseDuration; + @override + @JsonKey() + final bool vibrateWhenReset; + @override + @JsonKey() + final bool beepWhenReset; + @override + @JsonKey() + final bool fadeOutAudio; + @override + @JsonKey() + final double shakeDetectThreshold; + + /// if true, the player will automatically rewind the audio when the sleep timer is stopped + @override + @JsonKey() + final bool autoRewindWhenStopped; + + /// the key is the duration in minutes + final Map _autoRewindDurations; + + /// the key is the duration in minutes + @override + @JsonKey() + Map get autoRewindDurations { + if (_autoRewindDurations is EqualUnmodifiableMapView) + return _autoRewindDurations; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_autoRewindDurations); + } + + /// auto turn on timer settings + @override + @JsonKey() + final bool autoTurnOnTimer; + + /// always auto turn on timer settings or during specific times + @override + @JsonKey() + final bool alwaysAutoTurnOnTimer; + + /// auto timer settings, only used if [alwaysAutoTurnOnTimer] is false + /// + /// duration is the time from 00:00 + @override + @JsonKey() + final Duration autoTurnOnTime; + @override + @JsonKey() + final Duration autoTurnOffTime; + + @override + String toString() { + return 'SleepTimerSettings(defaultDuration: $defaultDuration, shakeSenseMode: $shakeSenseMode, shakeSenseDuration: $shakeSenseDuration, vibrateWhenReset: $vibrateWhenReset, beepWhenReset: $beepWhenReset, fadeOutAudio: $fadeOutAudio, shakeDetectThreshold: $shakeDetectThreshold, autoRewindWhenStopped: $autoRewindWhenStopped, autoRewindDurations: $autoRewindDurations, autoTurnOnTimer: $autoTurnOnTimer, alwaysAutoTurnOnTimer: $alwaysAutoTurnOnTimer, autoTurnOnTime: $autoTurnOnTime, autoTurnOffTime: $autoTurnOffTime)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SleepTimerSettingsImpl && + (identical(other.defaultDuration, defaultDuration) || + other.defaultDuration == defaultDuration) && + (identical(other.shakeSenseMode, shakeSenseMode) || + other.shakeSenseMode == shakeSenseMode) && + (identical(other.shakeSenseDuration, shakeSenseDuration) || + other.shakeSenseDuration == shakeSenseDuration) && + (identical(other.vibrateWhenReset, vibrateWhenReset) || + other.vibrateWhenReset == vibrateWhenReset) && + (identical(other.beepWhenReset, beepWhenReset) || + other.beepWhenReset == beepWhenReset) && + (identical(other.fadeOutAudio, fadeOutAudio) || + other.fadeOutAudio == fadeOutAudio) && + (identical(other.shakeDetectThreshold, shakeDetectThreshold) || + other.shakeDetectThreshold == shakeDetectThreshold) && + (identical(other.autoRewindWhenStopped, autoRewindWhenStopped) || + other.autoRewindWhenStopped == autoRewindWhenStopped) && + const DeepCollectionEquality() + .equals(other._autoRewindDurations, _autoRewindDurations) && + (identical(other.autoTurnOnTimer, autoTurnOnTimer) || + other.autoTurnOnTimer == autoTurnOnTimer) && + (identical(other.alwaysAutoTurnOnTimer, alwaysAutoTurnOnTimer) || + other.alwaysAutoTurnOnTimer == alwaysAutoTurnOnTimer) && + (identical(other.autoTurnOnTime, autoTurnOnTime) || + other.autoTurnOnTime == autoTurnOnTime) && + (identical(other.autoTurnOffTime, autoTurnOffTime) || + other.autoTurnOffTime == autoTurnOffTime)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, + defaultDuration, + shakeSenseMode, + shakeSenseDuration, + vibrateWhenReset, + beepWhenReset, + fadeOutAudio, + shakeDetectThreshold, + autoRewindWhenStopped, + const DeepCollectionEquality().hash(_autoRewindDurations), + autoTurnOnTimer, + alwaysAutoTurnOnTimer, + autoTurnOnTime, + autoTurnOffTime); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SleepTimerSettingsImplCopyWith<_$SleepTimerSettingsImpl> get copyWith => + __$$SleepTimerSettingsImplCopyWithImpl<_$SleepTimerSettingsImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$SleepTimerSettingsImplToJson( + this, + ); + } +} + +abstract class _SleepTimerSettings implements SleepTimerSettings { + const factory _SleepTimerSettings( + {final Duration defaultDuration, + final SleepTimerShakeSenseMode shakeSenseMode, + final Duration shakeSenseDuration, + final bool vibrateWhenReset, + final bool beepWhenReset, + final bool fadeOutAudio, + final double shakeDetectThreshold, + final bool autoRewindWhenStopped, + final Map autoRewindDurations, + final bool autoTurnOnTimer, + final bool alwaysAutoTurnOnTimer, + final Duration autoTurnOnTime, + final Duration autoTurnOffTime}) = _$SleepTimerSettingsImpl; + + factory _SleepTimerSettings.fromJson(Map json) = + _$SleepTimerSettingsImpl.fromJson; + + @override + Duration get defaultDuration; + @override + SleepTimerShakeSenseMode get shakeSenseMode; + @override + + /// the duration in which the shake is detected before the end of the timer and after the timer ends + /// only used if [shakeSenseMode] is [SleepTimerShakeSenseMode.nearEnds] + Duration get shakeSenseDuration; + @override + bool get vibrateWhenReset; + @override + bool get beepWhenReset; + @override + bool get fadeOutAudio; + @override + double get shakeDetectThreshold; + @override + + /// if true, the player will automatically rewind the audio when the sleep timer is stopped + bool get autoRewindWhenStopped; + @override + + /// the key is the duration in minutes + Map get autoRewindDurations; + @override + + /// auto turn on timer settings + bool get autoTurnOnTimer; + @override + + /// always auto turn on timer settings or during specific times + bool get alwaysAutoTurnOnTimer; + @override + + /// auto timer settings, only used if [alwaysAutoTurnOnTimer] is false + /// + /// duration is the time from 00:00 + Duration get autoTurnOnTime; + @override + Duration get autoTurnOffTime; + @override + @JsonKey(ignore: true) + _$$SleepTimerSettingsImplCopyWith<_$SleepTimerSettingsImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/settings/models/app_settings.g.dart b/lib/settings/models/app_settings.g.dart index 764d207..55cf0e2 100644 --- a/lib/settings/models/app_settings.g.dart +++ b/lib/settings/models/app_settings.g.dart @@ -42,9 +42,10 @@ _$PlayerSettingsImpl _$$PlayerSettingsImplFromJson(Map json) => ?.map((e) => (e as num).toDouble()) .toList() ?? const [0.75, 1, 1.25, 1.5, 1.75, 2], - sleepTimer: json['sleepTimer'] == null - ? const Duration(minutes: 15) - : Duration(microseconds: (json['sleepTimer'] as num).toInt()), + sleepTimerSettings: json['sleepTimerSettings'] == null + ? const SleepTimerSettings() + : SleepTimerSettings.fromJson( + json['sleepTimerSettings'] as Map), ); Map _$$PlayerSettingsImplToJson( @@ -55,7 +56,7 @@ Map _$$PlayerSettingsImplToJson( 'preferredDefaultVolume': instance.preferredDefaultVolume, 'preferredDefaultSpeed': instance.preferredDefaultSpeed, 'speedOptions': instance.speedOptions, - 'sleepTimer': instance.sleepTimer.inMicroseconds, + 'sleepTimerSettings': instance.sleepTimerSettings, }; _$ExpandedPlayerSettingsImpl _$$ExpandedPlayerSettingsImplFromJson( @@ -83,3 +84,69 @@ Map _$$MinimizedPlayerSettingsImplToJson( { 'useChapterInfo': instance.useChapterInfo, }; + +_$SleepTimerSettingsImpl _$$SleepTimerSettingsImplFromJson( + Map json) => + _$SleepTimerSettingsImpl( + defaultDuration: json['defaultDuration'] == null + ? const Duration(minutes: 15) + : Duration(microseconds: (json['defaultDuration'] as num).toInt()), + shakeSenseMode: $enumDecodeNullable( + _$SleepTimerShakeSenseModeEnumMap, json['shakeSenseMode']) ?? + SleepTimerShakeSenseMode.always, + shakeSenseDuration: json['shakeSenseDuration'] == null + ? const Duration(seconds: 30) + : Duration(microseconds: (json['shakeSenseDuration'] as num).toInt()), + vibrateWhenReset: json['vibrateWhenReset'] as bool? ?? true, + beepWhenReset: json['beepWhenReset'] as bool? ?? false, + fadeOutAudio: json['fadeOutAudio'] as bool? ?? false, + shakeDetectThreshold: + (json['shakeDetectThreshold'] as num?)?.toDouble() ?? 0.5, + autoRewindWhenStopped: json['autoRewindWhenStopped'] as bool? ?? false, + autoRewindDurations: + (json['autoRewindDurations'] as Map?)?.map( + (k, e) => MapEntry( + int.parse(k), Duration(microseconds: (e as num).toInt())), + ) ?? + const { + 5: Duration(seconds: 10), + 15: Duration(seconds: 30), + 45: Duration(seconds: 45), + 60: Duration(minutes: 1), + 120: Duration(minutes: 2) + }, + autoTurnOnTimer: json['autoTurnOnTimer'] as bool? ?? false, + alwaysAutoTurnOnTimer: json['alwaysAutoTurnOnTimer'] as bool? ?? true, + autoTurnOnTime: json['autoTurnOnTime'] == null + ? const Duration(hours: 22, minutes: 0) + : Duration(microseconds: (json['autoTurnOnTime'] as num).toInt()), + autoTurnOffTime: json['autoTurnOffTime'] == null + ? const Duration(hours: 6, minutes: 0) + : Duration(microseconds: (json['autoTurnOffTime'] as num).toInt()), + ); + +Map _$$SleepTimerSettingsImplToJson( + _$SleepTimerSettingsImpl instance) => + { + 'defaultDuration': instance.defaultDuration.inMicroseconds, + 'shakeSenseMode': + _$SleepTimerShakeSenseModeEnumMap[instance.shakeSenseMode]!, + 'shakeSenseDuration': instance.shakeSenseDuration.inMicroseconds, + 'vibrateWhenReset': instance.vibrateWhenReset, + 'beepWhenReset': instance.beepWhenReset, + 'fadeOutAudio': instance.fadeOutAudio, + 'shakeDetectThreshold': instance.shakeDetectThreshold, + 'autoRewindWhenStopped': instance.autoRewindWhenStopped, + 'autoRewindDurations': instance.autoRewindDurations + .map((k, e) => MapEntry(k.toString(), e.inMicroseconds)), + 'autoTurnOnTimer': instance.autoTurnOnTimer, + 'alwaysAutoTurnOnTimer': instance.alwaysAutoTurnOnTimer, + 'autoTurnOnTime': instance.autoTurnOnTime.inMicroseconds, + 'autoTurnOffTime': instance.autoTurnOffTime.inMicroseconds, + }; + +const _$SleepTimerShakeSenseModeEnumMap = { + SleepTimerShakeSenseMode.never: 'never', + SleepTimerShakeSenseMode.always: 'always', + SleepTimerShakeSenseMode.nearEnds: 'nearEnds', +}; diff --git a/pubspec.yaml b/pubspec.yaml index 08cecf8..f7997a5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,6 +70,7 @@ dependencies: riverpod_annotation: ^2.3.5 rxdart: ^0.27.7 scroll_loop_auto_scroll: ^0.0.5 + # sensors_plus: ^5.0.1 shelfsdk: path: ../../_dart/shelfsdk shimmer: ^3.0.0