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 diff --git a/.github/workflows/feature-branch-build.yaml b/.github/workflows/feature-branch-build.yaml new file mode 100644 index 0000000..264db81 --- /dev/null +++ b/.github/workflows/feature-branch-build.yaml @@ -0,0 +1,104 @@ +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: Prepare Android debug keystore directory + run: | + mkdir -p $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 + + - 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 diff --git a/lib/api/library_browser_provider.dart b/lib/api/library_browser_provider.dart new file mode 100644 index 0000000..df816e5 --- /dev/null +++ b/lib/api/library_browser_provider.dart @@ -0,0 +1,162 @@ +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; +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) { + 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; + } + } +} + +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 []; + } + + // 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 (genres == null) { + _logger.warning('Failed to fetch genres for library ${currentLibrary.id}'); + return []; + } + + _logger.fine('Fetched ${genres.length} genres'); + return genres; +} + +/// Provider for fetching all series in the current library +@riverpod +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 []; + } + + try { + // 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 keys: ${json is Map ? (json as Map).keys : json.runtimeType}'); + + 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(); + } + } + 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; + } +} 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/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..8f000ba --- /dev/null +++ b/lib/features/library_browser/view/filtered_library_items_page.dart @@ -0,0 +1,318 @@ +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({ + 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'), + ); + } + + // 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: sortParam, + limit: 100, + minified: false, // Request full metadata to get titles and authors + ), + ), + 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 GridView.builder( + padding: const EdgeInsets.all(16), + cacheExtent: 500, // Pre-render items for smoother scrolling + 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 RepaintBoundary( + child: LibraryItemCard(item: item), + ); + }, + ); + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stack) => Center( + child: Text('Error: $error'), + ), + ), + ); + } +} + +class LibraryItemCard extends ConsumerWidget { + const LibraryItemCard({ + super.key, + required this.item, + }); + + final LibraryItem item; + + @override + Widget build(BuildContext context, WidgetRef ref) { + 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; + + // Use map to handle Media variants + media.mapOrNull( + book: (book) { + final metadata = book.metadata; + _logger.info(' Metadata variant = ${metadata.variant}'); + metadata.mapOrNull( + book: (m) { + title = m.title ?? 'Unknown'; + if (m.authors.isNotEmpty) { + authorName = m.authors.map((a) => a.name).join(', '); + } + }, + bookMinified: (m) { + title = m.title ?? 'Unknown'; + authorName = m.authorName; + }, + bookExpanded: (m) { + title = m.title ?? 'Unknown'; + if (m.authors.isNotEmpty) { + 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(', '); + } + }, + bookMinifiedSeriesFilter: (m) { + _logger.info(' Found bookMinifiedSeriesFilter metadata variant!'); + title = m.title ?? 'Unknown'; + authorName = m.authorName; + }, + ); + }, + bookMinified: (book) { + final metadata = book.metadata; + _logger.info(' Metadata variant = ${metadata.variant}'); + metadata.mapOrNull( + book: (m) { + title = m.title ?? 'Unknown'; + if (m.authors.isNotEmpty) { + authorName = m.authors.map((a) => a.name).join(', '); + } + }, + bookMinified: (m) { + title = m.title ?? 'Unknown'; + authorName = m.authorName; + }, + bookExpanded: (m) { + title = m.title ?? 'Unknown'; + if (m.authors.isNotEmpty) { + 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(', '); + } + }, + bookMinifiedSeriesFilter: (m) { + _logger.info(' Found bookMinifiedSeriesFilter metadata variant!'); + title = m.title ?? 'Unknown'; + authorName = m.authorName; + }, + ); + }, + bookExpanded: (book) { + final metadata = book.metadata; + _logger.info(' Metadata variant = ${metadata.variant}'); + metadata.mapOrNull( + book: (m) { + title = m.title ?? 'Unknown'; + if (m.authors.isNotEmpty) { + authorName = m.authors.map((a) => a.name).join(', '); + } + }, + bookMinified: (m) { + title = m.title ?? 'Unknown'; + authorName = m.authorName; + }, + bookExpanded: (m) { + title = m.title ?? 'Unknown'; + if (m.authors.isNotEmpty) { + 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(', '); + } + }, + bookMinifiedSeriesFilter: (m) { + _logger.info(' Found bookMinifiedSeriesFilter metadata variant!'); + title = m.title ?? 'Unknown'; + authorName = m.authorName; + }, + ); + }, + ); + + _logger.info(' Extracted: title="$title", author="$authorName"'); + + final imageUrl = apiSettings.activeServer != null + ? '${apiSettings.activeServer!.serverUrl}/api/items/${item.id}/cover' + : null; + + return InkWell( + onTap: () { + // Navigate to item detail page + context.pushNamed( + Routes.libraryItem.name, + pathParameters: {'itemId': item.id}, + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Book cover - always square + AspectRatio( + aspectRatio: 1.0, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: imageUrl != null + ? 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': + '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, + ), + 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, + ), + ], + ), + ); + } +} 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..dfb665d --- /dev/null +++ b/lib/features/library_browser/view/library_authors_page.dart @@ -0,0 +1,181 @@ +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 { + 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'), + ); + } + + // Sort authors alphabetically by surname (last word in name) + final sortedAuthors = List.from(authors) + ..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), + cacheExtent: 500, // Pre-render items for smoother scrolling + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.75, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: sortedAuthors.length, + itemBuilder: (context, index) { + final author = sortedAuthors[index]; + return RepaintBoundary( + child: 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!.serverUrl}/api/authors/${author.id}/image'; + } + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () { + // Navigate to filtered items page with author filter + context.pushNamed( + Routes.libraryFiltered.name, + extra: { + 'filter': AuthorFilter(author.id), + 'title': author.name, + }, + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 3, + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + httpHeaders: { + '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( + 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..a619593 --- /dev/null +++ b/lib/features/library_browser/view/library_genres_page.dart @@ -0,0 +1,101 @@ +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}); + + @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), + cacheExtent: 500, // Pre-render items for smoother scrolling + 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: () { + // Navigate to filtered items page with genre filter + context.pushNamed( + 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 new file mode 100644 index 0000000..fa5705b --- /dev/null +++ b/lib/features/library_browser/view/library_series_page.dart @@ -0,0 +1,125 @@ +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}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final seriesAsync = ref.watch(librarySeriesProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Series'), + ), + body: seriesAsync.when( + data: (seriesList) { + if (seriesList.isEmpty) { + 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, + ), + ], + ), + ); + } + + 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]; + return SeriesListTile(series: series); + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stack) => Center( + 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, + ), + ], + ), + ), + ), + ), + ); + } +} + +class SeriesListTile extends StatelessWidget { + const SeriesListTile({ + super.key, + required this.series, + }); + + final SimpleSeries series; + + @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.secondaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.list, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + title: Text( + series.name, + style: Theme.of(context).textTheme.titleMedium, + ), + subtitle: series.numBooks != null + ? Text('${series.numBooks} ${series.numBooks == 1 ? 'book' : 'books'}') + : null, + trailing: const Icon(Icons.chevron_right), + onTap: () { + // Navigate to filtered items page with series filter + context.pushNamed( + Routes.libraryFiltered.name, + extra: { + 'filter': SeriesFilter(series.id), + 'title': series.name, + }, + ); + }, + ), + ); + } +} diff --git a/lib/router/constants.dart b/lib/router/constants.dart index 79c4556..41951ff 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -82,6 +82,31 @@ 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, + ); + + 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 eda348e..8212385 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -1,10 +1,15 @@ 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'; 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'; +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 +126,48 @@ 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()), + ), + 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, + ), + ); + }, + ), + ], ), ], ), 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; + } +}