diff --git a/lib/features/item_viewer/view/library_item_hero_section.dart b/lib/features/item_viewer/view/library_item_hero_section.dart index e8c58be..8f17c5f 100644 --- a/lib/features/item_viewer/view/library_item_hero_section.dart +++ b/lib/features/item_viewer/view/library_item_hero_section.dart @@ -1,7 +1,6 @@ import 'package:animated_theme_switcher/animated_theme_switcher.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelfsdk/audiobookshelf_api.dart' as shelfsdk; @@ -195,7 +194,7 @@ class _LibraryItemProgressIndicator extends HookConsumerWidget { // time remaining Text( // only show 2 decimal places - '${remainingTime.formattedBinary} left', + '${remainingTime.smartBinaryFormat} left', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: diff --git a/lib/features/player/view/player_when_expanded.dart b/lib/features/player/view/player_when_expanded.dart index fdcf4eb..f77f1ae 100644 --- a/lib/features/player/view/player_when_expanded.dart +++ b/lib/features/player/view/player_when_expanded.dart @@ -12,9 +12,11 @@ import 'package:whispering_pages/features/sleep_timer/providers/sleep_timer_prov show sleepTimerProvider; import 'package:whispering_pages/settings/app_settings_provider.dart'; import 'package:whispering_pages/shared/extensions/inverse_lerp.dart'; +import 'package:whispering_pages/shared/widgets/not_implemented.dart'; import 'widgets/audiobook_player_seek_button.dart'; import 'widgets/audiobook_player_seek_chapter_button.dart'; +import 'widgets/chapter_selection_button.dart'; import 'widgets/player_speed_adjust_button.dart'; var pendingPlayerModals = 0; @@ -46,7 +48,6 @@ class PlayerWhenExpanded extends HookConsumerWidget { earlyEnd, ) .clamp(0.0, 1.0); - final currentBook = ref.watch(currentlyPlayingBookProvider); final currentChapter = ref.watch(currentPlayingChapterProvider); final currentBookMetadata = ref.watch(currentBookMetadataProvider); @@ -86,7 +87,9 @@ class PlayerWhenExpanded extends HookConsumerWidget { // the cast button IconButton( icon: const Icon(Icons.cast), - onPressed: () {}, + onPressed: () { + showNotImplementedToast(context); + }, ), ], ), @@ -240,14 +243,14 @@ class PlayerWhenExpanded extends HookConsumerWidget { // sleep timer const SleepTimerButton(), // chapter list - IconButton( - icon: const Icon(Icons.menu_book_rounded), - onPressed: () {}, - ), + const ChapterSelectionButton(), // settings IconButton( icon: const Icon(Icons.more_horiz), - onPressed: () {}, + onPressed: () { + // show toast + showNotImplementedToast(context); + }, ), ], ), diff --git a/lib/features/player/view/widgets/chapter_selection_button.dart b/lib/features/player/view/widgets/chapter_selection_button.dart new file mode 100644 index 0000000..1bbf178 --- /dev/null +++ b/lib/features/player/view/widgets/chapter_selection_button.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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/player_when_expanded.dart'; +import 'package:whispering_pages/shared/extensions/chapter.dart'; +import 'package:whispering_pages/shared/extensions/duration_format.dart'; +import 'package:whispering_pages/shared/hooks.dart'; + +class ChapterSelectionButton extends HookConsumerWidget { + const ChapterSelectionButton({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Tooltip( + message: 'Chapters', + child: IconButton( + icon: const Icon(Icons.menu_book_rounded), + onPressed: () async { + pendingPlayerModals++; + await showModalBottomSheet( + context: context, + barrierLabel: 'Select Chapter', + constraints: BoxConstraints( + // 40% of the screen height + maxHeight: MediaQuery.of(context).size.height * 0.4, + ), + builder: (context) { + return const Padding( + padding: EdgeInsets.all(8.0), + child: ChapterSelectionModal(), + ); + }, + ); + pendingPlayerModals--; + }, + ), + ); + } +} + +class ChapterSelectionModal extends HookConsumerWidget { + const ChapterSelectionModal({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentChapter = ref.watch(currentPlayingChapterProvider); + final currentBook = ref.watch(currentlyPlayingBookProvider); + final notifier = ref.watch(audiobookPlayerProvider); + final currentChapterIndex = currentChapter?.id; + final chapterKey = GlobalKey(); + scrollToCurrentChapter() async { + debugPrint('scrolling to chapter'); + await Scrollable.ensureVisible( + chapterKey.currentContext!, + duration: 200.ms, + alignment: 0.5, + curve: Curves.easeInOut, + ); + } + + useTimer(scrollToCurrentChapter, 500.ms); + // useInterval(scrollToCurrentChapter, 500.ms); + return Column( + children: [ + ListTile( + title: Text( + 'Chapters${currentChapterIndex == null ? '' : ' (${currentChapterIndex + 1}/${currentBook?.chapters.length})'}', + ), + ), + // scroll to current chapter after opening the dialog + Expanded( + child: Scrollbar( + child: SingleChildScrollView( + child: currentBook?.chapters == null + ? const Text('No chapters found') + : Column( + children: [ + for (final chapter in currentBook!.chapters) + ListTile( + title: Text(chapter.title), + trailing: Text( + '(${chapter.duration.smartBinaryFormat})', + ), + selected: currentChapterIndex == chapter.id, + key: currentChapterIndex == chapter.id + ? chapterKey + : null, + onTap: () { + Navigator.of(context).pop(); + notifier.seek(chapter.start + 90.ms); + notifier.play(); + }, + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/shared/extensions/chapter.dart b/lib/shared/extensions/chapter.dart new file mode 100644 index 0000000..7490a64 --- /dev/null +++ b/lib/shared/extensions/chapter.dart @@ -0,0 +1,8 @@ +import 'package:shelfsdk/audiobookshelf_api.dart'; + +extension ChapterDuration on BookChapter { + Duration get duration { + // end - start + return end - start; + } +} diff --git a/lib/shared/extensions/duration_format.dart b/lib/shared/extensions/duration_format.dart index db6ed19..d871424 100644 --- a/lib/shared/extensions/duration_format.dart +++ b/lib/shared/extensions/duration_format.dart @@ -1,12 +1,20 @@ extension DurationFormat on Duration { - /// formats the duration of the book as `10h 30m` + /// formats the duration using only 2 units /// - /// will add up all the durations of the audio files first - /// then convert them to the given format - String get formattedBinary { + /// if the duration is more than 1 hour, it will return `10h 30m` + /// if the duration is less than 1 hour, it will return `30m 20s` + /// if the duration is less than 1 minute, it will return `20s` + String get smartBinaryFormat { final hours = inHours; final minutes = inMinutes.remainder(60); - return '${hours}h ${minutes}m'; + final seconds = inSeconds.remainder(60); + if (hours > 0) { + return '${hours}h ${minutes}m'; + } else if (minutes > 0) { + return '${minutes}m ${seconds}s'; + } else { + return '${seconds}s'; + } } } diff --git a/lib/shared/hooks.dart b/lib/shared/hooks.dart index e8f4297..8e27b3a 100644 --- a/lib/shared/hooks.dart +++ b/lib/shared/hooks.dart @@ -15,3 +15,16 @@ void useInterval(VoidCallback callback, Duration delay) { [delay], ); } + +void useTimer(VoidCallback callback, Duration delay) { + final savedCallback = useRef(callback); + savedCallback.value = callback; + + useEffect( + () { + final timer = Timer(delay, savedCallback.value); + return timer.cancel; + }, + [delay], + ); +} diff --git a/lib/shared/widgets/not_implemented.dart b/lib/shared/widgets/not_implemented.dart new file mode 100644 index 0000000..801f152 --- /dev/null +++ b/lib/shared/widgets/not_implemented.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +void showNotImplementedToast(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Not implemented"), + showCloseIcon: true, + ), + ); +}