progress on home screen

This commit is contained in:
Dr-Blank 2024-06-17 01:33:56 -04:00
parent 865a662b56
commit 479242427a
No known key found for this signature in database
GPG key ID: 7452CC63F210A266
9 changed files with 333 additions and 124 deletions

View file

@ -142,3 +142,12 @@ FutureOr<GetUserSessionsResponse> fetchContinueListening(
// ); // );
return res!; return res!;
} }
@riverpod
FutureOr<User> me(
MeRef ref,
) async {
final api = ref.watch(authenticatedApiProvider);
final res = await api.me.getUser();
return res!;
}

View file

@ -347,6 +347,20 @@ final fetchContinueListeningProvider =
typedef FetchContinueListeningRef typedef FetchContinueListeningRef
= AutoDisposeFutureProviderRef<GetUserSessionsResponse>; = AutoDisposeFutureProviderRef<GetUserSessionsResponse>;
String _$meHash() => r'bdc664c4fd867ad13018fa769ce7a6913248c44f';
/// See also [me].
@ProviderFor(me)
final meProvider = AutoDisposeFutureProvider<User>.internal(
me,
name: r'meProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$meHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef MeRef = AutoDisposeFutureProviderRef<User>;
String _$personalizedViewHash() => r'2e70fe2bfc766a963f7a8e94211ad50d959fbaa2'; String _$personalizedViewHash() => r'2e70fe2bfc766a963f7a8e94211ad50d959fbaa2';
/// fetch the personalized view /// fetch the personalized view

View file

@ -6,7 +6,7 @@ part of 'library_item_provider.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$libraryItemHash() => r'6442db4e802e0a072689b8ff6c2b9aaa99cf0f17'; String _$libraryItemHash() => r'4c9a9e6d6700c7c76fbf56ecf5c0873155d5061a';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View file

@ -9,4 +9,6 @@ class HeroTagPrefixes {
static const String authorName = 'author_name_'; static const String authorName = 'author_name_';
static const String bookTitle = 'book_title_'; static const String bookTitle = 'book_title_';
static const String narratorName = 'narrator_name_'; static const String narratorName = 'narrator_name_';
static const String libraryItemPlayButton = 'library_item_play_button_';
} }

View file

@ -1,6 +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' as shelfsdk; import 'package:shelfsdk/audiobookshelf_api.dart' as shelfsdk;
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/item_viewer/view/library_item_page.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/settings/app_settings_provider.dart'; import 'package:whispering_pages/settings/app_settings_provider.dart';
@ -120,39 +121,15 @@ class _LibraryItemPlayButton extends HookConsumerWidget {
} }
return ElevatedButton.icon( return ElevatedButton.icon(
onPressed: () async { onPressed: () => libraryItemPlayButtonOnPressed(
debugPrint('Pressed play/resume button'); ref: ref, book: book, userMediaProgress: userMediaProgress),
// set the book to the player if not already set icon: Hero(
if (!isCurrentBookSetInPlayer) { tag: HeroTagPrefixes.libraryItemPlayButton + book.libraryItemId,
debugPrint('Setting the book ${book.libraryItemId}'); child: DynamicItemPlayIcon(
debugPrint('Initial position: ${userMediaProgress?.currentTime}'); isCurrentBookSetInPlayer: isCurrentBookSetInPlayer,
await player.setSourceAudioBook( isPlayingThisBook: isPlayingThisBook,
book, isBookCompleted: isBookCompleted,
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()), label: Text(getPlayDisplayText()),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@ -163,3 +140,64 @@ class _LibraryItemPlayButton extends HookConsumerWidget {
); );
} }
} }
class DynamicItemPlayIcon extends StatelessWidget {
const DynamicItemPlayIcon({
super.key,
required this.isCurrentBookSetInPlayer,
required this.isPlayingThisBook,
required this.isBookCompleted,
});
final bool isCurrentBookSetInPlayer;
final bool isPlayingThisBook;
final bool isBookCompleted;
@override
Widget build(BuildContext context) {
return Icon(
isCurrentBookSetInPlayer
? isPlayingThisBook
? Icons.pause_rounded
: Icons.play_arrow_rounded
: isBookCompleted
? Icons.replay_rounded
: Icons.play_arrow_rounded,
);
}
}
Future<void> libraryItemPlayButtonOnPressed({
required WidgetRef ref,
required shelfsdk.BookExpanded book,
shelfsdk.MediaProgress? userMediaProgress,
}) async {
debugPrint('Pressed play/resume button');
final player = ref.watch(audiobookPlayerProvider);
final isCurrentBookSetInPlayer = player.book == book;
final isPlayingThisBook = player.playing && isCurrentBookSetInPlayer;
// 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,
);
}

