Vaani/lib/features/library_browser/view/library_authors_page.dart
Claude f60ea72659
fix: resolve Series deserialization issues and add author sorting
- Fix genres error by parsing JSON manually to avoid Series deserialization
- Fix series blank screen by using SimpleSeries class instead of full Series
- Add alphabetical sorting to authors page
- Work around shelfsdk Series variant detection issues

The Audiobookshelf API returns Series objects that don't match any of the
variant detection patterns in the shelfsdk SeriesConverter. Since we can't
modify the external shelfsdk, we parse the API responses manually to extract
only the data we need for display.
2025-11-20 16:17:09 +00:00

167 lines
5.5 KiB
Dart

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