diff --git a/lib/api/library_item_provider.dart b/lib/api/library_item_provider.dart index ded3fe7..da693d0 100644 --- a/lib/api/library_item_provider.dart +++ b/lib/api/library_item_provider.dart @@ -6,6 +6,7 @@ import 'package:shelfsdk/audiobookshelf_api.dart' as shelfsdk; import 'package:whispering_pages/api/api_provider.dart'; import 'package:whispering_pages/db/cache/cache_key.dart'; import 'package:whispering_pages/db/cache_manager.dart'; +import 'package:whispering_pages/shared/extensions/model_conversions.dart'; part 'library_item_provider.g.dart'; @@ -13,10 +14,10 @@ part 'library_item_provider.g.dart'; @riverpod class LibraryItem extends _$LibraryItem { @override - Stream build(String id) async* { + Stream build(String id) async* { final api = ref.watch(authenticatedApiProvider); - debugPrint('fetching library item: $id'); + debugPrint('LibraryItemProvider fetching library item: $id'); // ! this is a mock delay // await Future.delayed(const Duration(seconds: 10)); @@ -26,27 +27,41 @@ class LibraryItem extends _$LibraryItem { final cachedFile = await apiResponseCacheManager.getFileFromMemory(key) ?? await apiResponseCacheManager.getFileFromCache(key); if (cachedFile != null) { - debugPrint('reading from cache for $id from ${cachedFile.file}'); + debugPrint('LibraryItemProvider reading from cache for $id from ${cachedFile.file}'); // read file as json - final cachedItem = shelfsdk.LibraryItem.fromJson( + final cachedItem = shelfsdk.LibraryItemExpanded.fromJson( jsonDecode(await cachedFile.file.readAsString()), ); yield cachedItem; + } else { + debugPrint('LibraryItemProvider cache miss for $id'); } + + // ! this is a mock delay + // await Future.delayed(const Duration(seconds: 3)); + final item = await api.items.get( libraryItemId: id, - parameters: const shelfsdk.GetItemReqParams(expanded: true), + parameters: const shelfsdk.GetItemReqParams( + expanded: true, + include: [ + shelfsdk.GetItemIncludeOption.progress, + shelfsdk.GetItemIncludeOption.rssFeed, + shelfsdk.GetItemIncludeOption.authors, + shelfsdk.GetItemIncludeOption.downloads, + ], + ), ); if (item != null) { // save to cache final newFile = await apiResponseCacheManager.putFile( key, - utf8.encode(jsonEncode(item)), + utf8.encode(jsonEncode(item.asExpanded.toJson())), fileExtension: 'json', key: key, ); debugPrint('writing to cache: $newFile'); - yield item; + yield item.asExpanded; } } } diff --git a/lib/api/library_item_provider.g.dart b/lib/api/library_item_provider.g.dart index 4c8a2b4..186dfc6 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'ce6222e417b43dceed9ea7e5a8b43782755fc117'; +String _$libraryItemHash() => r'6442db4e802e0a072689b8ff6c2b9aaa99cf0f17'; /// Copied from Dart SDK class _SystemHash { @@ -30,10 +30,10 @@ class _SystemHash { } abstract class _$LibraryItem - extends BuildlessAutoDisposeStreamNotifier { + extends BuildlessAutoDisposeStreamNotifier { late final String id; - Stream build( + Stream build( String id, ); } @@ -47,7 +47,8 @@ const libraryItemProvider = LibraryItemFamily(); /// provides the library item for the given id /// /// Copied from [LibraryItem]. -class LibraryItemFamily extends Family> { +class LibraryItemFamily + extends Family> { /// provides the library item for the given id /// /// Copied from [LibraryItem]. @@ -92,7 +93,7 @@ class LibraryItemFamily extends Family> { /// /// Copied from [LibraryItem]. class LibraryItemProvider extends AutoDisposeStreamNotifierProviderImpl< - LibraryItem, shelfsdk.LibraryItem> { + LibraryItem, shelfsdk.LibraryItemExpanded> { /// provides the library item for the given id /// /// Copied from [LibraryItem]. @@ -125,7 +126,7 @@ class LibraryItemProvider extends AutoDisposeStreamNotifierProviderImpl< final String id; @override - Stream runNotifierBuild( + Stream runNotifierBuild( covariant LibraryItem notifier, ) { return notifier.build( @@ -150,8 +151,8 @@ class LibraryItemProvider extends AutoDisposeStreamNotifierProviderImpl< } @override - AutoDisposeStreamNotifierProviderElement - createElement() { + AutoDisposeStreamNotifierProviderElement createElement() { return _LibraryItemProviderElement(this); } @@ -170,14 +171,14 @@ class LibraryItemProvider extends AutoDisposeStreamNotifierProviderImpl< } mixin LibraryItemRef - on AutoDisposeStreamNotifierProviderRef { + on AutoDisposeStreamNotifierProviderRef { /// The parameter `id` of this provider. String get id; } class _LibraryItemProviderElement extends AutoDisposeStreamNotifierProviderElement with LibraryItemRef { + shelfsdk.LibraryItemExpanded> with LibraryItemRef { _LibraryItemProviderElement(super.provider); @override diff --git a/lib/db/cache/cache_key.dart b/lib/db/cache/cache_key.dart index a152de5..0f38f92 100644 --- a/lib/db/cache/cache_key.dart +++ b/lib/db/cache/cache_key.dart @@ -1,5 +1,5 @@ class CacheKey { - static libraryItem(String id) { + static String libraryItem(String id) { return 'library_item_$id'; } } diff --git a/lib/features/explore/view/explore_page.dart b/lib/features/explore/view/explore_page.dart index 6adb35b..6e63c67 100644 --- a/lib/features/explore/view/explore_page.dart +++ b/lib/features/explore/view/explore_page.dart @@ -14,6 +14,7 @@ import 'package:whispering_pages/features/explore/view/search_result_page.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'; +import 'package:whispering_pages/shared/extensions/model_conversions.dart'; import 'package:whispering_pages/shared/widgets/shelves/book_shelf.dart'; const Duration debounceDuration = Duration(milliseconds: 500); @@ -192,11 +193,8 @@ List buildBookSearchResult( options: options.book.map( (result) { // convert result to a book object - final book = - BookExpanded.fromJson(result.libraryItem.media.toJson()); - final metadata = BookMetadataExpanded.fromJson( - book.metadata.toJson(), - ); + final book = result.libraryItem.media.asBookExpanded; + final metadata = book.metadata.asBookMetadataExpanded; return BookSearchResultMini(book: book, metadata: metadata); }, ), diff --git a/lib/features/explore/view/search_result_page.dart b/lib/features/explore/view/search_result_page.dart index f863079..0b1440c 100644 --- a/lib/features/explore/view/search_result_page.dart +++ b/lib/features/explore/view/search_result_page.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:whispering_pages/features/explore/providers/search_result_provider.dart'; import 'package:whispering_pages/features/explore/view/explore_page.dart'; +import 'package:whispering_pages/shared/extensions/model_conversions.dart'; enum SearchResultCategory { books, @@ -52,12 +53,9 @@ class SearchResultPage extends HookConsumerWidget { SearchResultCategory.books => ListView.builder( itemCount: options.book.length, itemBuilder: (context, index) { - final book = BookExpanded.fromJson( - options.book[index].libraryItem.media.toJson(), - ); - final metadata = BookMetadataExpanded.fromJson( - book.metadata.toJson(), - ); + final book = + options.book[index].libraryItem.media.asBookExpanded; + final metadata = book.metadata.asBookMetadataExpanded; return BookSearchResultMini( book: book, diff --git a/lib/features/item_viewer/view/library_item_actions.dart b/lib/features/item_viewer/view/library_item_actions.dart new file mode 100644 index 0000000..0d987d8 --- /dev/null +++ b/lib/features/item_viewer/view/library_item_actions.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart' as shelfsdk; +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'; +import 'package:whispering_pages/shared/extensions/model_conversions.dart'; + +class LibraryItemActions extends HookConsumerWidget { + LibraryItemActions({ + super.key, + required this.item, + }) { + book = item.media.asBookExpanded; + } + + final shelfsdk.LibraryItemExpanded item; + late final shelfsdk.BookExpanded book; + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = ref.read(audiobookPlayerProvider); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + // play/resume button the same width as image + LayoutBuilder( + builder: (context, constraints) { + return SizedBox( + width: calculateWidth(context, constraints), + // a boxy button with icon and text but little rounded corner + child: _LibraryItemPlayButton(item: item), + ); + }, + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return SizedBox( + width: constraints.maxWidth * 0.6, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // read list button + IconButton( + onPressed: () {}, + icon: const Icon( + Icons.playlist_add_rounded, + ), + ), + // share button + IconButton( + onPressed: () {}, + icon: const Icon(Icons.share_rounded), + ), + // download button + IconButton( + onPressed: () {}, + icon: const Icon( + Icons.download_rounded, + ), + ), + // more button + IconButton( + onPressed: () {}, + icon: const Icon( + Icons.more_vert_rounded, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _LibraryItemPlayButton extends HookConsumerWidget { + const _LibraryItemPlayButton({ + super.key, + required this.item, + }); + + final shelfsdk.LibraryItemExpanded item; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final book = item.media.asBookExpanded; + final player = ref.watch(audiobookPlayerProvider); + final isCurrentBookSetInPlayer = player.book == book; + final isPlayingThisBook = player.playing && isCurrentBookSetInPlayer; + + final userMediaProgress = item.userMediaProgress; + final isBookCompleted = userMediaProgress?.isFinished ?? false; + + String getPlayDisplayText() { + // if book is not set to player + if (!isCurrentBookSetInPlayer) { + // either play or resume or listen again based on the progress + if (isBookCompleted) { + return 'Listen Again'; + } + // if some progress is made, then 'continue listening' + if (userMediaProgress?.progress != null) { + return 'Continue Listening'; + } + return 'Start Listening'; + } else { + // if book is set to player + if (isPlayingThisBook) { + return 'Pause'; + } + return 'Resume'; + } + } + + 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, + ), + label: Text(getPlayDisplayText()), + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + ); + } +} diff --git a/lib/features/item_viewer/view/library_item_hero_section.dart b/lib/features/item_viewer/view/library_item_hero_section.dart new file mode 100644 index 0000000..e8c58be --- /dev/null +++ b/lib/features/item_viewer/view/library_item_hero_section.dart @@ -0,0 +1,502 @@ +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; +import 'package:whispering_pages/api/image_provider.dart'; +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/router/models/library_item_extras.dart'; +import 'package:whispering_pages/settings/app_settings_provider.dart'; +import 'package:whispering_pages/shared/extensions/duration_format.dart'; +import 'package:whispering_pages/shared/extensions/model_conversions.dart'; +import 'package:whispering_pages/shared/widgets/shelves/book_shelf.dart'; + +class LibraryItemHeroSection extends HookConsumerWidget { + const LibraryItemHeroSection({ + super.key, + required this.itemId, + required this.extraMap, + required this.providedCacheImage, + required this.item, + required this.itemBookMetadata, + required this.bookDetailsCached, + required this.coverColorScheme, + }); + + final String itemId; + final LibraryItemExtras? extraMap; + final Image? providedCacheImage; + final AsyncValue item; + final shelfsdk.BookMetadataExpanded? itemBookMetadata; + final shelfsdk.BookMinified? bookDetailsCached; + final AsyncValue coverColorScheme; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SliverToBoxAdapter( + child: LayoutBuilder( + builder: (context, constraints) { + return Container( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + // book cover + LayoutBuilder( + builder: (context, constraints) { + return SizedBox( + width: calculateWidth(context, constraints), + child: Column( + children: [ + Hero( + tag: HeroTagPrefixes.bookCover + + itemId + + (extraMap?.heroTagSuffix ?? ''), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: _BookCover( + itemId: itemId, + extraMap: extraMap, + providedCacheImage: providedCacheImage, + coverColorScheme: coverColorScheme.valueOrNull, + item: item, + ), + ), + ), + // a progress bar if available + item.when( + data: (libraryItem) { + return Padding( + padding: const EdgeInsets.only( + top: 8.0, + right: 8.0, + left: 8.0, + ), + child: _LibraryItemProgressIndicator( + libraryItem: libraryItem, + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (error, stack) => const SizedBox.shrink(), + ), + ], + ), + ); + }, + ), + // book details + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + _BookTitle( + extraMap: extraMap, + itemBookMetadata: itemBookMetadata, + bookDetailsCached: bookDetailsCached, + ), + Container( + margin: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + // authors info if available + _BookAuthors( + itemBookMetadata: itemBookMetadata, + bookDetailsCached: bookDetailsCached, + ), + // narrators info if available + _BookNarrators( + itemBookMetadata: itemBookMetadata, + bookDetailsCached: bookDetailsCached, + ), + // series info if available + _BookSeries( + itemBookMetadata: itemBookMetadata, + bookDetailsCached: bookDetailsCached, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +class _LibraryItemProgressIndicator extends HookConsumerWidget { + const _LibraryItemProgressIndicator({ + super.key, + required this.libraryItem, + }); + + final shelfsdk.LibraryItemExpanded libraryItem; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = ref.watch(audiobookPlayerProvider); + final mediaProgress = libraryItem.userMediaProgress; + if (mediaProgress == null && player.book?.libraryItemId != libraryItem.id) { + return const SizedBox.shrink(); + } + + double progress; + Duration remainingTime; + if (player.book?.libraryItemId == libraryItem.id) { + // final positionStream = useStream(player.slowPositionStream); + progress = (player.positionInBook).inSeconds / + libraryItem.media.asBookExpanded.duration.inSeconds; + remainingTime = + libraryItem.media.asBookExpanded.duration - player.positionInBook; + } else { + progress = mediaProgress?.progress ?? 0; + remainingTime = (libraryItem.media.asBookExpanded.duration - + mediaProgress!.currentTime); + } + + final progressInPercent = progress * 100; + + return Tooltip( + message: 'Progress: ${progressInPercent.toStringAsFixed(2)}%', + waitDuration: 200.ms, + child: Column( + children: [ + // % progress + // Text( + // // only show 2 decimal places + // '${(progressInPercent).toStringAsFixed( + // progressInPercent < 10 ? 1 : 0, + // )}% completed', + // style: Theme.of(context).textTheme.bodySmall, + // ), + + // progress indicator + LinearProgressIndicator( + value: progress.clamp(0.05, 0.95), + borderRadius: BorderRadius.circular(8), + ), + const SizedBox.square( + dimension: 4.0, + ), + // time remaining + Text( + // only show 2 decimal places + '${remainingTime.formattedBinary} left', + + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: + Theme.of(context).colorScheme.onSurface.withOpacity(0.75), + ), + ), + ], + ), + ); + } +} + +class _HeroSectionSubLabelWithIcon extends HookConsumerWidget { + const _HeroSectionSubLabelWithIcon({ + super.key, + required this.icon, + required this.text, + }); + + final IconData icon; + final Widget text; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeData = Theme.of(context); + final useFontAwesome = + icon.runtimeType == FontAwesomeIcons.book.runtimeType; + final useMaterialThemeOnItemPage = + ref.watch(appSettingsProvider).useMaterialThemeOnItemPage; + final color = useMaterialThemeOnItemPage + ? themeData.colorScheme.primary + : themeData.colorScheme.onSurface.withOpacity(0.75); + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Container( + margin: const EdgeInsets.only(right: 8, top: 2), + child: useFontAwesome + ? FaIcon( + icon, + size: 16, + color: color, + ) + : Icon( + icon, + size: 16, + color: color, + ), + ), + Expanded( + child: text, + ), + ], + ), + ); + } +} + +class _BookSeries extends StatelessWidget { + const _BookSeries({ + super.key, + required this.itemBookMetadata, + required this.bookDetailsCached, + }); + + final shelfsdk.BookMetadataExpanded? itemBookMetadata; + final shelfsdk.BookMinified? bookDetailsCached; + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + String generateSeriesString() { + final series = (itemBookMetadata)?.series ?? []; + if (series.isEmpty) { + return (bookDetailsCached?.metadata as shelfsdk.BookMetadataMinified?) + ?.seriesName ?? + ''; + } + return series + .map((e) { + try { + e as shelfsdk.SeriesSequence; + final seq = e.sequence != null ? '#${e.sequence} of ' : ''; + return '$seq${e.name}'; + } catch (e) { + return ''; + } + }) + .toList() + .join(', '); + } + + return generateSeriesString() == '' + ? const SizedBox.shrink() + : _HeroSectionSubLabelWithIcon( + icon: Icons.library_books_rounded, + text: Text( + style: themeData.textTheme.titleSmall, + generateSeriesString(), + ), + ); + } +} + +class _BookNarrators extends StatelessWidget { + const _BookNarrators({ + super.key, + required this.itemBookMetadata, + required this.bookDetailsCached, + }); + + final shelfsdk.BookMetadataExpanded? itemBookMetadata; + final shelfsdk.BookMinified? bookDetailsCached; + + @override + Widget build(BuildContext context) { + String generateNarratorsString() { + final narrators = (itemBookMetadata)?.narrators ?? []; + if (narrators.isEmpty) { + return (bookDetailsCached?.metadata as shelfsdk.BookMetadataMinified?) + ?.narratorName ?? + ''; + } + return narrators.join(', '); + } + + final themeData = Theme.of(context); + + return generateNarratorsString() == '' + ? const SizedBox.shrink() + : _HeroSectionSubLabelWithIcon( + icon: Icons.record_voice_over, + text: Text( + style: themeData.textTheme.titleSmall, + generateNarratorsString(), + ), + ); + } +} + +class _BookCover extends HookConsumerWidget { + const _BookCover({ + super.key, + required this.itemId, + required this.extraMap, + required this.providedCacheImage, + required this.item, + this.coverColorScheme, + }); + + final String itemId; + final LibraryItemExtras? extraMap; + final Image? providedCacheImage; + final AsyncValue item; + final ColorScheme? coverColorScheme; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeData = Theme.of(context); + final useMaterialThemeOnItemPage = + ref.watch(appSettingsProvider).useMaterialThemeOnItemPage; + + return ThemeSwitcher( + builder: (context) { + // change theme after 2 seconds + if (useMaterialThemeOnItemPage) { + Future.delayed(150.ms, () { + try { + ThemeSwitcher.of(context).changeTheme( + theme: coverColorScheme != null + ? ThemeData.from( + colorScheme: coverColorScheme!, + textTheme: themeData.textTheme, + ) + : themeData, + ); + } catch (e) { + debugPrint('Error changing theme: $e'); + } + }); + } + return providedCacheImage ?? + item.when( + data: (libraryItem) { + final coverImage = ref.watch(coverImageProvider(libraryItem)); + return Stack( + children: [ + coverImage.when( + data: (image) { + // return const BookCoverSkeleton(); + if (image.isEmpty) { + return const Icon(Icons.error); + } + // cover 80% of parent height + return Image.memory( + image, + fit: BoxFit.cover, + // cacheWidth: (height * + // MediaQuery.of(context).devicePixelRatio) + // .round(), + ); + }, + loading: () { + return const Center( + child: BookCoverSkeleton(), + ); + }, + error: (error, stack) { + return const Icon(Icons.error); + }, + ), + ], + ); + }, + error: (error, stack) => const Icon(Icons.error), + loading: () => const Center(child: BookCoverSkeleton()), + ); + }, + ); + } +} + +class _BookTitle extends StatelessWidget { + const _BookTitle({ + super.key, + required this.extraMap, + required this.itemBookMetadata, + required this.bookDetailsCached, + }); + + final LibraryItemExtras? extraMap; + final shelfsdk.BookMetadataExpanded? itemBookMetadata; + final shelfsdk.BookMinified? bookDetailsCached; + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Hero( + tag: HeroTagPrefixes.bookTitle + + // itemId + + (extraMap?.heroTagSuffix ?? ''), + child: Text( + // mode: AutoScrollTextMode.bouncing, + // curve: Curves.fastEaseInToSlowEaseOut, + // velocity: const Velocity(pixelsPerSecond: Offset(30, 0)), + // delayBefore: 500.ms, + // pauseBetween: 150.ms, + // numberOfReps: 3, + style: themeData.textTheme.headlineLarge, + itemBookMetadata?.title ?? bookDetailsCached?.metadata.title ?? '', + ), + ), + // subtitle if available + itemBookMetadata?.subtitle == null + ? const SizedBox.shrink() + : Text( + style: themeData.textTheme.titleSmall?.copyWith( + color: themeData.colorScheme.onSurface.withOpacity(0.8), + ), + itemBookMetadata?.subtitle ?? '', + ), + ], + ); + } +} + +class _BookAuthors extends StatelessWidget { + const _BookAuthors({ + super.key, + required this.itemBookMetadata, + required this.bookDetailsCached, + }); + + final shelfsdk.BookMetadataExpanded? itemBookMetadata; + final shelfsdk.BookMinified? bookDetailsCached; + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + String generateAuthorsString() { + final authors = (itemBookMetadata)?.authors ?? []; + if (authors.isEmpty) { + return (bookDetailsCached?.metadata as shelfsdk.BookMetadataMinified?) + ?.authorName ?? + ''; + } + return authors.map((e) => e.name).join(', '); + } + + return generateAuthorsString() == '' + ? const SizedBox.shrink() + : _HeroSectionSubLabelWithIcon( + icon: FontAwesomeIcons.penNib, + text: Text( + style: themeData.textTheme.titleSmall, + generateAuthorsString(), + ), + ); + } +} diff --git a/lib/features/item_viewer/view/library_item_metadata.dart b/lib/features/item_viewer/view/library_item_metadata.dart new file mode 100644 index 0000000..1eef261 --- /dev/null +++ b/lib/features/item_viewer/view/library_item_metadata.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart' as shelfsdk; + +class LibraryItemMetadata extends StatelessWidget { + const LibraryItemMetadata({ + super.key, + required this.item, + this.itemBookMetadata, + this.bookDetailsCached, + }); + + final shelfsdk.LibraryItemExpanded item; + final shelfsdk.BookMetadataExpanded? itemBookMetadata; + final shelfsdk.BookMinified? bookDetailsCached; + + @override + Widget build(BuildContext context) { + final children = [ + // duration of the book + _MetadataItem( + title: switch (itemBookMetadata?.abridged) { + true => 'Abridged', + false => 'Unabridged', + _ => 'Length', + }, + value: getDurationFormatted() ?? 'time is just a concept', + ), + _MetadataItem( + title: 'Published', + value: itemBookMetadata?.publishedDate ?? + itemBookMetadata?.publishedYear ?? + 'Unknown', + ), + _MetadataItem( + title: getCodecAndBitrate() ?? 'Codec & Bitrate', + value: getSizeFormatted() ?? 'Unknown', + ), + ]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: IntrinsicHeight( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + // alternate between metadata and vertical divider + children: List.generate( + children.length * 2 - 1, + (index) { + if (index.isEven) { + return children[index ~/ 2]; + } + return VerticalDivider( + indent: 6, + endIndent: 6, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + ); + }, + ), + ), + ), + ); + } + + /// formats the duration of the book as `10h 30m` + /// + /// will add up all the durations of the audio files first + /// then convert them to the given format + String? getDurationFormatted() { + final book = (item.media as shelfsdk.BookExpanded?); + if (book == null) { + return null; + } + final duration = book.audioFiles + .map((e) => e.duration) + .reduce((value, element) => value + element); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + return '${hours}h ${minutes}m'; + } + + /// will return the size of the book in MB + /// + /// will add up all the sizes of the audio files first + /// then convert them to MB + String? getSizeFormatted() { + final book = (item.media as shelfsdk.BookExpanded?); + if (book == null) { + return null; + } + final size = book.audioFiles + .map((e) => e.metadata.size) + .reduce((value, element) => value + element); + return '${size / 1024 ~/ 1024} MB'; + } + + /// will return the codec and bitrate of the book + String? getCodecAndBitrate() { + final book = (item.media as shelfsdk.BookExpanded?); + if (book == null) { + return null; + } + final codec = book.audioFiles.first.codec.toUpperCase(); + // final bitrate = book.audioFiles.first.bitRate; + return codec; + } +} + +/// key-value pair to display as column +class _MetadataItem extends StatelessWidget { + const _MetadataItem({ + super.key, + required this.title, + required this.value, + }); + + final String title; + final String value; + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + style: themeData.textTheme.titleMedium?.copyWith( + color: themeData.colorScheme.onSurface.withOpacity(0.90), + ), + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + style: themeData.textTheme.bodySmall?.copyWith( + color: themeData.colorScheme.onSurface.withOpacity(0.7), + ), + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } +} diff --git a/lib/features/item_viewer/view/library_item_page.dart b/lib/features/item_viewer/view/library_item_page.dart index 5d270db..a3e40de 100644 --- a/lib/features/item_viewer/view/library_item_page.dart +++ b/lib/features/item_viewer/view/library_item_page.dart @@ -3,20 +3,18 @@ import 'dart:math'; import 'package:animated_theme_switcher/animated_theme_switcher.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.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; -import 'package:whispering_pages/api/image_provider.dart'; import 'package:whispering_pages/api/library_item_provider.dart'; -import 'package:whispering_pages/constants/hero_tag_conventions.dart'; -import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; +import 'package:whispering_pages/features/item_viewer/view/library_item_sliver_app_bar.dart'; import 'package:whispering_pages/router/models/library_item_extras.dart'; import 'package:whispering_pages/settings/app_settings_provider.dart'; -import 'package:whispering_pages/shared/widgets/shelves/book_shelf.dart'; +import 'package:whispering_pages/shared/extensions/model_conversions.dart'; +import 'package:whispering_pages/shared/widgets/expandable_description.dart'; import 'package:whispering_pages/theme/theme_from_cover_provider.dart'; -import '../../../shared/widgets/expandable_description.dart'; -import 'library_item_sliver_app_bar.dart'; +import 'library_item_actions.dart'; +import 'library_item_hero_section.dart'; +import 'library_item_metadata.dart'; class LibraryItemPage extends HookConsumerWidget { const LibraryItemPage({ @@ -38,12 +36,8 @@ class LibraryItemPage extends HookConsumerWidget { final itemFromApi = ref.watch(libraryItemProvider(itemId)); - var itemBookMetadata = itemFromApi.valueOrNull == null - ? null - : shelfsdk.BookMetadataExpanded.fromJson( - itemFromApi.valueOrNull!.media.metadata.toJson(), - ); - // itemFromApi.valueOrNull?.media.metadata as shelfsdk.BookMetadata?; + var itemBookMetadata = + itemFromApi.valueOrNull?.media.metadata.asBookMetadataExpanded; final useMaterialThemeOnItemPage = ref.watch(appSettingsProvider).useMaterialThemeOnItemPage; @@ -64,697 +58,55 @@ class LibraryItemPage extends HookConsumerWidget { initTheme: Theme.of(context), duration: 200.ms, child: ThemeSwitchingArea( - child: Builder( - builder: (context) { - return Scaffold( - body: CustomScrollView( - slivers: [ - const LibraryItemSliverAppBar(), - SliverPadding( - padding: const EdgeInsets.all(8), - sliver: LibraryItemHeroSection( - itemId: itemId, - extraMap: extraMap, - providedCacheImage: providedCacheImage, - item: itemFromApi, - itemBookMetadata: itemBookMetadata, - bookDetailsCached: bookDetailsCached, - coverColorScheme: coverColorScheme, - ), - ), - // a horizontal display with dividers of metadata - SliverToBoxAdapter( - child: itemFromApi.valueOrNull != null - ? LibraryItemMetadata( - item: itemFromApi.valueOrNull!, - itemBookMetadata: itemBookMetadata, - bookDetailsCached: bookDetailsCached, + child: Scaffold( + body: CustomScrollView( + slivers: [ + const LibraryItemSliverAppBar(), + SliverPadding( + padding: const EdgeInsets.all(8), + sliver: LibraryItemHeroSection( + itemId: itemId, + extraMap: extraMap, + providedCacheImage: providedCacheImage, + item: itemFromApi, + itemBookMetadata: itemBookMetadata, + bookDetailsCached: bookDetailsCached, + coverColorScheme: coverColorScheme, + ), + ), + // a horizontal display with dividers of metadata + SliverToBoxAdapter( + child: itemFromApi.valueOrNull != null + ? LibraryItemMetadata( + item: itemFromApi.valueOrNull!, + itemBookMetadata: itemBookMetadata, + bookDetailsCached: bookDetailsCached, + ) + : const SizedBox.shrink(), + ), + // a row of actions like play, download, share, etc + SliverToBoxAdapter( + child: itemFromApi.valueOrNull != null + ? LibraryItemActions(item: itemFromApi.valueOrNull!) + : const SizedBox.shrink(), + ), + // a expandable section for book description + SliverToBoxAdapter( + child: + itemFromApi.valueOrNull?.media.metadata.description != null + ? ExpandableDescription( + title: 'About the Book', + content: itemFromApi + .valueOrNull!.media.metadata.description!, ) : const SizedBox.shrink(), - ), - // a row of actions like play, download, share, etc - SliverToBoxAdapter( - child: itemFromApi.valueOrNull != null - ? LibraryItemActions(item: itemFromApi.valueOrNull!) - : const SizedBox.shrink(), - ), - // a expandable section for book description - SliverToBoxAdapter( - child: - itemFromApi.valueOrNull?.media.metadata.description != - null - ? ExpandableDescription( - title: 'About the Book', - content: itemFromApi - .valueOrNull!.media.metadata.description!, - ) - : const SizedBox.shrink(), - ), - ], ), - ); - }, - ), - ), - ); - } -} - -class LibraryItemMetadata extends StatelessWidget { - const LibraryItemMetadata({ - super.key, - required this.item, - this.itemBookMetadata, - this.bookDetailsCached, - }); - - final shelfsdk.LibraryItem item; - final shelfsdk.BookMetadataExpanded? itemBookMetadata; - final shelfsdk.BookMinified? bookDetailsCached; - - @override - Widget build(BuildContext context) { - final children = [ - // duration of the book - _MetadataItem( - title: switch (itemBookMetadata?.abridged) { - true => 'Abridged', - false => 'Unabridged', - _ => 'Length', - }, - value: getDurationFormatted() ?? 'time is just a concept', - ), - _MetadataItem( - title: 'Published', - value: itemBookMetadata?.publishedDate ?? - itemBookMetadata?.publishedYear ?? - 'Unknown', - ), - _MetadataItem( - title: getCodecAndBitrate() ?? 'Codec & Bitrate', - value: getSizeFormatted() ?? 'Unknown', - ), - ]; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: IntrinsicHeight( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - // alternate between metadata and vertical divider - children: List.generate( - children.length * 2 - 1, - (index) { - if (index.isEven) { - return children[index ~/ 2]; - } - return VerticalDivider( - indent: 6, - endIndent: 6, - color: - Theme.of(context).colorScheme.onBackground.withOpacity(0.6), - ); - }, + ], ), ), ), ); } - - /// formats the duration of the book as `10h 30m` - /// - /// will add up all the durations of the audio files first - /// then convert them to the given format - String? getDurationFormatted() { - final book = (item.media as shelfsdk.BookExpanded?); - if (book == null) { - return null; - } - final duration = book.audioFiles - .map((e) => e.duration) - .reduce((value, element) => value + element); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - return '${hours}h ${minutes}m'; - } - - /// will return the size of the book in MB - /// - /// will add up all the sizes of the audio files first - /// then convert them to MB - String? getSizeFormatted() { - final book = (item.media as shelfsdk.BookExpanded?); - if (book == null) { - return null; - } - final size = book.audioFiles - .map((e) => e.metadata.size) - .reduce((value, element) => value + element); - return '${size / 1024 ~/ 1024} MB'; - } - - /// will return the codec and bitrate of the book - String? getCodecAndBitrate() { - final book = (item.media as shelfsdk.BookExpanded?); - if (book == null) { - return null; - } - final codec = book.audioFiles.first.codec.toUpperCase(); - // final bitrate = book.audioFiles.first.bitRate; - return codec; - } -} - -/// key-value pair to display as column -class _MetadataItem extends StatelessWidget { - const _MetadataItem({ - super.key, - required this.title, - required this.value, - }); - - final String title; - final String value; - - @override - Widget build(BuildContext context) { - final themeData = Theme.of(context); - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - style: themeData.textTheme.titleMedium?.copyWith( - color: themeData.colorScheme.onBackground.withOpacity(0.90), - ), - value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - style: themeData.textTheme.bodySmall?.copyWith( - color: themeData.colorScheme.onBackground.withOpacity(0.7), - ), - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); - } -} - -class LibraryItemActions extends HookConsumerWidget { - LibraryItemActions({ - super.key, - required this.item, - }) { - book = shelfsdk.BookExpanded.fromJson(item.media.toJson()); - } - - final shelfsdk.LibraryItem item; - late final shelfsdk.BookExpanded book; - @override - Widget build(BuildContext context, WidgetRef ref) { - final player = ref.read(audiobookPlayerProvider); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - // play/resume button the same width as image - LayoutBuilder( - builder: (context, constraints) { - return SizedBox( - width: calculateWidth(context, constraints), - // a boxy button with icon and text but little rounded corner - child: ElevatedButton.icon( - onPressed: () async { - // play the book - debugPrint('Pressed play/resume button'); - // set the book to the player if not already set - if (player.book != book) { - debugPrint('Setting the book ${book.libraryItemId}'); - await player.setSourceAudioBook(book); - ref - .read(audiobookPlayerProvider.notifier) - .notifyListeners(); - } - // set the volume - await player.setVolume( - ref - .read(appSettingsProvider) - .playerSettings - .preferredDefaultVolume, - ); - // toggle play/pause - await player.play(); - }, - icon: const Icon(Icons.play_arrow_rounded), - label: const Text('Play/Resume'), - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ); - }, - ), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - return SizedBox( - width: constraints.maxWidth * 0.6, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - // read list button - IconButton( - onPressed: () {}, - icon: const Icon( - Icons.playlist_add_rounded, - ), - ), - // share button - IconButton( - onPressed: () {}, - icon: const Icon(Icons.share_rounded), - ), - // download button - IconButton( - onPressed: () {}, - icon: const Icon( - Icons.download_rounded, - ), - ), - // more button - IconButton( - onPressed: () {}, - icon: const Icon( - Icons.more_vert_rounded, - ), - ), - ], - ), - ); - }, - ), - ), - ], - ), - ); - } -} - -class LibraryItemHeroSection extends HookConsumerWidget { - const LibraryItemHeroSection({ - super.key, - required this.itemId, - required this.extraMap, - required this.providedCacheImage, - required this.item, - required this.itemBookMetadata, - required this.bookDetailsCached, - required this.coverColorScheme, - }); - - final String itemId; - final LibraryItemExtras? extraMap; - final Image? providedCacheImage; - final AsyncValue item; - final shelfsdk.BookMetadataExpanded? itemBookMetadata; - final shelfsdk.BookMinified? bookDetailsCached; - final AsyncValue coverColorScheme; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return SliverToBoxAdapter( - child: LayoutBuilder( - builder: (context, constraints) { - return Container( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - // book cover - LayoutBuilder( - builder: (context, constraints) { - return SizedBox( - width: calculateWidth(context, constraints), - child: Hero( - tag: HeroTagPrefixes.bookCover + - itemId + - (extraMap?.heroTagSuffix ?? ''), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: _BookCover( - itemId: itemId, - extraMap: extraMap, - providedCacheImage: providedCacheImage, - coverColorScheme: coverColorScheme.valueOrNull, - item: item, - ), - ), - ), - ); - }, - ), - // book details - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - _BookTitle( - extraMap: extraMap, - itemBookMetadata: itemBookMetadata, - bookDetailsCached: bookDetailsCached, - ), - Container( - margin: const EdgeInsets.symmetric(vertical: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - // authors info if available - _BookAuthors( - itemBookMetadata: itemBookMetadata, - bookDetailsCached: bookDetailsCached, - ), - // narrators info if available - _BookNarrators( - itemBookMetadata: itemBookMetadata, - bookDetailsCached: bookDetailsCached, - ), - // series info if available - _BookSeries( - itemBookMetadata: itemBookMetadata, - bookDetailsCached: bookDetailsCached, - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ); - }, - ), - ); - } -} - -class _HeroSectionSubLabelWithIcon extends HookConsumerWidget { - const _HeroSectionSubLabelWithIcon({ - super.key, - required this.icon, - required this.text, - }); - - final IconData icon; - final Widget text; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final themeData = Theme.of(context); - final useFontAwesome = - icon.runtimeType == FontAwesomeIcons.book.runtimeType; - final useMaterialThemeOnItemPage = - ref.watch(appSettingsProvider).useMaterialThemeOnItemPage; - final color = useMaterialThemeOnItemPage - ? themeData.colorScheme.primary - : themeData.colorScheme.onBackground.withOpacity(0.75); - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Row( - children: [ - Container( - margin: const EdgeInsets.only(right: 8, top: 2), - child: useFontAwesome - ? FaIcon( - icon, - size: 16, - color: color, - ) - : Icon( - icon, - size: 16, - color: color, - ), - ), - Expanded( - child: text, - ), - ], - ), - ); - } -} - -class _BookSeries extends StatelessWidget { - const _BookSeries({ - super.key, - required this.itemBookMetadata, - required this.bookDetailsCached, - }); - - final shelfsdk.BookMetadataExpanded? itemBookMetadata; - final shelfsdk.BookMinified? bookDetailsCached; - - @override - Widget build(BuildContext context) { - final themeData = Theme.of(context); - String generateSeriesString() { - final series = (itemBookMetadata)?.series ?? []; - if (series.isEmpty) { - return (bookDetailsCached?.metadata as shelfsdk.BookMetadataMinified?) - ?.seriesName ?? - ''; - } - return series - .map((e) { - try { - e as shelfsdk.SeriesSequence; - final seq = e.sequence != null ? '#${e.sequence} of ' : ''; - return '$seq${e.name}'; - } catch (e) { - return ''; - } - }) - .toList() - .join(', '); - } - - return generateSeriesString() == '' - ? const SizedBox.shrink() - : _HeroSectionSubLabelWithIcon( - icon: Icons.library_books_rounded, - text: Text( - style: themeData.textTheme.titleSmall, - generateSeriesString(), - ), - ); - } -} - -class _BookNarrators extends StatelessWidget { - const _BookNarrators({ - super.key, - required this.itemBookMetadata, - required this.bookDetailsCached, - }); - - final shelfsdk.BookMetadataExpanded? itemBookMetadata; - final shelfsdk.BookMinified? bookDetailsCached; - - @override - Widget build(BuildContext context) { - String generateNarratorsString() { - final narrators = (itemBookMetadata)?.narrators ?? []; - if (narrators.isEmpty) { - return (bookDetailsCached?.metadata as shelfsdk.BookMetadataMinified?) - ?.narratorName ?? - ''; - } - return narrators.join(', '); - } - - final themeData = Theme.of(context); - - return generateNarratorsString() == '' - ? const SizedBox.shrink() - : _HeroSectionSubLabelWithIcon( - icon: Icons.record_voice_over, - text: Text( - style: themeData.textTheme.titleSmall, - generateNarratorsString(), - ), - ); - } -} - -class _BookCover extends HookConsumerWidget { - const _BookCover({ - super.key, - required this.itemId, - required this.extraMap, - required this.providedCacheImage, - required this.item, - this.coverColorScheme, - }); - - final String itemId; - final LibraryItemExtras? extraMap; - final Image? providedCacheImage; - final AsyncValue item; - final ColorScheme? coverColorScheme; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final themeData = Theme.of(context); - final useMaterialThemeOnItemPage = - ref.watch(appSettingsProvider).useMaterialThemeOnItemPage; - - return ThemeSwitcher( - builder: (context) { - // change theme after 2 seconds - if (useMaterialThemeOnItemPage) { - Future.delayed(150.ms, () { - ThemeSwitcher.of(context).changeTheme( - theme: coverColorScheme != null - ? ThemeData.from( - colorScheme: coverColorScheme!, - textTheme: themeData.textTheme, - ) - : themeData, - ); - }); - } - return providedCacheImage ?? - item.when( - data: (libraryItem) { - final coverImage = ref.watch(coverImageProvider(libraryItem)); - return Stack( - children: [ - coverImage.when( - data: (image) { - // return const BookCoverSkeleton(); - if (image.isEmpty) { - return const Icon(Icons.error); - } - // cover 80% of parent height - return Image.memory( - image, - fit: BoxFit.cover, - // cacheWidth: (height * - // MediaQuery.of(context).devicePixelRatio) - // .round(), - ); - }, - loading: () { - return const Center( - child: BookCoverSkeleton(), - ); - }, - error: (error, stack) { - return const Icon(Icons.error); - }, - ), - ], - ); - }, - error: (error, stack) => const Icon(Icons.error), - loading: () => const Center(child: BookCoverSkeleton()), - ); - }, - ); - } -} - -class _BookTitle extends StatelessWidget { - const _BookTitle({ - super.key, - required this.extraMap, - required this.itemBookMetadata, - required this.bookDetailsCached, - }); - - final LibraryItemExtras? extraMap; - final shelfsdk.BookMetadataExpanded? itemBookMetadata; - final shelfsdk.BookMinified? bookDetailsCached; - - @override - Widget build(BuildContext context) { - final themeData = Theme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Hero( - tag: HeroTagPrefixes.bookTitle + - // itemId + - (extraMap?.heroTagSuffix ?? ''), - child: Text( - // mode: AutoScrollTextMode.bouncing, - // curve: Curves.fastEaseInToSlowEaseOut, - // velocity: const Velocity(pixelsPerSecond: Offset(30, 0)), - // delayBefore: 500.ms, - // pauseBetween: 150.ms, - // numberOfReps: 3, - style: themeData.textTheme.headlineLarge, - itemBookMetadata?.title ?? bookDetailsCached?.metadata.title ?? '', - ), - ), - // subtitle if available - itemBookMetadata?.subtitle == null - ? const SizedBox.shrink() - : Text( - style: themeData.textTheme.titleSmall?.copyWith( - color: themeData.colorScheme.onBackground.withOpacity(0.8), - ), - itemBookMetadata?.subtitle ?? '', - ), - ], - ); - } -} - -class _BookAuthors extends StatelessWidget { - const _BookAuthors({ - super.key, - required this.itemBookMetadata, - required this.bookDetailsCached, - }); - - final shelfsdk.BookMetadataExpanded? itemBookMetadata; - final shelfsdk.BookMinified? bookDetailsCached; - - @override - Widget build(BuildContext context) { - final themeData = Theme.of(context); - String generateAuthorsString() { - final authors = (itemBookMetadata)?.authors ?? []; - if (authors.isEmpty) { - return (bookDetailsCached?.metadata as shelfsdk.BookMetadataMinified?) - ?.authorName ?? - ''; - } - return authors.map((e) => e.name).join(', '); - } - - return generateAuthorsString() == '' - ? const SizedBox.shrink() - : _HeroSectionSubLabelWithIcon( - icon: FontAwesomeIcons.penNib, - text: Text( - style: themeData.textTheme.titleSmall, - generateAuthorsString(), - ), - ); - } } /// Calculate the width of the book cover based on the screen size diff --git a/lib/features/playback_reporting/core/playback_reporter.dart b/lib/features/playback_reporting/core/playback_reporter.dart index 5685d21..6495f26 100644 --- a/lib/features/playback_reporting/core/playback_reporter.dart +++ b/lib/features/playback_reporting/core/playback_reporter.dart @@ -128,16 +128,17 @@ class PlaybackReporter { /// current sessionId /// this is used to report the playback - String? sessionId; + PlaybackSession? _session; + String? get sessionId => _session?.id; - Future startSession() async { - if (sessionId != null) { - return sessionId!; + Future startSession() async { + if (_session != null) { + return _session!; } if (player.book == null) { throw NoAudiobookPlayingError(); } - final session = await authenticatedApi.items.play( + _session = await authenticatedApi.items.play( libraryItemId: player.book!.libraryItemId, parameters: PlayItemReqParams( deviceInfo: await _getDeviceInfo(), @@ -146,23 +147,22 @@ class PlaybackReporter { ), responseErrorHandler: _responseErrorHandler, ); - sessionId = session!.id; debugPrint('PlaybackReporter Started session: $sessionId'); - return sessionId; + return _session; } Future syncCurrentPosition() async { try { - sessionId ??= await startSession(); + _session ??= await startSession(); } on NoAudiobookPlayingError { debugPrint('PlaybackReporter No audiobook playing to sync position'); return; } - final currentPosition = player.position; + final currentPosition = player.positionInBook; await authenticatedApi.sessions.syncOpen( sessionId: sessionId!, - parameters: _getSyncData(), + parameters: _getSyncData()!, responseErrorHandler: _responseErrorHandler, ); @@ -185,7 +185,7 @@ class PlaybackReporter { parameters: _getSyncData(), responseErrorHandler: _responseErrorHandler, ); - sessionId = null; + _session = null; debugPrint('PlaybackReporter Closed session'); } @@ -209,9 +209,15 @@ class PlaybackReporter { } } - SyncSessionReqParams _getSyncData() { + SyncSessionReqParams? _getSyncData() { + if (player.book?.libraryItemId != _session?.libraryItemId) { + debugPrint( + 'PlaybackReporter Book changed, not syncing position for session: $sessionId', + ); + return null; + } return SyncSessionReqParams( - currentTime: player.position, + currentTime: player.positionInBook, timeListened: _stopwatch.elapsed, duration: player.book?.duration ?? Duration.zero, ); diff --git a/lib/features/player/core/audiobook_player.dart b/lib/features/player/core/audiobook_player.dart index c0edd8c..cc6c471 100644 --- a/lib/features/player/core/audiobook_player.dart +++ b/lib/features/player/core/audiobook_player.dart @@ -127,7 +127,7 @@ class AudiobookPlayer extends AudioPlayer { }).toList(), ), ).catchError((error) { - debugPrint('Error: $error'); + debugPrint('AudiobookPlayer Error: $error'); }); } diff --git a/lib/features/player/providers/currently_playing_provider.dart b/lib/features/player/providers/currently_playing_provider.dart index d8329f0..f535bfd 100644 --- a/lib/features/player/providers/currently_playing_provider.dart +++ b/lib/features/player/providers/currently_playing_provider.dart @@ -1,6 +1,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; +import 'package:whispering_pages/shared/extensions/model_conversions.dart'; part 'currently_playing_provider.g.dart'; @@ -26,7 +27,7 @@ BookChapter? currentPlayingChapter(CurrentPlayingChapterRef ref) { BookMetadataExpanded? currentBookMetadata(CurrentBookMetadataRef ref) { final player = ref.watch(audiobookPlayerProvider); if (player.book == null) return null; - return BookMetadataExpanded.fromJson(player.book!.metadata.toJson()); + return player.book!.metadata.asBookMetadataExpanded; } // /// volume of the player [0, 1] diff --git a/lib/features/player/providers/currently_playing_provider.g.dart b/lib/features/player/providers/currently_playing_provider.g.dart index 617a6f5..e6b8871 100644 --- a/lib/features/player/providers/currently_playing_provider.g.dart +++ b/lib/features/player/providers/currently_playing_provider.g.dart @@ -43,7 +43,7 @@ final currentPlayingChapterProvider = typedef CurrentPlayingChapterRef = AutoDisposeProviderRef; String _$currentBookMetadataHash() => - r'02b462a051fce5bcbdad6fdb708b60256fbb588c'; + r'9088debba151894b61f2dcba1bba12a89244b9b1'; /// provides the book metadata of the currently playing book /// diff --git a/lib/features/player/view/player_when_expanded.dart b/lib/features/player/view/player_when_expanded.dart index f12de3e..0fdfbd2 100644 --- a/lib/features/player/view/player_when_expanded.dart +++ b/lib/features/player/view/player_when_expanded.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:duration_picker/duration_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -356,16 +354,3 @@ extension DurationFormat on Duration { } } } - -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/settings/constants.dart b/lib/settings/constants.dart index 352533f..5d2ab80 100644 --- a/lib/settings/constants.dart +++ b/lib/settings/constants.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart' show immutable; @immutable class AppMetadata { const AppMetadata._(); + // TODO: use the packageinfo package to get the app name static const String appName = 'Whispering Pages'; static get appNameLowerCase => appName.toLowerCase().replaceAll(' ', '_'); diff --git a/lib/shared/extensions/duration_format.dart b/lib/shared/extensions/duration_format.dart new file mode 100644 index 0000000..98c9dbe --- /dev/null +++ b/lib/shared/extensions/duration_format.dart @@ -0,0 +1,11 @@ +extension DurationFormat on Duration { + /// formats the duration of the book as `10h 30m` + /// + /// will add up all the durations of the audio files first + /// then convert them to the given format + String get formattedBinary { + final hours = inHours; + final minutes = inMinutes.remainder(60); + return '${hours}h ${minutes}m'; + } +} diff --git a/lib/shared/extensions/model_conversions.dart b/lib/shared/extensions/model_conversions.dart new file mode 100644 index 0000000..d5bb7a3 --- /dev/null +++ b/lib/shared/extensions/model_conversions.dart @@ -0,0 +1,48 @@ +import 'package:shelfsdk/audiobookshelf_api.dart'; + +extension LibraryItemConversion on LibraryItem { + LibraryItemExpanded get asExpanded => + LibraryItemExpanded.fromJson(toJson()); + + LibraryItemMinified get asMinified => + LibraryItemMinified.fromJson(toJson()); +} + +extension MediaConversion on Media { + Book get asBook => Book.fromJson(toJson()); + BookExpanded get asBookExpanded => BookExpanded.fromJson(toJson()); + BookMinified get asBookMinified => BookMinified.fromJson(toJson()); + + Podcast get asPodcast => Podcast.fromJson(toJson()); + PodcastExpanded get asPodcastExpanded => PodcastExpanded.fromJson(toJson()); + PodcastMinified get asPodcastMinified => PodcastMinified.fromJson(toJson()); +} + +extension MediaMetadataConversion on MediaMetadata { + BookMetadata get asBookMetadata => BookMetadata.fromJson(toJson()); + BookMetadataExpanded get asBookMetadataExpanded => + BookMetadataExpanded.fromJson(toJson()); + BookMetadataMinified get asBookMetadataMinified => + BookMetadataMinified.fromJson(toJson()); + + BookMetadataSeriesFilter get asBookMetadataSeriesFilter => + BookMetadataSeriesFilter.fromJson(toJson()); + BookMetadataMinifiedSeriesFilter get asBookMetadataMinifiedSeriesFilter => + BookMetadataMinifiedSeriesFilter.fromJson(toJson()); + + PodcastMetadata get asPodcastMetadata => PodcastMetadata.fromJson(toJson()); + PodcastMetadataExpanded get asPodcastMetadataExpanded => + PodcastMetadataExpanded.fromJson(toJson()); +} + +extension AuthorConversion on Author { + AuthorExpanded get asExpanded => AuthorExpanded.fromJson(toJson()); + AuthorMinified get asMinified => AuthorMinified.fromJson(toJson()); +} + +extension ShelfConversion on Shelf { + LibraryItemShelf get asLibraryItemShelf => + LibraryItemShelf.fromJson(toJson()); + SeriesShelf get asSeriesShelf => SeriesShelf.fromJson(toJson()); + AuthorShelf get asAuthorShelf => AuthorShelf.fromJson(toJson()); +} diff --git a/lib/shared/hooks.dart b/lib/shared/hooks.dart new file mode 100644 index 0000000..e8f4297 --- /dev/null +++ b/lib/shared/hooks.dart @@ -0,0 +1,17 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +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/shared/widgets/shelves/author_shelf.dart b/lib/shared/widgets/shelves/author_shelf.dart index be0f0e1..09a42d0 100644 --- a/lib/shared/widgets/shelves/author_shelf.dart +++ b/lib/shared/widgets/shelves/author_shelf.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; -import 'package:whispering_pages/api/image_provider.dart'; +import 'package:whispering_pages/shared/extensions/model_conversions.dart'; import 'package:whispering_pages/shared/widgets/shelves/home_shelf.dart'; /// A shelf that displays Authors on the home page @@ -39,7 +39,7 @@ class AuthorOnShelf extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final author = AuthorMinified.fromJson(item.toJson()); + final author = item.asMinified; // final coverImage = ref.watch(coverImageProvider(item)); return Container( diff --git a/lib/shared/widgets/shelves/book_shelf.dart b/lib/shared/widgets/shelves/book_shelf.dart index 9ac5643..3f64936 100644 --- a/lib/shared/widgets/shelves/book_shelf.dart +++ b/lib/shared/widgets/shelves/book_shelf.dart @@ -9,6 +9,7 @@ import 'package:whispering_pages/api/image_provider.dart'; import 'package:whispering_pages/constants/hero_tag_conventions.dart'; import 'package:whispering_pages/router/models/library_item_extras.dart'; import 'package:whispering_pages/router/router.dart'; +import 'package:whispering_pages/shared/extensions/model_conversions.dart'; import 'package:whispering_pages/shared/widgets/shelves/home_shelf.dart'; /// A shelf that displays books on the home page @@ -57,8 +58,8 @@ class BookOnShelf extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final book = BookMinified.fromJson(item.media.toJson()); - final metadata = BookMetadataMinified.fromJson(book.metadata.toJson()); + final book = item.media.asBookMinified; + final metadata = book.metadata.asBookMetadataMinified; final coverImage = ref.watch(coverImageProvider(item)); return LayoutBuilder( builder: (context, constraints) { @@ -75,7 +76,6 @@ class BookOnShelf extends HookConsumerWidget { child: Center( child: Hero( tag: HeroTagPrefixes.bookCover + item.id + heroTagSuffix, - child: InkWell( onTap: () { // open the book @@ -91,7 +91,6 @@ class BookOnShelf extends HookConsumerWidget { ), ); }, - child: ClipRRect( borderRadius: BorderRadius.circular(10), child: coverImage.when( diff --git a/lib/shared/widgets/shelves/home_shelf.dart b/lib/shared/widgets/shelves/home_shelf.dart index a34a2db..11e34c4 100644 --- a/lib/shared/widgets/shelves/home_shelf.dart +++ b/lib/shared/widgets/shelves/home_shelf.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; +import 'package:whispering_pages/shared/extensions/model_conversions.dart'; import 'package:whispering_pages/shared/widgets/shelves/author_shelf.dart'; import 'package:whispering_pages/shared/widgets/shelves/book_shelf.dart'; @@ -24,11 +25,11 @@ class HomeShelf extends HookConsumerWidget { return switch (shelf.type) { ShelfType.book => BookHomeShelf( title: title, - shelf: LibraryItemShelf.fromJson(shelf.toJson()), + shelf: shelf.asLibraryItemShelf, ), ShelfType.authors => AuthorHomeShelf( title: title, - shelf: AuthorShelf.fromJson(shelf.toJson()), + shelf: shelf.asAuthorShelf, ), _ => Container(), };