mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-28 05:49:31 +00:00
progress visibility on item page
This commit is contained in:
parent
be7f5daa88
commit
865a662b56
21 changed files with 1009 additions and 765 deletions
165
lib/features/item_viewer/view/library_item_actions.dart
Normal file
165
lib/features/item_viewer/view/library_item_actions.dart
Normal file
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
502
lib/features/item_viewer/view/library_item_hero_section.dart
Normal file
502
lib/features/item_viewer/view/library_item_hero_section.dart
Normal file
|
|
@ -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<shelfsdk.LibraryItemExpanded> item;
|
||||
final shelfsdk.BookMetadataExpanded? itemBookMetadata;
|
||||
final shelfsdk.BookMinified? bookDetailsCached;
|
||||
final AsyncValue<ColorScheme?> 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 ?? <shelfsdk.SeriesSequence>[];
|
||||
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<shelfsdk.LibraryItemExpanded> 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
144
lib/features/item_viewer/view/library_item_metadata.dart
Normal file
144
lib/features/item_viewer/view/library_item_metadata.dart
Normal file
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<shelfsdk.LibraryItem> item;
|
||||
final shelfsdk.BookMetadataExpanded? itemBookMetadata;
|
||||
final shelfsdk.BookMinified? bookDetailsCached;
|
||||
final AsyncValue<ColorScheme?> 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 ?? <shelfsdk.SeriesSequence>[];
|
||||
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<shelfsdk.LibraryItem> 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue