2024-05-08 21:25:06 -04:00
|
|
|
import 'dart:math';
|
|
|
|
|
|
2024-06-17 01:33:56 -04:00
|
|
|
import 'package:collection/collection.dart';
|
2024-05-08 05:03:49 -04:00
|
|
|
import 'package:flutter/material.dart';
|
2024-05-09 00:41:19 -04:00
|
|
|
import 'package:go_router/go_router.dart';
|
2024-05-08 05:03:49 -04:00
|
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
|
import 'package:shelfsdk/audiobookshelf_api.dart';
|
2024-05-09 00:41:19 -04:00
|
|
|
import 'package:shimmer/shimmer.dart' show Shimmer;
|
2024-08-23 04:21:46 -04:00
|
|
|
import 'package:vaani/api/api_provider.dart';
|
|
|
|
|
import 'package:vaani/api/image_provider.dart';
|
2024-09-23 03:55:32 -04:00
|
|
|
import 'package:vaani/api/library_item_provider.dart' show libraryItemProvider;
|
2024-08-23 04:21:46 -04:00
|
|
|
import 'package:vaani/constants/hero_tag_conventions.dart';
|
|
|
|
|
import 'package:vaani/features/item_viewer/view/library_item_actions.dart';
|
|
|
|
|
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
|
|
|
|
import 'package:vaani/router/models/library_item_extras.dart';
|
|
|
|
|
import 'package:vaani/router/router.dart';
|
|
|
|
|
import 'package:vaani/settings/app_settings_provider.dart';
|
|
|
|
|
import 'package:vaani/shared/extensions/model_conversions.dart';
|
|
|
|
|
import 'package:vaani/shared/widgets/shelves/home_shelf.dart';
|
2024-10-05 10:01:08 -04:00
|
|
|
import 'package:vaani/theme/providers/theme_from_cover_provider.dart';
|
2024-05-08 05:03:49 -04:00
|
|
|
|
|
|
|
|
/// A shelf that displays books on the home page
|
|
|
|
|
class BookHomeShelf extends HookConsumerWidget {
|
|
|
|
|
const BookHomeShelf({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.shelf,
|
|
|
|
|
required this.title,
|
|
|
|
|
});
|
|
|
|
|
|
2024-05-08 21:25:06 -04:00
|
|
|
final String title;
|
2024-05-08 05:03:49 -04:00
|
|
|
final LibraryItemShelf shelf;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
|
|
|
return SimpleHomeShelf(
|
|
|
|
|
title: title,
|
|
|
|
|
children: shelf.entities
|
|
|
|
|
.map(
|
|
|
|
|
(item) => switch (item.mediaType) {
|
|
|
|
|
MediaType.book => BookOnShelf(
|
|
|
|
|
item: item,
|
|
|
|
|
key: ValueKey(shelf.id + item.id),
|
2024-05-09 00:41:19 -04:00
|
|
|
heroTagSuffix: shelf.id,
|
2024-05-08 05:03:49 -04:00
|
|
|
),
|
|
|
|
|
_ => Container(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.toList(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// a widget to display a item on the shelf
|
|
|
|
|
class BookOnShelf extends HookConsumerWidget {
|
|
|
|
|
const BookOnShelf({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.item,
|
2024-05-09 00:41:19 -04:00
|
|
|
this.heroTagSuffix = '',
|
2024-06-17 01:33:56 -04:00
|
|
|
this.showPlayButton = true,
|
2024-05-08 05:03:49 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final LibraryItem item;
|
|
|
|
|
|
2024-06-17 01:33:56 -04:00
|
|
|
/// whether to show the play button on the book cover
|
|
|
|
|
final bool showPlayButton;
|
|
|
|
|
|
2024-05-09 00:41:19 -04:00
|
|
|
/// makes the hero tag unique
|
|
|
|
|
final String heroTagSuffix;
|
|
|
|
|
|
2024-05-08 05:03:49 -04:00
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
2024-06-16 22:24:32 -04:00
|
|
|
final book = item.media.asBookMinified;
|
|
|
|
|
final metadata = book.metadata.asBookMetadataMinified;
|
2024-09-23 03:55:32 -04:00
|
|
|
final coverImage = ref.watch(coverImageProvider(item.id));
|
2024-05-08 21:25:06 -04:00
|
|
|
return LayoutBuilder(
|
|
|
|
|
builder: (context, constraints) {
|
|
|
|
|
final height = min(constraints.maxHeight, 500);
|
|
|
|
|
final width = height * 0.75;
|
2024-06-17 01:33:56 -04:00
|
|
|
handleTapOnBook() {
|
|
|
|
|
// open the book
|
|
|
|
|
context.pushNamed(
|
|
|
|
|
Routes.libraryItem.name,
|
|
|
|
|
pathParameters: {
|
|
|
|
|
Routes.libraryItem.pathParamName!: item.id,
|
|
|
|
|
},
|
|
|
|
|
extra: LibraryItemExtras(
|
|
|
|
|
book: book,
|
|
|
|
|
heroTagSuffix: heroTagSuffix,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-08 21:25:06 -04:00
|
|
|
return SizedBox(
|
|
|
|
|
width: width,
|
2024-06-17 01:33:56 -04:00
|
|
|
child: InkWell(
|
|
|
|
|
onTap: handleTapOnBook,
|
|
|
|
|
borderRadius: BorderRadius.circular(10),
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding:
|
|
|
|
|
const EdgeInsets.only(bottom: 8.0, right: 4.0, left: 4.0),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
// the cover image of the book
|
|
|
|
|
// take up remaining space hence the expanded
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Center(
|
|
|
|
|
child: Stack(
|
|
|
|
|
alignment: Alignment.bottomRight,
|
|
|
|
|
children: [
|
|
|
|
|
Hero(
|
|
|
|
|
tag: HeroTagPrefixes.bookCover +
|
|
|
|
|
item.id +
|
|
|
|
|
heroTagSuffix,
|
|
|
|
|
child: ClipRRect(
|
|
|
|
|
borderRadius: BorderRadius.circular(10),
|
2024-10-06 05:52:29 -04:00
|
|
|
child: AnimatedSwitcher(
|
|
|
|
|
duration: const Duration(milliseconds: 300),
|
|
|
|
|
child: coverImage.when(
|
|
|
|
|
data: (image) {
|
|
|
|
|
// return const BookCoverSkeleton();
|
|
|
|
|
if (image.isEmpty) {
|
|
|
|
|
return const Icon(Icons.error);
|
|
|
|
|
}
|
|
|
|
|
var imageWidget = Image.memory(
|
|
|
|
|
image,
|
|
|
|
|
fit: BoxFit.fill,
|
|
|
|
|
cacheWidth: (height *
|
|
|
|
|
1.2 *
|
|
|
|
|
MediaQuery.of(context)
|
|
|
|
|
.devicePixelRatio)
|
|
|
|
|
.round(),
|
|
|
|
|
);
|
|
|
|
|
return Container(
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: Theme.of(context)
|
|
|
|
|
.colorScheme
|
|
|
|
|
.onPrimaryContainer,
|
|
|
|
|
),
|
|
|
|
|
child: imageWidget,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
loading: () {
|
|
|
|
|
return const Center(
|
|
|
|
|
child: BookCoverSkeleton(),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
error: (error, stack) {
|
2024-06-17 01:33:56 -04:00
|
|
|
return const Icon(Icons.error);
|
2024-10-06 05:52:29 -04:00
|
|
|
},
|
|
|
|
|
),
|
2024-05-14 06:13:16 -04:00
|
|
|
),
|
2024-06-17 01:33:56 -04:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// a play button on the book cover
|
|
|
|
|
if (showPlayButton)
|
|
|
|
|
_BookOnShelfPlayButton(
|
|
|
|
|
libraryItemId: item.id,
|
|
|
|
|
),
|
|
|
|
|
],
|
2024-05-12 05:38:30 -04:00
|
|
|
),
|
2024-05-09 00:41:19 -04:00
|
|
|
),
|
2024-05-08 21:25:06 -04:00
|
|
|
),
|
2024-06-17 01:33:56 -04:00
|
|
|
// the title and author of the book
|
|
|
|
|
// AutoScrollText(
|
|
|
|
|
Hero(
|
|
|
|
|
tag: HeroTagPrefixes.bookTitle + item.id + heroTagSuffix,
|
|
|
|
|
child: Text(
|
|
|
|
|
metadata.title ?? '',
|
|
|
|
|
// mode: AutoScrollTextMode.bouncing,
|
|
|
|
|
// curve: Curves.easeInOut,
|
|
|
|
|
// velocity: const Velocity(pixelsPerSecond: Offset(15, 0)),
|
|
|
|
|
// delayBefore: const Duration(seconds: 2),
|
|
|
|
|
// pauseBetween: const Duration(seconds: 2),
|
|
|
|
|
// numberOfReps: 15,
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 3),
|
|
|
|
|
Hero(
|
|
|
|
|
tag: HeroTagPrefixes.authorName + item.id + heroTagSuffix,
|
|
|
|
|
child: Text(
|
|
|
|
|
metadata.authorName ?? '',
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _BookOnShelfPlayButton extends HookConsumerWidget {
|
|
|
|
|
const _BookOnShelfPlayButton({
|
|
|
|
|
required this.libraryItemId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// the id of the library item of the book
|
|
|
|
|
final String libraryItemId;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
|
|
|
final me = ref.watch(meProvider);
|
|
|
|
|
final player = ref.watch(audiobookPlayerProvider);
|
|
|
|
|
final isCurrentBookSetInPlayer =
|
|
|
|
|
player.book?.libraryItemId == libraryItemId;
|
|
|
|
|
final isPlayingThisBook = player.playing && isCurrentBookSetInPlayer;
|
|
|
|
|
|
|
|
|
|
final userProgress = me.valueOrNull?.mediaProgress
|
|
|
|
|
?.firstWhereOrNull((element) => element.libraryItemId == libraryItemId);
|
|
|
|
|
final isBookCompleted = userProgress?.isFinished ?? false;
|
|
|
|
|
|
|
|
|
|
const size = 40.0;
|
|
|
|
|
|
|
|
|
|
// if there is user progress for this book show a circular progress indicator around the play button
|
|
|
|
|
var strokeWidth = size / 8;
|
|
|
|
|
|
|
|
|
|
final useMaterialThemeOnItemPage =
|
2024-08-20 11:39:26 -04:00
|
|
|
ref.watch(appSettingsProvider).themeSettings.useMaterialThemeOnItemPage;
|
2024-06-17 01:33:56 -04:00
|
|
|
|
|
|
|
|
AsyncValue<ColorScheme?> coverColorScheme = const AsyncValue.loading();
|
|
|
|
|
if (useMaterialThemeOnItemPage && isCurrentBookSetInPlayer) {
|
|
|
|
|
coverColorScheme = ref.watch(
|
|
|
|
|
themeOfLibraryItemProvider(
|
2024-09-23 03:55:32 -04:00
|
|
|
libraryItemId,
|
2024-06-17 01:33:56 -04:00
|
|
|
brightness: Theme.of(context).brightness,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Theme(
|
|
|
|
|
// if current book is set in player, get theme from the cover image
|
|
|
|
|
data: ThemeData(
|
|
|
|
|
colorScheme:
|
|
|
|
|
coverColorScheme.valueOrNull ?? Theme.of(context).colorScheme,
|
|
|
|
|
),
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: EdgeInsets.all(strokeWidth / 2 + 2),
|
|
|
|
|
child: Stack(
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
// the circular progress indicator
|
|
|
|
|
if (userProgress != null)
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: size,
|
|
|
|
|
height: size,
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
value: userProgress.progress,
|
|
|
|
|
strokeWidth: strokeWidth,
|
2025-04-10 19:42:08 +05:30
|
|
|
backgroundColor: Theme.of(context)
|
|
|
|
|
.colorScheme
|
|
|
|
|
.onPrimary
|
|
|
|
|
.withValues(alpha: 0.8),
|
2024-06-17 01:33:56 -04:00
|
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
|
|
|
Theme.of(context).colorScheme.primary,
|
|
|
|
|
),
|
2024-05-08 05:03:49 -04:00
|
|
|
),
|
|
|
|
|
),
|
2024-06-17 01:33:56 -04:00
|
|
|
|
|
|
|
|
// the play button
|
|
|
|
|
IconButton(
|
|
|
|
|
color: Theme.of(context).colorScheme.primary,
|
|
|
|
|
style: ButtonStyle(
|
|
|
|
|
padding: WidgetStateProperty.all(
|
|
|
|
|
EdgeInsets.zero,
|
|
|
|
|
),
|
|
|
|
|
minimumSize: WidgetStateProperty.all(
|
|
|
|
|
const Size(size, size),
|
|
|
|
|
),
|
|
|
|
|
backgroundColor: WidgetStateProperty.all(
|
2025-04-10 19:42:08 +05:30
|
|
|
Theme.of(context)
|
|
|
|
|
.colorScheme
|
|
|
|
|
.onPrimary
|
|
|
|
|
.withValues(alpha: 0.9),
|
2024-05-11 04:06:25 -04:00
|
|
|
),
|
2024-05-08 21:25:06 -04:00
|
|
|
),
|
2024-06-17 01:33:56 -04:00
|
|
|
onPressed: () async {
|
|
|
|
|
final book =
|
|
|
|
|
await ref.watch(libraryItemProvider(libraryItemId).future);
|
|
|
|
|
|
|
|
|
|
libraryItemPlayButtonOnPressed(
|
|
|
|
|
ref: ref,
|
|
|
|
|
book: book.media.asBookExpanded,
|
|
|
|
|
userMediaProgress: userProgress,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
icon: Hero(
|
|
|
|
|
tag: HeroTagPrefixes.libraryItemPlayButton + libraryItemId,
|
|
|
|
|
child: DynamicItemPlayIcon(
|
|
|
|
|
isBookCompleted: isBookCompleted,
|
|
|
|
|
isPlayingThisBook: isPlayingThisBook,
|
|
|
|
|
isCurrentBookSetInPlayer: isCurrentBookSetInPlayer,
|
2024-05-11 04:06:25 -04:00
|
|
|
),
|
2024-05-08 21:25:06 -04:00
|
|
|
),
|
2024-06-17 01:33:56 -04:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
2024-05-08 21:25:06 -04:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// a skeleton for the book cover
|
|
|
|
|
class BookCoverSkeleton extends StatelessWidget {
|
|
|
|
|
const BookCoverSkeleton({
|
|
|
|
|
super.key,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return AspectRatio(
|
|
|
|
|
aspectRatio: 1,
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
width: 150,
|
|
|
|
|
child: Shimmer.fromColors(
|
2025-04-10 19:42:08 +05:30
|
|
|
baseColor:
|
|
|
|
|
Theme.of(context).colorScheme.surface.withValues(alpha: 0.3),
|
2024-05-08 21:25:06 -04:00
|
|
|
highlightColor:
|
2025-04-10 19:42:08 +05:30
|
|
|
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.1),
|
2024-05-08 21:25:06 -04:00
|
|
|
child: Container(
|
|
|
|
|
color: Theme.of(context).colorScheme.surface,
|
2024-05-08 05:03:49 -04:00
|
|
|
),
|
2024-05-08 21:25:06 -04:00
|
|
|
),
|
2024-05-08 05:03:49 -04:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|