diff --git a/lib/api/api_provider.dart b/lib/api/api_provider.dart index 219d353..0e5a590 100644 --- a/lib/api/api_provider.dart +++ b/lib/api/api_provider.dart @@ -142,3 +142,12 @@ FutureOr fetchContinueListening( // ); return res!; } + +@riverpod +FutureOr me( + MeRef ref, +) async { + final api = ref.watch(authenticatedApiProvider); + final res = await api.me.getUser(); + return res!; +} diff --git a/lib/api/api_provider.g.dart b/lib/api/api_provider.g.dart index d9d27d9..eb91f78 100644 --- a/lib/api/api_provider.g.dart +++ b/lib/api/api_provider.g.dart @@ -347,6 +347,20 @@ final fetchContinueListeningProvider = typedef FetchContinueListeningRef = AutoDisposeFutureProviderRef; +String _$meHash() => r'bdc664c4fd867ad13018fa769ce7a6913248c44f'; + +/// See also [me]. +@ProviderFor(me) +final meProvider = AutoDisposeFutureProvider.internal( + me, + name: r'meProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$meHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef MeRef = AutoDisposeFutureProviderRef; String _$personalizedViewHash() => r'2e70fe2bfc766a963f7a8e94211ad50d959fbaa2'; /// fetch the personalized view diff --git a/lib/api/library_item_provider.g.dart b/lib/api/library_item_provider.g.dart index 186dfc6..68b1586 100644 --- a/lib/api/library_item_provider.g.dart +++ b/lib/api/library_item_provider.g.dart @@ -6,7 +6,7 @@ part of 'library_item_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$libraryItemHash() => r'6442db4e802e0a072689b8ff6c2b9aaa99cf0f17'; +String _$libraryItemHash() => r'4c9a9e6d6700c7c76fbf56ecf5c0873155d5061a'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/constants/hero_tag_conventions.dart b/lib/constants/hero_tag_conventions.dart index 2605bd7..6934c00 100644 --- a/lib/constants/hero_tag_conventions.dart +++ b/lib/constants/hero_tag_conventions.dart @@ -9,4 +9,6 @@ class HeroTagPrefixes { static const String authorName = 'author_name_'; static const String bookTitle = 'book_title_'; static const String narratorName = 'narrator_name_'; + static const String libraryItemPlayButton = 'library_item_play_button_'; + } diff --git a/lib/features/item_viewer/view/library_item_actions.dart b/lib/features/item_viewer/view/library_item_actions.dart index 0d987d8..61b52a0 100644 --- a/lib/features/item_viewer/view/library_item_actions.dart +++ b/lib/features/item_viewer/view/library_item_actions.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelfsdk/audiobookshelf_api.dart' as shelfsdk; +import 'package:whispering_pages/constants/hero_tag_conventions.dart'; import 'package:whispering_pages/features/item_viewer/view/library_item_page.dart'; import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; import 'package:whispering_pages/settings/app_settings_provider.dart'; @@ -120,39 +121,15 @@ class _LibraryItemPlayButton extends HookConsumerWidget { } return ElevatedButton.icon( - onPressed: () async { - debugPrint('Pressed play/resume button'); - // set the book to the player if not already set - if (!isCurrentBookSetInPlayer) { - debugPrint('Setting the book ${book.libraryItemId}'); - debugPrint('Initial position: ${userMediaProgress?.currentTime}'); - await player.setSourceAudioBook( - book, - initialPosition: userMediaProgress?.currentTime, - ); - } else { - debugPrint('Book was already set'); - if (isPlayingThisBook) { - debugPrint('Pausing the book'); - await player.pause(); - return; - } - } - // toggle play/pause - await player.play(); - // set the volume as this is the first time playing and dismissing causes the volume to go to 0 - await player.setVolume( - ref.read(appSettingsProvider).playerSettings.preferredDefaultVolume, - ); - }, - icon: Icon( - isCurrentBookSetInPlayer - ? isPlayingThisBook - ? Icons.pause_rounded - : Icons.play_arrow_rounded - : isBookCompleted - ? Icons.replay_rounded - : Icons.play_arrow_rounded, + onPressed: () => libraryItemPlayButtonOnPressed( + ref: ref, book: book, userMediaProgress: userMediaProgress), + icon: Hero( + tag: HeroTagPrefixes.libraryItemPlayButton + book.libraryItemId, + child: DynamicItemPlayIcon( + isCurrentBookSetInPlayer: isCurrentBookSetInPlayer, + isPlayingThisBook: isPlayingThisBook, + isBookCompleted: isBookCompleted, + ), ), label: Text(getPlayDisplayText()), style: ElevatedButton.styleFrom( @@ -163,3 +140,64 @@ class _LibraryItemPlayButton extends HookConsumerWidget { ); } } + +class DynamicItemPlayIcon extends StatelessWidget { + const DynamicItemPlayIcon({ + super.key, + required this.isCurrentBookSetInPlayer, + required this.isPlayingThisBook, + required this.isBookCompleted, + }); + + final bool isCurrentBookSetInPlayer; + final bool isPlayingThisBook; + final bool isBookCompleted; + + @override + Widget build(BuildContext context) { + return Icon( + isCurrentBookSetInPlayer + ? isPlayingThisBook + ? Icons.pause_rounded + : Icons.play_arrow_rounded + : isBookCompleted + ? Icons.replay_rounded + : Icons.play_arrow_rounded, + ); + } +} + +Future libraryItemPlayButtonOnPressed({ + required WidgetRef ref, + required shelfsdk.BookExpanded book, + shelfsdk.MediaProgress? userMediaProgress, +}) async { + debugPrint('Pressed play/resume button'); + final player = ref.watch(audiobookPlayerProvider); + + final isCurrentBookSetInPlayer = player.book == book; + final isPlayingThisBook = player.playing && isCurrentBookSetInPlayer; + + // set the book to the player if not already set + if (!isCurrentBookSetInPlayer) { + debugPrint('Setting the book ${book.libraryItemId}'); + debugPrint('Initial position: ${userMediaProgress?.currentTime}'); + await player.setSourceAudioBook( + book, + initialPosition: userMediaProgress?.currentTime, + ); + } else { + debugPrint('Book was already set'); + if (isPlayingThisBook) { + debugPrint('Pausing the book'); + await player.pause(); + return; + } + } + // toggle play/pause + await player.play(); + // set the volume as this is the first time playing and dismissing causes the volume to go to 0 + await player.setVolume( + ref.read(appSettingsProvider).playerSettings.preferredDefaultVolume, + ); +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 2707163..c651c4c 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -12,9 +12,9 @@ class HomePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // hooks for the dark mode final settings = ref.watch(appSettingsProvider); final api = ref.watch(authenticatedApiProvider); + final me = ref.watch(meProvider); final views = ref.watch(personalizedViewProvider); final scrollController = useScrollController(); return Scaffold( diff --git a/lib/shared/extensions/model_conversions.dart b/lib/shared/extensions/model_conversions.dart index d5bb7a3..def0639 100644 --- a/lib/shared/extensions/model_conversions.dart +++ b/lib/shared/extensions/model_conversions.dart @@ -1,11 +1,9 @@ import 'package:shelfsdk/audiobookshelf_api.dart'; extension LibraryItemConversion on LibraryItem { - LibraryItemExpanded get asExpanded => - LibraryItemExpanded.fromJson(toJson()); + LibraryItemExpanded get asExpanded => LibraryItemExpanded.fromJson(toJson()); - LibraryItemMinified get asMinified => - LibraryItemMinified.fromJson(toJson()); + LibraryItemMinified get asMinified => LibraryItemMinified.fromJson(toJson()); } extension MediaConversion on Media { @@ -46,3 +44,10 @@ extension ShelfConversion on Shelf { SeriesShelf get asSeriesShelf => SeriesShelf.fromJson(toJson()); AuthorShelf get asAuthorShelf => AuthorShelf.fromJson(toJson()); } + +extension UserConversion on User { + UserWithSessionAndMostRecentProgress + get asUserWithSessionAndMostRecentProgress => + UserWithSessionAndMostRecentProgress.fromJson(toJson()); + User get asUser => User.fromJson(toJson()); +} diff --git a/lib/shared/widgets/shelves/book_shelf.dart b/lib/shared/widgets/shelves/book_shelf.dart index 3f64936..eacce4c 100644 --- a/lib/shared/widgets/shelves/book_shelf.dart +++ b/lib/shared/widgets/shelves/book_shelf.dart @@ -1,16 +1,24 @@ import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:shimmer/shimmer.dart' show Shimmer; +import 'package:whispering_pages/api/api_provider.dart'; import 'package:whispering_pages/api/image_provider.dart'; +import 'package:whispering_pages/api/library_item_provider.dart' + show libraryItemProvider; import 'package:whispering_pages/constants/hero_tag_conventions.dart'; +import 'package:whispering_pages/features/item_viewer/view/library_item_actions.dart'; +import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; import 'package:whispering_pages/router/models/library_item_extras.dart'; import 'package:whispering_pages/router/router.dart'; +import 'package:whispering_pages/settings/app_settings_provider.dart'; import 'package:whispering_pages/shared/extensions/model_conversions.dart'; import 'package:whispering_pages/shared/widgets/shelves/home_shelf.dart'; +import 'package:whispering_pages/theme/theme_from_cover_provider.dart'; /// A shelf that displays books on the home page class BookHomeShelf extends HookConsumerWidget { @@ -49,10 +57,14 @@ class BookOnShelf extends HookConsumerWidget { super.key, required this.item, this.heroTagSuffix = '', + this.showPlayButton = true, }); final LibraryItem item; + /// whether to show the play button on the book cover + final bool showPlayButton; + /// makes the hero tag unique final String heroTagSuffix; @@ -65,97 +77,119 @@ class BookOnShelf extends HookConsumerWidget { builder: (context, constraints) { final height = min(constraints.maxHeight, 500); final width = height * 0.75; + handleTapOnBook() { + // open the book + context.pushNamed( + Routes.libraryItem.name, + pathParameters: { + Routes.libraryItem.pathParamName!: item.id, + }, + extra: LibraryItemExtras( + book: book, + heroTagSuffix: heroTagSuffix, + coverImage: coverImage.valueOrNull, + ), + ); + } + return SizedBox( width: width, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // the cover image of the book - // take up remaining space hence the expanded - Expanded( - child: Center( - child: Hero( - tag: HeroTagPrefixes.bookCover + item.id + heroTagSuffix, - child: InkWell( - onTap: () { - // open the book - context.pushNamed( - Routes.libraryItem.name, - pathParameters: { - Routes.libraryItem.pathParamName!: item.id, - }, - extra: LibraryItemExtras( - book: book, - heroTagSuffix: heroTagSuffix, - coverImage: coverImage.valueOrNull, - ), - ); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: coverImage.when( - data: (image) { - // return const BookCoverSkeleton(); - if (image.isEmpty) { - return const Icon(Icons.error); - } - var imageWidget = Image.memory( - image, - fit: BoxFit.fill, - cacheWidth: (height * - 1.2 * - MediaQuery.of(context).devicePixelRatio) - .round(), - ); - return Container( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, + child: InkWell( + onTap: handleTapOnBook, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: + const EdgeInsets.only(bottom: 8.0, right: 4.0, left: 4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // the cover image of the book + // take up remaining space hence the expanded + Expanded( + child: Center( + child: Stack( + alignment: Alignment.bottomRight, + children: [ + Hero( + tag: HeroTagPrefixes.bookCover + + item.id + + heroTagSuffix, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: coverImage.when( + data: (image) { + // return const BookCoverSkeleton(); + if (image.isEmpty) { + return const Icon(Icons.error); + } + var imageWidget = Image.memory( + image, + fit: BoxFit.fill, + cacheWidth: (height * + 1.2 * + MediaQuery.of(context) + .devicePixelRatio) + .round(), + ); + return Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + child: imageWidget, + ); + }, + loading: () { + return const Center( + child: BookCoverSkeleton(), + ); + }, + error: (error, stack) { + return const Icon(Icons.error); + }, ), - child: imageWidget, - ); - }, - loading: () { - return const Center(child: BookCoverSkeleton()); - }, - error: (error, stack) { - return const Icon(Icons.error); - }, - ), + ), + ), + // a play button on the book cover + if (showPlayButton) + _BookOnShelfPlayButton( + libraryItemId: item.id, + ), + ], ), ), ), - ), + // the title and author of the book + // AutoScrollText( + Hero( + tag: HeroTagPrefixes.bookTitle + item.id + heroTagSuffix, + child: Text( + metadata.title ?? '', + // mode: AutoScrollTextMode.bouncing, + // curve: Curves.easeInOut, + // velocity: const Velocity(pixelsPerSecond: Offset(15, 0)), + // delayBefore: const Duration(seconds: 2), + // pauseBetween: const Duration(seconds: 2), + // numberOfReps: 15, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + const SizedBox(height: 3), + Hero( + tag: HeroTagPrefixes.authorName + item.id + heroTagSuffix, + child: Text( + metadata.authorName ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], ), - // the title and author of the book - // AutoScrollText( - Hero( - tag: HeroTagPrefixes.bookTitle + item.id + heroTagSuffix, - child: Text( - metadata.title ?? '', - // mode: AutoScrollTextMode.bouncing, - // curve: Curves.easeInOut, - // velocity: const Velocity(pixelsPerSecond: Offset(15, 0)), - // delayBefore: const Duration(seconds: 2), - // pauseBetween: const Duration(seconds: 2), - // numberOfReps: 15, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - const SizedBox(height: 3), - Hero( - tag: HeroTagPrefixes.authorName + item.id + heroTagSuffix, - child: Text( - metadata.authorName ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], + ), ), ); }, @@ -163,6 +197,113 @@ class BookOnShelf extends HookConsumerWidget { } } +class _BookOnShelfPlayButton extends HookConsumerWidget { + const _BookOnShelfPlayButton({ + super.key, + required this.libraryItemId, + }); + + /// the id of the library item of the book + final String libraryItemId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final me = ref.watch(meProvider); + final player = ref.watch(audiobookPlayerProvider); + final isCurrentBookSetInPlayer = + player.book?.libraryItemId == libraryItemId; + final isPlayingThisBook = player.playing && isCurrentBookSetInPlayer; + + final userProgress = me.valueOrNull?.mediaProgress + ?.firstWhereOrNull((element) => element.libraryItemId == libraryItemId); + final isBookCompleted = userProgress?.isFinished ?? false; + + const size = 40.0; + + // if there is user progress for this book show a circular progress indicator around the play button + var strokeWidth = size / 8; + + final useMaterialThemeOnItemPage = + ref.watch(appSettingsProvider).useMaterialThemeOnItemPage; + + AsyncValue coverColorScheme = const AsyncValue.loading(); + if (useMaterialThemeOnItemPage && isCurrentBookSetInPlayer) { + final itemFromApi = ref.watch(libraryItemProvider(libraryItemId)); + coverColorScheme = ref.watch( + themeOfLibraryItemProvider( + itemFromApi.valueOrNull, + brightness: Theme.of(context).brightness, + ), + ); + } + + return Theme( + // if current book is set in player, get theme from the cover image + data: ThemeData( + colorScheme: + coverColorScheme.valueOrNull ?? Theme.of(context).colorScheme, + ), + child: Padding( + padding: EdgeInsets.all(strokeWidth / 2 + 2), + child: Stack( + alignment: Alignment.center, + children: [ + // the circular progress indicator + if (userProgress != null) + SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + value: userProgress.progress, + strokeWidth: strokeWidth, + backgroundColor: + Theme.of(context).colorScheme.onPrimary.withOpacity(0.8), + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), + ), + + // the play button + IconButton( + color: Theme.of(context).colorScheme.primary, + style: ButtonStyle( + padding: WidgetStateProperty.all( + EdgeInsets.zero, + ), + minimumSize: WidgetStateProperty.all( + const Size(size, size), + ), + backgroundColor: WidgetStateProperty.all( + Theme.of(context).colorScheme.onPrimary.withOpacity(0.9), + ), + ), + onPressed: () async { + final book = + await ref.watch(libraryItemProvider(libraryItemId).future); + + libraryItemPlayButtonOnPressed( + ref: ref, + book: book.media.asBookExpanded, + userMediaProgress: userProgress, + ); + }, + icon: Hero( + tag: HeroTagPrefixes.libraryItemPlayButton + libraryItemId, + child: DynamicItemPlayIcon( + isBookCompleted: isBookCompleted, + isPlayingThisBook: isPlayingThisBook, + isCurrentBookSetInPlayer: isCurrentBookSetInPlayer, + ), + ), + ), + ], + ), + ), + ); + } +} + // a skeleton for the book cover class BookCoverSkeleton extends StatelessWidget { const BookCoverSkeleton({ diff --git a/lib/shared/widgets/shelves/home_shelf.dart b/lib/shared/widgets/shelves/home_shelf.dart index 11e34c4..c2d3a7f 100644 --- a/lib/shared/widgets/shelves/home_shelf.dart +++ b/lib/shared/widgets/shelves/home_shelf.dart @@ -83,7 +83,7 @@ class SimpleHomeShelf extends HookConsumerWidget { return const SizedBox(); } - return const SizedBox(width: 16); + return const SizedBox(width: 4); }, itemCount: children.length + 2, // add some extra space at the start and end so that the first and last items are not at the edge