From 53027bf74c00c9174aafbba1b90b329a8e58ebb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 10:52:18 +0000 Subject: [PATCH 01/24] 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. --- lib/api/library_browser_provider.dart | 79 +++++++++ .../view/library_authors_page.dart | 163 ++++++++++++++++++ .../view/library_browser_page.dart | 8 +- .../view/library_genres_page.dart | 96 +++++++++++ .../view/library_series_page.dart | 105 +++++++++++ lib/router/constants.dart | 19 ++ lib/router/router.dart | 20 +++ 7 files changed, 485 insertions(+), 5 deletions(-) create mode 100644 lib/api/library_browser_provider.dart create mode 100644 lib/features/library_browser/view/library_authors_page.dart create mode 100644 lib/features/library_browser/view/library_genres_page.dart create mode 100644 lib/features/library_browser/view/library_series_page.dart diff --git a/lib/api/library_browser_provider.dart b/lib/api/library_browser_provider.dart new file mode 100644 index 0000000..08fb524 --- /dev/null +++ b/lib/api/library_browser_provider.dart @@ -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> 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> 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 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; +} diff --git a/lib/features/library_browser/view/library_authors_page.dart b/lib/features/library_browser/view/library_authors_page.dart new file mode 100644 index 0000000..59db1a4 --- /dev/null +++ b/lib/features/library_browser/view/library_authors_page.dart @@ -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, + ), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/library_browser/view/library_browser_page.dart b/lib/features/library_browser/view/library_browser_page.dart index 4327b17..1490295 100644 --- a/lib/features/library_browser/view/library_browser_page.dart +++ b/lib/features/library_browser/view/library_browser_page.dart @@ -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 diff --git a/lib/features/library_browser/view/library_genres_page.dart b/lib/features/library_browser/view/library_genres_page.dart new file mode 100644 index 0000000..04056ac --- /dev/null +++ b/lib/features/library_browser/view/library_genres_page.dart @@ -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.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), + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/library_browser/view/library_series_page.dart b/lib/features/library_browser/view/library_series_page.dart new file mode 100644 index 0000000..a967b9b --- /dev/null +++ b/lib/features/library_browser/view/library_series_page.dart @@ -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), + ), + ); + }, + ), + ); + } +} diff --git a/lib/router/constants.dart b/lib/router/constants.dart index 79c4556..2cb2bdd 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -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', diff --git a/lib/router/router.dart b/lib/router/router.dart index eda348e..b90301c 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -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()), + ), + ], ), ], ), From e778e494b49741ac3671418d530b1bd423ea1974 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 11:17:26 +0000 Subject: [PATCH 02/24] ci: add feature branch build workflow for automatic APK generation This workflow automatically builds APKs for feature branches: - Triggers on pushes to branches starting with 'claude/', 'feature/', or 'dev/' - Runs build_runner to generate required .g.dart files - Supports both signed (if secrets available) and debug builds - Uploads APK artifacts with branch name and commit SHA - 30-day artifact retention for testing - Manual trigger support via workflow_dispatch This allows developers to test changes without manually building locally. Artifacts can be downloaded from the GitHub Actions run page. --- .github/workflows/feature-branch-build.yaml | 99 +++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 .github/workflows/feature-branch-build.yaml diff --git a/.github/workflows/feature-branch-build.yaml b/.github/workflows/feature-branch-build.yaml new file mode 100644 index 0000000..53ceab2 --- /dev/null +++ b/.github/workflows/feature-branch-build.yaml @@ -0,0 +1,99 @@ +name: Feature Branch Build + +on: + push: + branches: + - 'claude/**' + - 'feature/**' + - 'dev/**' + workflow_dispatch: + +jobs: + build_android_debug: + name: Build Android APK (Debug/Unsigned) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Flutter Environment + uses: ./.github/actions/flutter-setup + with: + flutter-channel: stable + java-version: 17 + + - name: Accept Android SDK Licenses + run: | + yes | sudo $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses + + - name: Run build_runner to generate code + run: flutter pub run build_runner build --delete-conflicting-outputs + + - name: Check for keystore (for signed builds) + id: check_keystore + run: | + if [ -n "${{ secrets.UPLOAD_KEYSTORE_JKS }}" ]; then + echo "has_keystore=true" >> $GITHUB_OUTPUT + else + echo "has_keystore=false" >> $GITHUB_OUTPUT + fi + + - name: Decode android/upload.jks (if available) + if: steps.check_keystore.outputs.has_keystore == 'true' + run: echo "${{ secrets.UPLOAD_KEYSTORE_JKS }}" | base64 --decode > android/upload.jks + + - name: Decode android/key.properties (if available) + if: steps.check_keystore.outputs.has_keystore == 'true' + run: echo "${{ secrets.KEY_PROPERTIES }}" | base64 --decode > android/key.properties + + - name: Build Signed APKs (if keystore available) + if: steps.check_keystore.outputs.has_keystore == 'true' + run: | + flutter build apk --release --split-per-abi + flutter build apk --release + + - name: Build Debug APKs (if no keystore) + if: steps.check_keystore.outputs.has_keystore == 'false' + run: | + echo "Building debug APK (no keystore found)" + flutter build apk --debug + + - name: Rename Universal APK (signed) + if: steps.check_keystore.outputs.has_keystore == 'true' + run: mv build/app/outputs/flutter-apk/{app-release,app-universal-release}.apk + + - name: Get branch name + id: branch_name + run: | + BRANCH_NAME=${GITHUB_REF#refs/heads/} + SAFE_BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9_-]/_/g') + echo "branch=$SAFE_BRANCH_NAME" >> $GITHUB_OUTPUT + echo "short_sha=${GITHUB_SHA:0:7}" >> $GITHUB_OUTPUT + + - name: Upload Android APK Artifacts + uses: actions/upload-artifact@v4 + with: + name: android-apk-${{ steps.branch_name.outputs.branch }}-${{ steps.branch_name.outputs.short_sha }} + path: | + build/app/outputs/flutter-apk/*.apk + retention-days: 30 + + - name: Comment on commit with artifact link + if: steps.check_keystore.outputs.has_keystore == 'true' + run: | + echo "✅ APK build complete!" + echo "📦 Artifacts will be available at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo "" + echo "Built files:" + ls -lh build/app/outputs/flutter-apk/*.apk + + - name: Comment on commit with debug build notice + if: steps.check_keystore.outputs.has_keystore == 'false' + run: | + echo "⚠️ Debug APK build complete (no signing keystore available)" + echo "📦 Artifacts will be available at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo "" + echo "Built files:" + ls -lh build/app/outputs/flutter-apk/*.apk From fafd4c531553dd7c8ccc26d40a998b3bd5bcbeb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 11:31:13 +0000 Subject: [PATCH 03/24] fix: remove unused parameters from librarySeriesProvider - Remove unused page and limit parameters from librarySeries provider - Update library_series_page to call provider without parentheses - Fix riverpod_generator compatibility issue --- lib/api/library_browser_provider.dart | 2 +- lib/features/library_browser/view/library_series_page.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/api/library_browser_provider.dart b/lib/api/library_browser_provider.dart index 08fb524..38aec82 100644 --- a/lib/api/library_browser_provider.dart +++ b/lib/api/library_browser_provider.dart @@ -56,7 +56,7 @@ Future> libraryGenres(Ref ref) async { /// Provider for fetching all series in the current library @riverpod -Future librarySeries(Ref ref, {int page = 0, int limit = 50}) async { +Future librarySeries(Ref ref) async { final api = ref.watch(authenticatedApiProvider); final currentLibrary = await ref.watch(currentLibraryProvider.future); diff --git a/lib/features/library_browser/view/library_series_page.dart b/lib/features/library_browser/view/library_series_page.dart index a967b9b..c2516c2 100644 --- a/lib/features/library_browser/view/library_series_page.dart +++ b/lib/features/library_browser/view/library_series_page.dart @@ -8,7 +8,7 @@ class LibrarySeriesPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final seriesAsync = ref.watch(librarySeriesProvider()); + final seriesAsync = ref.watch(librarySeriesProvider); return Scaffold( appBar: AppBar( From 3da7b60ded4f69e6f380de185008625e01ff2c00 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 12:45:29 +0000 Subject: [PATCH 04/24] fix: correct property names and Series.maybeMap usage Fix compilation errors in library browser views: - Use serverUrl instead of url on AudiobookShelfServer model - Use authToken instead of token on AuthenticatedUser model - Add all required variant handlers to Series.maybeMap call These changes align with the actual model definitions in the codebase. --- lib/features/library_browser/view/library_authors_page.dart | 4 ++-- lib/features/library_browser/view/library_series_page.dart | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/features/library_browser/view/library_authors_page.dart b/lib/features/library_browser/view/library_authors_page.dart index 59db1a4..c01cc8d 100644 --- a/lib/features/library_browser/view/library_authors_page.dart +++ b/lib/features/library_browser/view/library_authors_page.dart @@ -87,7 +87,7 @@ class AuthorCard extends HookConsumerWidget { // 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'; + imageUrl = '${apiSettings.activeServer!.serverUrl}/api/authors/${author.id}/image'; } return Card( @@ -111,7 +111,7 @@ class AuthorCard extends HookConsumerWidget { ? CachedNetworkImage( imageUrl: imageUrl, httpHeaders: { - 'Authorization': 'Bearer ${apiSettings.activeUser?.token}', + 'Authorization': 'Bearer ${apiSettings.activeUser?.authToken}', }, fit: BoxFit.cover, placeholder: (context, url) => Container( diff --git a/lib/features/library_browser/view/library_series_page.dart b/lib/features/library_browser/view/library_series_page.dart index c2516c2..50fbab1 100644 --- a/lib/features/library_browser/view/library_series_page.dart +++ b/lib/features/library_browser/view/library_series_page.dart @@ -62,8 +62,12 @@ class SeriesListTile extends StatelessWidget { // Extract series data based on variant final String seriesName = series.name; final int? numBooks = series.maybeMap( + (s) => null, // base variant numBooks: (s) => s.numBooks, books: (s) => s.books.length, + sequence: (s) => null, + shelf: (s) => s.books.length, + author: (s) => s.items?.length, orElse: () => null, ); From f29159e55b95a09d8a3bed536465dfa13f44d22d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 13:07:44 +0000 Subject: [PATCH 05/24] fix: create Android debug keystore directory before build Add step to create and set permissions for ~/.config/.android directory to fix 'Unable to create debug keystore' error during debug APK builds. --- .github/workflows/feature-branch-build.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/feature-branch-build.yaml b/.github/workflows/feature-branch-build.yaml index 53ceab2..c50b55a 100644 --- a/.github/workflows/feature-branch-build.yaml +++ b/.github/workflows/feature-branch-build.yaml @@ -28,6 +28,11 @@ jobs: run: | yes | sudo $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses + - name: Prepare Android debug keystore directory + run: | + mkdir -p $HOME/.config/.android + chmod -R 755 $HOME/.config/.android + - name: Run build_runner to generate code run: flutter pub run build_runner build --delete-conflicting-outputs From f750e8a3ebbcf3bd99de2b13478b1bd04bc36625 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 13:14:52 +0000 Subject: [PATCH 06/24] fix: use sudo for chmod on Android keystore directory The directory is created by the SDK license step with sudo, so we need sudo to change its permissions. This fixes the "Operation not permitted" error during the workflow run. --- .github/workflows/feature-branch-build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/feature-branch-build.yaml b/.github/workflows/feature-branch-build.yaml index c50b55a..52f6753 100644 --- a/.github/workflows/feature-branch-build.yaml +++ b/.github/workflows/feature-branch-build.yaml @@ -31,7 +31,7 @@ jobs: - name: Prepare Android debug keystore directory run: | mkdir -p $HOME/.config/.android - chmod -R 755 $HOME/.config/.android + sudo chmod -R 755 $HOME/.config/.android - name: Run build_runner to generate code run: flutter pub run build_runner build --delete-conflicting-outputs From 5afce165324e0f7074b181033d9bcb5e1580e448 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 14:08:06 +0000 Subject: [PATCH 07/24] fix: create Android keystore directory before SDK license step Move the directory creation before the SDK license acceptance step to avoid permission issues. The directory will be created with normal user permissions, preventing the "Operation not permitted" errors when the SDK manager (running with sudo) tries to use it. --- .github/workflows/feature-branch-build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/feature-branch-build.yaml b/.github/workflows/feature-branch-build.yaml index 52f6753..264db81 100644 --- a/.github/workflows/feature-branch-build.yaml +++ b/.github/workflows/feature-branch-build.yaml @@ -24,14 +24,14 @@ jobs: flutter-channel: stable java-version: 17 - - name: Accept Android SDK Licenses - run: | - yes | sudo $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses - - name: Prepare Android debug keystore directory run: | mkdir -p $HOME/.config/.android - sudo chmod -R 755 $HOME/.config/.android + chmod -R 755 $HOME/.config/.android + + - name: Accept Android SDK Licenses + run: | + yes | sudo $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses - name: Run build_runner to generate code run: flutter pub run build_runner build --delete-conflicting-outputs From f60ea726591cf193a77fd193589e3f7ebd3364b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 16:17:09 +0000 Subject: [PATCH 08/24] fix: resolve Series deserialization issues and add author sorting - Fix genres error by parsing JSON manually to avoid Series deserialization - Fix series blank screen by using SimpleSeries class instead of full Series - Add alphabetical sorting to authors page - Work around shelfsdk Series variant detection issues The Audiobookshelf API returns Series objects that don't match any of the variant detection patterns in the shelfsdk SeriesConverter. Since we can't modify the external shelfsdk, we parse the API responses manually to extract only the data we need for display. --- lib/api/library_browser_provider.dart | 72 +++++++++++++++---- .../view/library_authors_page.dart | 8 ++- .../view/library_series_page.dart | 31 +++----- 3 files changed, 72 insertions(+), 39 deletions(-) diff --git a/lib/api/library_browser_provider.dart b/lib/api/library_browser_provider.dart index 38aec82..607cb56 100644 --- a/lib/api/library_browser_provider.dart +++ b/lib/api/library_browser_provider.dart @@ -1,13 +1,33 @@ 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:shelfsdk/audiobookshelf_api.dart' show Author; import 'package:vaani/api/api_provider.dart' show authenticatedApiProvider; import 'package:vaani/api/library_provider.dart' show currentLibraryProvider; part 'library_browser_provider.g.dart'; +/// Simple series data for display purposes +class SimpleSeries { + final String id; + final String name; + final int? numBooks; + + SimpleSeries({ + required this.id, + required this.name, + this.numBooks, + }); + + factory SimpleSeries.fromJson(Map json) { + return SimpleSeries( + id: json['id'] as String, + name: json['name'] as String, + numBooks: json['numBooks'] as int?, + ); + } +} + final _logger = Logger('LibraryBrowserProvider'); /// Provider for fetching all authors in the current library @@ -43,37 +63,59 @@ Future> libraryGenres(Ref ref) async { return []; } - final filterData = await api.libraries.getFilterData(libraryId: currentLibrary.id); + // Use raw API call to avoid Series deserialization issues in LibraryFilterData + final genres = await api.getJson>( + path: '/api/libraries/${currentLibrary.id}/filterdata', + requiresAuth: true, + fromJson: (json) { + if (json is Map && json.containsKey('genres')) { + final genresList = json['genres'] as List; + return genresList.map((e) => e.toString()).toList(); + } + return []; + }, + ); - if (filterData == null) { - _logger.warning('Failed to fetch filter data for library ${currentLibrary.id}'); + if (genres == null) { + _logger.warning('Failed to fetch genres for library ${currentLibrary.id}'); return []; } - _logger.fine('Fetched ${filterData.genres.length} genres'); - return filterData.genres; + _logger.fine('Fetched ${genres.length} genres'); + return genres; } /// Provider for fetching all series in the current library @riverpod -Future librarySeries(Ref ref) async { +Future> librarySeries(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 null; + return []; } - final series = await api.libraries.getSeries( - libraryId: currentLibrary.id, + // Use raw API call to avoid Series deserialization issues + final seriesList = await api.getJson>( + path: '/api/libraries/${currentLibrary.id}/series', + requiresAuth: true, + fromJson: (json) { + if (json is Map && json.containsKey('results')) { + final results = json['results'] as List; + return results + .map((e) => SimpleSeries.fromJson(e as Map)) + .toList(); + } + return []; + }, ); - if (series == null) { + if (seriesList == null) { _logger.warning('Failed to fetch series for library ${currentLibrary.id}'); - return null; + return []; } - _logger.fine('Fetched ${series.results.length} series (${series.total} total)'); - return series; + _logger.fine('Fetched ${seriesList.length} series'); + return seriesList; } diff --git a/lib/features/library_browser/view/library_authors_page.dart b/lib/features/library_browser/view/library_authors_page.dart index c01cc8d..7c42ac8 100644 --- a/lib/features/library_browser/view/library_authors_page.dart +++ b/lib/features/library_browser/view/library_authors_page.dart @@ -25,6 +25,10 @@ class LibraryAuthorsPage extends HookConsumerWidget { ); } + // Sort authors alphabetically by name + final sortedAuthors = List.from(authors) + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return GridView.builder( padding: const EdgeInsets.all(16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( @@ -33,9 +37,9 @@ class LibraryAuthorsPage extends HookConsumerWidget { crossAxisSpacing: 16, mainAxisSpacing: 16, ), - itemCount: authors.length, + itemCount: sortedAuthors.length, itemBuilder: (context, index) { - final author = authors[index]; + final author = sortedAuthors[index]; return AuthorCard(author: author); }, ); diff --git a/lib/features/library_browser/view/library_series_page.dart b/lib/features/library_browser/view/library_series_page.dart index 50fbab1..33248c5 100644 --- a/lib/features/library_browser/view/library_series_page.dart +++ b/lib/features/library_browser/view/library_series_page.dart @@ -1,6 +1,5 @@ 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 { @@ -15,8 +14,8 @@ class LibrarySeriesPage extends HookConsumerWidget { title: const Text('Series'), ), body: seriesAsync.when( - data: (seriesResponse) { - if (seriesResponse == null || seriesResponse.results.isEmpty) { + data: (seriesList) { + if (seriesList.isEmpty) { return const Center( child: Text('No series found'), ); @@ -24,9 +23,9 @@ class LibrarySeriesPage extends HookConsumerWidget { return ListView.builder( padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: seriesResponse.results.length, + itemCount: seriesList.length, itemBuilder: (context, index) { - final series = seriesResponse.results[index]; + final series = seriesList[index]; return SeriesListTile(series: series); }, ); @@ -55,22 +54,10 @@ class SeriesListTile extends StatelessWidget { required this.series, }); - final Series series; + final SimpleSeries series; @override Widget build(BuildContext context) { - // Extract series data based on variant - final String seriesName = series.name; - final int? numBooks = series.maybeMap( - (s) => null, // base variant - numBooks: (s) => s.numBooks, - books: (s) => s.books.length, - sequence: (s) => null, - shelf: (s) => s.books.length, - author: (s) => s.items?.length, - orElse: () => null, - ); - return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile( @@ -87,18 +74,18 @@ class SeriesListTile extends StatelessWidget { ), ), title: Text( - seriesName, + series.name, style: Theme.of(context).textTheme.titleMedium, ), - subtitle: numBooks != null - ? Text('$numBooks ${numBooks == 1 ? 'book' : 'books'}') + subtitle: series.numBooks != null + ? Text('${series.numBooks} ${series.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'), + content: Text('Tapped on ${series.name}'), duration: const Duration(seconds: 1), ), ); From 43712643a2b3513e722e94012b3575829bf9f11b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 16:43:21 +0000 Subject: [PATCH 09/24] feat: implement filtered book navigation and improve author sorting - Sort authors by surname (last word in name) instead of first name - Create FilteredLibraryItemsPage to display books filtered by author/genre/series - Add navigation from authors/genres/series pages to filtered book lists - Use AuthorFilter, GenreFilter, and SeriesFilter from shelfsdk - Add libraryFiltered route with filter parameter support Users can now: - Browse authors sorted by surname - Tap on an author to see all their books - Tap on a genre to see all books in that genre - Tap on a series to see all books in that series --- .../view/filtered_library_items_page.dart | 208 ++++++++++++++++++ .../view/library_authors_page.dart | 23 +- .../view/library_genres_page.dart | 16 +- .../view/library_series_page.dart | 16 +- lib/router/constants.dart | 6 + lib/router/router.dart | 26 +++ 6 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 lib/features/library_browser/view/filtered_library_items_page.dart diff --git a/lib/features/library_browser/view/filtered_library_items_page.dart b/lib/features/library_browser/view/filtered_library_items_page.dart new file mode 100644 index 0000000..9296e38 --- /dev/null +++ b/lib/features/library_browser/view/filtered_library_items_page.dart @@ -0,0 +1,208 @@ +import 'package:cached_network_image/cached_network_image.dart'; +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:vaani/api/api_provider.dart'; +import 'package:vaani/api/library_provider.dart'; +import 'package:vaani/router/router.dart'; +import 'package:vaani/settings/api_settings_provider.dart'; + +/// Page that displays library items filtered by author, genre, or series +class FilteredLibraryItemsPage extends HookConsumerWidget { + const FilteredLibraryItemsPage({ + super.key, + required this.filter, + required this.title, + }); + + final Filter filter; + final String title; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final api = ref.watch(authenticatedApiProvider); + final currentLibraryAsync = ref.watch(currentLibraryProvider); + + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: currentLibraryAsync.when( + data: (library) { + if (library == null) { + return const Center( + child: Text('No library selected'), + ); + } + + return FutureBuilder( + future: api.libraries.getItems( + libraryId: library.id, + parameters: GetLibrarysItemsReqParams( + filter: filter, + sort: 'media.metadata.title', + limit: 100, + ), + ), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (snapshot.hasError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, + size: 48, color: Colors.red), + const SizedBox(height: 16), + Text('Error: ${snapshot.error}'), + ], + ), + ); + } + + final response = snapshot.data; + if (response == null || response.results.isEmpty) { + return const Center( + child: Text('No items found'), + ); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: response.results.length, + itemBuilder: (context, index) { + final item = response.results[index]; + return LibraryItemListTile(item: item); + }, + ); + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stack) => Center( + child: Text('Error: $error'), + ), + ), + ); + } +} + +class LibraryItemListTile extends ConsumerWidget { + const LibraryItemListTile({ + super.key, + required this.item, + }); + + final LibraryItem item; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final apiSettings = ref.watch(apiSettingsProvider); + final media = item.media; + + // Extract book info + String title = ''; + String? subtitle; + String? authorName; + + if (media is MediaBook) { + final metadata = media.metadata; + if (metadata is BookMetadata) { + title = metadata.title ?? 'Unknown'; + subtitle = metadata.subtitle; + authorName = metadata.authorName; + } else if (metadata is BookMetadataMinified) { + title = metadata.title ?? 'Unknown'; + authorName = metadata.authorName; + } else if (metadata is BookMetadataExpanded) { + title = metadata.title ?? 'Unknown'; + subtitle = metadata.subtitle; + final authors = metadata.authors; + if (authors != null && authors.isNotEmpty) { + authorName = authors.map((a) => a.name).join(', '); + } + } + } + + final imageUrl = apiSettings.activeServer != null + ? '${apiSettings.activeServer!.serverUrl}/api/items/${item.id}/cover' + : null; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ListTile( + leading: imageUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(4), + child: CachedNetworkImage( + imageUrl: imageUrl, + width: 48, + height: 48, + fit: BoxFit.cover, + httpHeaders: { + if (apiSettings.activeUser?.authToken != null) + 'Authorization': + 'Bearer ${apiSettings.activeUser!.authToken}', + }, + placeholder: (context, url) => Container( + width: 48, + height: 48, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.book, size: 24), + ), + errorWidget: (context, url, error) => Container( + width: 48, + height: 48, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.book, size: 24), + ), + ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: const Icon(Icons.book, size: 24), + ), + title: Text( + title, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (authorName != null) Text(authorName), + if (subtitle != null) + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + trailing: const Icon(Icons.chevron_right), + onTap: () { + // Navigate to item detail page + context.goNamed( + Routes.you.name, + pathParameters: {'libraryItemId': item.id}, + ); + }, + ), + ); + } +} diff --git a/lib/features/library_browser/view/library_authors_page.dart b/lib/features/library_browser/view/library_authors_page.dart index 7c42ac8..52a9feb 100644 --- a/lib/features/library_browser/view/library_authors_page.dart +++ b/lib/features/library_browser/view/library_authors_page.dart @@ -1,9 +1,11 @@ import 'package:cached_network_image/cached_network_image.dart'; 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:vaani/api/api_provider.dart'; import 'package:vaani/api/library_browser_provider.dart'; +import 'package:vaani/router/router.dart'; import 'package:vaani/settings/api_settings_provider.dart'; class LibraryAuthorsPage extends HookConsumerWidget { @@ -25,9 +27,13 @@ class LibraryAuthorsPage extends HookConsumerWidget { ); } - // Sort authors alphabetically by name + // Sort authors alphabetically by surname (last word in name) final sortedAuthors = List.from(authors) - ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + ..sort((a, b) { + final aSurname = a.name.split(' ').last.toLowerCase(); + final bSurname = b.name.split(' ').last.toLowerCase(); + return aSurname.compareTo(bSurname); + }); return GridView.builder( padding: const EdgeInsets.all(16), @@ -98,12 +104,13 @@ class AuthorCard extends HookConsumerWidget { 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), - ), + // Navigate to filtered items page with author filter + context.goNamed( + Routes.libraryFiltered.name, + extra: { + 'filter': AuthorFilter(author.id), + 'title': author.name, + }, ); }, child: Column( diff --git a/lib/features/library_browser/view/library_genres_page.dart b/lib/features/library_browser/view/library_genres_page.dart index 04056ac..3fbf985 100644 --- a/lib/features/library_browser/view/library_genres_page.dart +++ b/lib/features/library_browser/view/library_genres_page.dart @@ -1,6 +1,9 @@ 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:vaani/api/library_browser_provider.dart'; +import 'package:vaani/router/router.dart'; class LibraryGenresPage extends HookConsumerWidget { const LibraryGenresPage({super.key}); @@ -82,12 +85,13 @@ class GenreListTile extends StatelessWidget { ), 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), - ), + // Navigate to filtered items page with genre filter + context.goNamed( + Routes.libraryFiltered.name, + extra: { + 'filter': GenreFilter(genre), + 'title': genre, + }, ); }, ), diff --git a/lib/features/library_browser/view/library_series_page.dart b/lib/features/library_browser/view/library_series_page.dart index 33248c5..5f5bf8e 100644 --- a/lib/features/library_browser/view/library_series_page.dart +++ b/lib/features/library_browser/view/library_series_page.dart @@ -1,6 +1,9 @@ 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:vaani/api/library_browser_provider.dart'; +import 'package:vaani/router/router.dart'; class LibrarySeriesPage extends HookConsumerWidget { const LibrarySeriesPage({super.key}); @@ -82,12 +85,13 @@ class SeriesListTile extends StatelessWidget { : null, trailing: const Icon(Icons.chevron_right), onTap: () { - // TODO: Navigate to series detail page - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Tapped on ${series.name}'), - duration: const Duration(seconds: 1), - ), + // Navigate to filtered items page with series filter + context.goNamed( + Routes.libraryFiltered.name, + extra: { + 'filter': SeriesFilter(series.id), + 'title': series.name, + }, ); }, ), diff --git a/lib/router/constants.dart b/lib/router/constants.dart index 2cb2bdd..41951ff 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -101,6 +101,12 @@ class Routes { parentRoute: libraryBrowser, ); + static const libraryFiltered = _SimpleRoute( + pathName: 'filtered', + name: 'libraryFiltered', + parentRoute: libraryBrowser, + ); + // you page for the user static const you = _SimpleRoute( pathName: 'you', diff --git a/lib/router/router.dart b/lib/router/router.dart index b90301c..dbea576 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -4,6 +4,7 @@ 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/filtered_library_items_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'; @@ -140,6 +141,31 @@ class MyAppRouter { name: Routes.librarySeries.name, pageBuilder: defaultPageBuilder(const LibrarySeriesPage()), ), + GoRoute( + path: Routes.libraryFiltered.pathName, + name: Routes.libraryFiltered.name, + pageBuilder: (context, state) { + final extra = state.extra as Map?; + final filter = extra?['filter'] as Filter?; + final title = extra?['title'] as String? ?? 'Filtered Items'; + if (filter == null) { + return MaterialPage( + child: Scaffold( + appBar: AppBar(title: const Text('Error')), + body: const Center( + child: Text('Invalid filter'), + ), + ), + ); + } + return MaterialPage( + child: FilteredLibraryItemsPage( + filter: filter, + title: title, + ), + ); + }, + ), ], ), ], From 8300cc75716f90754a4566eecf9d6641fcf36dc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 16:54:06 +0000 Subject: [PATCH 10/24] fix: correct type checking and imports in filtered library page - Add shelfsdk import to router.dart for Filter types - Fix Media and MediaMetadata type checking using freezed mapOrNull - Handle all Media variants (book, bookMinified, bookExpanded) - Handle all MediaMetadata variants to extract title, subtitle, and authors - Fix BookMetadata accessing authors list instead of authorName property Resolves compilation errors in GitHub Actions build. --- .../view/filtered_library_items_page.dart | 93 +++++++++++++++---- lib/router/router.dart | 1 + 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/lib/features/library_browser/view/filtered_library_items_page.dart b/lib/features/library_browser/view/filtered_library_items_page.dart index 9296e38..936f659 100644 --- a/lib/features/library_browser/view/filtered_library_items_page.dart +++ b/lib/features/library_browser/view/filtered_library_items_page.dart @@ -113,24 +113,81 @@ class LibraryItemListTile extends ConsumerWidget { String? subtitle; String? authorName; - if (media is MediaBook) { - final metadata = media.metadata; - if (metadata is BookMetadata) { - title = metadata.title ?? 'Unknown'; - subtitle = metadata.subtitle; - authorName = metadata.authorName; - } else if (metadata is BookMetadataMinified) { - title = metadata.title ?? 'Unknown'; - authorName = metadata.authorName; - } else if (metadata is BookMetadataExpanded) { - title = metadata.title ?? 'Unknown'; - subtitle = metadata.subtitle; - final authors = metadata.authors; - if (authors != null && authors.isNotEmpty) { - authorName = authors.map((a) => a.name).join(', '); - } - } - } + // Use map to handle Media variants + media.mapOrNull( + book: (book) { + final metadata = book.metadata; + metadata.mapOrNull( + book: (m) { + title = m.title ?? 'Unknown'; + subtitle = m.subtitle; + if (m.authors.isNotEmpty) { + authorName = m.authors.map((a) => a.name).join(', '); + } + }, + bookMinified: (m) { + title = m.title ?? 'Unknown'; + subtitle = m.subtitle; + authorName = m.authorName; + }, + bookExpanded: (m) { + title = m.title ?? 'Unknown'; + subtitle = m.subtitle; + if (m.authors.isNotEmpty) { + authorName = m.authors.map((a) => a.name).join(', '); + } + }, + ); + }, + bookMinified: (book) { + final metadata = book.metadata; + metadata.mapOrNull( + book: (m) { + title = m.title ?? 'Unknown'; + subtitle = m.subtitle; + if (m.authors.isNotEmpty) { + authorName = m.authors.map((a) => a.name).join(', '); + } + }, + bookMinified: (m) { + title = m.title ?? 'Unknown'; + subtitle = m.subtitle; + authorName = m.authorName; + }, + bookExpanded: (m) { + title = m.title ?? 'Unknown'; + subtitle = m.subtitle; + if (m.authors.isNotEmpty) { + authorName = m.authors.map((a) => a.name).join(', '); + } + }, + ); + }, + bookExpanded: (book) { + final metadata = book.metadata; + metadata.mapOrNull( + book: (m) { + title = m.title ?? 'Unknown'; + subtitle = m.subtitle; + if (m.authors.isNotEmpty) { + authorName = m.authors.map((a) => a.name).join(', '); + } + }, + bookMinified: (m) { + title = m.title ?? 'Unknown'; + subtitle = m.subtitle; + authorName = m.authorName; + }, + bookExpanded: (m) { + title = m.title ?? 'Unknown'; + subtitle = m.subtitle; + if (m.authors.isNotEmpty) { + authorName = m.authors.map((a) => a.name).join(', '); + } + }, + ); + }, + ); final imageUrl = apiSettings.activeServer != null ? '${apiSettings.activeServer!.serverUrl}/api/items/${item.id}/cover' diff --git a/lib/router/router.dart b/lib/router/router.dart index dbea576..8212385 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; 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'; From 86eddcac4525d93015df61197a160c49d7f2976d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 17:07:18 +0000 Subject: [PATCH 11/24] fix: add null safety assertions for Text widgets Add non-null assertion operators for authorName and subtitle in Text widgets where null checks are already performed. This resolves the null safety compilation errors. --- .../library_browser/view/filtered_library_items_page.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/features/library_browser/view/filtered_library_items_page.dart b/lib/features/library_browser/view/filtered_library_items_page.dart index 936f659..e0cc58a 100644 --- a/lib/features/library_browser/view/filtered_library_items_page.dart +++ b/lib/features/library_browser/view/filtered_library_items_page.dart @@ -241,10 +241,10 @@ class LibraryItemListTile extends ConsumerWidget { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (authorName != null) Text(authorName), + if (authorName != null) Text(authorName!), if (subtitle != null) Text( - subtitle, + subtitle!, style: Theme.of(context).textTheme.bodySmall, maxLines: 1, overflow: TextOverflow.ellipsis, From fed36b1e882bfd48714483c213f753b3356dd575 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 17:16:31 +0000 Subject: [PATCH 12/24] chore: trigger workflow build From e4a522cc0daa115421ac45a5454208714a2b444a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 17:18:13 +0000 Subject: [PATCH 13/24] chore: force workflow trigger --- .github/workflows/.trigger | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/workflows/.trigger diff --git a/.github/workflows/.trigger b/.github/workflows/.trigger new file mode 100644 index 0000000..bc29c9f --- /dev/null +++ b/.github/workflows/.trigger @@ -0,0 +1 @@ +# Trigger build From f4d1914b21d850997423fff68947aecd0686d059 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 19:29:23 +0000 Subject: [PATCH 14/24] debug: add extensive logging and error handling for series --- lib/api/library_browser_provider.dart | 54 ++++++++++++------- .../view/library_series_page.dart | 42 +++++++++++---- 2 files changed, 67 insertions(+), 29 deletions(-) diff --git a/lib/api/library_browser_provider.dart b/lib/api/library_browser_provider.dart index 607cb56..8f76711 100644 --- a/lib/api/library_browser_provider.dart +++ b/lib/api/library_browser_provider.dart @@ -96,26 +96,40 @@ Future> librarySeries(Ref ref) async { return []; } - // Use raw API call to avoid Series deserialization issues - final seriesList = await api.getJson>( - path: '/api/libraries/${currentLibrary.id}/series', - requiresAuth: true, - fromJson: (json) { - if (json is Map && json.containsKey('results')) { - final results = json['results'] as List; - return results - .map((e) => SimpleSeries.fromJson(e as Map)) - .toList(); - } - return []; - }, - ); + try { + // Use raw API call to avoid Series deserialization issues + final seriesList = await api.getJson>( + path: '/api/libraries/${currentLibrary.id}/series', + requiresAuth: true, + fromJson: (json) { + _logger.info('Series API response: $json'); - if (seriesList == null) { - _logger.warning('Failed to fetch series for library ${currentLibrary.id}'); - return []; + if (json is Map) { + if (json.containsKey('results')) { + final results = json['results'] as List; + _logger.info('Found ${results.length} series in results'); + return results + .map((e) => SimpleSeries.fromJson(e as Map)) + .toList(); + } else { + _logger.warning('No results key in response. Keys: ${json.keys}'); + } + } else { + _logger.warning('Response is not a Map. Type: ${json.runtimeType}'); + } + return []; + }, + ); + + if (seriesList == null) { + _logger.warning('Failed to fetch series for library ${currentLibrary.id}'); + return []; + } + + _logger.fine('Fetched ${seriesList.length} series'); + return seriesList; + } catch (e, stackTrace) { + _logger.severe('Error fetching series: $e', e, stackTrace); + rethrow; } - - _logger.fine('Fetched ${seriesList.length} series'); - return seriesList; } diff --git a/lib/features/library_browser/view/library_series_page.dart b/lib/features/library_browser/view/library_series_page.dart index 5f5bf8e..afaddfc 100644 --- a/lib/features/library_browser/view/library_series_page.dart +++ b/lib/features/library_browser/view/library_series_page.dart @@ -19,8 +19,20 @@ class LibrarySeriesPage extends HookConsumerWidget { body: seriesAsync.when( data: (seriesList) { if (seriesList.isEmpty) { - return const Center( - child: Text('No series found'), + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.library_books_outlined, size: 48), + const SizedBox(height: 16), + const Text('No series found'), + const SizedBox(height: 8), + Text( + 'Check logs for API response details', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), ); } @@ -37,13 +49,25 @@ class LibrarySeriesPage extends HookConsumerWidget { 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'), - ], + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text( + 'Error loading series:', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + '$error', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), ), ), ), From 9e0f25f0e26366131b275c7a5f7eb6b616ee9ac1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 19:30:18 +0000 Subject: [PATCH 15/24] fix: try multiple endpoints and improve series parsing - Try filterdata endpoint first (like genres) - Fall back to series endpoint if needed - Make SimpleSeries.fromJson more robust with multiple field name attempts - Add extensive logging to debug series loading issues --- lib/api/library_browser_provider.dart | 49 +++++++++++++++++++++------ 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/lib/api/library_browser_provider.dart b/lib/api/library_browser_provider.dart index 8f76711..df816e5 100644 --- a/lib/api/library_browser_provider.dart +++ b/lib/api/library_browser_provider.dart @@ -20,11 +20,18 @@ class SimpleSeries { }); factory SimpleSeries.fromJson(Map json) { - return SimpleSeries( - id: json['id'] as String, - name: json['name'] as String, - numBooks: json['numBooks'] as int?, - ); + try { + return SimpleSeries( + id: json['id'] as String, + name: json['name'] as String, + numBooks: json['numBooks'] as int? ?? + json['num_books'] as int? ?? + (json['books'] as List?)?.length, + ); + } catch (e) { + _logger.warning('Error parsing series: $e. JSON: $json'); + rethrow; + } } } @@ -97,12 +104,36 @@ Future> librarySeries(Ref ref) async { } try { - // Use raw API call to avoid Series deserialization issues + // First try: Get from filterdata endpoint (same as genres) + final filterDataSeries = await api.getJson>( + path: '/api/libraries/${currentLibrary.id}/filterdata', + requiresAuth: true, + fromJson: (json) { + _logger.info('FilterData API response keys: ${json is Map ? (json as Map).keys : json.runtimeType}'); + + if (json is Map && json.containsKey('series')) { + final seriesList = json['series'] as List; + _logger.info('Found ${seriesList.length} series in filterdata'); + return seriesList + .map((e) => SimpleSeries.fromJson(e as Map)) + .toList(); + } + return []; + }, + ); + + if (filterDataSeries != null && filterDataSeries.isNotEmpty) { + _logger.fine('Fetched ${filterDataSeries.length} series from filterdata'); + return filterDataSeries; + } + + // Second try: Get from series endpoint + _logger.info('Trying series endpoint...'); final seriesList = await api.getJson>( path: '/api/libraries/${currentLibrary.id}/series', requiresAuth: true, fromJson: (json) { - _logger.info('Series API response: $json'); + _logger.info('Series API response keys: ${json is Map ? (json as Map).keys : json.runtimeType}'); if (json is Map) { if (json.containsKey('results')) { @@ -111,11 +142,7 @@ Future> librarySeries(Ref ref) async { return results .map((e) => SimpleSeries.fromJson(e as Map)) .toList(); - } else { - _logger.warning('No results key in response. Keys: ${json.keys}'); } - } else { - _logger.warning('Response is not a Map. Type: ${json.runtimeType}'); } return []; }, From cf0778e263a6ff3d537b76d6bc29d2c23d4619b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 20:31:50 +0000 Subject: [PATCH 16/24] feat: convert filtered items to grid view and fix navigation - Change from ListView to GridView with 3 columns for better book browsing - Redesign LibraryItemCard as grid cards with cover images and metadata - Fix navigation to use Routes.libraryItem instead of Routes.you - Use correct path parameter 'itemId' for book detail navigation - Remove subtitle from cards to simplify grid layout - Books now open properly when tapped, same as home page --- .../view/filtered_library_items_page.dart | 152 +++++++++--------- 1 file changed, 72 insertions(+), 80 deletions(-) diff --git a/lib/features/library_browser/view/filtered_library_items_page.dart b/lib/features/library_browser/view/filtered_library_items_page.dart index e0cc58a..0d32a2c 100644 --- a/lib/features/library_browser/view/filtered_library_items_page.dart +++ b/lib/features/library_browser/view/filtered_library_items_page.dart @@ -73,12 +73,18 @@ class FilteredLibraryItemsPage extends HookConsumerWidget { ); } - return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), + return GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 0.65, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + ), itemCount: response.results.length, itemBuilder: (context, index) { final item = response.results[index]; - return LibraryItemListTile(item: item); + return LibraryItemCard(item: item); }, ); }, @@ -95,8 +101,8 @@ class FilteredLibraryItemsPage extends HookConsumerWidget { } } -class LibraryItemListTile extends ConsumerWidget { - const LibraryItemListTile({ +class LibraryItemCard extends ConsumerWidget { + const LibraryItemCard({ super.key, required this.item, }); @@ -110,7 +116,6 @@ class LibraryItemListTile extends ConsumerWidget { // Extract book info String title = ''; - String? subtitle; String? authorName; // Use map to handle Media variants @@ -120,19 +125,16 @@ class LibraryItemListTile extends ConsumerWidget { metadata.mapOrNull( book: (m) { title = m.title ?? 'Unknown'; - subtitle = m.subtitle; if (m.authors.isNotEmpty) { authorName = m.authors.map((a) => a.name).join(', '); } }, bookMinified: (m) { title = m.title ?? 'Unknown'; - subtitle = m.subtitle; authorName = m.authorName; }, bookExpanded: (m) { title = m.title ?? 'Unknown'; - subtitle = m.subtitle; if (m.authors.isNotEmpty) { authorName = m.authors.map((a) => a.name).join(', '); } @@ -144,19 +146,16 @@ class LibraryItemListTile extends ConsumerWidget { metadata.mapOrNull( book: (m) { title = m.title ?? 'Unknown'; - subtitle = m.subtitle; if (m.authors.isNotEmpty) { authorName = m.authors.map((a) => a.name).join(', '); } }, bookMinified: (m) { title = m.title ?? 'Unknown'; - subtitle = m.subtitle; authorName = m.authorName; }, bookExpanded: (m) { title = m.title ?? 'Unknown'; - subtitle = m.subtitle; if (m.authors.isNotEmpty) { authorName = m.authors.map((a) => a.name).join(', '); } @@ -168,19 +167,16 @@ class LibraryItemListTile extends ConsumerWidget { metadata.mapOrNull( book: (m) { title = m.title ?? 'Unknown'; - subtitle = m.subtitle; if (m.authors.isNotEmpty) { authorName = m.authors.map((a) => a.name).join(', '); } }, bookMinified: (m) { title = m.title ?? 'Unknown'; - subtitle = m.subtitle; authorName = m.authorName; }, bookExpanded: (m) { title = m.title ?? 'Unknown'; - subtitle = m.subtitle; if (m.authors.isNotEmpty) { authorName = m.authors.map((a) => a.name).join(', '); } @@ -193,72 +189,68 @@ class LibraryItemListTile extends ConsumerWidget { ? '${apiSettings.activeServer!.serverUrl}/api/items/${item.id}/cover' : null; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: ListTile( - leading: imageUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(4), - child: CachedNetworkImage( - imageUrl: imageUrl, - width: 48, - height: 48, - fit: BoxFit.cover, - httpHeaders: { - if (apiSettings.activeUser?.authToken != null) - 'Authorization': - 'Bearer ${apiSettings.activeUser!.authToken}', - }, - placeholder: (context, url) => Container( - width: 48, - height: 48, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const Icon(Icons.book, size: 24), - ), - errorWidget: (context, url, error) => Container( - width: 48, - height: 48, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const Icon(Icons.book, size: 24), - ), + return InkWell( + onTap: () { + // Navigate to item detail page + context.goNamed( + Routes.libraryItem.name, + pathParameters: {'itemId': item.id}, + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Book cover + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + httpHeaders: { + if (apiSettings.activeUser?.authToken != null) + 'Authorization': + 'Bearer ${apiSettings.activeUser!.authToken}', + }, + 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.book, size: 48), + ), + ) + : Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.book, size: 48), + ), + ), + ), + const SizedBox(height: 8), + // Book title + Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, ), - ) - : Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: const Icon(Icons.book, size: 24), - ), - title: Text( - title, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (authorName != null) Text(authorName!), - if (subtitle != null) - Text( - subtitle!, - style: Theme.of(context).textTheme.bodySmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - trailing: const Icon(Icons.chevron_right), - onTap: () { - // Navigate to item detail page - context.goNamed( - Routes.you.name, - pathParameters: {'libraryItemId': item.id}, - ); - }, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + // Author name + if (authorName != null) + Text( + authorName!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), ); } From 8667bd03dd3826f7ab0a21f49b90bc50e853ba8d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 21:03:15 +0000 Subject: [PATCH 17/24] fix: improve navigation stack and ensure square cover aspect ratios Navigation fixes: - Change goNamed to pushNamed in all library browser pages - Maintains proper back navigation stack - Back button now goes step by step instead of to beginning - Prevents app from closing when navigating back from book detail Grid layout fixes: - Book covers are now always square (1.0 aspect ratio) - Overall card aspect ratio is 0.75 to provide space for text - Wrap cover in AspectRatio widget instead of using Expanded - Ensures title and author info always visible below covers - Fixes issue where book info was missing in series view --- .../view/filtered_library_items_page.dart | 9 +++++---- .../library_browser/view/library_authors_page.dart | 2 +- .../library_browser/view/library_genres_page.dart | 2 +- .../library_browser/view/library_series_page.dart | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/features/library_browser/view/filtered_library_items_page.dart b/lib/features/library_browser/view/filtered_library_items_page.dart index 0d32a2c..e370465 100644 --- a/lib/features/library_browser/view/filtered_library_items_page.dart +++ b/lib/features/library_browser/view/filtered_library_items_page.dart @@ -77,7 +77,7 @@ class FilteredLibraryItemsPage extends HookConsumerWidget { padding: const EdgeInsets.all(16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, - childAspectRatio: 0.65, + childAspectRatio: 0.75, crossAxisSpacing: 12, mainAxisSpacing: 16, ), @@ -192,7 +192,7 @@ class LibraryItemCard extends ConsumerWidget { return InkWell( onTap: () { // Navigate to item detail page - context.goNamed( + context.pushNamed( Routes.libraryItem.name, pathParameters: {'itemId': item.id}, ); @@ -200,8 +200,9 @@ class LibraryItemCard extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Book cover - Expanded( + // Book cover - always square + AspectRatio( + aspectRatio: 1.0, child: ClipRRect( borderRadius: BorderRadius.circular(8), child: imageUrl != null diff --git a/lib/features/library_browser/view/library_authors_page.dart b/lib/features/library_browser/view/library_authors_page.dart index 52a9feb..b84d21b 100644 --- a/lib/features/library_browser/view/library_authors_page.dart +++ b/lib/features/library_browser/view/library_authors_page.dart @@ -105,7 +105,7 @@ class AuthorCard extends HookConsumerWidget { child: InkWell( onTap: () { // Navigate to filtered items page with author filter - context.goNamed( + context.pushNamed( Routes.libraryFiltered.name, extra: { 'filter': AuthorFilter(author.id), diff --git a/lib/features/library_browser/view/library_genres_page.dart b/lib/features/library_browser/view/library_genres_page.dart index 3fbf985..e8cd02a 100644 --- a/lib/features/library_browser/view/library_genres_page.dart +++ b/lib/features/library_browser/view/library_genres_page.dart @@ -86,7 +86,7 @@ class GenreListTile extends StatelessWidget { trailing: const Icon(Icons.chevron_right), onTap: () { // Navigate to filtered items page with genre filter - context.goNamed( + context.pushNamed( Routes.libraryFiltered.name, extra: { 'filter': GenreFilter(genre), diff --git a/lib/features/library_browser/view/library_series_page.dart b/lib/features/library_browser/view/library_series_page.dart index afaddfc..b680133 100644 --- a/lib/features/library_browser/view/library_series_page.dart +++ b/lib/features/library_browser/view/library_series_page.dart @@ -110,7 +110,7 @@ class SeriesListTile extends StatelessWidget { trailing: const Icon(Icons.chevron_right), onTap: () { // Navigate to filtered items page with series filter - context.goNamed( + context.pushNamed( Routes.libraryFiltered.name, extra: { 'filter': SeriesFilter(series.id), From 509491508671a60cd05c830f0077092e4df0bfbc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 21:23:11 +0000 Subject: [PATCH 18/24] fix: adjust grid aspect ratio to prevent overflow - Change childAspectRatio from 0.75 to 0.68 for more vertical space - Fixes 'bottom overflowed by X pixels' error in grid cards - Provides adequate space for square cover + title + author text - Cards are slightly taller but layout is now correct --- .../library_browser/view/filtered_library_items_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/library_browser/view/filtered_library_items_page.dart b/lib/features/library_browser/view/filtered_library_items_page.dart index e370465..fb166cb 100644 --- a/lib/features/library_browser/view/filtered_library_items_page.dart +++ b/lib/features/library_browser/view/filtered_library_items_page.dart @@ -77,7 +77,7 @@ class FilteredLibraryItemsPage extends HookConsumerWidget { padding: const EdgeInsets.all(16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, - childAspectRatio: 0.75, + childAspectRatio: 0.68, crossAxisSpacing: 12, mainAxisSpacing: 16, ), From a1844c225c8ee07f664c08cd97f5cf3051e93ee3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 21:44:32 +0000 Subject: [PATCH 19/24] fix: increase card height to prevent 2-line title overflow - Change childAspectRatio from 0.68 to 0.65 - Fixes 3.3 pixel overflow when titles span two lines - Single-line titles already worked, this ensures two-line titles fit --- .../library_browser/view/filtered_library_items_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/library_browser/view/filtered_library_items_page.dart b/lib/features/library_browser/view/filtered_library_items_page.dart index fb166cb..0a1515a 100644 --- a/lib/features/library_browser/view/filtered_library_items_page.dart +++ b/lib/features/library_browser/view/filtered_library_items_page.dart @@ -77,7 +77,7 @@ class FilteredLibraryItemsPage extends HookConsumerWidget { padding: const EdgeInsets.all(16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, - childAspectRatio: 0.68, + childAspectRatio: 0.65, crossAxisSpacing: 12, mainAxisSpacing: 16, ), From 7d2877c1bb331345703b91e99d7a6eacc55170bd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 21:47:46 +0000 Subject: [PATCH 20/24] fix: add series sequence sorting and debug metadata variants Series sorting fix: - Detect SeriesFilter and use 'sequence' sort parameter - Other filters (author/genre) continue using alphabetical title sort - Books in series now display in proper sequence order Series metadata debugging: - Add logging to show media and metadata variants for each item - Add bookSeriesFilter metadata variant handlers - Log extracted title and author for debugging - This will help identify why series-filtered books show no info The logging will show what metadata structure the API returns for series-filtered items so we can properly extract title/author data. --- .../view/filtered_library_items_page.dart | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/features/library_browser/view/filtered_library_items_page.dart b/lib/features/library_browser/view/filtered_library_items_page.dart index 0a1515a..6529fb3 100644 --- a/lib/features/library_browser/view/filtered_library_items_page.dart +++ b/lib/features/library_browser/view/filtered_library_items_page.dart @@ -2,12 +2,15 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:vaani/api/api_provider.dart'; import 'package:vaani/api/library_provider.dart'; import 'package:vaani/router/router.dart'; import 'package:vaani/settings/api_settings_provider.dart'; +final _logger = Logger('FilteredLibraryItemsPage'); + /// Page that displays library items filtered by author, genre, or series class FilteredLibraryItemsPage extends HookConsumerWidget { const FilteredLibraryItemsPage({ @@ -36,12 +39,17 @@ class FilteredLibraryItemsPage extends HookConsumerWidget { ); } + // Determine sort parameter based on filter type + final sortParam = filter is SeriesFilter + ? 'sequence' // Sort by series sequence number + : 'media.metadata.title'; // Sort alphabetically by title + return FutureBuilder( future: api.libraries.getItems( libraryId: library.id, parameters: GetLibrarysItemsReqParams( filter: filter, - sort: 'media.metadata.title', + sort: sortParam, limit: 100, ), ), @@ -114,6 +122,9 @@ class LibraryItemCard extends ConsumerWidget { final apiSettings = ref.watch(apiSettingsProvider); final media = item.media; + // Log media variant for debugging + _logger.info('Item ${item.id}: Media variant = ${media.variant}'); + // Extract book info String title = ''; String? authorName; @@ -122,6 +133,7 @@ class LibraryItemCard extends ConsumerWidget { media.mapOrNull( book: (book) { final metadata = book.metadata; + _logger.info(' Metadata variant = ${metadata.variant}'); metadata.mapOrNull( book: (m) { title = m.title ?? 'Unknown'; @@ -139,10 +151,18 @@ class LibraryItemCard extends ConsumerWidget { authorName = m.authors.map((a) => a.name).join(', '); } }, + bookSeriesFilter: (m) { + _logger.info(' Found bookSeriesFilter metadata variant!'); + title = m.title ?? 'Unknown'; + if (m.authors.isNotEmpty) { + authorName = m.authors.map((a) => a.name).join(', '); + } + }, ); }, bookMinified: (book) { final metadata = book.metadata; + _logger.info(' Metadata variant = ${metadata.variant}'); metadata.mapOrNull( book: (m) { title = m.title ?? 'Unknown'; @@ -160,10 +180,18 @@ class LibraryItemCard extends ConsumerWidget { authorName = m.authors.map((a) => a.name).join(', '); } }, + bookSeriesFilter: (m) { + _logger.info(' Found bookSeriesFilter metadata variant!'); + title = m.title ?? 'Unknown'; + if (m.authors.isNotEmpty) { + authorName = m.authors.map((a) => a.name).join(', '); + } + }, ); }, bookExpanded: (book) { final metadata = book.metadata; + _logger.info(' Metadata variant = ${metadata.variant}'); metadata.mapOrNull( book: (m) { title = m.title ?? 'Unknown'; @@ -181,10 +209,19 @@ class LibraryItemCard extends ConsumerWidget { authorName = m.authors.map((a) => a.name).join(', '); } }, + bookSeriesFilter: (m) { + _logger.info(' Found bookSeriesFilter metadata variant!'); + title = m.title ?? 'Unknown'; + if (m.authors.isNotEmpty) { + authorName = m.authors.map((a) => a.name).join(', '); + } + }, ); }, ); + _logger.info(' Extracted: title="$title", author="$authorName"'); + final imageUrl = apiSettings.activeServer != null ? '${apiSettings.activeServer!.serverUrl}/api/items/${item.id}/cover' : null; From ccb8318341f0129a1eb75ef15b8073af888295a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 22:08:39 +0000 Subject: [PATCH 21/24] fix: request full metadata for series-filtered books Added minified: false to GetLibrarysItemsReqParams to ensure the API returns complete book metadata including titles and authors. The logs showed that series-filtered items were returning bookMinified variant with empty title and null authorName fields. This change requests the full metadata variant which includes all necessary fields. --- .../library_browser/view/filtered_library_items_page.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/features/library_browser/view/filtered_library_items_page.dart b/lib/features/library_browser/view/filtered_library_items_page.dart index 6529fb3..07392d1 100644 --- a/lib/features/library_browser/view/filtered_library_items_page.dart +++ b/lib/features/library_browser/view/filtered_library_items_page.dart @@ -51,6 +51,7 @@ class FilteredLibraryItemsPage extends HookConsumerWidget { filter: filter, sort: sortParam, limit: 100, + minified: false, // Request full metadata to get titles and authors ), ), builder: (context, snapshot) { From b434f73b2a5d2d39d5cf160fbfae8dc835587eaf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 22:18:20 +0000 Subject: [PATCH 22/24] perf: optimize scrolling and image loading performance Added performance optimizations to all library browser views: - Added cacheExtent: 500 to all GridView/ListView builders to pre-render items and reduce stuttering during scrolling - Wrapped grid items in RepaintBoundary to isolate repaints and improve performance - Optimized CachedNetworkImage with: - fadeInDuration/fadeOutDuration: Duration.zero to remove animation overhead - memCacheHeight: 300 to limit in-memory cache size - maxHeightDiskCache: 600 to resize images for better performance These changes should significantly reduce the stuttering observed when scrolling the authors grid and filtering books, especially on first load. --- .../view/filtered_library_items_page.dart | 9 ++++++++- .../library_browser/view/library_authors_page.dart | 9 ++++++++- .../library_browser/view/library_genres_page.dart | 1 + .../library_browser/view/library_series_page.dart | 1 + 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/features/library_browser/view/filtered_library_items_page.dart b/lib/features/library_browser/view/filtered_library_items_page.dart index 07392d1..12f88b8 100644 --- a/lib/features/library_browser/view/filtered_library_items_page.dart +++ b/lib/features/library_browser/view/filtered_library_items_page.dart @@ -84,6 +84,7 @@ class FilteredLibraryItemsPage extends HookConsumerWidget { return GridView.builder( padding: const EdgeInsets.all(16), + cacheExtent: 500, // Pre-render items for smoother scrolling gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, childAspectRatio: 0.65, @@ -93,7 +94,9 @@ class FilteredLibraryItemsPage extends HookConsumerWidget { itemCount: response.results.length, itemBuilder: (context, index) { final item = response.results[index]; - return LibraryItemCard(item: item); + return RepaintBoundary( + child: LibraryItemCard(item: item), + ); }, ); }, @@ -247,6 +250,10 @@ class LibraryItemCard extends ConsumerWidget { ? CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, + fadeInDuration: Duration.zero, // Remove fade animation for better performance + fadeOutDuration: Duration.zero, + memCacheHeight: 300, // Limit memory cache size + maxHeightDiskCache: 600, // Limit disk cache size httpHeaders: { if (apiSettings.activeUser?.authToken != null) 'Authorization': diff --git a/lib/features/library_browser/view/library_authors_page.dart b/lib/features/library_browser/view/library_authors_page.dart index b84d21b..dfb665d 100644 --- a/lib/features/library_browser/view/library_authors_page.dart +++ b/lib/features/library_browser/view/library_authors_page.dart @@ -37,6 +37,7 @@ class LibraryAuthorsPage extends HookConsumerWidget { return GridView.builder( padding: const EdgeInsets.all(16), + cacheExtent: 500, // Pre-render items for smoother scrolling gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, childAspectRatio: 0.75, @@ -46,7 +47,9 @@ class LibraryAuthorsPage extends HookConsumerWidget { itemCount: sortedAuthors.length, itemBuilder: (context, index) { final author = sortedAuthors[index]; - return AuthorCard(author: author); + return RepaintBoundary( + child: AuthorCard(author: author), + ); }, ); }, @@ -125,6 +128,10 @@ class AuthorCard extends HookConsumerWidget { 'Authorization': 'Bearer ${apiSettings.activeUser?.authToken}', }, fit: BoxFit.cover, + fadeInDuration: Duration.zero, // Remove fade animation for better performance + fadeOutDuration: Duration.zero, + memCacheHeight: 300, // Limit memory cache size + maxHeightDiskCache: 600, // Limit disk cache size placeholder: (context, url) => Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, child: const Center( diff --git a/lib/features/library_browser/view/library_genres_page.dart b/lib/features/library_browser/view/library_genres_page.dart index e8cd02a..a619593 100644 --- a/lib/features/library_browser/view/library_genres_page.dart +++ b/lib/features/library_browser/view/library_genres_page.dart @@ -29,6 +29,7 @@ class LibraryGenresPage extends HookConsumerWidget { return ListView.builder( padding: const EdgeInsets.symmetric(vertical: 8), + cacheExtent: 500, // Pre-render items for smoother scrolling itemCount: sortedGenres.length, itemBuilder: (context, index) { final genre = sortedGenres[index]; diff --git a/lib/features/library_browser/view/library_series_page.dart b/lib/features/library_browser/view/library_series_page.dart index b680133..fa5705b 100644 --- a/lib/features/library_browser/view/library_series_page.dart +++ b/lib/features/library_browser/view/library_series_page.dart @@ -38,6 +38,7 @@ class LibrarySeriesPage extends HookConsumerWidget { return ListView.builder( padding: const EdgeInsets.symmetric(vertical: 8), + cacheExtent: 500, // Pre-render items for smoother scrolling itemCount: seriesList.length, itemBuilder: (context, index) { final series = seriesList[index]; From 2b314696aca9fd95f36ec01023bc284cbbd9c8d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 08:31:53 +0000 Subject: [PATCH 23/24] fix: add support for bookMinifiedSeriesFilter metadata variant in series view --- .../view/filtered_library_items_page.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/features/library_browser/view/filtered_library_items_page.dart b/lib/features/library_browser/view/filtered_library_items_page.dart index 12f88b8..8f000ba 100644 --- a/lib/features/library_browser/view/filtered_library_items_page.dart +++ b/lib/features/library_browser/view/filtered_library_items_page.dart @@ -162,6 +162,11 @@ class LibraryItemCard extends ConsumerWidget { authorName = m.authors.map((a) => a.name).join(', '); } }, + bookMinifiedSeriesFilter: (m) { + _logger.info(' Found bookMinifiedSeriesFilter metadata variant!'); + title = m.title ?? 'Unknown'; + authorName = m.authorName; + }, ); }, bookMinified: (book) { @@ -191,6 +196,11 @@ class LibraryItemCard extends ConsumerWidget { authorName = m.authors.map((a) => a.name).join(', '); } }, + bookMinifiedSeriesFilter: (m) { + _logger.info(' Found bookMinifiedSeriesFilter metadata variant!'); + title = m.title ?? 'Unknown'; + authorName = m.authorName; + }, ); }, bookExpanded: (book) { @@ -220,6 +230,11 @@ class LibraryItemCard extends ConsumerWidget { authorName = m.authors.map((a) => a.name).join(', '); } }, + bookMinifiedSeriesFilter: (m) { + _logger.info(' Found bookMinifiedSeriesFilter metadata variant!'); + title = m.title ?? 'Unknown'; + authorName = m.authorName; + }, ); }, ); From d6e49238ecfb283fca5386a3b4aca4719ffc3719 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 11:05:38 +0000 Subject: [PATCH 24/24] fix: strip HTML tags from book descriptions for proper display --- .../item_viewer/view/library_item_page.dart | 10 ++++++- lib/shared/utils/html_utils.dart | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 lib/shared/utils/html_utils.dart diff --git a/lib/features/item_viewer/view/library_item_page.dart b/lib/features/item_viewer/view/library_item_page.dart index e7b9310..f297320 100644 --- a/lib/features/item_viewer/view/library_item_page.dart +++ b/lib/features/item_viewer/view/library_item_page.dart @@ -10,6 +10,7 @@ import 'package:vaani/features/item_viewer/view/library_item_sliver_app_bar.dart import 'package:vaani/features/player/view/mini_player_bottom_padding.dart'; import 'package:vaani/router/models/library_item_extras.dart'; import 'package:vaani/shared/widgets/expandable_description.dart'; +import 'package:vaani/shared/utils/html_utils.dart'; import 'library_item_actions.dart'; import 'library_item_hero_section.dart'; @@ -149,9 +150,16 @@ class LibraryItemDescription extends HookConsumerWidget { if (item == null) { return const SizedBox(); } + + // Get description and strip HTML tags + final rawDescription = item.media.metadata.description; + final cleanDescription = rawDescription != null + ? HtmlUtils.stripHtml(rawDescription) + : 'Sorry, no description found'; + return ExpandableDescription( title: 'About the Book', - content: item.media.metadata.description ?? 'Sorry, no description found', + content: cleanDescription, ); } } diff --git a/lib/shared/utils/html_utils.dart b/lib/shared/utils/html_utils.dart new file mode 100644 index 0000000..46160f8 --- /dev/null +++ b/lib/shared/utils/html_utils.dart @@ -0,0 +1,26 @@ +/// Utility functions for handling HTML content +class HtmlUtils { + /// Strips HTML tags and decodes common HTML entities from a string + static String stripHtml(String htmlString) { + // Remove HTML tags + String text = htmlString.replaceAll(RegExp(r'<[^>]*>'), ''); + + // Decode common HTML entities + text = text + .replaceAll(' ', ' ') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'"); + + // Replace multiple spaces with single space + text = text.replaceAll(RegExp(r'\s+'), ' '); + + // Trim leading/trailing whitespace + text = text.trim(); + + return text; + } +}