This commit is contained in:
CollotsSpot 2025-11-21 11:05:42 +00:00 committed by GitHub
commit 4f6ffc7fb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1102 additions and 6 deletions

1
.github/workflows/.trigger vendored Normal file
View file

@ -0,0 +1 @@
# Trigger build

View file

@ -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

View file

@ -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<String, dynamic> 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<List<Author>> libraryAuthors(Ref ref) async {
final api = ref.watch(authenticatedApiProvider);
final currentLibrary = await ref.watch(currentLibraryProvider.future);
if (currentLibrary == null) {
_logger.warning('No current library found');
return [];
}
final authors = await api.libraries.getAuthors(libraryId: currentLibrary.id);
if (authors == null) {
_logger.warning('Failed to fetch authors for library ${currentLibrary.id}');
return [];
}
_logger.fine('Fetched ${authors.length} authors');
return authors;
}
/// Provider for fetching all genres in the current library
@riverpod
Future<List<String>> libraryGenres(Ref ref) async {
final api = ref.watch(authenticatedApiProvider);
final currentLibrary = await ref.watch(currentLibraryProvider.future);
if (currentLibrary == null) {
_logger.warning('No current library found');
return [];
}
// Use raw API call to avoid Series deserialization issues in LibraryFilterData
final genres = await api.getJson<List<String>>(
path: '/api/libraries/${currentLibrary.id}/filterdata',
requiresAuth: true,
fromJson: (json) {
if (json is Map<String, dynamic> && json.containsKey('genres')) {
final genresList = json['genres'] as List<dynamic>;
return genresList.map((e) => e.toString()).toList();
}
return <String>[];
},
);
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<List<SimpleSeries>> 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<List<SimpleSeries>>(
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<String, dynamic> && json.containsKey('series')) {
final seriesList = json['series'] as List<dynamic>;
_logger.info('Found ${seriesList.length} series in filterdata');
return seriesList
.map((e) => SimpleSeries.fromJson(e as Map<String, dynamic>))
.toList();
}
return <SimpleSeries>[];
},
);
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<List<SimpleSeries>>(
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<String, dynamic>) {
if (json.containsKey('results')) {
final results = json['results'] as List<dynamic>;
_logger.info('Found ${results.length} series in results');
return results
.map((e) => SimpleSeries.fromJson(e as Map<String, dynamic>))
.toList();
}
}
return <SimpleSeries>[];
},
);
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;
}
}

View file

@ -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,
);
}
}

View file

@ -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<GetLibrarysItemsResponse?>(
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,
),
],
),
);
}
}

View file

@ -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<Author>.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,
),
],
],
),
),
),
],
),
),
);
}
}

View file

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

View file

@ -0,0 +1,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<String>.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,
},
);
},
),
);
}
}

View file

@ -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,
},
);
},
),
);
}
}

View file

@ -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',

View file

@ -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<String, dynamic>?;
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,
),
);
},
),
],
),
],
),

View file

@ -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('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'");
// Replace multiple spaces with single space
text = text.replaceAll(RegExp(r'\s+'), ' ');
// Trim leading/trailing whitespace
text = text.trim();
return text;
}
}