feat: implement library view with Authors, Genres, and Series browsing

This commit implements a comprehensive library browsing feature:

- Add LibraryBrowserProvider with providers for authors, genres, and series data
- Create LibraryAuthorsPage with grid view of authors including images and book counts
- Create LibraryGenresPage with list view of all genres
- Create LibrarySeriesPage with list view of series and book counts
- Update LibraryBrowserPage navigation to route to the new views
- Add routes for /browser/authors, /browser/genres, and /browser/series
- Replace "Not Implemented" toasts with functional navigation

The implementation uses the Audiobookshelf API via shelfsdk to fetch:
- Authors list with metadata (getAuthors)
- Genres from library filter data (getFilterData)
- Series with pagination support (getSeries)

All views follow Material Design 3 patterns and include proper loading/error states.
This commit is contained in:
Claude 2025-11-20 10:52:18 +00:00
parent 07aea41c6e
commit 53027bf74c
No known key found for this signature in database
7 changed files with 485 additions and 5 deletions

View file

@ -0,0 +1,79 @@
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 Author, GetLibrarysSeriesResponse, LibraryFilterData;
import 'package:vaani/api/api_provider.dart' show authenticatedApiProvider;
import 'package:vaani/api/library_provider.dart' show currentLibraryProvider;
part 'library_browser_provider.g.dart';
final _logger = Logger('LibraryBrowserProvider');
/// Provider for fetching all authors in the current library
@riverpod
Future<List<Author>> libraryAuthors(Ref ref) async {
final api = ref.watch(authenticatedApiProvider);
final currentLibrary = await ref.watch(currentLibraryProvider.future);
if (currentLibrary == null) {
_logger.warning('No current library found');
return [];
}
final authors = await api.libraries.getAuthors(libraryId: currentLibrary.id);
if (authors == null) {
_logger.warning('Failed to fetch authors for library ${currentLibrary.id}');
return [];
}
_logger.fine('Fetched ${authors.length} authors');
return authors;
}
/// Provider for fetching all genres in the current library
@riverpod
Future<List<String>> libraryGenres(Ref ref) async {
final api = ref.watch(authenticatedApiProvider);
final currentLibrary = await ref.watch(currentLibraryProvider.future);
if (currentLibrary == null) {
_logger.warning('No current library found');
return [];
}
final filterData = await api.libraries.getFilterData(libraryId: currentLibrary.id);
if (filterData == null) {
_logger.warning('Failed to fetch filter data for library ${currentLibrary.id}');
return [];
}
_logger.fine('Fetched ${filterData.genres.length} genres');
return filterData.genres;
}
/// Provider for fetching all series in the current library
@riverpod
Future<GetLibrarysSeriesResponse?> librarySeries(Ref ref, {int page = 0, int limit = 50}) async {
final api = ref.watch(authenticatedApiProvider);
final currentLibrary = await ref.watch(currentLibraryProvider.future);
if (currentLibrary == null) {
_logger.warning('No current library found');
return null;
}
final series = await api.libraries.getSeries(
libraryId: currentLibrary.id,
);
if (series == null) {
_logger.warning('Failed to fetch series for library ${currentLibrary.id}');
return null;
}
_logger.fine('Fetched ${series.results.length} series (${series.total} total)');
return series;
}

View file

@ -0,0 +1,163 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/api/api_provider.dart';
import 'package:vaani/api/library_browser_provider.dart';
import 'package:vaani/settings/api_settings_provider.dart';
class LibraryAuthorsPage extends HookConsumerWidget {
const LibraryAuthorsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authorsAsync = ref.watch(libraryAuthorsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Authors'),
),
body: authorsAsync.when(
data: (authors) {
if (authors.isEmpty) {
return const Center(
child: Text('No authors found'),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.75,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: authors.length,
itemBuilder: (context, index) {
final author = authors[index];
return AuthorCard(author: author);
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text('Error loading authors: $error'),
],
),
),
),
);
}
}
class AuthorCard extends HookConsumerWidget {
const AuthorCard({
super.key,
required this.author,
});
final Author author;
@override
Widget build(BuildContext context, WidgetRef ref) {
final apiSettings = ref.watch(apiSettingsProvider);
final api = ref.watch(authenticatedApiProvider);
// Determine the author variant and extract relevant data
final String? imagePath = author.map(
(base) => base.imagePath,
minified: (minified) => null,
expanded: (expanded) => expanded.imagePath,
);
final int? numBooks = author.map(
(base) => base.libraryItems?.length,
minified: (minified) => null,
expanded: (expanded) => expanded.numBooks,
);
// Build the image URL if imagePath is available
String? imageUrl;
if (imagePath != null && apiSettings.activeServer != null) {
imageUrl = '${apiSettings.activeServer!.url}/api/authors/${author.id}/image';
}
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
// TODO: Navigate to author detail page
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Tapped on ${author.name}'),
duration: const Duration(seconds: 1),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
httpHeaders: {
'Authorization': 'Bearer ${apiSettings.activeUser?.token}',
},
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Center(
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.person, size: 48),
),
)
: Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.person, size: 48),
),
),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
author.name,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (numBooks != null) ...[
const SizedBox(height: 4),
Text(
'$numBooks ${numBooks == 1 ? 'book' : 'books'}',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
),
],
),
),
);
}
}

View file

