mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-06 19:19:28 +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
|
|
@ -6,6 +6,7 @@ import 'package:shelfsdk/audiobookshelf_api.dart' as shelfsdk;
|
||||||
import 'package:whispering_pages/api/api_provider.dart';
|
import 'package:whispering_pages/api/api_provider.dart';
|
||||||
import 'package:whispering_pages/db/cache/cache_key.dart';
|
import 'package:whispering_pages/db/cache/cache_key.dart';
|
||||||
import 'package:whispering_pages/db/cache_manager.dart';
|
import 'package:whispering_pages/db/cache_manager.dart';
|
||||||
|
import 'package:whispering_pages/shared/extensions/model_conversions.dart';
|
||||||
|
|
||||||
part 'library_item_provider.g.dart';
|
part 'library_item_provider.g.dart';
|
||||||
|
|
||||||
|
|
@ -13,10 +14,10 @@ part 'library_item_provider.g.dart';
|
||||||
@riverpod
|
@riverpod
|
||||||
class LibraryItem extends _$LibraryItem {
|
class LibraryItem extends _$LibraryItem {
|
||||||
@override
|
@override
|
||||||
Stream<shelfsdk.LibraryItem> build(String id) async* {
|
Stream<shelfsdk.LibraryItemExpanded> build(String id) async* {
|
||||||
final api = ref.watch(authenticatedApiProvider);
|
final api = ref.watch(authenticatedApiProvider);
|
||||||
|
|
||||||
debugPrint('fetching library item: $id');
|
debugPrint('LibraryItemProvider fetching library item: $id');
|
||||||
|
|
||||||
// ! this is a mock delay
|
// ! this is a mock delay
|
||||||
// await Future.delayed(const Duration(seconds: 10));
|
// await Future.delayed(const Duration(seconds: 10));
|
||||||
|
|
@ -26,27 +27,41 @@ class LibraryItem extends _$LibraryItem {
|
||||||
final cachedFile = await apiResponseCacheManager.getFileFromMemory(key) ??
|
final cachedFile = await apiResponseCacheManager.getFileFromMemory(key) ??
|
||||||
await apiResponseCacheManager.getFileFromCache(key);
|
await apiResponseCacheManager.getFileFromCache(key);
|
||||||
if (cachedFile != null) {
|
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
|
// read file as json
|
||||||
final cachedItem = shelfsdk.LibraryItem.fromJson(
|
final cachedItem = shelfsdk.LibraryItemExpanded.fromJson(
|
||||||
jsonDecode(await cachedFile.file.readAsString()),
|
jsonDecode(await cachedFile.file.readAsString()),
|
||||||
);
|
);
|
||||||
yield cachedItem;
|
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(
|
final item = await api.items.get(
|
||||||
libraryItemId: id,
|
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) {
|
if (item != null) {
|
||||||
// save to cache
|
// save to cache
|
||||||
final newFile = await apiResponseCacheManager.putFile(
|
final newFile = await apiResponseCacheManager.putFile(
|
||||||
key,
|
key,
|
||||||
utf8.encode(jsonEncode(item)),
|
utf8.encode(jsonEncode(item.asExpanded.toJson())),
|
||||||
fileExtension: 'json',
|
fileExtension: 'json',
|
||||||
key: key,
|
key: key,
|
||||||
);
|
);
|
||||||
debugPrint('writing to cache: $newFile');
|
debugPrint('writing to cache: $newFile');
|
||||||
yield item;
|
yield item.asExpanded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ part of 'library_item_provider.dart';
|
||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$libraryItemHash() => r'ce6222e417b43dceed9ea7e5a8b43782755fc117';
|
String _$libraryItemHash() => r'6442db4e802e0a072689b8ff6c2b9aaa99cf0f17';
|
||||||
|
|
||||||
/// Copied from Dart SDK
|
/// Copied from Dart SDK
|
||||||
class _SystemHash {
|
class _SystemHash {
|
||||||
|
|
@ -30,10 +30,10 @@ class _SystemHash {
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class _$LibraryItem
|
abstract class _$LibraryItem
|
||||||
extends BuildlessAutoDisposeStreamNotifier<shelfsdk.LibraryItem> {
|
extends BuildlessAutoDisposeStreamNotifier<shelfsdk.LibraryItemExpanded> {
|
||||||
late final String id;
|
late final String id;
|
||||||
|
|
||||||
Stream<shelfsdk.LibraryItem> build(
|
Stream<shelfsdk.LibraryItemExpanded> build(
|
||||||
String id,
|
String id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +47,8 @@ const libraryItemProvider = LibraryItemFamily();
|
||||||
/// provides the library item for the given id
|
/// provides the library item for the given id
|
||||||
///
|
///
|
||||||
/// Copied from [LibraryItem].
|
/// 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
|
/// provides the library item for the given id
|
||||||
///
|
///
|
||||||
/// Copied from [LibraryItem].
|
/// Copied from [LibraryItem].
|
||||||
|
|
@ -92,7 +93,7 @@ class LibraryItemFamily extends Family<AsyncValue<shelfsdk.LibraryItem>> {
|
||||||
///
|
///
|
||||||
/// Copied from [LibraryItem].
|
/// Copied from [LibraryItem].
|
||||||
class LibraryItemProvider extends AutoDisposeStreamNotifierProviderImpl<
|
class LibraryItemProvider extends AutoDisposeStreamNotifierProviderImpl<
|
||||||
LibraryItem, shelfsdk.LibraryItem> {
|
LibraryItem, shelfsdk.LibraryItemExpanded> {
|
||||||
/// provides the library item for the given id
|
/// provides the library item for the given id
|
||||||
///
|
///
|
||||||
/// Copied from [LibraryItem].
|
/// Copied from [LibraryItem].
|
||||||
|
|
@ -125,7 +126,7 @@ class LibraryItemProvider extends AutoDisposeStreamNotifierProviderImpl<
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<shelfsdk.LibraryItem> runNotifierBuild(
|
Stream<shelfsdk.LibraryItemExpanded> runNotifierBuild(
|
||||||
covariant LibraryItem notifier,
|
covariant LibraryItem notifier,
|
||||||
) {
|
) {
|
||||||
return notifier.build(
|
return notifier.build(
|
||||||
|
|
@ -150,8 +151,8 @@ class LibraryItemProvider extends AutoDisposeStreamNotifierProviderImpl<
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AutoDisposeStreamNotifierProviderElement<LibraryItem, shelfsdk.LibraryItem>
|
AutoDisposeStreamNotifierProviderElement<LibraryItem,
|
||||||
createElement() {
|
shelfsdk.LibraryItemExpanded> createElement() {
|
||||||
return _LibraryItemProviderElement(this);
|
return _LibraryItemProviderElement(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,14 +171,14 @@ class LibraryItemProvider extends AutoDisposeStreamNotifierProviderImpl<
|
||||||
}
|
}
|
||||||
|
|
||||||
mixin LibraryItemRef
|
mixin LibraryItemRef
|
||||||
on AutoDisposeStreamNotifierProviderRef<shelfsdk.LibraryItem> {
|
on AutoDisposeStreamNotifierProviderRef<shelfsdk.LibraryItemExpanded> {
|
||||||
/// The parameter `id` of this provider.
|
/// The parameter `id` of this provider.
|
||||||
String get id;
|
String get id;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LibraryItemProviderElement
|
class _LibraryItemProviderElement
|
||||||
extends AutoDisposeStreamNotifierProviderElement<LibraryItem,
|
extends AutoDisposeStreamNotifierProviderElement<LibraryItem,
|
||||||
shelfsdk.LibraryItem> with LibraryItemRef {
|
shelfsdk.LibraryItemExpanded> with LibraryItemRef {
|
||||||
_LibraryItemProviderElement(super.provider);
|
_LibraryItemProviderElement(super.provider);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
2
lib/db/cache/cache_key.dart
vendored
2
lib/db/cache/cache_key.dart
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
class CacheKey {
|
class CacheKey {
|
||||||
static libraryItem(String id) {
|
static String libraryItem(String id) {
|
||||||
return 'library_item_$id';
|
return 'library_item_$id';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/router/router.dart';
|
||||||
import 'package:whispering_pages/settings/api_settings_provider.dart';
|
import 'package:whispering_pages/settings/api_settings_provider.dart';
|
||||||
import 'package:whispering_pages/settings/app_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';
|
import 'package:whispering_pages/shared/widgets/shelves/book_shelf.dart';
|
||||||
|
|
||||||
const Duration debounceDuration = Duration(milliseconds: 500);
|
const Duration debounceDuration = Duration(milliseconds: 500);
|
||||||
|
|
@ -192,11 +193,8 @@ List<Widget> buildBookSearchResult(
|
||||||
options: options.book.map(
|
options: options.book.map(
|
||||||
(result) {
|
(result) {
|
||||||
// convert result to a book object
|
// convert result to a book object
|
||||||
final book =
|
final book = result.libraryItem.media.asBookExpanded;
|
||||||
BookExpanded.fromJson(result.libraryItem.media.toJson());
|
final metadata = book.metadata.asBookMetadataExpanded;
|
||||||
final metadata = BookMetadataExpanded.fromJson(
|
|
||||||
book.metadata.toJson(),
|
|
||||||
);
|
|
||||||
return BookSearchResultMini(book: book, metadata: metadata);
|
return BookSearchResultMini(book: book, metadata: metadata);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:shelfsdk/audiobookshelf_api.dart';
|
import 'package:shelfsdk/audiobookshelf_api.dart';
|
||||||
import 'package:whispering_pages/features/explore/providers/search_result_provider.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/features/explore/view/explore_page.dart';
|
||||||
|
import 'package:whispering_pages/shared/extensions/model_conversions.dart';
|
||||||
|
|
||||||
enum SearchResultCategory {
|
enum SearchResultCategory {
|
||||||
books,
|
books,
|
||||||
|
|
@ -52,12 +53,9 @@ class SearchResultPage extends HookConsumerWidget {
|
||||||
SearchResultCategory.books => ListView.builder(
|
SearchResultCategory.books => ListView.builder(
|
||||||
itemCount: options.book.length,
|
itemCount: options.book.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final book = BookExpanded.fromJson(
|
final book =
|
||||||
options.book[index].libraryItem.media.toJson(),
|
options.book[index].libraryItem.media.asBookExpanded;
|
||||||
);
|
final metadata = book.metadata.asBookMetadataExpanded;
|
||||||
final metadata = BookMetadataExpanded.fromJson(
|
|
||||||
book.metadata.toJson(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return BookSearchResultMini(
|
return BookSearchResultMini(
|
||||||
book: book,
|
book: book,
|
||||||
|
|
|
||||||
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:animated_theme_switcher/animated_theme_switcher.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.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: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/api/library_item_provider.dart';
|
||||||
import 'package:whispering_pages/constants/hero_tag_conventions.dart';
|
import 'package:whispering_pages/features/item_viewer/view/library_item_sliver_app_bar.dart';
|
||||||
import 'package:whispering_pages/features/player/providers/audiobook_player.dart';
|
|
||||||
import 'package:whispering_pages/router/models/library_item_extras.dart';
|
import 'package:whispering_pages/router/models/library_item_extras.dart';
|
||||||
import 'package:whispering_pages/settings/app_settings_provider.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 'package:whispering_pages/theme/theme_from_cover_provider.dart';
|
||||||
|
|
||||||
import '../../../shared/widgets/expandable_description.dart';
|
import 'library_item_actions.dart';
|
||||||
import 'library_item_sliver_app_bar.dart';
|
import 'library_item_hero_section.dart';
|
||||||
|
import 'library_item_metadata.dart';
|
||||||
|
|
||||||
class LibraryItemPage extends HookConsumerWidget {
|
class LibraryItemPage extends HookConsumerWidget {
|
||||||
const LibraryItemPage({
|
const LibraryItemPage({
|
||||||
|
|
@ -38,12 +36,8 @@ class LibraryItemPage extends HookConsumerWidget {
|
||||||
|
|
||||||
final itemFromApi = ref.watch(libraryItemProvider(itemId));
|
final itemFromApi = ref.watch(libraryItemProvider(itemId));
|
||||||
|
|
||||||
var itemBookMetadata = itemFromApi.valueOrNull == null
|
var itemBookMetadata =
|
||||||
? null
|
itemFromApi.valueOrNull?.media.metadata.asBookMetadataExpanded;
|
||||||
: shelfsdk.BookMetadataExpanded.fromJson(
|
|
||||||
itemFromApi.valueOrNull!.media.metadata.toJson(),
|
|
||||||
);
|
|
||||||
// itemFromApi.valueOrNull?.media.metadata as shelfsdk.BookMetadata?;
|
|
||||||
|
|
||||||
final useMaterialThemeOnItemPage =
|
final useMaterialThemeOnItemPage =
|
||||||
ref.watch(appSettingsProvider).useMaterialThemeOnItemPage;
|
ref.watch(appSettingsProvider).useMaterialThemeOnItemPage;
|
||||||
|
|
@ -64,9 +58,7 @@ class LibraryItemPage extends HookConsumerWidget {
|
||||||
initTheme: Theme.of(context),
|
initTheme: Theme.of(context),
|
||||||
duration: 200.ms,
|
duration: 200.ms,
|
||||||
child: ThemeSwitchingArea(
|
child: ThemeSwitchingArea(
|
||||||
child: Builder(
|
child: Scaffold(
|
||||||
builder: (context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
const LibraryItemSliverAppBar(),
|
const LibraryItemSliverAppBar(),
|
||||||
|
|
@ -101,8 +93,7 @@ class LibraryItemPage extends HookConsumerWidget {
|
||||||
// a expandable section for book description
|
// a expandable section for book description
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child:
|
child:
|
||||||
itemFromApi.valueOrNull?.media.metadata.description !=
|
itemFromApi.valueOrNull?.media.metadata.description != null
|
||||||
null
|
|
||||||
? ExpandableDescription(
|
? ExpandableDescription(
|
||||||
title: 'About the Book',
|
title: 'About the Book',
|
||||||
content: itemFromApi
|
content: itemFromApi
|
||||||
|
|
@ -112,651 +103,12 @@ class LibraryItemPage extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
/// Calculate the width of the book cover based on the screen size
|
||||||
double calculateWidth(
|
double calculateWidth(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
|
|
|
||||||
|
|
@ -128,16 +128,17 @@ class PlaybackReporter {
|
||||||
|
|
||||||
/// current sessionId
|
/// current sessionId
|
||||||
/// this is used to report the playback
|
/// this is used to report the playback
|
||||||
String? sessionId;
|
PlaybackSession? _session;
|
||||||
|
String? get sessionId => _session?.id;
|
||||||
|
|
||||||
Future<String?> startSession() async {
|
Future<PlaybackSession?> startSession() async {
|
||||||
if (sessionId != null) {
|
if (_session != null) {
|
||||||
return sessionId!;
|
return _session!;
|
||||||
}
|
}
|
||||||
if (player.book == null) {
|
if (player.book == null) {
|
||||||
throw NoAudiobookPlayingError();
|
throw NoAudiobookPlayingError();
|
||||||
}
|
}
|
||||||
final session = await authenticatedApi.items.play(
|
_session = await authenticatedApi.items.play(
|
||||||
libraryItemId: player.book!.libraryItemId,
|
libraryItemId: player.book!.libraryItemId,
|
||||||
parameters: PlayItemReqParams(
|
parameters: PlayItemReqParams(
|
||||||
deviceInfo: await _getDeviceInfo(),
|
deviceInfo: await _getDeviceInfo(),
|
||||||
|
|
@ -146,23 +147,22 @@ class PlaybackReporter {
|
||||||
),
|
),
|
||||||
responseErrorHandler: _responseErrorHandler,
|
responseErrorHandler: _responseErrorHandler,
|
||||||
);
|
);
|
||||||
sessionId = session!.id;
|
|
||||||
debugPrint('PlaybackReporter Started session: $sessionId');
|
debugPrint('PlaybackReporter Started session: $sessionId');
|
||||||
return sessionId;
|
return _session;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> syncCurrentPosition() async {
|
Future<void> syncCurrentPosition() async {
|
||||||
try {
|
try {
|
||||||
sessionId ??= await startSession();
|
_session ??= await startSession();
|
||||||
} on NoAudiobookPlayingError {
|
} on NoAudiobookPlayingError {
|
||||||
debugPrint('PlaybackReporter No audiobook playing to sync position');
|
debugPrint('PlaybackReporter No audiobook playing to sync position');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final currentPosition = player.position;
|
final currentPosition = player.positionInBook;
|
||||||
|
|
||||||
await authenticatedApi.sessions.syncOpen(
|
await authenticatedApi.sessions.syncOpen(
|
||||||
sessionId: sessionId!,
|
sessionId: sessionId!,
|
||||||
parameters: _getSyncData(),
|
parameters: _getSyncData()!,
|
||||||
responseErrorHandler: _responseErrorHandler,
|
responseErrorHandler: _responseErrorHandler,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -185,7 +185,7 @@ class PlaybackReporter {
|
||||||
parameters: _getSyncData(),
|
parameters: _getSyncData(),
|
||||||
responseErrorHandler: _responseErrorHandler,
|
responseErrorHandler: _responseErrorHandler,
|
||||||
);
|
);
|
||||||
sessionId = null;
|
_session = null;
|
||||||
debugPrint('PlaybackReporter Closed session');
|
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(
|
return SyncSessionReqParams(
|
||||||
currentTime: player.position,
|
currentTime: player.positionInBook,
|
||||||
timeListened: _stopwatch.elapsed,
|
timeListened: _stopwatch.elapsed,
|
||||||
duration: player.book?.duration ?? Duration.zero,
|
duration: player.book?.duration ?? Duration.zero,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ class AudiobookPlayer extends AudioPlayer {
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
).catchError((error) {
|
).catchError((error) {
|
||||||
debugPrint('Error: $error');
|
debugPrint('AudiobookPlayer Error: $error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:shelfsdk/audiobookshelf_api.dart';
|
import 'package:shelfsdk/audiobookshelf_api.dart';
|
||||||
import 'package:whispering_pages/features/player/providers/audiobook_player.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';
|
part 'currently_playing_provider.g.dart';
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ BookChapter? currentPlayingChapter(CurrentPlayingChapterRef ref) {
|
||||||
BookMetadataExpanded? currentBookMetadata(CurrentBookMetadataRef ref) {
|
BookMetadataExpanded? currentBookMetadata(CurrentBookMetadataRef ref) {
|
||||||
final player = ref.watch(audiobookPlayerProvider);
|
final player = ref.watch(audiobookPlayerProvider);
|
||||||
if (player.book == null) return null;
|
if (player.book == null) return null;
|
||||||
return BookMetadataExpanded.fromJson(player.book!.metadata.toJson());
|
return player.book!.metadata.asBookMetadataExpanded;
|
||||||
}
|
}
|
||||||
|
|
||||||
// /// volume of the player [0, 1]
|
// /// volume of the player [0, 1]
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ final currentPlayingChapterProvider =
|
||||||
|
|
||||||
typedef CurrentPlayingChapterRef = AutoDisposeProviderRef<BookChapter?>;
|
typedef CurrentPlayingChapterRef = AutoDisposeProviderRef<BookChapter?>;
|
||||||
String _$currentBookMetadataHash() =>
|
String _$currentBookMetadataHash() =>
|
||||||
r'02b462a051fce5bcbdad6fdb708b60256fbb588c';
|
r'9088debba151894b61f2dcba1bba12a89244b9b1';
|
||||||
|
|
||||||
/// provides the book metadata of the currently playing book
|
/// provides the book metadata of the currently playing book
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:duration_picker/duration_picker.dart';
|
import 'package:duration_picker/duration_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.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],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart' show immutable;
|
||||||
@immutable
|
@immutable
|
||||||
class AppMetadata {
|
class AppMetadata {
|
||||||
const AppMetadata._();
|
const AppMetadata._();
|
||||||
|
// TODO: use the packageinfo package to get the app name
|
||||||
static const String appName = 'Whispering Pages';
|
static const String appName = 'Whispering Pages';
|
||||||
|
|
||||||
static get appNameLowerCase => appName.toLowerCase().replaceAll(' ', '_');
|
static get appNameLowerCase => appName.toLowerCase().replaceAll(' ', '_');
|
||||||
|
|
|
||||||
11
lib/shared/extensions/duration_format.dart
Normal file
11
lib/shared/extensions/duration_format.dart
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
48
lib/shared/extensions/model_conversions.dart
Normal file
48
lib/shared/extensions/model_conversions.dart
Normal 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
17
lib/shared/hooks.dart
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:shelfsdk/audiobookshelf_api.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';
|
import 'package:whispering_pages/shared/widgets/shelves/home_shelf.dart';
|
||||||
|
|
||||||
/// A shelf that displays Authors on the home page
|
/// A shelf that displays Authors on the home page
|
||||||
|
|
@ -39,7 +39,7 @@ class AuthorOnShelf extends HookConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final author = AuthorMinified.fromJson(item.toJson());
|
final author = item.asMinified;
|
||||||
// final coverImage = ref.watch(coverImageProvider(item));
|
// final coverImage = ref.watch(coverImageProvider(item));
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
|
|
||||||
|
|
@ -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/constants/hero_tag_conventions.dart';
|
||||||
import 'package:whispering_pages/router/models/library_item_extras.dart';
|
import 'package:whispering_pages/router/models/library_item_extras.dart';
|
||||||
import 'package:whispering_pages/router/router.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';
|
import 'package:whispering_pages/shared/widgets/shelves/home_shelf.dart';
|
||||||
|
|
||||||
/// A shelf that displays books on the home page
|
/// A shelf that displays books on the home page
|
||||||
|
|
@ -57,8 +58,8 @@ class BookOnShelf extends HookConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final book = BookMinified.fromJson(item.media.toJson());
|
final book = item.media.asBookMinified;
|
||||||
final metadata = BookMetadataMinified.fromJson(book.metadata.toJson());
|
final metadata = book.metadata.asBookMetadataMinified;
|
||||||
final coverImage = ref.watch(coverImageProvider(item));
|
final coverImage = ref.watch(coverImageProvider(item));
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
|
@ -75,7 +76,6 @@ class BookOnShelf extends HookConsumerWidget {
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: HeroTagPrefixes.bookCover + item.id + heroTagSuffix,
|
tag: HeroTagPrefixes.bookCover + item.id + heroTagSuffix,
|
||||||
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// open the book
|
// open the book
|
||||||
|
|
@ -91,7 +91,6 @@ class BookOnShelf extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: coverImage.when(
|
child: coverImage.when(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:shelfsdk/audiobookshelf_api.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/author_shelf.dart';
|
||||||
import 'package:whispering_pages/shared/widgets/shelves/book_shelf.dart';
|
import 'package:whispering_pages/shared/widgets/shelves/book_shelf.dart';
|
||||||
|
|
||||||
|
|
@ -24,11 +25,11 @@ class HomeShelf extends HookConsumerWidget {
|
||||||
return switch (shelf.type) {
|
return switch (shelf.type) {
|
||||||
ShelfType.book => BookHomeShelf(
|
ShelfType.book => BookHomeShelf(
|
||||||
title: title,
|
title: title,
|
||||||
shelf: LibraryItemShelf.fromJson(shelf.toJson()),
|
shelf: shelf.asLibraryItemShelf,
|
||||||
),
|
),
|
||||||
ShelfType.authors => AuthorHomeShelf(
|
ShelfType.authors => AuthorHomeShelf(
|
||||||
title: title,
|
title: title,
|
||||||
shelf: AuthorShelf.fromJson(shelf.toJson()),
|
shelf: shelf.asAuthorShelf,
|
||||||
),
|
),
|
||||||
_ => Container(),
|
_ => Container(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue