2025-12-05 17:59:13 +08:00
|
|
|
import 'dart:math';
|
|
|
|
|
|
2024-05-09 00:41:19 -04:00
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
2025-12-05 17:59:13 +08:00
|
|
|
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
|
|
|
|
import 'package:go_router/go_router.dart';
|
2024-05-09 00:41:19 -04:00
|
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
2025-12-05 17:59:13 +08:00
|
|
|
import 'package:shelfsdk/audiobookshelf_api.dart';
|
2024-08-23 04:21:46 -04:00
|
|
|
import 'package:vaani/api/api_provider.dart';
|
2025-12-05 17:59:13 +08:00
|
|
|
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/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';
|
2024-05-09 00:41:19 -04:00
|
|
|
|
|
|
|
|
// TODO: implement the library page
|
|
|
|
|
class LibraryPage extends HookConsumerWidget {
|
|
|
|
|
const LibraryPage({this.libraryId, super.key});
|
|
|
|
|
|
|
|
|
|
final String? libraryId;
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
2025-12-05 17:59:13 +08:00
|
|
|
final currentLibrary = ref.watch(currentLibraryProvider).valueOrNull;
|
|
|
|
|
|
|
|
|
|
// Determine the icon to use, with a fallback
|
|
|
|
|
final IconData libraryIconData =
|
|
|
|
|
AbsIcons.getIconByName(currentLibrary?.icon) ?? Icons.library_books;
|
|
|
|
|
|
|
|
|
|
// Determine the title text
|
|
|
|
|
final String appBarTitle = currentLibrary?.name ?? S.of(context).library;
|
2024-05-09 00:41:19 -04:00
|
|
|
// set the library id as the active library
|
2025-12-05 17:59:13 +08:00
|
|
|
// if (libraryId != null) {
|
|
|
|
|
// ref.read(apiSettingsProvider.notifier).updateState(
|
|
|
|
|
// ref.watch(apiSettingsProvider).copyWith(activeLibraryId: libraryId),
|
|
|
|
|
// );
|
|
|
|
|
// }
|
2024-05-09 00:41:19 -04:00
|
|
|
|
2025-12-05 17:59:13 +08:00
|
|
|
final views = ref.watch(currentLibraryItemsProvider);
|
2024-05-09 00:41:19 -04:00
|
|
|
final scrollController = useScrollController();
|
|
|
|
|
|
|
|
|
|
return Scaffold(
|
|
|
|
|
appBar: AppBar(
|
|
|
|
|
title: GestureDetector(
|
2025-12-05 17:59:13 +08:00
|
|
|
child: Text(appBarTitle),
|
2024-05-09 00:41:19 -04:00
|
|
|
onTap: () {
|
|
|
|
|
// scroll to the top of the page
|
|
|
|
|
scrollController.animateTo(
|
|
|
|
|
0,
|
|
|
|
|
duration: const Duration(milliseconds: 300),
|
|
|
|
|
curve: Curves.easeInOut,
|
|
|
|
|
);
|
|
|
|
|
// refresh the view
|
|
|
|
|
ref.invalidate(personalizedViewProvider);
|
|
|
|
|
},
|
|
|
|
|
),
|
2025-12-05 17:59:13 +08:00
|
|
|
leading: IconButton(
|
|
|
|
|
icon: Icon(libraryIconData),
|
|
|
|
|
tooltip:
|
|
|
|
|
S.of(context).librarySwitchTooltip, // Helpful tooltip for users
|
|
|
|
|
onPressed: () {
|
|
|
|
|
showLibrarySwitcher(context, ref);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
IconButton(
|
|
|
|
|
icon: Icon(Icons.refresh),
|
|
|
|
|
tooltip: '刷新', // Helpful tooltip for users
|
|
|
|
|
onPressed: () {
|
|
|
|
|
return ref.refresh(currentLibraryItemsProvider);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
IconButton(
|
|
|
|
|
icon: Icon(Icons.download),
|
|
|
|
|
tooltip: S.of(context).bookDownloads, // Helpful tooltip for users
|
|
|
|
|
onPressed: () {
|
|
|
|
|
GoRouter.of(context).pushNamed(Routes.downloads.name);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
],
|
2024-05-09 00:41:19 -04:00
|
|
|
),
|
2025-12-05 17:59:13 +08:00
|
|
|
// drawer: const MyDrawer(),
|
|
|
|
|
body: RefreshIndicator(
|
|
|
|
|
onRefresh: () async {
|
|
|
|
|
return ref.refresh(currentLibraryItemsProvider);
|
|
|
|
|
},
|
2024-05-09 00:41:19 -04:00
|
|
|
child: views.when(
|
|
|
|
|
data: (data) {
|
2025-12-05 17:59:13 +08:00
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
2024-05-09 00:41:19 -04:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
loading: () => const LibraryPageSkeleton(),
|
|
|
|
|
error: (error, stack) {
|
|
|
|
|
return Text('Error: $error');
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-12-05 17:59:13 +08:00
|
|
|
|
|
|
|
|
double getDefaultHeight(
|
|
|
|
|
BuildContext context, {
|
|
|
|
|
bool ignoreWidth = false,
|
|
|
|
|
atMin = 150.0,
|
|
|
|
|
perCent = 0.3,
|
|
|
|
|
}) {
|
|
|
|
|
double referenceSide;
|
|
|
|
|
if (ignoreWidth) {
|
|
|
|
|
referenceSide = MediaQuery.of(context).size.height;
|
|
|
|
|
} else {
|
|
|
|
|
referenceSide = min(
|
|
|
|
|
MediaQuery.of(context).size.width,
|
|
|
|
|
MediaQuery.of(context).size.height,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return max(atMin, referenceSide * perCent);
|
|
|
|
|
}
|
2024-05-09 00:41:19 -04:00
|
|
|
}
|
|
|
|
|
|
2025-12-05 17:59:13 +08:00
|
|
|
// 加载
|
2024-05-09 00:41:19 -04:00
|
|
|
class LibraryPageSkeleton extends StatelessWidget {
|
|
|
|
|
const LibraryPageSkeleton({super.key});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return const Scaffold(
|
|
|
|
|
body: Center(
|
|
|
|
|
child: CircularProgressIndicator(),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-05 17:59:13 +08:00
|
|
|
|
|
|
|
|
class LibraryPageItem extends HookConsumerWidget {
|
|
|
|
|
const LibraryPageItem({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.item,
|
|
|
|
|
});
|
|
|
|
|
final LibraryItem item;
|
|
|
|
|
@override
|
|
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 3),
|
|
|
|
|
Text(
|
|
|
|
|
metadata.authorName ?? '',
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class BookCoverWidget extends HookConsumerWidget {
|
|
|
|
|
const BookCoverWidget({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.itemId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final String itemId;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
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 Image.memory(
|
|
|
|
|
image,
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
loading: () {
|
|
|
|
|
return const Center(
|
|
|
|
|
child: BookCoverSkeleton(),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
error: (error, stack) {
|
|
|
|
|
return const Center(child: Icon(Icons.error));
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|