Compare commits

...
13 Commits
Author SHA1 Message Date
Your NameandClaude Opus 4.5 290f302da6 chore: bump version to 2.2.0-beta+19
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 15:27:01 +00:00
Your NameandClaude Opus 4.5 c359e5551e feat(series): add Hero animation from home row to detail screen
Wrap cover in Hero widget with unique tag and pass to detail screen

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:48:45 +00:00
Your NameandClaude Opus 4.5 abf7e11a23 fix(series): square covers and colored empty cells for home row
- Use AspectRatio instead of Expanded to guarantee square covers
- Add color extraction from book covers for empty cell backgrounds
- Add nested grid patterns (solid, 2x2, 3x3) in empty cells
- Pass initialCovers to series detail for smooth Hero animation
- Removes grey bars that appeared due to non-square container

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:33:05 +00:00
Your NameandClaude Opus 4.5 d312f37fdb style(series): match book count text to album card styling
Use 0.7 opacity to match artist text in album cards

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:23:03 +00:00
Your NameandClaude Opus 4.5 9614f8e4d4 fix(series): remove grid lines from cover and show book count
- Remove margins between grid cells in series detail cover for seamless appearance
- Add book count caching when loading series covers
- Display book count under series titles in all layouts (list, grid, home row)
- Book count now shows when covers load, even if not provided by API

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:21:38 +00:00
Your NameandClaude Opus 4.5 2a01eb2344 feat(home): add drag-and-drop row ordering in settings
- Add homeRowOrder setting to persist row display order
- Replace separate toggle sections with ReorderableListView
- Add drag handles to each row for reordering
- Home screen now respects the saved row order
- Remove legacy random favorites order logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:11:45 +00:00
Your NameandClaude Opus 4.5 aeb3428115 fix(series): ensure square aspect ratio for series row covers
- Wrap cover grid in AspectRatio(1) to force square
- Use Column/Row instead of GridView for consistent sizing
- Eliminates grey bar at bottom of covers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:04:33 +00:00
Your NameandClaude Opus 4.5 cc948c3c06 feat(series): add Hero animation for series list layout
- Add Hero wrapper to series list tile leading image
- Pass heroTag and initialCovers to series detail screen
- First cover in list becomes first cover in detail grid

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 13:45:39 +00:00
Your NameandClaude Opus 4.5 c1fd25f359 fix(series): use dynamic grid size in series detail header
- 1 cover = 1x1 grid
- 2-4 covers = 2x2 grid
- 5+ covers = 3x3 grid

