mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-23 19:39:30 +00:00
Merge d6e49238ec into 07aea41c6e
This commit is contained in:
commit
4f6ffc7fb4
12 changed files with 1102 additions and 6 deletions
1
.github/workflows/.trigger
vendored
Normal file
1
.github/workflows/.trigger
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Trigger build
|
||||
104
.github/workflows/feature-branch-build.yaml
vendored
Normal file
104
.github/workflows/feature-branch-build.yaml
vendored
Normal 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
|
||||
162
lib/api/library_browser_provider.dart
Normal file
162
lib/api/library_browser_provider.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
181
lib/features/library_browser/view/library_authors_page.dart
Normal file
181
lib/features/library_browser/view/library_authors_page.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
101
lib/features/library_browser/view/library_genres_page.dart
Normal file
101
lib/features/library_browser/view/library_genres_page.dart
Normal 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,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
125
lib/features/library_browser/view/library_series_page.dart
Normal file
125
lib/features/library_browser/view/library_series_page.dart
Normal 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,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
26
lib/shared/utils/html_utils.dart
Normal file
26
lib/shared/utils/html_utils.dart
Normal 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(' ', ' ')
|
||||
.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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue