progress visibility on item page

This commit is contained in:
Dr-Blank 2024-06-16 22:24:32 -04:00
parent be7f5daa88
commit 865a662b56
No known key found for this signature in database
GPG key ID: 7452CC63F210A266
21 changed files with 1009 additions and 765 deletions

View file

@ -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<shelfsdk.LibraryItem> build(String id) async* {
Stream<shelfsdk.LibraryItemExpanded> 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;
}
}
}

View file

@ -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<shelfsdk.LibraryItem> {
extends BuildlessAutoDisposeStreamNotifier<shelfsdk.LibraryItemExpanded> {
late final String id;
Stream<shelfsdk.LibraryItem> build(
Stream<shelfsdk.LibraryItemExpanded> 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<AsyncValue<shelfsdk.LibraryItem>> {
class LibraryItemFamily
extends Family<AsyncValue<shelfsdk.LibraryItemExpanded>> {
/// provides the library item for the given id
///
/// Copied from [LibraryItem].
@ -92,7 +93,7 @@ class LibraryItemFamily extends Family<AsyncValue<shelfsdk.LibraryItem>> {
///
/// 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<shelfsdk.LibraryItem> runNotifierBuild(
Stream<shelfsdk.LibraryItemExpanded> runNotifierBuild(
covariant LibraryItem notifier,
) {
return notifier.build(
@ -150,8 +151,8 @@ class LibraryItemProvider extends AutoDisposeStreamNotifierProviderImpl<
}
@override
AutoDisposeStreamNotifierProviderElement<LibraryItem, shelfsdk.LibraryItem>
createElement() {
AutoDisposeStreamNotifierProviderElement<LibraryItem,
shelfsdk.LibraryItemExpanded> createElement() {
return _LibraryItemProviderElement(this);
}
@ -170,14 +171,14 @@ class LibraryItemProvider extends AutoDisposeStreamNotifierProviderImpl<
}
mixin LibraryItemRef
on AutoDisposeStreamNotifierProviderRef<shelfsdk.LibraryItem> {
on AutoDisposeStreamNotifierProviderRef<shelfsdk.LibraryItemExpanded> {
/// The parameter `id` of this provider.
String get id;
}
class _LibraryItemProviderElement
extends AutoDisposeStreamNotifierProviderElement<LibraryItem,
shelfsdk.LibraryItem> with LibraryItemRef {
shelfsdk.LibraryItemExpanded> with LibraryItemRef {
_LibraryItemProviderElement(super.provider);
@override

View file

@ -1,5 +1,5 @@
class CacheKey {
static libraryItem(String id) {
static String libraryItem(String id) {
return 'library_item_$id';
}
}

View file

@ -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<Widget> 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);
},
),

View file

@ -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,

View 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),
),
),
);
}
}

View 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(),
),
);
}
}

View 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,
),
],
);
}
}

View file

@ -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

View file

@ -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<String?> startSession() async {
if (sessionId != null) {
return sessionId!;
Future<PlaybackSession?> 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<void> 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,
);

View file

@ -127,7 +127,7 @@ class AudiobookPlayer extends AudioPlayer {
}).toList(),
),
).catchError((error) {
debugPrint('Error: $error');
debugPrint('AudiobookPlayer Error: $error');
});
}

View file

@ -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]

View file

@ -43,7 +43,7 @@ final currentPlayingChapterProvider =
typedef CurrentPlayingChapterRef = AutoDisposeProviderRef<BookChapter?>;
String _$currentBookMetadataHash() =>
r'02b462a051fce5bcbdad6fdb708b60256fbb588c';
r'9088debba151894b61f2dcba1bba12a89244b9b1';
/// provides the book metadata of the currently playing book
///

View file

@ -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],
);
}

View file

@ -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(' ', '_');

View file

@ -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';
}
}

View file

@ -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());
}

17
lib/shared/hooks.dart Normal file
View file

@ -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],
);
}

View file

@ -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(

View file

@ -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(

View file

@ -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(),
};