@ -6,8 +6,6 @@ import 'package:vaani/features/you/view/widgets/library_switch_chip.dart'
show showLibrarySwitcher;
import 'package:vaani/router/router.dart' show Routes;
import 'package:vaani/shared/icons/abs_icons.dart' show AbsIcons;
import 'package:vaani/shared/widgets/not_implemented.dart'
show showNotImplementedToast;
class LibraryBrowserPage extends HookConsumerWidget {
const LibraryBrowserPage({super.key});
@ -48,7 +46,7 @@ class LibraryBrowserPage extends HookConsumerWidget {
leading: const Icon(Icons.person),
trailing: const Icon(Icons.chevron_right),
onTap: () {
showNotImplementedToast(context);
GoRouter.of(context).pushNamed(Routes.libraryAuthors.name);
},
),
ListTile(
@ -56,7 +54,7 @@ class LibraryBrowserPage extends HookConsumerWidget {
leading: const Icon(Icons.category),
trailing: const Icon(Icons.chevron_right),
onTap: () {
showNotImplementedToast(context);
GoRouter.of(context).pushNamed(Routes.libraryGenres.name);
},
),
ListTile(
@ -64,7 +62,7 @@ class LibraryBrowserPage extends HookConsumerWidget {
leading: const Icon(Icons.list),
trailing: const Icon(Icons.chevron_right),
onTap: () {
showNotImplementedToast(context);
GoRouter.of(context).pushNamed(Routes.librarySeries.name);
},
),
// Downloads

View file

@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:vaani/api/library_browser_provider.dart';
class LibraryGenresPage extends HookConsumerWidget {
const LibraryGenresPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final genresAsync = ref.watch(libraryGenresProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Genres'),
),
body: genresAsync.when(
data: (genres) {
if (genres.isEmpty) {
return const Center(
child: Text('No genres found'),
);
}
// Sort genres alphabetically
final sortedGenres = List<String>.from(genres)..sort();
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: sortedGenres.length,
itemBuilder: (context, index) {
final genre = sortedGenres[index];
return GenreListTile(genre: genre);
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text('Error loading genres: $error'),
],
),
),
),
);
}
}
class GenreListTile extends StatelessWidget {
const GenreListTile({
super.key,
required this.genre,
});
final String genre;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.category,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
title: Text(
genre,
style: Theme.of(context).textTheme.titleMedium,
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// TODO: Navigate to books in this genre
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Tapped on $genre'),
duration: const Duration(seconds: 1),
),
);
},
),
);
}
}

View file

@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/api/library_browser_provider.dart';
class LibrarySeriesPage extends HookConsumerWidget {
const LibrarySeriesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final seriesAsync = ref.watch(librarySeriesProvider());
return Scaffold(
appBar: AppBar(
title: const Text('Series'),
),
body: seriesAsync.when(
data: (seriesResponse) {
if (seriesResponse == null || seriesResponse.results.isEmpty) {
return const Center(
child: Text('No series found'),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: seriesResponse.results.length,
itemBuilder: (context, index) {
final series = seriesResponse.results[index];
return SeriesListTile(series: series);
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text('Error loading series: $error'),
],
),
),
),
);
}
}
class SeriesListTile extends StatelessWidget {
const SeriesListTile({
super.key,
required this.series,
});
final Series series;
@override
Widget build(BuildContext context) {
// Extract series data based on variant
final String seriesName = series.name;
final int? numBooks = series.maybeMap(
numBooks: (s) => s.numBooks,
books: (s) => s.books.length,
orElse: () => null,
);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.list,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
title: Text(
seriesName,
style: Theme.of(context).textTheme.titleMedium,
),
subtitle: numBooks != null
? Text('$numBooks ${numBooks == 1 ? 'book' : 'books'}')
: null,
trailing: const Icon(Icons.chevron_right),
onTap: () {
// TODO: Navigate to series detail page
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Tapped on $seriesName'),
duration: const Duration(seconds: 1),
),
);
},
),
);
}
}

View file

@ -82,6 +82,25 @@ class Routes {
// parentRoute: library,
);
// library browser sub-routes
static const libraryAuthors = _SimpleRoute(
pathName: 'authors',
name: 'libraryAuthors',
parentRoute: libraryBrowser,
);
static const libraryGenres = _SimpleRoute(
pathName: 'genres',
name: 'libraryGenres',
parentRoute: libraryBrowser,
);
static const librarySeries = _SimpleRoute(
pathName: 'series',
name: 'librarySeries',
parentRoute: libraryBrowser,
);
// you page for the user
static const you = _SimpleRoute(
pathName: 'you',

View file

@ -4,7 +4,10 @@ import 'package:vaani/features/downloads/view/downloads_page.dart';
import 'package:vaani/features/explore/view/explore_page.dart';
import 'package:vaani/features/explore/view/search_result_page.dart';
import 'package:vaani/features/item_viewer/view/library_item_page.dart';
import 'package:vaani/features/library_browser/view/library_authors_page.dart';
import 'package:vaani/features/library_browser/view/library_browser_page.dart';
import 'package:vaani/features/library_browser/view/library_genres_page.dart';
import 'package:vaani/features/library_browser/view/library_series_page.dart';
import 'package:vaani/features/logging/view/logs_page.dart';
import 'package:vaani/features/onboarding/view/callback_page.dart';
import 'package:vaani/features/onboarding/view/onboarding_single_page.dart';
@ -121,6 +124,23 @@ class MyAppRouter {
path: Routes.libraryBrowser.localPath,
name: Routes.libraryBrowser.name,
pageBuilder: defaultPageBuilder(const LibraryBrowserPage()),
routes: [
GoRoute(
path: Routes.libraryAuthors.pathName,
name: Routes.libraryAuthors.name,
pageBuilder: defaultPageBuilder(const LibraryAuthorsPage()),
),
GoRoute(
path: Routes.libraryGenres.pathName,
name: Routes.libraryGenres.name,
pageBuilder: defaultPageBuilder(const LibraryGenresPage()),
),
GoRoute(
path: Routes.librarySeries.pathName,
name: Routes.librarySeries.name,
pageBuilder: defaultPageBuilder(const LibrarySeriesPage()),
),
],
),
],
),