diff --git a/lib/features/playback_reporting/core/playback_reporter.dart b/lib/features/playback_reporting/core/playback_reporter.dart index 81c6265..faffb4d 100644 --- a/lib/features/playback_reporting/core/playback_reporter.dart +++ b/lib/features/playback_reporting/core/playback_reporter.dart @@ -166,7 +166,8 @@ class PlaybackReporter { return _session!; } if (player.book == null) { - throw NoAudiobookPlayingError(); + _logger.warning('No audiobook playing to start session'); + return null; } _session = await authenticatedApi.items.play( libraryItemId: player.book!.libraryItemId, @@ -204,8 +205,11 @@ class PlaybackReporter { } try { _session ??= await startSession(); - } on NoAudiobookPlayingError { - _logger.warning('No audiobook playing to sync position'); + } on Error catch (e) { + _logger.warning('Error starting session: $e'); + } + if (_session == null) { + _logger.warning('No session to sync position'); return; } final currentPosition = player.positionInBook; diff --git a/lib/features/player/view/widgets/speed_selector.dart b/lib/features/player/view/widgets/speed_selector.dart index a93550e..289b736 100644 --- a/lib/features/player/view/widgets/speed_selector.dart +++ b/lib/features/player/view/widgets/speed_selector.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -16,7 +18,8 @@ class SpeedSelector extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final appSettings = ref.watch(appSettingsProvider); - final speeds = appSettings.playerSettings.speedOptions; + final playerSettings = appSettings.playerSettings; + final speeds = playerSettings.speedOptions; final currentSpeed = ref.watch(audiobookPlayerProvider).speed; final speedState = useState(currentSpeed); @@ -30,10 +33,16 @@ class SpeedSelector extends HookConsumerWidget { ); // the speed options - const minSpeed = 0.1; - const maxSpeed = 4.0; - const speedIncrement = 0.05; - final availableSpeeds = ((maxSpeed - minSpeed) / speedIncrement).ceil(); + final minSpeed = min( + speeds.reduce((minSpeedSoFar, element) => min(minSpeedSoFar, element)), + playerSettings.minSpeed, + ); + final maxSpeed = max( + speeds.reduce((maxSpeedSoFar, element) => max(maxSpeedSoFar, element)), + playerSettings.maxSpeed, + ); + final speedIncrement = playerSettings.speedIncrement; + final availableSpeeds = ((maxSpeed - minSpeed) / speedIncrement).ceil() + 1; final availableSpeedsList = List.generate( availableSpeeds, (index) { @@ -52,154 +61,210 @@ class SpeedSelector extends HookConsumerWidget { 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, - ), + // the title + Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: Text( + 'Playback Speed: ${speedState.value}x', + style: Theme.of(context).textTheme.titleLarge, ), ), ), + + // the speed selector 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.onSurface, - ), - // 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 - .onSurface, - ), - ), - ], - ), - ) - .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, - ); - } - }, - ), - ], + child: SpeedWheel( + availableSpeedsList: availableSpeedsList, + speedState: speedState, + scrollController: scrollController, + itemExtent: itemExtent, ), ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + + // the speed buttons + Wrap( + spacing: 8.0, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, 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) + (speed) => TextButton( + style: speed == speedState.value + ? TextButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.primaryContainer, + foregroundColor: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ) + // border if not selected + : TextButton.styleFrom( + side: BorderSide( + color: 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, + ), + ), + 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'), - ), + // call the onSpeedSelected function + speedState.value = speed; + }, + child: Text('$speed'), ), ) .toList(), ), - const SizedBox( - height: 8, - ), ], ), ); } } + +class SpeedWheel extends StatelessWidget { + const SpeedWheel({ + super.key, + required this.availableSpeedsList, + required this.speedState, + required this.scrollController, + required this.itemExtent, + this.showIncrementButtons = true, + }); + + final List availableSpeedsList; + final ValueNotifier speedState; + final FixedExtentScrollController scrollController; + final double itemExtent; + final bool showIncrementButtons; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // a minus button to decrease the speed + if (showIncrementButtons) + 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, + ); + } + }, + ), + // the speed selector wheel + 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) => Expanded( + child: SpeedLine(itemExtent: itemExtent, speed: speed), + ), + ) + .toList(), + onSelectedItemChanged: (index) { + speedState.value = availableSpeedsList[index]; + }, + ), + ), + + if (showIncrementButtons) + // 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, + ); + } + }, + ), + ], + ); + } +} + +class SpeedLine extends StatelessWidget { + const SpeedLine({ + super.key, + required this.itemExtent, + required this.speed, + }); + + final double itemExtent; + final double speed; + + @override + Widget build(BuildContext context) { + return 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.onSurface, + ), + // the speed text but only at .5 increments of speed + if (speed % 0.25 == 0) + Expanded( + child: Text.rich( + TextSpan( + text: speed.floor().toString(), + children: [ + TextSpan( + text: '.${speed.toStringAsFixed(2).split('.').last}', + style: TextStyle( + fontSize: + Theme.of(context).textTheme.labelSmall?.fontSize, + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/router/constants.dart b/lib/router/constants.dart index 9dd15ad..71bb446 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -37,6 +37,11 @@ class Routes { name: 'notificationSettings', parentRoute: settings, ); + static const playerSettings = _SimpleRoute( + pathName: 'player', + name: 'playerSettings', + parentRoute: settings, + ); // search and explore static const search = _SimpleRoute( diff --git a/lib/router/router.dart b/lib/router/router.dart index 8781e6f..edf9433 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -13,6 +13,7 @@ import 'package:vaani/pages/home_page.dart'; import 'package:vaani/settings/view/app_settings_page.dart'; import 'package:vaani/settings/view/auto_sleep_timer_settings_page.dart'; import 'package:vaani/settings/view/notification_settings_page.dart'; +import 'package:vaani/settings/view/player_settings_page.dart'; import 'scaffold_with_nav_bar.dart'; import 'transitions/slide.dart'; @@ -188,6 +189,12 @@ class MyAppRouter { const NotificationSettingsPage(), ), ), + GoRoute( + path: Routes.playerSettings.pathName, + name: Routes.playerSettings.name, + pageBuilder: + defaultPageBuilder(const PlayerSettingsPage()), + ), ], ), GoRoute( diff --git a/lib/settings/models/app_settings.dart b/lib/settings/models/app_settings.dart index 8ccb320..4b70f64 100644 --- a/lib/settings/models/app_settings.dart +++ b/lib/settings/models/app_settings.dart @@ -44,6 +44,9 @@ 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(0.05) double speedIncrement, + @Default(0.1) double minSpeed, + @Default(4) double maxSpeed, @Default(SleepTimerSettings()) SleepTimerSettings sleepTimerSettings, @Default(Duration(seconds: 10)) Duration minimumPositionForReporting, @Default(Duration(seconds: 10)) Duration playbackReportInterval, diff --git a/lib/settings/models/app_settings.freezed.dart b/lib/settings/models/app_settings.freezed.dart index b07b684..415a60a 100644 --- a/lib/settings/models/app_settings.freezed.dart +++ b/lib/settings/models/app_settings.freezed.dart @@ -512,6 +512,9 @@ mixin _$PlayerSettings { double get preferredDefaultVolume => throw _privateConstructorUsedError; double get preferredDefaultSpeed => throw _privateConstructorUsedError; List get speedOptions => throw _privateConstructorUsedError; + double get speedIncrement => throw _privateConstructorUsedError; + double get minSpeed => throw _privateConstructorUsedError; + double get maxSpeed => throw _privateConstructorUsedError; SleepTimerSettings get sleepTimerSettings => throw _privateConstructorUsedError; Duration get minimumPositionForReporting => @@ -542,6 +545,9 @@ abstract class $PlayerSettingsCopyWith<$Res> { double preferredDefaultVolume, double preferredDefaultSpeed, List speedOptions, + double speedIncrement, + double minSpeed, + double maxSpeed, SleepTimerSettings sleepTimerSettings, Duration minimumPositionForReporting, Duration playbackReportInterval, @@ -573,6 +579,9 @@ class _$PlayerSettingsCopyWithImpl<$Res, $Val extends PlayerSettings> Object? preferredDefaultVolume = null, Object? preferredDefaultSpeed = null, Object? speedOptions = null, + Object? speedIncrement = null, + Object? minSpeed = null, + Object? maxSpeed = null, Object? sleepTimerSettings = null, Object? minimumPositionForReporting = null, Object? playbackReportInterval = null, @@ -600,6 +609,18 @@ class _$PlayerSettingsCopyWithImpl<$Res, $Val extends PlayerSettings> ? _value.speedOptions : speedOptions // ignore: cast_nullable_to_non_nullable as List, + speedIncrement: null == speedIncrement + ? _value.speedIncrement + : speedIncrement // ignore: cast_nullable_to_non_nullable + as double, + minSpeed: null == minSpeed + ? _value.minSpeed + : minSpeed // ignore: cast_nullable_to_non_nullable + as double, + maxSpeed: null == maxSpeed + ? _value.maxSpeed + : maxSpeed // ignore: cast_nullable_to_non_nullable + as double, sleepTimerSettings: null == sleepTimerSettings ? _value.sleepTimerSettings : sleepTimerSettings // ignore: cast_nullable_to_non_nullable @@ -671,6 +692,9 @@ abstract class _$$PlayerSettingsImplCopyWith<$Res> double preferredDefaultVolume, double preferredDefaultSpeed, List speedOptions, + double speedIncrement, + double minSpeed, + double maxSpeed, SleepTimerSettings sleepTimerSettings, Duration minimumPositionForReporting, Duration playbackReportInterval, @@ -703,6 +727,9 @@ class __$$PlayerSettingsImplCopyWithImpl<$Res> Object? preferredDefaultVolume = null, Object? preferredDefaultSpeed = null, Object? speedOptions = null, + Object? speedIncrement = null, + Object? minSpeed = null, + Object? maxSpeed = null, Object? sleepTimerSettings = null, Object? minimumPositionForReporting = null, Object? playbackReportInterval = null, @@ -730,6 +757,18 @@ class __$$PlayerSettingsImplCopyWithImpl<$Res> ? _value._speedOptions : speedOptions // ignore: cast_nullable_to_non_nullable as List, + speedIncrement: null == speedIncrement + ? _value.speedIncrement + : speedIncrement // ignore: cast_nullable_to_non_nullable + as double, + minSpeed: null == minSpeed + ? _value.minSpeed + : minSpeed // ignore: cast_nullable_to_non_nullable + as double, + maxSpeed: null == maxSpeed + ? _value.maxSpeed + : maxSpeed // ignore: cast_nullable_to_non_nullable + as double, sleepTimerSettings: null == sleepTimerSettings ? _value.sleepTimerSettings : sleepTimerSettings // ignore: cast_nullable_to_non_nullable @@ -763,6 +802,9 @@ 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.speedIncrement = 0.05, + this.minSpeed = 0.1, + this.maxSpeed = 4, this.sleepTimerSettings = const SleepTimerSettings(), this.minimumPositionForReporting = const Duration(seconds: 10), this.playbackReportInterval = const Duration(seconds: 10), @@ -794,6 +836,15 @@ class _$PlayerSettingsImpl implements _PlayerSettings { return EqualUnmodifiableListView(_speedOptions); } + @override + @JsonKey() + final double speedIncrement; + @override + @JsonKey() + final double minSpeed; + @override + @JsonKey() + final double maxSpeed; @override @JsonKey() final SleepTimerSettings sleepTimerSettings; @@ -812,7 +863,7 @@ class _$PlayerSettingsImpl implements _PlayerSettings { @override String toString() { - return 'PlayerSettings(miniPlayerSettings: $miniPlayerSettings, expandedPlayerSettings: $expandedPlayerSettings, preferredDefaultVolume: $preferredDefaultVolume, preferredDefaultSpeed: $preferredDefaultSpeed, speedOptions: $speedOptions, sleepTimerSettings: $sleepTimerSettings, minimumPositionForReporting: $minimumPositionForReporting, playbackReportInterval: $playbackReportInterval, markCompleteWhenTimeLeft: $markCompleteWhenTimeLeft, configurePlayerForEveryBook: $configurePlayerForEveryBook)'; + return 'PlayerSettings(miniPlayerSettings: $miniPlayerSettings, expandedPlayerSettings: $expandedPlayerSettings, preferredDefaultVolume: $preferredDefaultVolume, preferredDefaultSpeed: $preferredDefaultSpeed, speedOptions: $speedOptions, speedIncrement: $speedIncrement, minSpeed: $minSpeed, maxSpeed: $maxSpeed, sleepTimerSettings: $sleepTimerSettings, minimumPositionForReporting: $minimumPositionForReporting, playbackReportInterval: $playbackReportInterval, markCompleteWhenTimeLeft: $markCompleteWhenTimeLeft, configurePlayerForEveryBook: $configurePlayerForEveryBook)'; } @override @@ -830,6 +881,12 @@ class _$PlayerSettingsImpl implements _PlayerSettings { other.preferredDefaultSpeed == preferredDefaultSpeed) && const DeepCollectionEquality() .equals(other._speedOptions, _speedOptions) && + (identical(other.speedIncrement, speedIncrement) || + other.speedIncrement == speedIncrement) && + (identical(other.minSpeed, minSpeed) || + other.minSpeed == minSpeed) && + (identical(other.maxSpeed, maxSpeed) || + other.maxSpeed == maxSpeed) && (identical(other.sleepTimerSettings, sleepTimerSettings) || other.sleepTimerSettings == sleepTimerSettings) && (identical(other.minimumPositionForReporting, @@ -856,6 +913,9 @@ class _$PlayerSettingsImpl implements _PlayerSettings { preferredDefaultVolume, preferredDefaultSpeed, const DeepCollectionEquality().hash(_speedOptions), + speedIncrement, + minSpeed, + maxSpeed, sleepTimerSettings, minimumPositionForReporting, playbackReportInterval, @@ -886,6 +946,9 @@ abstract class _PlayerSettings implements PlayerSettings { final double preferredDefaultVolume, final double preferredDefaultSpeed, final List speedOptions, + final double speedIncrement, + final double minSpeed, + final double maxSpeed, final SleepTimerSettings sleepTimerSettings, final Duration minimumPositionForReporting, final Duration playbackReportInterval, @@ -906,6 +969,12 @@ abstract class _PlayerSettings implements PlayerSettings { @override List get speedOptions; @override + double get speedIncrement; + @override + double get minSpeed; + @override + double get maxSpeed; + @override SleepTimerSettings get sleepTimerSettings; @override Duration get minimumPositionForReporting; diff --git a/lib/settings/models/app_settings.g.dart b/lib/settings/models/app_settings.g.dart index c1554c7..c01610a 100644 --- a/lib/settings/models/app_settings.g.dart +++ b/lib/settings/models/app_settings.g.dart @@ -69,6 +69,9 @@ _$PlayerSettingsImpl _$$PlayerSettingsImplFromJson(Map json) => ?.map((e) => (e as num).toDouble()) .toList() ?? const [0.75, 1, 1.25, 1.5, 1.75, 2], + speedIncrement: (json['speedIncrement'] as num?)?.toDouble() ?? 0.05, + minSpeed: (json['minSpeed'] as num?)?.toDouble() ?? 0.1, + maxSpeed: (json['maxSpeed'] as num?)?.toDouble() ?? 4, sleepTimerSettings: json['sleepTimerSettings'] == null ? const SleepTimerSettings() : SleepTimerSettings.fromJson( @@ -98,6 +101,9 @@ Map _$$PlayerSettingsImplToJson( 'preferredDefaultVolume': instance.preferredDefaultVolume, 'preferredDefaultSpeed': instance.preferredDefaultSpeed, 'speedOptions': instance.speedOptions, + 'speedIncrement': instance.speedIncrement, + 'minSpeed': instance.minSpeed, + 'maxSpeed': instance.maxSpeed, 'sleepTimerSettings': instance.sleepTimerSettings, 'minimumPositionForReporting': instance.minimumPositionForReporting.inMicroseconds, diff --git a/lib/settings/view/app_settings_page.dart b/lib/settings/view/app_settings_page.dart index bca69fe..bbdf1a3 100644 --- a/lib/settings/view/app_settings_page.dart +++ b/lib/settings/view/app_settings_page.dart @@ -51,6 +51,16 @@ class AppSettingsPage extends HookConsumerWidget { context.pushNamed(Routes.notificationSettings.name); }, ), + SettingsTile( + title: const Text('Player Settings'), + leading: const Icon(Icons.play_arrow), + description: const Text( + 'Customize the player settings', + ), + onPressed: (context) { + context.pushNamed(Routes.playerSettings.name); + }, + ), ], ), // Appearance section diff --git a/lib/settings/view/buttons.dart b/lib/settings/view/buttons.dart new file mode 100644 index 0000000..0dea11f --- /dev/null +++ b/lib/settings/view/buttons.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class OkButton extends StatelessWidget { + const OkButton({ + super.key, + this.onPressed, + }); + + final void Function()? onPressed; + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onPressed, + child: const Text('OK'), + ); + } +} + +class CancelButton extends StatelessWidget { + const CancelButton({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ); + } +} diff --git a/lib/settings/view/notification_settings_page.dart b/lib/settings/view/notification_settings_page.dart index df1c5e5..b673add 100644 --- a/lib/settings/view/notification_settings_page.dart +++ b/lib/settings/view/notification_settings_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_settings_ui/flutter_settings_ui.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:vaani/settings/app_settings_provider.dart'; import 'package:vaani/settings/models/app_settings.dart'; +import 'package:vaani/settings/view/buttons.dart'; import 'package:vaani/settings/view/simple_settings_page.dart'; class NotificationSettingsPage extends HookConsumerWidget { @@ -220,17 +221,11 @@ class MediaControlsPicker extends HookConsumerWidget { return AlertDialog( title: const Text('Media Controls'), actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Cancel'), - ), - TextButton( + const CancelButton(), + OkButton( onPressed: () { Navigator.of(context).pop(selectedMediaControls.value); - }, - child: const Text('OK'), + } ), ], // a list of chips to easily select the media controls to display @@ -333,17 +328,11 @@ class NotificationTitlePicker extends HookConsumerWidget { return AlertDialog( title: Text(title), actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Cancel'), - ), - TextButton( + const CancelButton(), + OkButton( onPressed: () { Navigator.of(context).pop(selectedTitle.value); - }, - child: const Text('OK'), + } ), ], // a list of chips to easily insert available fields into the text field diff --git a/lib/settings/view/player_settings_page.dart b/lib/settings/view/player_settings_page.dart new file mode 100644 index 0000000..c07f8cf --- /dev/null +++ b/lib/settings/view/player_settings_page.dart @@ -0,0 +1,410 @@ +import 'package:duration_picker/duration_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_settings_ui/flutter_settings_ui.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:vaani/settings/app_settings_provider.dart'; +import 'package:vaani/settings/view/buttons.dart'; +import 'package:vaani/settings/view/simple_settings_page.dart'; +import 'package:vaani/shared/extensions/duration_format.dart'; + +class PlayerSettingsPage extends HookConsumerWidget { + const PlayerSettingsPage({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appSettings = ref.watch(appSettingsProvider); + final playerSettings = appSettings.playerSettings; + + return SimpleSettingsPage( + title: const Text('Player Settings'), + sections: [ + SettingsSection( + margin: const EdgeInsetsDirectional.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + tiles: [ + // preferred settings for every book + SettingsTile.switchTile( + title: const Text('Remember Player Settings for Every Book'), + leading: const Icon(Icons.settings_applications), + description: const Text( + 'Settings like speed, loudness, etc. will be remembered for every book', + ), + initialValue: playerSettings.configurePlayerForEveryBook, + onToggle: (value) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.playerSettings( + configurePlayerForEveryBook: value, + ), + ); + }, + ), + + // preferred default speed + SettingsTile( + title: const Text('Default Speed'), + description: Text('${playerSettings.preferredDefaultSpeed}x'), + leading: const Icon(Icons.speed), + onPressed: (context) async { + final newSpeed = await showDialog( + context: context, + builder: (context) => SpeedPicker( + initialValue: playerSettings.preferredDefaultSpeed, + ), + ); + if (newSpeed != null) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.playerSettings( + preferredDefaultSpeed: newSpeed, + ), + ); + } + }, + ), + // preferred speed options + SettingsTile( + title: const Text('Speed Options'), + description: Text( + playerSettings.speedOptions.map((e) => '${e}x').join(', '), + ), + leading: const Icon(Icons.speed), + onPressed: (context) async { + final newSpeedOptions = await showDialog?>( + context: context, + builder: (context) => SpeedOptionsPicker( + initialValue: playerSettings.speedOptions, + ), + ); + if (newSpeedOptions != null) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.playerSettings( + speedOptions: newSpeedOptions..sort(), + ), + ); + } + }, + ), + ], + ), + + // Playback Reporting + SettingsSection( + title: const Text('Playback Reporting'), + tiles: [ + SettingsTile( + title: const Text('Minimum Position to Report'), + description: Text.rich( + TextSpan( + text: 'Do not report playback for the first ', + children: [ + TextSpan( + text: playerSettings + .minimumPositionForReporting.smartBinaryFormat, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const TextSpan(text: ' of the book'), + ], + ), + ), + leading: const Icon(Icons.timer), + onPressed: (context) async { + final newDuration = await showDialog( + context: context, + builder: (context) { + return TimeDurationSelector( + title: const Text('Ignore Playback Position Less Than'), + baseUnit: BaseUnit.second, + initialValue: playerSettings.minimumPositionForReporting, + ); + }, + ); + if (newDuration != null) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.playerSettings( + minimumPositionForReporting: newDuration, + ), + ); + } + }, + ), + // when to mark complete + SettingsTile( + title: const Text('Mark Complete When Time Left'), + description: Text.rich( + TextSpan( + text: 'Mark complete when less than ', + children: [ + TextSpan( + text: playerSettings + .markCompleteWhenTimeLeft.smartBinaryFormat, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const TextSpan(text: ' left in the book'), + ], + ), + ), + leading: const Icon(Icons.cloud_done), + onPressed: (context) async { + final newDuration = await showDialog( + context: context, + builder: (context) { + return TimeDurationSelector( + title: const Text('Mark Complete When Time Left'), + baseUnit: BaseUnit.second, + initialValue: playerSettings.markCompleteWhenTimeLeft, + ); + }, + ); + if (newDuration != null) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.playerSettings( + markCompleteWhenTimeLeft: newDuration, + ), + ); + } + }, + ), + // playback report interval + SettingsTile( + title: const Text('Playback Report Interval'), + description: Text.rich( + TextSpan( + text: 'Report progress every ', + children: [ + TextSpan( + text: playerSettings + .playbackReportInterval.smartBinaryFormat, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const TextSpan(text: ' to the server'), + ], + ), + ), + leading: const Icon(Icons.change_circle_outlined), + onPressed: (context) async { + final newDuration = await showDialog( + context: context, + builder: (context) { + return TimeDurationSelector( + title: const Text('Playback Report Interval'), + baseUnit: BaseUnit.second, + initialValue: playerSettings.playbackReportInterval, + ); + }, + ); + if (newDuration != null) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.playerSettings( + playbackReportInterval: newDuration, + ), + ); + } + }, + ), + ], + ), + // Display Settings + SettingsSection( + title: const Text('Display Settings'), + tiles: [ + // show total progress + SettingsTile.switchTile( + title: const Text('Show Total Progress'), + leading: const Icon(Icons.show_chart), + description: const Text( + 'Show the total progress of the book in the player', + ), + initialValue: + playerSettings.expandedPlayerSettings.showTotalProgress, + onToggle: (value) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.playerSettings + .expandedPlayerSettings(showTotalProgress: value), + ); + }, + ), + // show chapter progress + SettingsTile.switchTile( + title: const Text('Show Chapter Progress'), + leading: const Icon(Icons.show_chart), + description: const Text( + 'Show the progress of the current chapter in the player', + ), + initialValue: + playerSettings.expandedPlayerSettings.showChapterProgress, + onToggle: (value) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.playerSettings( + expandedPlayerSettings: playerSettings + .expandedPlayerSettings + .copyWith(showChapterProgress: value), + ), + ); + }, + ), + ], + ), + ], + ); + } +} + +class TimeDurationSelector extends HookConsumerWidget { + const TimeDurationSelector({ + super.key, + this.title = const Text('Select Duration'), + this.baseUnit = BaseUnit.second, + this.initialValue = Duration.zero, + }); + + final Widget title; + final BaseUnit baseUnit; + final Duration initialValue; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final duration = useState(initialValue); + return AlertDialog( + title: title, + content: DurationPicker( + duration: duration.value, + baseUnit: baseUnit, + onChange: (value) { + duration.value = value; + }, + ), + actions: [ + const CancelButton(), + OkButton( + onPressed: () { + Navigator.of(context).pop(duration.value); + }, + ), + ], + ); + } +} + +class SpeedPicker extends HookConsumerWidget { + const SpeedPicker({ + super.key, + this.initialValue = 1, + }); + + final double initialValue; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final speedController = + useTextEditingController(text: initialValue.toString()); + final speed = useState(initialValue); + return AlertDialog( + title: const Text('Select Speed'), + content: TextField( + controller: speedController, + onChanged: (value) => speed.value = double.tryParse(value), + onSubmitted: (value) { + Navigator.of(context).pop(speed.value); + }, + autofocus: true, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Speed', + helper: Text( + 'Enter the speed you want to set when playing for the first time', + ), + ), + ), + actions: [ + const CancelButton(), + OkButton( + onPressed: () { + Navigator.of(context).pop(speed.value); + }, + ), + ], + ); + } +} + +class SpeedOptionsPicker extends HookConsumerWidget { + const SpeedOptionsPicker({ + super.key, + this.initialValue = const [0.75, 1, 1.25, 1.5, 1.75, 2], + }); + + final List initialValue; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final speedOptionAddController = useTextEditingController(); + final speedOptions = useState>(initialValue); + final focusNode = useFocusNode(); + return AlertDialog( + title: const Text('Select Speed Options'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: speedOptions.value + .map( + (speed) => Chip( + label: Text('${speed}x'), + onDeleted: speed == 1 + ? null + : () { + speedOptions.value = + speedOptions.value.where((element) { + // speed option 1 can't be removed + return element != speed; + }).toList(); + }, + ), + ) + .toList() + ..sort((a, b) { + // if (a.label == const Text('1x')) { + // return -1; + // } else if (b.label == const Text('1x')) { + // return 1; + // } + return a.label.toString().compareTo(b.label.toString()); + }), + ), + TextField( + focusNode: focusNode, + autofocus: true, + controller: speedOptionAddController, + onSubmitted: (value) { + final newSpeed = double.tryParse(value); + if (newSpeed != null && !speedOptions.value.contains(newSpeed)) { + speedOptions.value = [...speedOptions.value, newSpeed]; + } + speedOptionAddController.clear(); + focusNode.requestFocus(); + }, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Add Speed Option', + helper: Text('Enter a new speed option to add'), + ), + ), + ], + ), + actions: [ + const CancelButton(), + OkButton( + onPressed: () { + Navigator.of(context).pop(speedOptions.value); + }, + ), + ], + ); + } +} diff --git a/lib/settings/view/simple_settings_page.dart b/lib/settings/view/simple_settings_page.dart index 586e986..471b022 100644 --- a/lib/settings/view/simple_settings_page.dart +++ b/lib/settings/view/simple_settings_page.dart @@ -46,6 +46,8 @@ class SimpleSettingsPage extends HookConsumerWidget { ], ), ), + // some padding at the bottom + const SliverPadding(padding: EdgeInsets.only(bottom: 20)), ], ), );