diff --git a/lib/features/item_viewer/view/library_item_page.dart b/lib/features/item_viewer/view/library_item_page.dart index badd134..5d270db 100644 --- a/lib/features/item_viewer/view/library_item_page.dart +++ b/lib/features/item_viewer/view/library_item_page.dart @@ -304,10 +304,10 @@ class LibraryItemActions extends HookConsumerWidget { ref .read(appSettingsProvider) .playerSettings - .preferredVolume, + .preferredDefaultVolume, ); // toggle play/pause - player.togglePlayPause(); + await player.play(); }, icon: const Icon(Icons.play_arrow_rounded), label: const Text('Play/Resume'), diff --git a/lib/features/player/core/audiobook_player.dart b/lib/features/player/core/audiobook_player.dart index 043bc5b..c0edd8c 100644 --- a/lib/features/player/core/audiobook_player.dart +++ b/lib/features/player/core/audiobook_player.dart @@ -225,6 +225,10 @@ class AudiobookPlayer extends AudioPlayer { if (_book == null) { return null; } + // if the list is empty, return null + if (_book!.chapters.isEmpty) { + return null; + } return _book!.chapters.firstWhere( (element) { return element.start <= positionInBook && element.end >= positionInBook; diff --git a/lib/features/player/view/audiobook_player.dart b/lib/features/player/view/audiobook_player.dart index d4bf1f5..a75024b 100644 --- a/lib/features/player/view/audiobook_player.dart +++ b/lib/features/player/view/audiobook_player.dart @@ -72,7 +72,7 @@ class AudiobookPlayer extends HookConsumerWidget { // the image width when the player is expanded final maxImgSize = availWidth * 0.9; - final preferredVolume = appSettings.playerSettings.preferredVolume; + final preferredVolume = appSettings.playerSettings.preferredDefaultVolume; return Theme( data: ThemeData( colorScheme: imageTheme.valueOrNull ?? Theme.of(context).colorScheme, diff --git a/lib/features/player/view/player_when_expanded.dart b/lib/features/player/view/player_when_expanded.dart index dfa0d12..ed2062c 100644 --- a/lib/features/player/view/player_when_expanded.dart +++ b/lib/features/player/view/player_when_expanded.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:miniplayer/miniplayer.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/providers/player_form.dart'; import 'package:whispering_pages/features/player/view/audiobook_player.dart'; +import 'package:whispering_pages/settings/app_settings_provider.dart'; import 'package:whispering_pages/shared/extensions/inverse_lerp.dart'; class PlayerWhenExpanded extends HookConsumerWidget { @@ -127,27 +129,27 @@ class PlayerWhenExpanded extends HookConsumerWidget { ), // the chapter title - currentChapter == null - ? const SizedBox() - : Opacity( - opacity: earlyPercentage, - child: Padding( - padding: EdgeInsets.only( - top: AppElementSizes.paddingRegular * 4 * earlyPercentage, - // horizontal: 16.0, - ), - // child: SizedBox( - // same as the image width - // width: imageSize, - child: Text( + Opacity( + opacity: earlyPercentage, + child: Padding( + padding: EdgeInsets.only( + top: AppElementSizes.paddingRegular * 4 * earlyPercentage, + // horizontal: 16.0, + ), + // child: SizedBox( + // same as the image width + // width: imageSize, + child: currentChapter == null + ? const SizedBox() + : Text( currentChapter.title, style: Theme.of(context).textTheme.titleLarge, maxLines: 1, overflow: TextOverflow.ellipsis, ), - // ), - ), - ), + // ), + ), + ), // the book name and author Opacity( @@ -232,10 +234,7 @@ class PlayerWhenExpanded extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ // speed control - IconButton( - icon: const Icon(Icons.speed), - onPressed: () {}, - ), + const PlayerSpeedAdjustButton(), // sleep timer IconButton( icon: const Icon(Icons.timer), @@ -260,6 +259,65 @@ class PlayerWhenExpanded extends HookConsumerWidget { } } +class PlayerSpeedAdjustButton extends HookConsumerWidget { + const PlayerSpeedAdjustButton({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = ref.watch(audiobookPlayerProvider); + return TextButton( + child: Text('${player.speed}x'), + // icon: const Icon(Icons.speed), + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) { + return SpeedSelector( + onSpeedSelected: (speed) { + player.setSpeed(speed); + Navigator.of(context).pop(); + }, + ); + }, + ); + }, + ); + } +} + +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; + return SizedBox( + child: ListView.builder( + itemCount: speeds.length, + itemBuilder: (context, index) { + final speed = speeds[index]; + return ListTile( + title: Text(speed.toString()), + onTap: () { + onSpeedSelected(speed); + }, + trailing: currentSpeed == speed ? const Icon(Icons.check) : null, + ); + }, + ), + ); + } +} + class AudiobookPlayerSeekButton extends HookConsumerWidget { const AudiobookPlayerSeekButton({ super.key, @@ -301,6 +359,43 @@ class AudiobookPlayerSeekChapterButton extends HookConsumerWidget { 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, @@ -310,37 +405,15 @@ class AudiobookPlayerSeekChapterButton extends HookConsumerWidget { 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) { - // instead of seeking to the end of the chapter, go to the next chapter start - // player.seek(player.currentChapter!.end); - final index = player.book!.chapters.indexOf(player.currentChapter!); - if (index < player.book!.chapters.length - 1) { - player.seek( - player.book!.chapters[index + 1].start + - const Duration( - milliseconds: 10, - ), // add a small offset so the display does not show the previous chapter for a split second - ); - } else { - player.seek(player.currentChapter!.end); - } + seekForward(); } else { - // if player position is less than 5 seconds into the chapter, go to the previous chapter - final chapterPosition = - player.positionInBook - player.currentChapter!.start; - if (chapterPosition < const Duration(seconds: 5)) { - final index = player.book!.chapters.indexOf(player.currentChapter!); - if (index > 0) { - player.seek( - player.book!.chapters[index - 1].start + - const Duration(milliseconds: 10), - ); - } - } else { - player.seek( - player.currentChapter!.start + const Duration(milliseconds: 10), - ); - } + seekBackward(); } }, ); diff --git a/lib/settings/models/app_settings.dart b/lib/settings/models/app_settings.dart index bd39c67..948e4f5 100644 --- a/lib/settings/models/app_settings.dart +++ b/lib/settings/models/app_settings.dart @@ -27,8 +27,9 @@ class PlayerSettings with _$PlayerSettings { MinimizedPlayerSettings miniPlayerSettings, @Default(ExpandedPlayerSettings()) ExpandedPlayerSettings expandedPlayerSettings, - @Default(1) double preferredVolume, - @Default(1) double preferredSpeed, + @Default(1) double preferredDefaultVolume, + @Default(1) double preferredDefaultSpeed, + @Default([0.8, 1, 1.25, 1.5, 1.75, 2]) List speedOptions, @Default(Duration(minutes: 15)) Duration sleepTimer, }) = _PlayerSettings; @@ -46,6 +47,7 @@ class ExpandedPlayerSettings with _$ExpandedPlayerSettings { factory ExpandedPlayerSettings.fromJson(Map json) => _$ExpandedPlayerSettingsFromJson(json); } + @freezed class MinimizedPlayerSettings with _$MinimizedPlayerSettings { const factory MinimizedPlayerSettings({ diff --git a/lib/settings/models/app_settings.freezed.dart b/lib/settings/models/app_settings.freezed.dart index 7cd566d..d1e693b 100644 --- a/lib/settings/models/app_settings.freezed.dart +++ b/lib/settings/models/app_settings.freezed.dart @@ -226,8 +226,9 @@ mixin _$PlayerSettings { throw _privateConstructorUsedError; ExpandedPlayerSettings get expandedPlayerSettings => throw _privateConstructorUsedError; - double get preferredVolume => throw _privateConstructorUsedError; - double get preferredSpeed => throw _privateConstructorUsedError; + double get preferredDefaultVolume => throw _privateConstructorUsedError; + double get preferredDefaultSpeed => throw _privateConstructorUsedError; + List get speedOptions => throw _privateConstructorUsedError; Duration get sleepTimer => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @@ -245,8 +246,9 @@ abstract class $PlayerSettingsCopyWith<$Res> { $Res call( {MinimizedPlayerSettings miniPlayerSettings, ExpandedPlayerSettings expandedPlayerSettings, - double preferredVolume, - double preferredSpeed, + double preferredDefaultVolume, + double preferredDefaultSpeed, + List speedOptions, Duration sleepTimer}); $MinimizedPlayerSettingsCopyWith<$Res> get miniPlayerSettings; @@ -268,8 +270,9 @@ class _$PlayerSettingsCopyWithImpl<$Res, $Val extends PlayerSettings> $Res call({ Object? miniPlayerSettings = null, Object? expandedPlayerSettings = null, - Object? preferredVolume = null, - Object? preferredSpeed = null, + Object? preferredDefaultVolume = null, + Object? preferredDefaultSpeed = null, + Object? speedOptions = null, Object? sleepTimer = null, }) { return _then(_value.copyWith( @@ -281,14 +284,18 @@ class _$PlayerSettingsCopyWithImpl<$Res, $Val extends PlayerSettings> ? _value.expandedPlayerSettings : expandedPlayerSettings // ignore: cast_nullable_to_non_nullable as ExpandedPlayerSettings, - preferredVolume: null == preferredVolume - ? _value.preferredVolume - : preferredVolume // ignore: cast_nullable_to_non_nullable + preferredDefaultVolume: null == preferredDefaultVolume + ? _value.preferredDefaultVolume + : preferredDefaultVolume // ignore: cast_nullable_to_non_nullable as double, - preferredSpeed: null == preferredSpeed - ? _value.preferredSpeed - : preferredSpeed // ignore: cast_nullable_to_non_nullable + preferredDefaultSpeed: null == preferredDefaultSpeed + ? _value.preferredDefaultSpeed + : preferredDefaultSpeed // ignore: cast_nullable_to_non_nullable as double, + speedOptions: null == speedOptions + ? _value.speedOptions + : speedOptions // ignore: cast_nullable_to_non_nullable + as List, sleepTimer: null == sleepTimer ? _value.sleepTimer : sleepTimer // ignore: cast_nullable_to_non_nullable @@ -326,8 +333,9 @@ abstract class _$$PlayerSettingsImplCopyWith<$Res> $Res call( {MinimizedPlayerSettings miniPlayerSettings, ExpandedPlayerSettings expandedPlayerSettings, - double preferredVolume, - double preferredSpeed, + double preferredDefaultVolume, + double preferredDefaultSpeed, + List speedOptions, Duration sleepTimer}); @override @@ -349,8 +357,9 @@ class __$$PlayerSettingsImplCopyWithImpl<$Res> $Res call({ Object? miniPlayerSettings = null, Object? expandedPlayerSettings = null, - Object? preferredVolume = null, - Object? preferredSpeed = null, + Object? preferredDefaultVolume = null, + Object? preferredDefaultSpeed = null, + Object? speedOptions = null, Object? sleepTimer = null, }) { return _then(_$PlayerSettingsImpl( @@ -362,14 +371,18 @@ class __$$PlayerSettingsImplCopyWithImpl<$Res> ? _value.expandedPlayerSettings : expandedPlayerSettings // ignore: cast_nullable_to_non_nullable as ExpandedPlayerSettings, - preferredVolume: null == preferredVolume - ? _value.preferredVolume - : preferredVolume // ignore: cast_nullable_to_non_nullable + preferredDefaultVolume: null == preferredDefaultVolume + ? _value.preferredDefaultVolume + : preferredDefaultVolume // ignore: cast_nullable_to_non_nullable as double, - preferredSpeed: null == preferredSpeed - ? _value.preferredSpeed - : preferredSpeed // ignore: cast_nullable_to_non_nullable + preferredDefaultSpeed: null == preferredDefaultSpeed + ? _value.preferredDefaultSpeed + : preferredDefaultSpeed // ignore: cast_nullable_to_non_nullable as double, + speedOptions: null == speedOptions + ? _value._speedOptions + : speedOptions // ignore: cast_nullable_to_non_nullable + as List, sleepTimer: null == sleepTimer ? _value.sleepTimer : sleepTimer // ignore: cast_nullable_to_non_nullable @@ -384,9 +397,11 @@ class _$PlayerSettingsImpl implements _PlayerSettings { const _$PlayerSettingsImpl( {this.miniPlayerSettings = const MinimizedPlayerSettings(), this.expandedPlayerSettings = const ExpandedPlayerSettings(), - this.preferredVolume = 1, - this.preferredSpeed = 1, - this.sleepTimer = const Duration(minutes: 15)}); + this.preferredDefaultVolume = 1, + this.preferredDefaultSpeed = 1, + final List speedOptions = const [0.8, 1, 1.25, 1.5, 1.75, 2], + this.sleepTimer = const Duration(minutes: 15)}) + : _speedOptions = speedOptions; factory _$PlayerSettingsImpl.fromJson(Map json) => _$$PlayerSettingsImplFromJson(json); @@ -399,17 +414,26 @@ class _$PlayerSettingsImpl implements _PlayerSettings { final ExpandedPlayerSettings expandedPlayerSettings; @override @JsonKey() - final double preferredVolume; + final double preferredDefaultVolume; @override @JsonKey() - final double preferredSpeed; + final double preferredDefaultSpeed; + final List _speedOptions; + @override + @JsonKey() + List get speedOptions { + if (_speedOptions is EqualUnmodifiableListView) return _speedOptions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_speedOptions); + } + @override @JsonKey() final Duration sleepTimer; @override String toString() { - return 'PlayerSettings(miniPlayerSettings: $miniPlayerSettings, expandedPlayerSettings: $expandedPlayerSettings, preferredVolume: $preferredVolume, preferredSpeed: $preferredSpeed, sleepTimer: $sleepTimer)'; + return 'PlayerSettings(miniPlayerSettings: $miniPlayerSettings, expandedPlayerSettings: $expandedPlayerSettings, preferredDefaultVolume: $preferredDefaultVolume, preferredDefaultSpeed: $preferredDefaultSpeed, speedOptions: $speedOptions, sleepTimer: $sleepTimer)'; } @override @@ -421,18 +445,26 @@ class _$PlayerSettingsImpl implements _PlayerSettings { other.miniPlayerSettings == miniPlayerSettings) && (identical(other.expandedPlayerSettings, expandedPlayerSettings) || other.expandedPlayerSettings == expandedPlayerSettings) && - (identical(other.preferredVolume, preferredVolume) || - other.preferredVolume == preferredVolume) && - (identical(other.preferredSpeed, preferredSpeed) || - other.preferredSpeed == preferredSpeed) && + (identical(other.preferredDefaultVolume, preferredDefaultVolume) || + other.preferredDefaultVolume == preferredDefaultVolume) && + (identical(other.preferredDefaultSpeed, preferredDefaultSpeed) || + other.preferredDefaultSpeed == preferredDefaultSpeed) && + const DeepCollectionEquality() + .equals(other._speedOptions, _speedOptions) && (identical(other.sleepTimer, sleepTimer) || other.sleepTimer == sleepTimer)); } @JsonKey(ignore: true) @override - int get hashCode => Object.hash(runtimeType, miniPlayerSettings, - expandedPlayerSettings, preferredVolume, preferredSpeed, sleepTimer); + int get hashCode => Object.hash( + runtimeType, + miniPlayerSettings, + expandedPlayerSettings, + preferredDefaultVolume, + preferredDefaultSpeed, + const DeepCollectionEquality().hash(_speedOptions), + sleepTimer); @JsonKey(ignore: true) @override @@ -453,8 +485,9 @@ abstract class _PlayerSettings implements PlayerSettings { const factory _PlayerSettings( {final MinimizedPlayerSettings miniPlayerSettings, final ExpandedPlayerSettings expandedPlayerSettings, - final double preferredVolume, - final double preferredSpeed, + final double preferredDefaultVolume, + final double preferredDefaultSpeed, + final List speedOptions, final Duration sleepTimer}) = _$PlayerSettingsImpl; factory _PlayerSettings.fromJson(Map json) = @@ -465,9 +498,11 @@ abstract class _PlayerSettings implements PlayerSettings { @override ExpandedPlayerSettings get expandedPlayerSettings; @override - double get preferredVolume; + double get preferredDefaultVolume; @override - double get preferredSpeed; + double get preferredDefaultSpeed; + @override + List get speedOptions; @override Duration get sleepTimer; @override diff --git a/lib/settings/models/app_settings.g.dart b/lib/settings/models/app_settings.g.dart index 278da73..d4f710c 100644 --- a/lib/settings/models/app_settings.g.dart +++ b/lib/settings/models/app_settings.g.dart @@ -34,8 +34,14 @@ _$PlayerSettingsImpl _$$PlayerSettingsImplFromJson(Map json) => ? const ExpandedPlayerSettings() : ExpandedPlayerSettings.fromJson( json['expandedPlayerSettings'] as Map), - preferredVolume: (json['preferredVolume'] as num?)?.toDouble() ?? 1, - preferredSpeed: (json['preferredSpeed'] as num?)?.toDouble() ?? 1, + preferredDefaultVolume: + (json['preferredDefaultVolume'] as num?)?.toDouble() ?? 1, + preferredDefaultSpeed: + (json['preferredDefaultSpeed'] as num?)?.toDouble() ?? 1, + speedOptions: (json['speedOptions'] as List?) + ?.map((e) => (e as num).toDouble()) + .toList() ?? + const [0.8, 1, 1.25, 1.5, 1.75, 2], sleepTimer: json['sleepTimer'] == null ? const Duration(minutes: 15) : Duration(microseconds: (json['sleepTimer'] as num).toInt()), @@ -46,8 +52,9 @@ Map _$$PlayerSettingsImplToJson( { 'miniPlayerSettings': instance.miniPlayerSettings, 'expandedPlayerSettings': instance.expandedPlayerSettings, - 'preferredVolume': instance.preferredVolume, - 'preferredSpeed': instance.preferredSpeed, + 'preferredDefaultVolume': instance.preferredDefaultVolume, + 'preferredDefaultSpeed': instance.preferredDefaultSpeed, + 'speedOptions': instance.speedOptions, 'sleepTimer': instance.sleepTimer.inMicroseconds, };