View file

@ -12,9 +12,9 @@ class HomePage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// hooks for the dark mode
final settings = ref.watch(appSettingsProvider); final settings = ref.watch(appSettingsProvider);
final api = ref.watch(authenticatedApiProvider); final api = ref.watch(authenticatedApiProvider);
final me = ref.watch(meProvider);
final views = ref.watch(personalizedViewProvider); final views = ref.watch(personalizedViewProvider);
final scrollController = useScrollController(); final scrollController = useScrollController();
return Scaffold( return Scaffold(

View file

@ -1,11 +1,9 @@
import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:shelfsdk/audiobookshelf_api.dart';
extension LibraryItemConversion on LibraryItem { extension LibraryItemConversion on LibraryItem {
LibraryItemExpanded get asExpanded => LibraryItemExpanded get asExpanded => LibraryItemExpanded.fromJson(toJson());
LibraryItemExpanded.fromJson(toJson());
LibraryItemMinified get asMinified => LibraryItemMinified get asMinified => LibraryItemMinified.fromJson(toJson());
LibraryItemMinified.fromJson(toJson());
} }
extension MediaConversion on Media { extension MediaConversion on Media {
@ -46,3 +44,10 @@ extension ShelfConversion on Shelf {
SeriesShelf get asSeriesShelf => SeriesShelf.fromJson(toJson()); SeriesShelf get asSeriesShelf => SeriesShelf.fromJson(toJson());
AuthorShelf get asAuthorShelf => AuthorShelf.fromJson(toJson()); AuthorShelf get asAuthorShelf => AuthorShelf.fromJson(toJson());
} }
extension UserConversion on User {
UserWithSessionAndMostRecentProgress
get asUserWithSessionAndMostRecentProgress =>
UserWithSessionAndMostRecentProgress.fromJson(toJson());
User get asUser => User.fromJson(toJson());
}

View file

@ -1,16 +1,24 @@
import 'dart:math'; import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.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:shimmer/shimmer.dart' show Shimmer; import 'package:shimmer/shimmer.dart' show Shimmer;
import 'package:whispering_pages/api/api_provider.dart';
import 'package:whispering_pages/api/image_provider.dart'; import 'package:whispering_pages/api/image_provider.dart';
import 'package:whispering_pages/api/library_item_provider.dart'
show libraryItemProvider;
import 'package:whispering_pages/constants/hero_tag_conventions.dart'; import 'package:whispering_pages/constants/hero_tag_conventions.dart';
import 'package:whispering_pages/features/item_viewer/view/library_item_actions.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/router/router.dart'; import 'package:whispering_pages/router/router.dart';
import 'package:whispering_pages/settings/app_settings_provider.dart';
import 'package:whispering_pages/shared/extensions/model_conversions.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';
import 'package:whispering_pages/theme/theme_from_cover_provider.dart';
/// A shelf that displays books on the home page /// A shelf that displays books on the home page
class BookHomeShelf extends HookConsumerWidget { class BookHomeShelf extends HookConsumerWidget {
@ -49,10 +57,14 @@ class BookOnShelf extends HookConsumerWidget {
super.key, super.key,
required this.item, required this.item,
this.heroTagSuffix = '', this.heroTagSuffix = '',
this.showPlayButton = true,
}); });
final LibraryItem item; final LibraryItem item;
/// whether to show the play button on the book cover
final bool showPlayButton;
/// makes the hero tag unique /// makes the hero tag unique
final String heroTagSuffix; final String heroTagSuffix;
@ -65,97 +77,119 @@ class BookOnShelf extends HookConsumerWidget {
builder: (context, constraints) { builder: (context, constraints) {
final height = min(constraints.maxHeight, 500); final height = min(constraints.maxHeight, 500);
final width = height * 0.75; final width = height * 0.75;
handleTapOnBook() {
// open the book
context.pushNamed(
Routes.libraryItem.name,
pathParameters: {
Routes.libraryItem.pathParamName!: item.id,
},
extra: LibraryItemExtras(
book: book,
heroTagSuffix: heroTagSuffix,
coverImage: coverImage.valueOrNull,
),
);
}
return SizedBox( return SizedBox(
width: width, width: width,
child: Column( child: InkWell(
crossAxisAlignment: CrossAxisAlignment.start, onTap: handleTapOnBook,
children: [ borderRadius: BorderRadius.circular(10),
// the cover image of the book child: Padding(
// take up remaining space hence the expanded padding:
Expanded( const EdgeInsets.only(bottom: 8.0, right: 4.0, left: 4.0),
child: Center( child: Column(
child: Hero( crossAxisAlignment: CrossAxisAlignment.start,
tag: HeroTagPrefixes.bookCover + item.id + heroTagSuffix, children: [
child: InkWell( // the cover image of the book
onTap: () { // take up remaining space hence the expanded
// open the book Expanded(
context.pushNamed( child: Center(
Routes.libraryItem.name, child: Stack(
pathParameters: { alignment: Alignment.bottomRight,
Routes.libraryItem.pathParamName!: item.id, children: [
}, Hero(
extra: LibraryItemExtras( tag: HeroTagPrefixes.bookCover +
book: book, item.id +
heroTagSuffix: heroTagSuffix, heroTagSuffix,
coverImage: coverImage.valueOrNull, child: ClipRRect(
), borderRadius: BorderRadius.circular(10),
); child: coverImage.when(
}, data: (image) {
child: ClipRRect( // return const BookCoverSkeleton();
borderRadius: BorderRadius.circular(10), if (image.isEmpty) {
child: coverImage.when( return const Icon(Icons.error);
data: (image) { }
// return const BookCoverSkeleton(); var imageWidget = Image.memory(
if (image.isEmpty) { image,
return const Icon(Icons.error); fit: BoxFit.fill,
} cacheWidth: (height *
var imageWidget = Image.memory( 1.2 *
image, MediaQuery.of(context)
fit: BoxFit.fill, .devicePixelRatio)
cacheWidth: (height * .round(),
1.2 * );
MediaQuery.of(context).devicePixelRatio) return Container(
.round(), decoration: BoxDecoration(
); color: Theme.of(context)
return Container( .colorScheme
decoration: BoxDecoration( .onPrimaryContainer,
color: Theme.of(context) ),
.colorScheme child: imageWidget,
.onPrimaryContainer, );
},
loading: () {
return const Center(
child: BookCoverSkeleton(),
);
},
error: (error, stack) {
return const Icon(Icons.error);
},
), ),
child: imageWidget, ),
); ),
}, // a play button on the book cover
loading: () { if (showPlayButton)
return const Center(child: BookCoverSkeleton()); _BookOnShelfPlayButton(
}, libraryItemId: item.id,
error: (error, stack) { ),
return const Icon(Icons.error); ],
},
),
), ),
), ),
), ),
), // 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,
),
),
],
), ),
// 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,
),
),
],
), ),
); );
}, },
@ -163,6 +197,113 @@ class BookOnShelf extends HookConsumerWidget {
} }
} }
class _BookOnShelfPlayButton extends HookConsumerWidget {
const _BookOnShelfPlayButton({
super.key,
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 =
ref.watch(appSettingsProvider).useMaterialThemeOnItemPage;
AsyncValue<ColorScheme?> coverColorScheme = const AsyncValue.loading();
if (useMaterialThemeOnItemPage && isCurrentBookSetInPlayer) {
final itemFromApi = ref.watch(libraryItemProvider(libraryItemId));
coverColorScheme = ref.watch(
themeOfLibraryItemProvider(
itemFromApi.valueOrNull,
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,
backgroundColor:
Theme.of(context).colorScheme.onPrimary.withOpacity(0.8),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
// 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(
Theme.of(context).colorScheme.onPrimary.withOpacity(0.9),
),
),
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,
),
),
),
],
),
),
);
}
}
// a skeleton for the book cover // a skeleton for the book cover
class BookCoverSkeleton extends StatelessWidget { class BookCoverSkeleton extends StatelessWidget {
const BookCoverSkeleton({ const BookCoverSkeleton({

View file

@ -83,7 +83,7 @@ class SimpleHomeShelf extends HookConsumerWidget {
return const SizedBox(); return const SizedBox();
} }
return const SizedBox(width: 16); return const SizedBox(width: 4);
}, },
itemCount: children.length + itemCount: children.length +
2, // add some extra space at the start and end so that the first and last items are not at the edge 2, // add some extra space at the start and end so that the first and last items are not at the edge