From 861572db87b4e6728b39eff18861e6c1d36f2504 Mon Sep 17 00:00:00 2001 From: rang <378694192@qq.com> Date: Sat, 20 Dec 2025 11:54:14 +0800 Subject: [PATCH] 123 --- lib/api/library_provider.dart | 203 +++++++++++++++--- lib/api/library_provider.g.dart | 42 +--- lib/features/explore/view/explore_page.dart | 6 +- .../view/library_item_hero_section.dart | 2 +- .../core/abs_audio_player_platform.dart | 7 +- lib/pages/home_page.dart | 16 +- lib/pages/library_page.dart | 158 +++++++------- lib/router/router.dart | 2 +- lib/router/transitions/slide.dart | 27 +-- lib/shared/widgets/shelves/book_shelf.dart | 28 +-- lib/shared/widgets/skeletons.dart | 44 ++++ 11 files changed, 326 insertions(+), 209 deletions(-) create mode 100644 lib/shared/widgets/skeletons.dart diff --git a/lib/api/library_provider.dart b/lib/api/library_provider.dart index 7db659a..9a30a75 100644 --- a/lib/api/library_provider.dart +++ b/lib/api/library_provider.dart @@ -1,12 +1,12 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first import 'package:hooks_riverpod/hooks_riverpod.dart' show Ref; import 'package:logging/logging.dart' show Logger; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shelfsdk/audiobookshelf_api.dart' - show GetLibrarysItemsReqParams, Library, LibraryItemMinified, LibraryItem; + show GetLibrarysItemsReqParams, Library, LibraryItem; import 'package:vaani/api/api_provider.dart' show authenticatedApiProvider; import 'package:vaani/features/settings/api_settings_provider.dart' show apiSettingsProvider; -import 'package:vaani/shared/extensions/model_conversions.dart'; part 'library_provider.g.dart'; @@ -59,38 +59,177 @@ class Libraries extends _$Libraries { } } -@riverpod -class LibraryItemsParams extends _$LibraryItemsParams { - @override - GetLibrarysItemsReqParams build() { - return const GetLibrarysItemsReqParams( - limit: 18, - page: 0, - minified: true, +class LibraryItemsState { + final List items; + final int limit; + final int page; + final String? sort; + final bool? desc; + final bool isLoading; + final bool isRefreshing; + final bool hasMore; + final bool hasError; + final String? errorMessage; + + const LibraryItemsState({ + this.items = const [], + this.limit = 18, + this.page = 0, + this.sort, + this.desc, + this.isLoading = false, + this.isRefreshing = false, + this.hasMore = false, + this.hasError = false, + this.errorMessage, + }); + + LibraryItemsState copyWith({ + List? items, + int? limit, + int? page, + String? sort, + bool? desc, + bool? isLoading, + bool? isRefreshing, + bool? hasMore, + bool? hasError, + String? errorMessage, + }) { + return LibraryItemsState( + items: items ?? this.items, + limit: limit ?? this.limit, + page: page ?? this.page, + sort: sort ?? this.sort, + desc: desc ?? this.desc, + isLoading: isLoading ?? this.isLoading, + isRefreshing: isRefreshing ?? this.isRefreshing, + hasMore: hasMore ?? this.hasMore, + hasError: hasError ?? this.hasError, + errorMessage: errorMessage ?? this.errorMessage, ); } + + factory LibraryItemsState.initial() => const LibraryItemsState(); +} + +@riverpod +class LibraryItems extends _$LibraryItems { + @override + LibraryItemsState build() { + // 初始加载数据 + Future.microtask(_loadInitialData); + return LibraryItemsState.initial(); + } + + // 下拉刷新 + Future refresh() async { + if (state.isRefreshing) return; + + // 开始刷新 + state = state.copyWith( + page: 0, + isRefreshing: true, + hasError: false, + errorMessage: null, + ); + try { + final items = await _load(); + state = state.copyWith( + items: [...items], + page: state.page + 1, + isRefreshing: false, + hasMore: items.length == state.limit, + ); + } catch (e) { + state = state.copyWith( + isRefreshing: false, + hasError: true, + errorMessage: e.toString(), + ); + } + } + + // 初始加载数据 + Future _loadInitialData() async { + // await _loadMore(skip: true); + await _loadMore(); + } + + // 上拉加载更多 + Future loadMore() async { + if (state.isLoading || !state.hasMore) return; + await _loadMore(); + } + + // 内部加载方法 + Future _loadMore({bool skip = false}) async { + if (!skip) { + state = state.copyWith( + isLoading: true, + hasError: false, + errorMessage: null, + ); + } + try { + final items = await _load(); + state = state.copyWith( + items: [...state.items, ...items], + page: state.page + 1, + isLoading: false, + hasMore: items.length == state.limit, + ); + } catch (e) { + state = state.copyWith( + isLoading: false, + hasError: true, + errorMessage: e.toString(), + ); + } + } + + // 加载方法 + Future> _load() async { + final api = ref.read(authenticatedApiProvider); + final libraryId = + ref.watch(apiSettingsProvider.select((s) => s.activeLibraryId)); + if (libraryId != null) { + final newItems = await api.libraries.getItems( + libraryId: libraryId, + parameters: GetLibrarysItemsReqParams( + limit: state.limit, + page: state.page, + sort: state.sort, + desc: state.desc, + minified: true, + ), + ); + return newItems?.results ?? []; + } + return []; + } } // 查询库下所有项目 -@riverpod -Future> currentLibraryItems(Ref ref) async { - final api = ref.watch(authenticatedApiProvider); - final libraryId = - ref.watch(apiSettingsProvider.select((s) => s.activeLibraryId)); - if (libraryId == null) { - _logger.warning('No active library id found'); - return []; - } - final items = await api.libraries.getItems( - libraryId: libraryId, - parameters: const GetLibrarysItemsReqParams( - limit: 18, - page: 0, - minified: true, - ), - ); - if (items == null) { - return []; - } - return items.results; -} +// @riverpod +// Future> currentLibraryItems(Ref ref) async { +// final api = ref.watch(authenticatedApiProvider); +// final libraryId = +// ref.watch(apiSettingsProvider.select((s) => s.activeLibraryId)); +// if (libraryId == null) { +// _logger.warning('No active library id found'); +// return []; +// } +// final items = await api.libraries.getItems( +// libraryId: libraryId, +// parameters: const GetLibrarysItemsReqParams( +// limit: 18, +// page: 0, +// minified: true, +// ), +// ); +// if (items == null) { +// return []; +// } +// return items.results; +// } diff --git a/lib/api/library_provider.g.dart b/lib/api/library_provider.g.dart index 0abdc83..a5a1add 100644 --- a/lib/api/library_provider.g.dart +++ b/lib/api/library_provider.g.dart @@ -173,26 +173,6 @@ final currentLibraryProvider = AutoDisposeFutureProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef CurrentLibraryRef = AutoDisposeFutureProviderRef; -String _$currentLibraryItemsHash() => - r'b0d0dcca86e760ee08f327c06b5ad5deaf7852e1'; - -/// See also [currentLibraryItems]. -@ProviderFor(currentLibraryItems) -final currentLibraryItemsProvider = - AutoDisposeFutureProvider>.internal( - currentLibraryItems, - name: r'currentLibraryItemsProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$currentLibraryItemsHash, - dependencies: null, - allTransitiveDependencies: null, -); - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -typedef CurrentLibraryItemsRef - = AutoDisposeFutureProviderRef>; String _$librariesHash() => r'95ebd4d1ac0cc2acf7617dc22895eff0ca30600f'; /// See also [Libraries]. @@ -208,22 +188,20 @@ final librariesProvider = ); typedef _$Libraries = AutoDisposeAsyncNotifier>; -String _$libraryItemsParamsHash() => - r'9e7f11ab185eb99e926ae87e06466fc12aee7f72'; +String _$libraryItemsHash() => r'847ff8f5c325a786f257c2b98986098a9664cbb5'; -/// See also [LibraryItemsParams]. -@ProviderFor(LibraryItemsParams) -final libraryItemsParamsProvider = AutoDisposeNotifierProvider< - LibraryItemsParams, GetLibrarysItemsReqParams>.internal( - LibraryItemsParams.new, - name: r'libraryItemsParamsProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$libraryItemsParamsHash, +/// See also [LibraryItems]. +@ProviderFor(LibraryItems) +final libraryItemsProvider = + AutoDisposeNotifierProvider.internal( + LibraryItems.new, + name: r'libraryItemsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$libraryItemsHash, dependencies: null, allTransitiveDependencies: null, ); -typedef _$LibraryItemsParams = AutoDisposeNotifier; +typedef _$LibraryItems = AutoDisposeNotifier; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/explore/view/explore_page.dart b/lib/features/explore/view/explore_page.dart index 9429df5..9256bba 100644 --- a/lib/features/explore/view/explore_page.dart +++ b/lib/features/explore/view/explore_page.dart @@ -11,12 +11,12 @@ import 'package:vaani/api/library_item_provider.dart'; import 'package:vaani/constants/hero_tag_conventions.dart'; import 'package:vaani/features/explore/providers/search_controller.dart'; import 'package:vaani/features/explore/view/search_result_page.dart'; -import 'package:vaani/generated/l10n.dart'; -import 'package:vaani/router/router.dart'; import 'package:vaani/features/settings/api_settings_provider.dart'; import 'package:vaani/features/settings/app_settings_provider.dart'; +import 'package:vaani/generated/l10n.dart'; +import 'package:vaani/router/router.dart'; import 'package:vaani/shared/extensions/model_conversions.dart'; -import 'package:vaani/shared/widgets/shelves/book_shelf.dart'; +import 'package:vaani/shared/widgets/skeletons.dart'; const Duration debounceDuration = Duration(milliseconds: 500); diff --git a/lib/features/item_viewer/view/library_item_hero_section.dart b/lib/features/item_viewer/view/library_item_hero_section.dart index b1c0e23..38af406 100644 --- a/lib/features/item_viewer/view/library_item_hero_section.dart +++ b/lib/features/item_viewer/view/library_item_hero_section.dart @@ -12,7 +12,7 @@ import 'package:vaani/features/settings/app_settings_provider.dart'; import 'package:vaani/router/models/library_item_extras.dart'; import 'package:vaani/shared/extensions/duration_format.dart'; import 'package:vaani/shared/extensions/model_conversions.dart'; -import 'package:vaani/shared/widgets/shelves/book_shelf.dart'; +import 'package:vaani/shared/widgets/skeletons.dart'; class LibraryItemHeroSection extends HookConsumerWidget { const LibraryItemHeroSection({ diff --git a/lib/features/player/core/abs_audio_player_platform.dart b/lib/features/player/core/abs_audio_player_platform.dart index e928e69..f512548 100644 --- a/lib/features/player/core/abs_audio_player_platform.dart +++ b/lib/features/player/core/abs_audio_player_platform.dart @@ -2,7 +2,6 @@ import 'package:just_audio/just_audio.dart'; import 'package:just_audio_media_kit/just_audio_media_kit.dart'; import 'package:logging/logging.dart'; import 'package:vaani/features/player/core/abs_audio_player.dart'; -import 'package:vaani/shared/extensions/chapter.dart'; final _logger = Logger('AbsPlatformAudioPlayer'); @@ -39,9 +38,9 @@ class AbsPlatformAudioPlayer extends AbsAudioPlayer { positionInBook >= (chapter?.end ?? Duration.zero)) { final chapter = book?.findChapterAtTime(positionInBook); if (chapter != currentChapter) { - print('当前章节时长: ${currentChapter?.duration}'); - print('切换章节时长: ${chapter?.duration}'); - print('当前播放音轨时长: ${_player.duration}'); + // print('当前章节时长: ${currentChapter?.duration}'); + // print('切换章节时长: ${chapter?.duration}'); + // print('当前播放音轨时长: ${_player.duration}'); chapterStreamController.add(chapter); } } diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 856c725..0a0e9a7 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -9,6 +9,7 @@ import 'package:vaani/router/router.dart'; import 'package:vaani/features/settings/api_settings_provider.dart'; import 'package:vaani/features/settings/app_settings_provider.dart' show appSettingsProvider; +import 'package:vaani/shared/widgets/skeletons.dart'; import '../shared/widgets/shelves/home_shelf.dart'; @@ -112,7 +113,7 @@ class HomePage extends HookConsumerWidget { ), ); }, - loading: () => const HomePageSkeleton(), + loading: () => const PageSkeleton(), error: (error, stack) { if (apiSettings.activeUser == null || apiSettings.activeServer == null) { @@ -138,16 +139,3 @@ class HomePage extends HookConsumerWidget { ); } } - -class HomePageSkeleton extends StatelessWidget { - const HomePageSkeleton({super.key}); - - @override - Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), - ); - } -} diff --git a/lib/pages/library_page.dart b/lib/pages/library_page.dart index bb4d627..2e509db 100644 --- a/lib/pages/library_page.dart +++ b/lib/pages/library_page.dart @@ -11,14 +11,15 @@ import 'package:vaani/api/image_provider.dart'; import 'package:vaani/api/library_provider.dart'; import 'package:vaani/features/you/view/widgets/library_switch_chip.dart'; import 'package:vaani/generated/l10n.dart'; +import 'package:vaani/router/models/library_item_extras.dart'; import 'package:vaani/router/router.dart'; import 'package:vaani/shared/extensions/model_conversions.dart'; import 'package:vaani/shared/icons/abs_icons.dart'; -import 'package:vaani/shared/widgets/shelves/book_shelf.dart'; +import 'package:vaani/shared/widgets/skeletons.dart'; // TODO: implement the library page class LibraryPage extends HookConsumerWidget { - const LibraryPage({this.libraryId, super.key}); + LibraryPage({this.libraryId, super.key}); final String? libraryId; @override @@ -38,7 +39,10 @@ class LibraryPage extends HookConsumerWidget { // ); // } - final views = ref.watch(currentLibraryItemsProvider); + // final views = ref.watch(currentLibraryItemsProvider); + final pageData = ref.watch(libraryItemsProvider); + final items = pageData.items; + final scrollController = useScrollController(); return Scaffold( @@ -68,9 +72,7 @@ class LibraryPage extends HookConsumerWidget { IconButton( icon: Icon(Icons.refresh), tooltip: '刷新', // Helpful tooltip for users - onPressed: () { - return ref.refresh(currentLibraryItemsProvider); - }, + onPressed: () => ref.read(libraryItemsProvider.notifier).refresh(), ), IconButton( icon: Icon(Icons.download), @@ -83,36 +85,26 @@ class LibraryPage extends HookConsumerWidget { ), // drawer: const MyDrawer(), body: RefreshIndicator( - onRefresh: () async { - return ref.refresh(currentLibraryItemsProvider); - }, - child: views.when( - data: (data) { - return LayoutBuilder( - builder: (context, constraints) { - final height = getDefaultHeight(context); - // final height = min(constraints.maxHeight, 500.0); - final width = height * 0.75; - return AlignedGridView.count( - crossAxisCount: constraints.maxWidth ~/ width, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - padding: EdgeInsets.only(top: 0, left: 8, right: 8), - itemCount: data.length, - controller: scrollController, - itemBuilder: (context, index) { - return LibraryPageItem( - item: data[index], - ); - }, + onRefresh: () => ref.read(libraryItemsProvider.notifier).refresh(), + child: LayoutBuilder( + builder: (context, constraints) { + final height = getDefaultHeight(context); + // final height = min(constraints.maxHeight, 500.0); + final width = height * 0.75; + return AlignedGridView.count( + crossAxisCount: constraints.maxWidth ~/ width, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + padding: EdgeInsets.only(top: 0, left: 8, right: 8), + itemCount: items.length, + controller: scrollController, + itemBuilder: (context, index) { + return LibraryPageItem( + item: items[index], ); }, ); }, - loading: () => const LibraryPageSkeleton(), - error: (error, stack) { - return Text('Error: $error'); - }, ), ), ); @@ -137,20 +129,6 @@ class LibraryPage extends HookConsumerWidget { } } -// 加载 -class LibraryPageSkeleton extends StatelessWidget { - const LibraryPageSkeleton({super.key}); - - @override - Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), - ); - } -} - class LibraryPageItem extends HookConsumerWidget { const LibraryPageItem({ super.key, @@ -161,25 +139,37 @@ class LibraryPageItem extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final book = item.media.asBookMinified; final metadata = book.metadata.asBookMetadataMinified; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BookCoverWidget(itemId: item.id), - const SizedBox(height: 3), - Text( - metadata.title ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyLarge, + return InkWell( + onTap: () => context.pushNamed( + Routes.libraryItem.name, + pathParameters: { + Routes.libraryItem.pathParamName!: item.id, + }, + extra: LibraryItemExtras( + book: book, ), - const SizedBox(height: 3), - Text( - metadata.authorName ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ), - ], + ), + borderRadius: BorderRadius.circular(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BookCoverWidget(itemId: item.id), + const SizedBox(height: 3), + Text( + metadata.title ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 2), + Text( + metadata.authorName ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), ); } } @@ -196,26 +186,28 @@ class BookCoverWidget extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final coverImage = ref.watch(coverImageProvider(itemId)); - return coverImage.when( - data: (image) { - // return const BookCoverSkeleton(); - if (image.isEmpty) { - return const Icon(Icons.error); - } + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: coverImage.when( + data: (image) { + if (image.isEmpty) { + return const Icon(Icons.error); + } - return Image.memory( - image, - fit: BoxFit.cover, - ); - }, - loading: () { - return const Center( - child: BookCoverSkeleton(), - ); - }, - error: (error, stack) { - return const Center(child: Icon(Icons.error)); - }, + return Image.memory( + image, + fit: BoxFit.cover, + ); + }, + loading: () { + return const Center( + child: BookCoverSkeleton(), + ); + }, + error: (error, stack) { + return const Center(child: Icon(Icons.error)); + }, + ), ); } } diff --git a/lib/router/router.dart b/lib/router/router.dart index be1bad8..8fc0fc2 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -126,7 +126,7 @@ class MyAppRouter { GoRoute( path: Routes.library.localPath, name: Routes.library.name, - pageBuilder: defaultPageBuilder(const LibraryPage()), + pageBuilder: defaultPageBuilder(LibraryPage()), ), ], ), diff --git a/lib/router/transitions/slide.dart b/lib/router/transitions/slide.dart index 969edea..0d1cf11 100644 --- a/lib/router/transitions/slide.dart +++ b/lib/router/transitions/slide.dart @@ -31,21 +31,24 @@ CustomTransitionPage buildPageWithDefaultTransition({ // transitionDuration: 1250.ms, // reverseTransitionDuration: 1250.ms, child: child, - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition( - opacity: animation, - child: SlideTransition( - position: animation.drive( - Tween( - begin: const Offset(0, 1.50), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + // 将 CurvedAnimation 提取出来,避免重复创建 + final curvedAnimation = animation.drive( + CurveTween(curve: Curves.easeOut), + ); + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.3), end: Offset.zero, - ).chain( - CurveTween(curve: Curves.easeOut), + ).animate( + curvedAnimation, ), + child: child, ), - child: child, - ), - ), + ); + }, ); } diff --git a/lib/shared/widgets/shelves/book_shelf.dart b/lib/shared/widgets/shelves/book_shelf.dart index 4ee702d..6659d40 100644 --- a/lib/shared/widgets/shelves/book_shelf.dart +++ b/lib/shared/widgets/shelves/book_shelf.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; -import 'package:shimmer/shimmer.dart' show Shimmer; import 'package:vaani/api/api_provider.dart'; import 'package:vaani/api/image_provider.dart'; import 'package:vaani/api/library_item_provider.dart' show libraryItemProvider; @@ -17,6 +16,7 @@ import 'package:vaani/router/models/library_item_extras.dart'; import 'package:vaani/router/router.dart'; import 'package:vaani/shared/extensions/model_conversions.dart'; import 'package:vaani/shared/widgets/shelves/home_shelf.dart'; +import 'package:vaani/shared/widgets/skeletons.dart'; import 'package:vaani/theme/providers/theme_from_cover_provider.dart'; /// A shelf that displays books on the home page @@ -316,32 +316,6 @@ class _BookOnShelfPlayButton extends HookConsumerWidget { } } -// 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( - baseColor: - Theme.of(context).colorScheme.surface.withValues(alpha: 0.3), - highlightColor: - Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.1), - child: Container( - color: Theme.of(context).colorScheme.surface, - ), - ), - ), - ); - } -} - class BookCoverWidget extends HookConsumerWidget { const BookCoverWidget({ super.key, diff --git a/lib/shared/widgets/skeletons.dart b/lib/shared/widgets/skeletons.dart new file mode 100644 index 0000000..8f2cc1b --- /dev/null +++ b/lib/shared/widgets/skeletons.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +/// 加载框 + +// 页面加载 +class PageSkeleton extends StatelessWidget { + const PageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } +} + +// 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( + baseColor: + Theme.of(context).colorScheme.surface.withValues(alpha: 0.3), + highlightColor: + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.1), + child: Container( + color: Theme.of(context).colorScheme.surface, + ), + ), + ), + ); + } +}