Files
Ensemble/lib/screens/audiobook_detail_screen.dart
ChrisandClaude Opus 4.6 8181e75a08 Fix detail screen layout: reduce cover size and spacing
- Reduce artist and audiobook author cover size from 70%/280px to 50%/200px
- Reduce expanded height padding for better space utilization
- Reduce top padding across all detail screens from 24px to 8px
- Move audiobook author three-dot menu from SliverAppBar to inline with author info

This fixes issue #60 where album art/titles were cut off by bottom menu bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 09:59:15 +00:00

1173 lines
42 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class AudiobookDetailScreen extends StatefulWidget {
final Audiobook audiobook;
final String? heroTagSuffix;
/// Initial image URL from the source for seamless hero animation
final String? initialImageUrl;
const AudiobookDetailScreen({
super.key,
required this.audiobook,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
State<AudiobookDetailScreen> createState() => _AudiobookDetailScreenState();
}
class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final _logger = DebugLogger();
bool _isDescriptionExpanded = false;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
int? _expandedChapterIndex;
Audiobook? _fullAudiobook;
bool _isLoadingDetails = false;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
// Use full audiobook if loaded, otherwise use widget audiobook
Audiobook get _audiobook => _fullAudiobook ?? widget.audiobook;
/// Check if audiobook is in library
bool _checkIfInLibrary(Audiobook audiobook) {
if (audiobook.provider == 'library') return true;
return audiobook.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override
void initState() {
super.initState();
_isFavorite = widget.audiobook.favorite ?? false;
_isInLibrary = _checkIfInLibrary(widget.audiobook);
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
// Load full audiobook details with chapters
_loadAudiobookDetails();
});
}
Future<void> _loadAudiobookDetails() async {
if (_isLoadingDetails) return;
setState(() {
_isLoadingDetails = true;
});
try {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final fullBook = await maProvider.api!.getAudiobookDetails(
widget.audiobook.provider,
widget.audiobook.itemId,
);
if (mounted && fullBook != null) {
_logger.log('📚 Loaded full audiobook: ${fullBook.name}, chapters: ${fullBook.chapters?.length ?? 0}');
setState(() {
_fullAudiobook = fullBook;
});
}
}
if (mounted) {
setState(() {
_isLoadingDetails = false;
});
}
} catch (e) {
_logger.log('Error loading audiobook details: $e');
if (mounted) {
setState(() {
_isLoadingDetails = false;
});
}
}
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
if (imageUrl == null) return;
try {
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Failed to extract colors for audiobook: $e');
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isFavorite;
bool success;
if (newState) {
String actualProvider = widget.audiobook.provider;
String actualItemId = widget.audiobook.itemId;
if (widget.audiobook.providerMappings != null && widget.audiobook.providerMappings!.isNotEmpty) {
final mapping = widget.audiobook.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.audiobook.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.audiobook.providerMappings!.first,
),
);
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
_logger.log('Adding audiobook to favorites: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToFavorites(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
if (widget.audiobook.provider == 'library') {
libraryItemId = int.tryParse(widget.audiobook.itemId);
} else if (widget.audiobook.providerMappings != null) {
final libraryMapping = widget.audiobook.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.audiobook.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
throw Exception('Could not determine library ID for this audiobook');
}
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isFavorite = newState;
});
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling audiobook favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2),
),
);
}
}
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.audiobook.providerMappings != null && widget.audiobook.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.audiobook.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (widget.audiobook.provider != 'library') {
actualProvider = widget.audiobook.provider;
actualItemId = widget.audiobook.itemId;
} else {
return;
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.addToLibrary(
mediaType: 'audiobook',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
} else {
int? libraryItemId;
if (widget.audiobook.provider == 'library') {
libraryItemId = int.tryParse(widget.audiobook.itemId);
} else if (widget.audiobook.providerMappings != null) {
final libraryMapping = widget.audiobook.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.audiobook.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.removeFromLibrary(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
}
} catch (e) {
_logger.log('Error toggling audiobook library: $e');
}
}
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForAudiobook,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
maProvider.setCurrentAudiobook(_audiobook);
await maProvider.api?.playAudiobook(
player.playerId,
widget.audiobook,
);
},
);
}
Future<void> _markAsFinished() async {
try {
// Update local audiobook state to show as finished
final updatedAudiobook = Audiobook(
itemId: _audiobook.itemId,
provider: _audiobook.provider,
name: _audiobook.name,
authors: _audiobook.authors,
narrators: _audiobook.narrators,
publisher: _audiobook.publisher,
description: _audiobook.description,
year: _audiobook.year,
chapters: _audiobook.chapters,
resumePositionMs: _audiobook.duration?.inMilliseconds, // Set to end
fullyPlayed: true,
sortName: _audiobook.sortName,
uri: _audiobook.uri,
providerMappings: _audiobook.providerMappings,
metadata: _audiobook.metadata,
favorite: _audiobook.favorite,
duration: _audiobook.duration,
);
setState(() {
_fullAudiobook = updatedAudiobook;
});
// TODO: Call MA/ABS API to update server-side progress when available
_logger.log('📚 Marked audiobook as finished: ${_audiobook.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.markedAsFinished(_audiobook.name)),
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
_logger.log('Error marking audiobook as finished: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToMarkFinished(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _markAsUnplayed() async {
try {
// Update local audiobook state to show as unplayed
final updatedAudiobook = Audiobook(
itemId: _audiobook.itemId,
provider: _audiobook.provider,
name: _audiobook.name,
authors: _audiobook.authors,
narrators: _audiobook.narrators,
publisher: _audiobook.publisher,
description: _audiobook.description,
year: _audiobook.year,
chapters: _audiobook.chapters,
resumePositionMs: 0, // Reset to start
fullyPlayed: false,
sortName: _audiobook.sortName,
uri: _audiobook.uri,
providerMappings: _audiobook.providerMappings,
metadata: _audiobook.metadata,
favorite: _audiobook.favorite,
duration: _audiobook.duration,
);
setState(() {
_fullAudiobook = updatedAudiobook;
});
// TODO: Call MA/ABS API to update server-side progress when available
_logger.log('📚 Marked audiobook as unplayed: ${_audiobook.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.markedAsUnplayed(_audiobook.name)),
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
_logger.log('Error marking audiobook as unplayed: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToMarkUnplayed(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _playAudiobook({int? startPositionMs}) async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try {
// Set the audiobook context for player controls (uses full audiobook with chapters if available)
maProvider.setCurrentAudiobook(_audiobook);
// Record to local recently played (per-profile)
RecentlyPlayedService.instance.recordAudiobookPlayed(_audiobook);
// Play the audiobook
await maProvider.api?.playAudiobook(
selectedPlayer.playerId,
widget.audiobook,
);
// If we have a resume position, seek to it
if (startPositionMs != null && startPositionMs > 0) {
await Future.delayed(const Duration(milliseconds: 500));
await maProvider.api?.seek(
selectedPlayer.playerId,
startPositionMs ~/ 1000, // Convert to seconds
);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.playing(widget.audiobook.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error playing audiobook: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToPlay(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final providerImageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
// Use initialImageUrl as fallback for seamless hero animation
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveLightScheme,
);
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveDarkScheme,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
ColorScheme? adaptiveScheme;
if (adaptiveTheme) {
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
}
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final book = _audiobook;
final hasResumePosition = (book.resumePositionMs ?? 0) > 0;
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
clearAdaptiveColorsOnBack(context);
}
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 70% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () {
clearAdaptiveColorsOnBack(context);
Navigator.pop(context);
},
color: colorScheme.onSurface,
),
actions: [
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
color: colorScheme.onSurface,
),
onSelected: (value) {
switch (value) {
case 'mark_finished':
_markAsFinished();
break;
case 'mark_unplayed':
_markAsUnplayed();
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'mark_finished',
child: Row(
children: [
Icon(
Icons.check_circle_outline,
color: colorScheme.onSurface,
size: 20,
),
const SizedBox(width: 12),
Text(S.of(context)!.markAsFinished),
],
),
),
PopupMenuItem(
value: 'mark_unplayed',
child: Row(
children: [
Icon(
Icons.restart_alt,
color: colorScheme.onSurface,
size: 20,
),
const SizedBox(width: 12),
Text(S.of(context)!.markAsUnplayed),
],
),
),
],
),
],
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
Hero(
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: coverSize,
height: coverSize,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.audiobook.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.audiobook.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Hero(
tag: HeroTags.audiobookTitle + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix,
child: Material(
color: Colors.transparent,
child: Text(
book.name,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
),
),
Spacing.vGap8,
// Author
Text(
S.of(context)!.byAuthor(book.authorsString),
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.8),
),
),
// Narrator
if (book.narratorsString != S.of(context)!.unknownNarrator) ...[
const SizedBox(height: 4),
Text(
S.of(context)!.narratedBy(book.narratorsString),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
// Duration & Progress
Spacing.vGap16,
Row(
children: [
if (book.duration != null) ...[
Icon(
Icons.schedule,
size: 16,
color: colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
_formatDuration(book.duration!),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
if (book.progress > 0) ...[
const SizedBox(width: 16),
Icon(
Icons.bookmark,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 4),
Text(
S.of(context)!.percentComplete((book.progress * 100).toInt()),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.primary,
),
),
],
],
),
// Progress bar
if (book.progress > 0) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 6,
),
),
],
const SizedBox(height: 24),
// Action Buttons
Row(
children: [
// Play/Resume Button
Expanded(
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: () => _playAudiobook(
startPositionMs: hasResumePosition ? book.resumePositionMs : null,
),
icon: Icon(hasResumePosition ? Icons.play_arrow : Icons.play_arrow),
label: Text(hasResumePosition ? S.of(context)!.resume : S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
if (hasResumePosition) ...[
const SizedBox(width: 12),
// Start Over Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: () => _playAudiobook(startPositionMs: 0),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.replay, size: 25),
),
),
],
const SizedBox(width: 12),
// Library Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary
? colorScheme.primary
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Favorite Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleFavorite,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
Icons.favorite,
size: 25,
color: _isFavorite
? colorScheme.error
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.audiobook,
item: widget.audiobook,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: Colors.white70,
),
),
),
),
],
),
// Description
if (book.description != null && book.description!.isNotEmpty) ...[
const SizedBox(height: 24),
Text(
S.of(context)!.about,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
Spacing.vGap8,
InkWell(
onTap: () {
setState(() {
_isDescriptionExpanded = !_isDescriptionExpanded;
});
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
_stripHtml(book.description!),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.8),
),
maxLines: _isDescriptionExpanded ? null : 4,
overflow: _isDescriptionExpanded ? null : TextOverflow.ellipsis,
),
),
),
],
// Chapters Header
const SizedBox(height: 24),
Text(
S.of(context)!.chapters,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
Spacing.vGap8,
],
),
),
),
// Chapter List or Loading/Empty State
// Use MA chapters if available, otherwise use ABS chapters
..._buildChapterSection(book, colorScheme, textTheme),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
],
);
},
),
),
);
}
/// Build the chapter section with loading, chapters, or empty state
List<Widget> _buildChapterSection(Audiobook book, ColorScheme colorScheme, TextTheme textTheme) {
// Loading state
if (_isLoadingDetails) {
return [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Center(
child: Column(
children: [
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
const SizedBox(height: 12),
Text(
S.of(context)!.loadingChapters,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
),
),
),
];
}
// Get chapters from MA
final chapters = book.chapters;
// Show chapters if available
if (chapters != null && chapters.isNotEmpty) {
return [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildChapterTile(chapters[index], index, colorScheme, textTheme),
childCount: chapters.length,
),
),
),
];
}
// Empty state
return [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Center(
child: Text(
S.of(context)!.noChapterInfoAvailable,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
),
),
),
];
}
Widget _buildChapterTile(Chapter chapter, int index, ColorScheme colorScheme, TextTheme textTheme) {
final book = _audiobook;
final resumeMs = book.resumePositionMs ?? 0;
final chapterEndMs = chapter.positionMs + (chapter.duration?.inMilliseconds ?? 0);
// Determine if chapter is played, in progress, or not started
final isPlayed = resumeMs >= chapterEndMs;
final isInProgress = resumeMs >= chapter.positionMs && resumeMs < chapterEndMs;
final isExpanded = _expandedChapterIndex == index;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isPlayed
? colorScheme.primary
: isInProgress
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: isPlayed
? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
: Text(
'${index + 1}',
style: TextStyle(
color: isInProgress
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
title: Text(
chapter.title,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: isInProgress ? FontWeight.bold : FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: isInProgress
? Text(
S.of(context)!.inProgress,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
)
: null,
trailing: Text(
_formatPositionTime(chapter.positionMs),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
fontWeight: FontWeight.w500,
),
),
onTap: () {
if (isExpanded) {
setState(() {
_expandedChapterIndex = null;
});
} else {
_playAudiobook(startPositionMs: chapter.positionMs);
}
},
onLongPress: () {
setState(() {
_expandedChapterIndex = isExpanded ? null : index;
});
},
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: isExpanded
? Padding(
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Play from here button
SizedBox(
height: 44,
child: FilledButton.tonal(
onPressed: () {
setState(() {
_expandedChapterIndex = null;
});
_playAudiobook(startPositionMs: chapter.positionMs);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_arrow, size: 20),
const SizedBox(width: 8),
Text(isInProgress ? S.of(context)!.resume : S.of(context)!.play),
],
),
),
),
],
),
)
: const SizedBox.shrink(),
),
],
);
}
/// Format position time in milliseconds as "h:mm:ss" or "m:ss"
String _formatPositionTime(int positionMs) {
final duration = Duration(milliseconds: positionMs);
final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
final seconds = duration.inSeconds % 60;
if (hours > 0) {
return '${hours}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
return '${minutes}:${seconds.toString().padLeft(2, '0')}';
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
final seconds = duration.inSeconds % 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
} else if (minutes > 0) {
return '${minutes}m ${seconds}s';
}
return '${seconds}s';
}
/// Strip HTML tags from text and clean up whitespace
String _stripHtml(String htmlText) {
// Remove HTML tags
final withoutTags = htmlText.replaceAll(RegExp(r'<[^>]*>'), '');
// Decode common HTML entities
final decoded = withoutTags
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
// Clean up multiple spaces/newlines
return decoded.replaceAll(RegExp(r'\s+'), ' ').trim();
}
}