Matches the library screen grid sizing logic for consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 12:03:29 +00:00
Your NameandClaude Opus 4.5 115b2729d8 fix(series): smooth Hero animation when opening series detail
- Pass pre-loaded cover URLs from library to series detail screen
- Use initial covers immediately instead of showing loading placeholder
- Extract colors from initial covers for consistent empty cell appearance
- This ensures the Hero animation works smoothly in both directions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 10:55:48 +00:00
Your NameandClaude Opus 4.5 3e48066030 refactor(series): tone down grid colors and simplify patterns
- Remove grid lines between nested cells (seamless appearance)
- Limit nested grids to 1x1, 2x2, 3x3 only (no 4x4)
- Adjust distribution: 50% solid, 30% 2x2, 20% 3x3
- Reduce color saturation (50%) and darken (70%) for subtler look

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 09:55:42 +00:00
Your NameandClaude Opus 4.5 7ed02232e6 feat(series): extract colors from book covers for empty grid cells
- Extract dark muted colors from first 4 book covers per series
- Use extracted colors for empty cells instead of static palette
- Colors extracted asynchronously to avoid blocking UI
- Falls back to static dark pastel palette when colors not yet loaded

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 09:39:09 +00:00
Your NameandClaude Opus 4.5 de72736146 feat(series): add nested grid patterns to empty cover cells
- Add dark pastel color palette to series detail screen header
- Implement random nested grids (2x2, 3x3, 4x4) inside empty squares
- Use deterministic pattern based on series ID for consistency
- Distribution: ~40% solid, ~25% 2x2, ~20% 3x3, ~15% 4x4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 09:25:10 +00:00
7 changed files with 819 additions and 421 deletions
+196 -35
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.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:palette_generator/palette_generator.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart' show BottomSpacing;
@@ -14,11 +15,14 @@ import 'audiobook_detail_screen.dart';
class AudiobookSeriesScreen extends StatefulWidget {
final AudiobookSeries series;
final String? heroTag;
/// Pre-loaded cover URLs from the library screen for smooth Hero animation
final List<String>? initialCovers;
const AudiobookSeriesScreen({
super.key,
required this.series,
this.heroTag,
this.initialCovers,
});
@override
@@ -35,9 +39,21 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
String _sortOrder = 'series'; // 'series' (by series number) or 'alpha'
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
// Extracted colors from book covers
List<Color> _extractedColors = [];
// Cover URLs - start with initial covers, update when books load
List<String> _coverUrls = [];
@override
void initState() {
super.initState();
// Use initial covers immediately for smooth Hero animation
if (widget.initialCovers != null && widget.initialCovers!.isNotEmpty) {
_coverUrls = List.from(widget.initialCovers!);
// Start color extraction from initial covers
_extractColorsFromUrls(widget.initialCovers!);
}
_loadViewPreferences();
// Defer loading until after first frame to ensure UI renders first
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -47,6 +63,42 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
});
}
/// Extract colors from cover URLs (used for initial covers)
Future<void> _extractColorsFromUrls(List<String> coverUrls) async {
final extractedColors = <Color>[];
for (final url in coverUrls.take(4)) {
try {
final palette = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(url),
maximumColorCount: 8,
);
if (palette.darkMutedColor != null) {
extractedColors.add(palette.darkMutedColor!.color);
}
if (palette.mutedColor != null) {
extractedColors.add(palette.mutedColor!.color);
}
if (palette.darkVibrantColor != null) {
extractedColors.add(palette.darkVibrantColor!.color);
}
if (palette.dominantColor != null) {
final hsl = HSLColor.fromColor(palette.dominantColor!.color);
extractedColors.add(hsl.withLightness((hsl.lightness * 0.4).clamp(0.1, 0.3)).toColor());
}
} catch (e) {
_logger.log('📚 Error extracting colors: $e');
}
}
if (extractedColors.isNotEmpty && mounted) {
setState(() {
_extractedColors = extractedColors;
});
}
}
Future<void> _loadViewPreferences() async {
final sortOrder = await SettingsService.getSeriesAudiobooksSortOrder();
final viewMode = await SettingsService.getSeriesAudiobooksViewMode();
@@ -132,9 +184,22 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
}
if (mounted) {
// Update cover URLs from loaded books
final newCovers = <String>[];
for (final book in books.take(9)) {
final imageUrl = maProvider.getImageUrl(book);
if (imageUrl != null) {
newCovers.add(imageUrl);
}
}
setState(() {
_audiobooks = books;
_sortAudiobooks(); // Sort inside setState so UI updates
// Update covers if we got new ones (may have more than initial)
if (newCovers.isNotEmpty) {
_coverUrls = newCovers;
}
_isLoading = false;
});
// Debug: Log sorted order
@@ -142,6 +207,9 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
for (final book in _audiobooks) {
_logger.log(' - ${book.seriesSequence ?? "null"}: ${book.name}');
}
// Extract colors from book covers asynchronously
_extractCoverColors(maProvider);
}
} catch (e, stack) {
_logger.log('📚 SeriesScreen error: $e');
@@ -155,6 +223,48 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
}
}
/// Extract colors from book covers for empty grid cells
Future<void> _extractCoverColors(MusicAssistantProvider maProvider) async {
final extractedColors = <Color>[];
// Extract colors from first few book covers
for (final book in _audiobooks.take(4)) {
final imageUrl = maProvider.getImageUrl(book);
if (imageUrl == null) continue;
try {
final palette = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(imageUrl),
maximumColorCount: 8,
);
// Get dark muted colors for grid squares
if (palette.darkMutedColor != null) {
extractedColors.add(palette.darkMutedColor!.color);
}
if (palette.mutedColor != null) {
extractedColors.add(palette.mutedColor!.color);
}
if (palette.darkVibrantColor != null) {
extractedColors.add(palette.darkVibrantColor!.color);
}
if (palette.dominantColor != null) {
// Darken the dominant color for better appearance
final hsl = HSLColor.fromColor(palette.dominantColor!.color);
extractedColors.add(hsl.withLightness((hsl.lightness * 0.4).clamp(0.1, 0.3)).toColor());
}
} catch (e) {
_logger.log('📚 Error extracting colors: $e');
}
}
if (extractedColors.isNotEmpty && mounted) {
setState(() {
_extractedColors = extractedColors;
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -341,9 +451,22 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
);
}
// Dark pastel colors for empty cells - muted and book-like
static const _emptyColors = [
Color(0xFF2D3436), // Dark slate
Color(0xFF34495E), // Dark blue-grey
Color(0xFF4A3728), // Dark brown
Color(0xFF2C3E50), // Midnight blue
Color(0xFF3D3D3D), // Charcoal
Color(0xFF4A4458), // Dark purple-grey
Color(0xFF3E4A47), // Dark teal-grey
Color(0xFF4A3F35), // Dark warm grey
];
Widget _buildSeriesCover(ColorScheme colorScheme, MusicAssistantProvider maProvider) {
// If still loading or no books, show placeholder
if (_isLoading || _audiobooks.isEmpty) {
// Use _coverUrls which may have initial covers or loaded covers
// Only show placeholder if we truly have no covers
if (_coverUrls.isEmpty) {
return Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
@@ -356,32 +479,23 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
);
}
// Get cover URLs from loaded books
final covers = <String>[];
for (final book in _audiobooks.take(9)) {
final imageUrl = maProvider.getImageUrl(book);
if (imageUrl != null) {
covers.add(imageUrl);
}
final covers = _coverUrls;
// Dynamic grid size based on number of covers (matches library screen)
// 1 cover = 1x1, 2-4 covers = 2x2, 5+ covers = 3x3
int gridSize;
if (covers.length == 1) {
gridSize = 1;
} else if (covers.length <= 4) {
gridSize = 2;
} else {
gridSize = 3;
}
if (covers.isEmpty) {
return Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.collections_bookmark_rounded,
size: 64,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
);
}
// Use series ID to pick consistent colors and nested grid patterns
final colorSeed = widget.series.id.hashCode;
// Always use 3x3 grid for series detail
const int gridSize = 3;
// Pad covers to fill grid, using empty strings for missing slots
// Pad covers to fill grid
final displayCovers = List<String?>.filled(gridSize * gridSize, null);
for (var i = 0; i < covers.length && i < displayCovers.length; i++) {
displayCovers[i] = covers[i];
@@ -397,12 +511,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
final coverUrl = displayCovers[index];
return Expanded(
child: Container(
margin: EdgeInsets.only(
right: col < gridSize - 1 ? 1 : 0,
bottom: row < gridSize - 1 ? 1 : 0,
),
child: coverUrl != null
child: coverUrl != null
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
@@ -418,10 +527,62 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
),
),
: _buildEmptyCell(colorSeed, index),
);
}),
),
);
}),
);
}
/// Builds an empty cell with either a solid color or a nested grid
/// The pattern is deterministic based on series ID and cell index
Widget _buildEmptyCell(int colorSeed, int cellIndex) {
// Use extracted colors if available, otherwise fall back to static palette
final baseColors = _extractedColors.isNotEmpty ? _extractedColors : _emptyColors;
// Tone down colors - reduce saturation and darken
final colors = baseColors.map((c) {
final hsl = HSLColor.fromColor(c);
return hsl
.withSaturation((hsl.saturation * 0.5).clamp(0.05, 0.25))
.withLightness((hsl.lightness * 0.7).clamp(0.08, 0.20))
.toColor();
}).toList();
// Use combined seed for deterministic but varied patterns
final seed = colorSeed + cellIndex * 17; // Prime multiplier for better distribution
// Determine nested grid size: 1 (solid), 2 (2x2), or 3 (3x3)
// Distribution: ~50% solid, ~30% 2x2, ~20% 3x3
final sizeRoll = seed.abs() % 100;
int nestedSize;
if (sizeRoll < 50) {
nestedSize = 1; // Solid color
} else if (sizeRoll < 80) {
nestedSize = 2; // 2x2 grid
} else {
nestedSize = 3; // 3x3 grid
}
if (nestedSize == 1) {
// Solid color
final colorIndex = seed.abs() % colors.length;
return Container(color: colors[colorIndex]);
}
// Build nested grid (no margins - seamless)
return Column(
children: List.generate(nestedSize, (row) {
return Expanded(
child: Row(
children: List.generate(nestedSize, (col) {
final nestedIndex = row * nestedSize + col;
// Use different seed for each nested cell
final nestedSeed = seed + nestedIndex * 7;
final colorIndex = nestedSeed.abs() % colors.length;
return Expanded(
child: Container(color: colors[colorIndex]),
);
}),
),
+83 -96
View File
@@ -38,8 +38,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
bool _showDiscoverSeries = false;
// Random order for favorites (generated once per session)
late List<int> _favoritesOrder;
// Row order (loaded from settings)
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
@override
bool get wantKeepAlive => true;
@@ -48,8 +48,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Generate random order for favorites rows (0, 1, 2 shuffled)
_favoritesOrder = [0, 1, 2]..shuffle();
_loadSettings();
}
@@ -77,6 +75,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
final rowOrder = await SettingsService.getHomeRowOrder();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -88,6 +87,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
});
}
}
@@ -242,9 +242,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
final rowHeight = availableHeight / 3;
// Build favorites rows in random order
final favoritesWidgets = _buildFavoritesRows(provider, rowHeight);
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
@@ -269,64 +266,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
child: Column(
key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Recently played albums (optional)
if (_showRecentAlbums)
AlbumRow(
key: const ValueKey('recent-albums'),
title: 'Recently Played',
loadAlbums: () => provider.getRecentAlbumsWithCache(),
rowHeight: rowHeight,
),
// Discover Artists (optional)
if (_showDiscoverArtists)
ArtistRow(
key: const ValueKey('discover-artists'),
title: 'Discover Artists',
loadArtists: () => provider.getDiscoverArtistsWithCache(),
rowHeight: rowHeight,
),
// Discover Albums (optional)
if (_showDiscoverAlbums)
AlbumRow(
key: const ValueKey('discover-albums'),
title: 'Discover Albums',
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
rowHeight: rowHeight,
),
// Continue Listening Audiobooks (optional)
if (_showContinueListeningAudiobooks)
AudiobookRow(
key: const ValueKey('continue-listening-audiobooks'),
title: 'Continue Listening',
loadAudiobooks: () => provider.getInProgressAudiobooks(),
rowHeight: rowHeight,
),
// Discover Audiobooks (optional)
if (_showDiscoverAudiobooks)
AudiobookRow(
key: const ValueKey('discover-audiobooks'),
title: 'Discover Audiobooks',
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
rowHeight: rowHeight,
),
// Discover Series (optional)
if (_showDiscoverSeries)
SeriesRow(
key: const ValueKey('discover-series'),
title: 'Discover Series',
loadSeries: () => provider.getDiscoverSeries(),
rowHeight: rowHeight,
),
// Favorites rows in random order
...favoritesWidgets,
],
children: _buildOrderedRows(provider, rowHeight),
),
),
),
@@ -335,51 +275,98 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
);
}
/// Build favorites rows in random order (order set once per session)
List<Widget> _buildFavoritesRows(MusicAssistantProvider provider, double rowHeight) {
// Create list of enabled favorites with their order index
final enabledFavorites = <MapEntry<int, Widget>>[];
/// Build rows in the user's configured order
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight) {
final rows = <Widget>[];
if (_showFavoriteAlbums) {
enabledFavorites.add(MapEntry(
_favoritesOrder[0],
AlbumRow(
for (final rowId in _homeRowOrder) {
final widget = _buildRowWidget(rowId, provider, rowHeight);
if (widget != null) {
rows.add(widget);
}
}
return rows;
}
/// Build a single row widget by ID, returns null if row is disabled
Widget? _buildRowWidget(String rowId, MusicAssistantProvider provider, double rowHeight) {
switch (rowId) {
case 'recent-albums':
if (!_showRecentAlbums) return null;
return AlbumRow(
key: const ValueKey('recent-albums'),
title: 'Recently Played',
loadAlbums: () => provider.getRecentAlbumsWithCache(),
rowHeight: rowHeight,
);
case 'discover-artists':
if (!_showDiscoverArtists) return null;
return ArtistRow(
key: const ValueKey('discover-artists'),
title: 'Discover Artists',
loadArtists: () => provider.getDiscoverArtistsWithCache(),
rowHeight: rowHeight,
);
case 'discover-albums':
if (!_showDiscoverAlbums) return null;
return AlbumRow(
key: const ValueKey('discover-albums'),
title: 'Discover Albums',
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
rowHeight: rowHeight,
);
case 'continue-listening':
if (!_showContinueListeningAudiobooks) return null;
return AudiobookRow(
key: const ValueKey('continue-listening'),
title: 'Continue Listening',
loadAudiobooks: () => provider.getInProgressAudiobooks(),
rowHeight: rowHeight,
);
case 'discover-audiobooks':
if (!_showDiscoverAudiobooks) return null;
return AudiobookRow(
key: const ValueKey('discover-audiobooks'),
title: 'Discover Audiobooks',
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
rowHeight: rowHeight,
);
case 'discover-series':
if (!_showDiscoverSeries) return null;
return SeriesRow(
key: const ValueKey('discover-series'),
title: 'Discover Series',
loadSeries: () => provider.getDiscoverSeries(),
rowHeight: rowHeight,
);
case 'favorite-albums':
if (!_showFavoriteAlbums) return null;
return AlbumRow(
key: const ValueKey('favorite-albums'),
title: 'Favorite Albums',
loadAlbums: () => provider.getFavoriteAlbums(),
rowHeight: rowHeight,
),
));
}
if (_showFavoriteArtists) {
enabledFavorites.add(MapEntry(
_favoritesOrder[1],
ArtistRow(
);
case 'favorite-artists':
if (!_showFavoriteArtists) return null;
return ArtistRow(
key: const ValueKey('favorite-artists'),
title: 'Favorite Artists',
loadArtists: () => provider.getFavoriteArtists(),
rowHeight: rowHeight,
),
));
}
if (_showFavoriteTracks) {
enabledFavorites.add(MapEntry(
_favoritesOrder[2],
TrackRow(
);
case 'favorite-tracks':
if (!_showFavoriteTracks) return null;
return TrackRow(
key: const ValueKey('favorite-tracks'),
title: 'Favorite Tracks',
loadTracks: () => provider.getFavoriteTracks(),
rowHeight: rowHeight,
),
));
);
default:
return null;
}
// Sort by the random order index
enabledFavorites.sort((a, b) => a.key.compareTo(b.key));
return enabledFavorites.map((e) => e.value).toList();
}
}
+166 -44
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.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:palette_generator/palette_generator.dart';
import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../widgets/global_player_overlay.dart';
@@ -68,6 +69,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Series book covers cache: seriesId -> list of book thumbnail URLs
final Map<String, List<String>> _seriesBookCovers = {};
final Set<String> _seriesCoversLoading = {};
// Series extracted colors cache: seriesId -> list of colors from book covers
final Map<String, List<Color>> _seriesExtractedColors = {};
// Series book counts cache: seriesId -> number of books
final Map<String, int> _seriesBookCounts = {};
bool _seriesLoaded = false;
// Restoration: Remember selected tab across app restarts
@@ -577,8 +582,12 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (mounted) {
setState(() {
_seriesBookCovers[seriesId] = covers;
_seriesBookCounts[seriesId] = books.length;
_seriesCoversLoading.remove(seriesId);
});
// Extract colors from covers asynchronously (don't block UI)
_extractSeriesColors(seriesId, covers);
}
}
} catch (e) {
@@ -587,6 +596,47 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
}
/// Extract dominant colors from series book covers for empty cell backgrounds
Future<void> _extractSeriesColors(String seriesId, List<String> coverUrls) async {
if (coverUrls.isEmpty) return;
final extractedColors = <Color>[];
// Extract colors from first few covers (limit to avoid too much processing)
for (final url in coverUrls.take(4)) {
try {
final palette = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(url),
maximumColorCount: 8,
);
// Get dark muted colors for grid squares (matches the aesthetic)
if (palette.darkMutedColor != null) {
extractedColors.add(palette.darkMutedColor!.color);
}
if (palette.mutedColor != null) {
extractedColors.add(palette.mutedColor!.color);
}
if (palette.darkVibrantColor != null) {
extractedColors.add(palette.darkVibrantColor!.color);
}
if (palette.dominantColor != null) {
// Darken the dominant color for better appearance
final hsl = HSLColor.fromColor(palette.dominantColor!.color);
extractedColors.add(hsl.withLightness((hsl.lightness * 0.4).clamp(0.1, 0.3)).toColor());
}
} catch (e) {
_logger.log('📚 Error extracting colors from $url: $e');
}
}
if (extractedColors.isNotEmpty && mounted) {
setState(() {
_seriesExtractedColors[seriesId] = extractedColors;
});
}
}
void _toggleFavoritesMode(bool value) {
setState(() {
_showFavoritesOnly = value;
@@ -1481,32 +1531,36 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final covers = _seriesBookCovers[series.id];
final firstCover = covers != null && covers.isNotEmpty ? covers.first : null;
final heroTag = 'series_cover_${series.id}';
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: firstCover != null
? CachedNetworkImage(
imageUrl: firstCover,
fit: BoxFit.cover,
placeholder: (_, __) => Icon(
leading: Hero(
tag: heroTag,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: firstCover != null
? CachedNetworkImage(
imageUrl: firstCover,
fit: BoxFit.cover,
placeholder: (_, __) => Icon(
Icons.collections_bookmark_rounded,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
Icons.collections_bookmark_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.collections_bookmark_rounded,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
Icons.collections_bookmark_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.collections_bookmark_rounded,
color: colorScheme.onSurfaceVariant,
),
),
),
),
title: Text(
@@ -1517,14 +1571,18 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: series.bookCount != null
? Text(
'${series.bookCount} ${series.bookCount == 1 ? 'book' : 'books'}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
)
: null,
subtitle: Builder(
builder: (context) {
final count = series.bookCount ?? _seriesBookCounts[series.id];
if (count == null) return const SizedBox.shrink();
return Text(
'$count ${count == 1 ? 'book' : 'books'}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
);
},
),
onTap: () {
_logger.log('📚 Tapped series: ${series.name}, path: ${series.id}');
Navigator.push(
@@ -1532,6 +1590,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
MaterialPageRoute(
builder: (context) => AudiobookSeriesScreen(
series: series,
heroTag: heroTag,
initialCovers: covers,
),
),
);
@@ -1553,6 +1613,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Matches books tab style - square artwork with text below
final heroTag = 'series_cover_${series.id}';
final cachedCovers = _seriesBookCovers[series.id];
return GestureDetector(
onTap: () {
_logger.log('📚 Tapped series: ${series.name}, path: ${series.id}');
@@ -1562,6 +1623,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
builder: (context) => AudiobookSeriesScreen(
series: series,
heroTag: heroTag,
initialCovers: cachedCovers,
),
),
);
@@ -1592,15 +1654,21 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (series.bookCount != null) ...[
const SizedBox(height: 2),
Text(
'${series.bookCount} book${series.bookCount == 1 ? '' : 's'}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
Builder(
builder: (context) {
final count = series.bookCount ?? _seriesBookCounts[series.id];
if (count == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'$count ${count == 1 ? 'book' : 'books'}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
);
},
),
],
),
);
@@ -1626,8 +1694,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
gridSize = gridSize.clamp(1, maxGridSize);
final displayCovers = covers.take(gridSize * gridSize).toList();
// Dark pastel colors for empty cells - muted and book-like
const emptyColors = [
// Use extracted colors from book covers if available, otherwise fall back to static palette
final extractedColors = _seriesExtractedColors[series.id];
const fallbackColors = [
Color(0xFF2D3436), // Dark slate
Color(0xFF34495E), // Dark blue-grey
Color(0xFF4A3728), // Dark brown
@@ -1637,6 +1706,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
Color(0xFF3E4A47), // Dark teal-grey
Color(0xFF4A3F35), // Dark warm grey
];
final emptyColors = (extractedColors != null && extractedColors.isNotEmpty)
? extractedColors
: fallbackColors;
// Use series ID to pick consistent colors for this series
final colorSeed = series.id.hashCode;
@@ -1650,12 +1722,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
children: List.generate(gridSize, (col) {
final index = row * gridSize + col;
if (index >= displayCovers.length) {
// Empty cell - use dark pastel color
final colorIndex = (colorSeed + index) % emptyColors.length;
// Empty cell - use nested grid pattern
return Expanded(
child: Container(
color: emptyColors[colorIndex],
),
child: _buildEmptyCell(colorSeed, index, emptyColors),
);
}
return Expanded(
@@ -1710,6 +1779,59 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
/// Builds an empty cell with either a solid color or a nested grid
/// The pattern is deterministic based on series ID and cell index
Widget _buildEmptyCell(int colorSeed, int cellIndex, List<Color> emptyColors) {
// Tone down colors - reduce saturation and darken
final colors = emptyColors.map((c) {
final hsl = HSLColor.fromColor(c);
return hsl
.withSaturation((hsl.saturation * 0.5).clamp(0.05, 0.25))
.withLightness((hsl.lightness * 0.7).clamp(0.08, 0.20))
.toColor();
}).toList();
// Use combined seed for deterministic but varied patterns
final seed = colorSeed + cellIndex * 17; // Prime multiplier for better distribution
// Determine nested grid size: 1 (solid), 2 (2x2), or 3 (3x3)
// Distribution: ~50% solid, ~30% 2x2, ~20% 3x3
final sizeRoll = seed.abs() % 100;
int nestedSize;
if (sizeRoll < 50) {
nestedSize = 1; // Solid color
} else if (sizeRoll < 80) {
nestedSize = 2; // 2x2 grid
} else {
nestedSize = 3; // 3x3 grid
}
if (nestedSize == 1) {
// Solid color
final colorIndex = seed.abs() % colors.length;
return Container(color: colors[colorIndex]);
}
// Build nested grid (no margins - seamless)
return Column(
children: List.generate(nestedSize, (row) {
return Expanded(
child: Row(
children: List.generate(nestedSize, (col) {
final nestedIndex = row * nestedSize + col;
// Use different seed for each nested cell
final nestedSeed = seed + nestedIndex * 7;
final colorIndex = nestedSeed.abs() % colors.length;
return Expanded(
child: Container(color: colors[colorIndex]),
);
}),
),
);
}),
);
}
// ============ PODCASTS TAB (Placeholder) ============
Widget _buildPodcastsComingSoonTab(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
+158 -207
View File
@@ -30,6 +30,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
bool _showDiscoverSeries = false;
// Home row order
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Audiobook libraries
List<Map<String, String>> _discoveredLibraries = [];
Map<String, bool> _libraryEnabled = {};
@@ -64,6 +66,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
// Load home row order
final rowOrder = await SettingsService.getHomeRowOrder();
// Load audiobook library settings
final discovered = await SettingsService.getDiscoveredAbsLibraries() ?? [];
final enabled = await SettingsService.getEnabledAbsLibraries();
@@ -85,6 +90,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showContinueListeningAudiobooks = showContinueAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_discoveredLibraries = discovered;
_libraryEnabled = libraryEnabled;
});
@@ -98,6 +104,102 @@ class _SettingsScreenState extends State<SettingsScreen> {
super.dispose();
}
// Helper to get row display info
Map<String, String> _getRowInfo(String rowId) {
switch (rowId) {
case 'recent-albums':
return {'title': 'Recently Played', 'subtitle': 'Show recently played albums'};
case 'discover-artists':
return {'title': 'Discover Artists', 'subtitle': 'Show random artists to discover'};
case 'discover-albums':
return {'title': 'Discover Albums', 'subtitle': 'Show random albums to discover'};
case 'continue-listening':
return {'title': 'Continue Listening', 'subtitle': 'Show audiobooks in progress'};
case 'discover-audiobooks':
return {'title': 'Discover Audiobooks', 'subtitle': 'Show random audiobooks to discover'};
case 'discover-series':
return {'title': 'Discover Series', 'subtitle': 'Show random audiobook series to discover'};
case 'favorite-albums':
return {'title': 'Favorite Albums', 'subtitle': 'Show a row of your favorite albums'};
case 'favorite-artists':
return {'title': 'Favorite Artists', 'subtitle': 'Show a row of your favorite artists'};
case 'favorite-tracks':
return {'title': 'Favorite Tracks', 'subtitle': 'Show a row of your favorite tracks'};
default:
return {'title': rowId, 'subtitle': ''};
}
}
// Helper to get row enabled state
bool _getRowEnabled(String rowId) {
switch (rowId) {
case 'recent-albums':
return _showRecentAlbums;
case 'discover-artists':
return _showDiscoverArtists;
case 'discover-albums':
return _showDiscoverAlbums;
case 'continue-listening':
return _showContinueListeningAudiobooks;
case 'discover-audiobooks':
return _showDiscoverAudiobooks;
case 'discover-series':
return _showDiscoverSeries;
case 'favorite-albums':
return _showFavoriteAlbums;
case 'favorite-artists':
return _showFavoriteArtists;
case 'favorite-tracks':
return _showFavoriteTracks;
default:
return false;
}
}
// Helper to set row enabled state
void _setRowEnabled(String rowId, bool value) {
setState(() {
switch (rowId) {
case 'recent-albums':
_showRecentAlbums = value;
SettingsService.setShowRecentAlbums(value);
break;
case 'discover-artists':
_showDiscoverArtists = value;
SettingsService.setShowDiscoverArtists(value);
break;
case 'discover-albums':
_showDiscoverAlbums = value;
SettingsService.setShowDiscoverAlbums(value);
break;
case 'continue-listening':
_showContinueListeningAudiobooks = value;
SettingsService.setShowContinueListeningAudiobooks(value);
break;
case 'discover-audiobooks':
_showDiscoverAudiobooks = value;
SettingsService.setShowDiscoverAudiobooks(value);
break;
case 'discover-series':
_showDiscoverSeries = value;
SettingsService.setShowDiscoverSeries(value);
break;
case 'favorite-albums':
_showFavoriteAlbums = value;
SettingsService.setShowFavoriteAlbums(value);
break;
case 'favorite-artists':
_showFavoriteArtists = value;
SettingsService.setShowFavoriteArtists(value);
break;
case 'favorite-tracks':
_showFavoriteTracks = value;
SettingsService.setShowFavoriteTracks(value);
break;
}
});
}
Future<void> _disconnect() async {
final provider = context.read<MusicAssistantProvider>();
await provider.disconnect();
@@ -379,221 +481,70 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 16),
// Main rows section
// Reorderable home rows list
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
SwitchListTile(
title: Text(
'Recently Played',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show recently played albums',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showRecentAlbums,
onChanged: (value) {
setState(() => _showRecentAlbums = value);
SettingsService.setShowRecentAlbums(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Discover Artists',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show random artists to discover',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showDiscoverArtists,
onChanged: (value) {
setState(() => _showDiscoverArtists = value);
SettingsService.setShowDiscoverArtists(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Discover Albums',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show random albums to discover',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showDiscoverAlbums,
onChanged: (value) {
setState(() => _showDiscoverAlbums = value);
SettingsService.setShowDiscoverAlbums(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
],
),
),
clipBehavior: Clip.antiAlias,
child: ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _homeRowOrder.length,
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = _homeRowOrder.removeAt(oldIndex);
_homeRowOrder.insert(newIndex, item);
});
SettingsService.setHomeRowOrder(_homeRowOrder);
},
itemBuilder: (context, index) {
final rowId = _homeRowOrder[index];
final rowInfo = _getRowInfo(rowId);
final isEnabled = _getRowEnabled(rowId);
const SizedBox(height: 16),
Text(
'Favorites',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 8),
// Favorites rows section
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
SwitchListTile(
title: Text(
'Favorite Albums',
style: TextStyle(color: colorScheme.onSurface),
return Container(
key: ValueKey(rowId),
decoration: BoxDecoration(
border: index < _homeRowOrder.length - 1
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
: null,
),
subtitle: Text(
'Show a row of your favorite albums',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
child: Row(
children: [
ReorderableDragStartListener(
index: index,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
child: Icon(
Icons.drag_handle,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
Expanded(
child: SwitchListTile(
title: Text(
rowInfo['title']!,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
rowInfo['subtitle']!,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: isEnabled,
onChanged: (value) => _setRowEnabled(rowId, value),
activeColor: colorScheme.primary,
contentPadding: const EdgeInsets.only(right: 8),
),
),
],
),
value: _showFavoriteAlbums,
onChanged: (value) {
setState(() => _showFavoriteAlbums = value);
SettingsService.setShowFavoriteAlbums(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Favorite Artists',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show a row of your favorite artists',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showFavoriteArtists,
onChanged: (value) {
setState(() => _showFavoriteArtists = value);
SettingsService.setShowFavoriteArtists(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Favorite Tracks',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show a row of your favorite tracks',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showFavoriteTracks,
onChanged: (value) {
setState(() => _showFavoriteTracks = value);
SettingsService.setShowFavoriteTracks(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
],
),
),
const SizedBox(height: 16),
Text(
'Audiobooks',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 8),
// Audiobook home rows section
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
SwitchListTile(
title: Text(
'Continue Listening',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show audiobooks in progress',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showContinueListeningAudiobooks,
onChanged: (value) {
setState(() => _showContinueListeningAudiobooks = value);
SettingsService.setShowContinueListeningAudiobooks(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Discover Audiobooks',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show random audiobooks to discover',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showDiscoverAudiobooks,
onChanged: (value) {
setState(() => _showDiscoverAudiobooks = value);
SettingsService.setShowDiscoverAudiobooks(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Discover Series',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show random audiobook series to discover',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showDiscoverSeries,
onChanged: (value) {
setState(() => _showDiscoverSeries = value);
SettingsService.setShowDiscoverSeries(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
],
);
},
),
),
+34
View File
@@ -30,6 +30,20 @@ class SettingsService {
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
// Default row order
static const List<String> defaultHomeRowOrder = [
'recent-albums',
'discover-artists',
'discover-albums',
'continue-listening',
'discover-audiobooks',
'discover-series',
'favorite-albums',
'favorite-artists',
'favorite-tracks',
];
// View Mode Settings
static const String _keyArtistAlbumsSortOrder = 'artist_albums_sort_order'; // 'alpha' or 'year'
@@ -442,6 +456,26 @@ class SettingsService {
await prefs.setBool(_keyShowFavoriteTracks, show);
}
// Home Row Order
static Future<List<String>> getHomeRowOrder() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_keyHomeRowOrder);
if (json != null) {
try {
final List<dynamic> decoded = jsonDecode(json);
return decoded.cast<String>();
} catch (_) {
return List.from(defaultHomeRowOrder);
}
}
return List.from(defaultHomeRowOrder);
}
static Future<void> setHomeRowOrder(List<String> order) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyHomeRowOrder, jsonEncode(order));
}
// View Mode Settings - Artist Albums
static Future<String> getArtistAlbumsSortOrder() async {
final prefs = await SharedPreferences.getInstance();
+181 -38
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -30,6 +31,10 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
// Cache for series cover images
final Map<String, List<String>> _seriesCovers = {};
final Set<String> _loadingCovers = {};
// Cache for series book counts
final Map<String, int> _seriesBookCounts = {};
// Cache for extracted colors from covers
final Map<String, List<Color>> _seriesExtractedColors = {};
@override
bool get wantKeepAlive => true;
@@ -70,7 +75,10 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
if (mounted) {
setState(() {
_seriesCovers[seriesId] = covers;
_seriesBookCounts[seriesId] = books.length;
});
// Extract colors asynchronously
_extractSeriesColors(seriesId, covers);
}
}
} finally {
@@ -78,6 +86,44 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
}
}
/// Extract colors from series book covers for empty cell backgrounds
Future<void> _extractSeriesColors(String seriesId, List<String> covers) async {
if (_seriesExtractedColors.containsKey(seriesId) || covers.isEmpty) return;
final extractedColors = <Color>[];
for (final url in covers.take(4)) {
try {
final palette = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(url),
maximumColorCount: 8,
);
if (palette.darkMutedColor != null) {
extractedColors.add(palette.darkMutedColor!.color);
}
if (palette.mutedColor != null) {
extractedColors.add(palette.mutedColor!.color);
}
if (palette.darkVibrantColor != null) {
extractedColors.add(palette.darkVibrantColor!.color);
}
if (palette.dominantColor != null) {
final hsl = HSLColor.fromColor(palette.dominantColor!.color);
extractedColors.add(hsl.withLightness((hsl.lightness * 0.4).clamp(0.1, 0.3)).toColor());
}
} catch (e) {
_logger.log('📚 Error extracting colors: $e');
}
}
if (extractedColors.isNotEmpty && mounted) {
setState(() {
_seriesExtractedColors[seriesId] = extractedColors;
});
}
}
static final _logger = DebugLogger();
@override
@@ -165,6 +211,8 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
child: _SeriesCard(
series: s,
covers: _seriesCovers[s.id],
cachedBookCount: _seriesBookCounts[s.id],
extractedColors: _seriesExtractedColors[s.id],
colorScheme: colorScheme,
textTheme: textTheme,
),
@@ -187,37 +235,59 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
class _SeriesCard extends StatelessWidget {
final AudiobookSeries series;
final List<String>? covers;
final int? cachedBookCount;
final List<Color>? extractedColors;
final ColorScheme colorScheme;
final TextTheme textTheme;
// Fallback colors when no extracted colors available
static const _fallbackColors = [
Color(0xFF2D3436), // Dark slate
Color(0xFF34495E), // Dark blue-grey
Color(0xFF4A3728), // Dark brown
Color(0xFF2C3E50), // Midnight blue
Color(0xFF3D3D3D), // Charcoal
Color(0xFF4A4458), // Dark purple-grey
Color(0xFF3E4A47), // Dark teal-grey
Color(0xFF4A3F35), // Dark warm grey
];
const _SeriesCard({
required this.series,
required this.covers,
this.cachedBookCount,
this.extractedColors,
required this.colorScheme,
required this.textTheme,
});
@override
Widget build(BuildContext context) {
final heroTag = 'series_cover_home_${series.id}';
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AudiobookSeriesScreen(series: series),
builder: (context) => AudiobookSeriesScreen(
series: series,
heroTag: heroTag,
initialCovers: covers,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: double.infinity,
height: double.infinity,
color: colorScheme.surfaceVariant,
// Use AspectRatio to guarantee square cover
Hero(
tag: heroTag,
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildCoverGrid(),
),
),
@@ -231,15 +301,20 @@ class _SeriesCard extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (series.bookCount != null)
Text(
'${series.bookCount} ${series.bookCount == 1 ? 'book' : 'books'}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Builder(
builder: (context) {
final count = series.bookCount ?? cachedBookCount;
if (count == null) return const SizedBox.shrink();
return Text(
'$count ${count == 1 ? 'book' : 'books'}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),
],
),
);
@@ -247,7 +322,7 @@ class _SeriesCard extends StatelessWidget {
Widget _buildCoverGrid() {
if (covers == null || covers!.isEmpty) {
// Static placeholder - no animation
// Static placeholder
return Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
@@ -270,29 +345,97 @@ class _SeriesCard extends StatelessWidget {
gridSize = 3;
}
final displayCovers = covers!.take(gridSize * gridSize).toList();
// Pad covers to fill grid
final displayCovers = List<String?>.filled(gridSize * gridSize, null);
for (var i = 0; i < covers!.length && i < displayCovers.length; i++) {
displayCovers[i] = covers![i];
}
return GridView.builder(
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: gridSize,
childAspectRatio: 1,
crossAxisSpacing: 1,
mainAxisSpacing: 1,
),
itemCount: displayCovers.length,
itemBuilder: (context, index) {
return CachedNetworkImage(
imageUrl: displayCovers[index],
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => Container(
color: colorScheme.surfaceContainerHighest,
// Use series ID to pick consistent colors
final colorSeed = series.id.hashCode;
// Get colors for empty cells
final emptyColors = (extractedColors != null && extractedColors!.isNotEmpty)
? extractedColors!
: _fallbackColors;
// Build grid using Column/Row (parent AspectRatio ensures square)
return Column(
children: List.generate(gridSize, (row) {
return Expanded(
child: Row(
children: List.generate(gridSize, (col) {
final index = row * gridSize + col;
final coverUrl = displayCovers[index];
return Expanded(
child: coverUrl != null
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => Container(
color: colorScheme.surfaceContainerHighest,
),
)
: _buildEmptyCell(colorSeed, index, emptyColors),
);
}),
),
);
},
}),
);
}
/// Builds an empty cell with either a solid color or a nested grid
Widget _buildEmptyCell(int colorSeed, int cellIndex, List<Color> emptyColors) {
// Tone down colors
final colors = emptyColors.map((c) {
final hsl = HSLColor.fromColor(c);
return hsl
.withSaturation((hsl.saturation * 0.5).clamp(0.05, 0.25))
.withLightness((hsl.lightness * 0.7).clamp(0.08, 0.20))
.toColor();
}).toList();
// Use combined seed for deterministic but varied patterns
final seed = colorSeed + cellIndex * 17;
// Determine nested grid size: 1 (solid), 2 (2x2), or 3 (3x3)
// Distribution: ~50% solid, ~30% 2x2, ~20% 3x3
final sizeRoll = seed.abs() % 100;
int nestedSize;
if (sizeRoll < 50) {
nestedSize = 1;
} else if (sizeRoll < 80) {
nestedSize = 2;
} else {
nestedSize = 3;
}
if (nestedSize == 1) {
final colorIndex = seed.abs() % colors.length;
return Container(color: colors[colorIndex]);
}
// Build nested grid (seamless)
return Column(
children: List.generate(nestedSize, (row) {
return Expanded(
child: Row(
children: List.generate(nestedSize, (col) {
final nestedIndex = row * nestedSize + col;
final nestedSeed = seed + nestedIndex * 7;
final colorIndex = nestedSeed.abs() % colors.length;
return Expanded(
child: Container(color: colors[colorIndex]),
);
}),
),
);
}),
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.1.2-beta+18
version: 2.2.0-beta+19
environment:
sdk: '>=3.0.0 <4.0.0'