Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
290f302da6 | ||
|
|
c359e5551e | ||
|
|
abf7e11a23 | ||
|
|
d312f37fdb | ||
|
|
9614f8e4d4 | ||
|
|
2a01eb2344 | ||
|
|
aeb3428115 | ||
|
|
cc948c3c06 | ||
|
|
c1fd25f359 | ||
|
|
115b2729d8 | ||
|
|
3e48066030 | ||
|
|
7ed02232e6 | ||
|
|
de72736146 |
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.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 '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../widgets/global_player_overlay.dart' show BottomSpacing;
|
import '../widgets/global_player_overlay.dart' show BottomSpacing;
|
||||||
@@ -14,11 +15,14 @@ import 'audiobook_detail_screen.dart';
|
|||||||
class AudiobookSeriesScreen extends StatefulWidget {
|
class AudiobookSeriesScreen extends StatefulWidget {
|
||||||
final AudiobookSeries series;
|
final AudiobookSeries series;
|
||||||
final String? heroTag;
|
final String? heroTag;
|
||||||
|
/// Pre-loaded cover URLs from the library screen for smooth Hero animation
|
||||||
|
final List<String>? initialCovers;
|
||||||
|
|
||||||
const AudiobookSeriesScreen({
|
const AudiobookSeriesScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.series,
|
required this.series,
|
||||||
this.heroTag,
|
this.heroTag,
|
||||||
|
this.initialCovers,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -35,9 +39,21 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
String _sortOrder = 'series'; // 'series' (by series number) or 'alpha'
|
String _sortOrder = 'series'; // 'series' (by series number) or 'alpha'
|
||||||
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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();
|
_loadViewPreferences();
|
||||||
// Defer loading until after first frame to ensure UI renders first
|
// Defer loading until after first frame to ensure UI renders first
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
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 {
|
Future<void> _loadViewPreferences() async {
|
||||||
final sortOrder = await SettingsService.getSeriesAudiobooksSortOrder();
|
final sortOrder = await SettingsService.getSeriesAudiobooksSortOrder();
|
||||||
final viewMode = await SettingsService.getSeriesAudiobooksViewMode();
|
final viewMode = await SettingsService.getSeriesAudiobooksViewMode();
|
||||||
@@ -132,9 +184,22 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
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(() {
|
setState(() {
|
||||||
_audiobooks = books;
|
_audiobooks = books;
|
||||||
_sortAudiobooks(); // Sort inside setState so UI updates
|
_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;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
// Debug: Log sorted order
|
// Debug: Log sorted order
|
||||||
@@ -142,6 +207,9 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
for (final book in _audiobooks) {
|
for (final book in _audiobooks) {
|
||||||
_logger.log(' - ${book.seriesSequence ?? "null"}: ${book.name}');
|
_logger.log(' - ${book.seriesSequence ?? "null"}: ${book.name}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract colors from book covers asynchronously
|
||||||
|
_extractCoverColors(maProvider);
|
||||||
}
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
_logger.log('📚 SeriesScreen error: $e');
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
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) {
|
Widget _buildSeriesCover(ColorScheme colorScheme, MusicAssistantProvider maProvider) {
|
||||||
// If still loading or no books, show placeholder
|
// Use _coverUrls which may have initial covers or loaded covers
|
||||||
if (_isLoading || _audiobooks.isEmpty) {
|
// Only show placeholder if we truly have no covers
|
||||||
|
if (_coverUrls.isEmpty) {
|
||||||
return Container(
|
return Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -356,32 +479,23 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get cover URLs from loaded books
|
final covers = _coverUrls;
|
||||||
final covers = <String>[];
|
|
||||||
for (final book in _audiobooks.take(9)) {
|
// Dynamic grid size based on number of covers (matches library screen)
|
||||||
final imageUrl = maProvider.getImageUrl(book);
|
// 1 cover = 1x1, 2-4 covers = 2x2, 5+ covers = 3x3
|
||||||
if (imageUrl != null) {
|
int gridSize;
|
||||||
covers.add(imageUrl);
|
if (covers.length == 1) {
|
||||||
}
|
gridSize = 1;
|
||||||
|
} else if (covers.length <= 4) {
|
||||||
|
gridSize = 2;
|
||||||
|
} else {
|
||||||
|
gridSize = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (covers.isEmpty) {
|
// Use series ID to pick consistent colors and nested grid patterns
|
||||||
return Container(
|
final colorSeed = widget.series.id.hashCode;
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
child: Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.collections_bookmark_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use 3x3 grid for series detail
|
// Pad covers to fill grid
|
||||||
const int gridSize = 3;
|
|
||||||
|
|
||||||
// Pad covers to fill grid, using empty strings for missing slots
|
|
||||||
final displayCovers = List<String?>.filled(gridSize * gridSize, null);
|
final displayCovers = List<String?>.filled(gridSize * gridSize, null);
|
||||||
for (var i = 0; i < covers.length && i < displayCovers.length; i++) {
|
for (var i = 0; i < covers.length && i < displayCovers.length; i++) {
|
||||||
displayCovers[i] = covers[i];
|
displayCovers[i] = covers[i];
|
||||||
@@ -397,12 +511,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
final coverUrl = displayCovers[index];
|
final coverUrl = displayCovers[index];
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Container(
|
child: coverUrl != null
|
||||||
margin: EdgeInsets.only(
|
|
||||||
right: col < gridSize - 1 ? 1 : 0,
|
|
||||||
bottom: row < gridSize - 1 ? 1 : 0,
|
|
||||||
),
|
|
||||||
child: coverUrl != null
|
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: coverUrl,
|
imageUrl: coverUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
@@ -418,10 +527,62 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Container(
|
: _buildEmptyCell(colorSeed, index),
|
||||||
color: colorScheme.surfaceContainerHighest,
|
);
|
||||||
),
|
}),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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]),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
bool _showContinueListeningAudiobooks = false;
|
bool _showContinueListeningAudiobooks = false;
|
||||||
bool _showDiscoverAudiobooks = false;
|
bool _showDiscoverAudiobooks = false;
|
||||||
bool _showDiscoverSeries = false;
|
bool _showDiscoverSeries = false;
|
||||||
// Random order for favorites (generated once per session)
|
// Row order (loaded from settings)
|
||||||
late List<int> _favoritesOrder;
|
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
@@ -48,8 +48,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
// Generate random order for favorites rows (0, 1, 2 shuffled)
|
|
||||||
_favoritesOrder = [0, 1, 2]..shuffle();
|
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +75,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||||
|
final rowOrder = await SettingsService.getHomeRowOrder();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_showRecentAlbums = showRecent;
|
_showRecentAlbums = showRecent;
|
||||||
@@ -88,6 +87,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
_showContinueListeningAudiobooks = showContAudiobooks;
|
_showContinueListeningAudiobooks = showContAudiobooks;
|
||||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||||
_showDiscoverSeries = showDiscSeries;
|
_showDiscoverSeries = showDiscSeries;
|
||||||
|
_homeRowOrder = rowOrder;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,9 +242,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
||||||
final rowHeight = availableHeight / 3;
|
final rowHeight = availableHeight / 3;
|
||||||
|
|
||||||
// Build favorites rows in random order
|
|
||||||
final favoritesWidgets = _buildFavoritesRows(provider, rowHeight);
|
|
||||||
|
|
||||||
// Use Android 12+ stretch overscroll effect
|
// Use Android 12+ stretch overscroll effect
|
||||||
return NotificationListener<ScrollNotification>(
|
return NotificationListener<ScrollNotification>(
|
||||||
onNotification: (notification) {
|
onNotification: (notification) {
|
||||||
@@ -269,64 +266,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
child: Column(
|
child: Column(
|
||||||
key: _refreshKey,
|
key: _refreshKey,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: _buildOrderedRows(provider, rowHeight),
|
||||||
// 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,
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -335,51 +275,98 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build favorites rows in random order (order set once per session)
|
/// Build rows in the user's configured order
|
||||||
List<Widget> _buildFavoritesRows(MusicAssistantProvider provider, double rowHeight) {
|
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight) {
|
||||||
// Create list of enabled favorites with their order index
|
final rows = <Widget>[];
|
||||||
final enabledFavorites = <MapEntry<int, Widget>>[];
|
|
||||||
|
|
||||||
if (_showFavoriteAlbums) {
|
for (final rowId in _homeRowOrder) {
|
||||||
enabledFavorites.add(MapEntry(
|
final widget = _buildRowWidget(rowId, provider, rowHeight);
|
||||||
_favoritesOrder[0],
|
if (widget != null) {
|
||||||
AlbumRow(
|
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'),
|
key: const ValueKey('favorite-albums'),
|
||||||
title: 'Favorite Albums',
|
title: 'Favorite Albums',
|
||||||
loadAlbums: () => provider.getFavoriteAlbums(),
|
loadAlbums: () => provider.getFavoriteAlbums(),
|
||||||
rowHeight: rowHeight,
|
rowHeight: rowHeight,
|
||||||
),
|
);
|
||||||
));
|
case 'favorite-artists':
|
||||||
}
|
if (!_showFavoriteArtists) return null;
|
||||||
|
return ArtistRow(
|
||||||
if (_showFavoriteArtists) {
|
|
||||||
enabledFavorites.add(MapEntry(
|
|
||||||
_favoritesOrder[1],
|
|
||||||
ArtistRow(
|
|
||||||
key: const ValueKey('favorite-artists'),
|
key: const ValueKey('favorite-artists'),
|
||||||
title: 'Favorite Artists',
|
title: 'Favorite Artists',
|
||||||
loadArtists: () => provider.getFavoriteArtists(),
|
loadArtists: () => provider.getFavoriteArtists(),
|
||||||
rowHeight: rowHeight,
|
rowHeight: rowHeight,
|
||||||
),
|
);
|
||||||
));
|
case 'favorite-tracks':
|
||||||
}
|
if (!_showFavoriteTracks) return null;
|
||||||
|
return TrackRow(
|
||||||
if (_showFavoriteTracks) {
|
|
||||||
enabledFavorites.add(MapEntry(
|
|
||||||
_favoritesOrder[2],
|
|
||||||
TrackRow(
|
|
||||||
key: const ValueKey('favorite-tracks'),
|
key: const ValueKey('favorite-tracks'),
|
||||||
title: 'Favorite Tracks',
|
title: 'Favorite Tracks',
|
||||||
loadTracks: () => provider.getFavoriteTracks(),
|
loadTracks: () => provider.getFavoriteTracks(),
|
||||||
rowHeight: rowHeight,
|
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.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 '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../widgets/global_player_overlay.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
|
// Series book covers cache: seriesId -> list of book thumbnail URLs
|
||||||
final Map<String, List<String>> _seriesBookCovers = {};
|
final Map<String, List<String>> _seriesBookCovers = {};
|
||||||
final Set<String> _seriesCoversLoading = {};
|
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;
|
bool _seriesLoaded = false;
|
||||||
|
|
||||||
// Restoration: Remember selected tab across app restarts
|
// Restoration: Remember selected tab across app restarts
|
||||||
@@ -577,8 +582,12 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_seriesBookCovers[seriesId] = covers;
|
_seriesBookCovers[seriesId] = covers;
|
||||||
|
_seriesBookCounts[seriesId] = books.length;
|
||||||
_seriesCoversLoading.remove(seriesId);
|
_seriesCoversLoading.remove(seriesId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Extract colors from covers asynchronously (don't block UI)
|
||||||
|
_extractSeriesColors(seriesId, covers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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) {
|
void _toggleFavoritesMode(bool value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_showFavoritesOnly = value;
|
_showFavoritesOnly = value;
|
||||||
@@ -1481,32 +1531,36 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
|
|
||||||
final covers = _seriesBookCovers[series.id];
|
final covers = _seriesBookCovers[series.id];
|
||||||
final firstCover = covers != null && covers.isNotEmpty ? covers.first : null;
|
final firstCover = covers != null && covers.isNotEmpty ? covers.first : null;
|
||||||
|
final heroTag = 'series_cover_${series.id}';
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
leading: ClipRRect(
|
leading: Hero(
|
||||||
borderRadius: BorderRadius.circular(4),
|
tag: heroTag,
|
||||||
child: Container(
|
child: ClipRRect(
|
||||||
width: 56,
|
borderRadius: BorderRadius.circular(4),
|
||||||
height: 56,
|
child: Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
width: 56,
|
||||||
child: firstCover != null
|
height: 56,
|
||||||
? CachedNetworkImage(
|
color: colorScheme.surfaceContainerHighest,
|
||||||
imageUrl: firstCover,
|
child: firstCover != null
|
||||||
fit: BoxFit.cover,
|
? CachedNetworkImage(
|
||||||
placeholder: (_, __) => Icon(
|
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,
|
Icons.collections_bookmark_rounded,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
errorWidget: (_, __, ___) => Icon(
|
),
|
||||||
Icons.collections_bookmark_rounded,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
Icons.collections_bookmark_rounded,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
@@ -1517,14 +1571,18 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: series.bookCount != null
|
subtitle: Builder(
|
||||||
? Text(
|
builder: (context) {
|
||||||
'${series.bookCount} ${series.bookCount == 1 ? 'book' : 'books'}',
|
final count = series.bookCount ?? _seriesBookCounts[series.id];
|
||||||
style: textTheme.bodySmall?.copyWith(
|
if (count == null) return const SizedBox.shrink();
|
||||||
color: colorScheme.onSurface.withOpacity(0.6),
|
return Text(
|
||||||
),
|
'$count ${count == 1 ? 'book' : 'books'}',
|
||||||
)
|
style: textTheme.bodySmall?.copyWith(
|
||||||
: null,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
_logger.log('📚 Tapped series: ${series.name}, path: ${series.id}');
|
_logger.log('📚 Tapped series: ${series.name}, path: ${series.id}');
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
@@ -1532,6 +1590,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => AudiobookSeriesScreen(
|
builder: (context) => AudiobookSeriesScreen(
|
||||||
series: series,
|
series: series,
|
||||||
|
heroTag: heroTag,
|
||||||
|
initialCovers: covers,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1553,6 +1613,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
|
|
||||||
// Matches books tab style - square artwork with text below
|
// Matches books tab style - square artwork with text below
|
||||||
final heroTag = 'series_cover_${series.id}';
|
final heroTag = 'series_cover_${series.id}';
|
||||||
|
final cachedCovers = _seriesBookCovers[series.id];
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
_logger.log('📚 Tapped series: ${series.name}, path: ${series.id}');
|
_logger.log('📚 Tapped series: ${series.name}, path: ${series.id}');
|
||||||
@@ -1562,6 +1623,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
builder: (context) => AudiobookSeriesScreen(
|
builder: (context) => AudiobookSeriesScreen(
|
||||||
series: series,
|
series: series,
|
||||||
heroTag: heroTag,
|
heroTag: heroTag,
|
||||||
|
initialCovers: cachedCovers,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1592,15 +1654,21 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
if (series.bookCount != null) ...[
|
Builder(
|
||||||
const SizedBox(height: 2),
|
builder: (context) {
|
||||||
Text(
|
final count = series.bookCount ?? _seriesBookCounts[series.id];
|
||||||
'${series.bookCount} book${series.bookCount == 1 ? '' : 's'}',
|
if (count == null) return const SizedBox.shrink();
|
||||||
style: textTheme.bodySmall?.copyWith(
|
return Padding(
|
||||||
color: colorScheme.onSurface.withOpacity(0.6),
|
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);
|
gridSize = gridSize.clamp(1, maxGridSize);
|
||||||
final displayCovers = covers.take(gridSize * gridSize).toList();
|
final displayCovers = covers.take(gridSize * gridSize).toList();
|
||||||
|
|
||||||
// Dark pastel colors for empty cells - muted and book-like
|
// Use extracted colors from book covers if available, otherwise fall back to static palette
|
||||||
const emptyColors = [
|
final extractedColors = _seriesExtractedColors[series.id];
|
||||||
|
const fallbackColors = [
|
||||||
Color(0xFF2D3436), // Dark slate
|
Color(0xFF2D3436), // Dark slate
|
||||||
Color(0xFF34495E), // Dark blue-grey
|
Color(0xFF34495E), // Dark blue-grey
|
||||||
Color(0xFF4A3728), // Dark brown
|
Color(0xFF4A3728), // Dark brown
|
||||||
@@ -1637,6 +1706,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
Color(0xFF3E4A47), // Dark teal-grey
|
Color(0xFF3E4A47), // Dark teal-grey
|
||||||
Color(0xFF4A3F35), // Dark warm grey
|
Color(0xFF4A3F35), // Dark warm grey
|
||||||
];
|
];
|
||||||
|
final emptyColors = (extractedColors != null && extractedColors.isNotEmpty)
|
||||||
|
? extractedColors
|
||||||
|
: fallbackColors;
|
||||||
|
|
||||||
// Use series ID to pick consistent colors for this series
|
// Use series ID to pick consistent colors for this series
|
||||||
final colorSeed = series.id.hashCode;
|
final colorSeed = series.id.hashCode;
|
||||||
@@ -1650,12 +1722,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
children: List.generate(gridSize, (col) {
|
children: List.generate(gridSize, (col) {
|
||||||
final index = row * gridSize + col;
|
final index = row * gridSize + col;
|
||||||
if (index >= displayCovers.length) {
|
if (index >= displayCovers.length) {
|
||||||
// Empty cell - use dark pastel color
|
// Empty cell - use nested grid pattern
|
||||||
final colorIndex = (colorSeed + index) % emptyColors.length;
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Container(
|
child: _buildEmptyCell(colorSeed, index, emptyColors),
|
||||||
color: emptyColors[colorIndex],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Expanded(
|
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) ============
|
// ============ PODCASTS TAB (Placeholder) ============
|
||||||
Widget _buildPodcastsComingSoonTab(BuildContext context) {
|
Widget _buildPodcastsComingSoonTab(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|||||||
+158
-207
@@ -30,6 +30,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _showContinueListeningAudiobooks = false;
|
bool _showContinueListeningAudiobooks = false;
|
||||||
bool _showDiscoverAudiobooks = false;
|
bool _showDiscoverAudiobooks = false;
|
||||||
bool _showDiscoverSeries = false;
|
bool _showDiscoverSeries = false;
|
||||||
|
// Home row order
|
||||||
|
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
|
||||||
// Audiobook libraries
|
// Audiobook libraries
|
||||||
List<Map<String, String>> _discoveredLibraries = [];
|
List<Map<String, String>> _discoveredLibraries = [];
|
||||||
Map<String, bool> _libraryEnabled = {};
|
Map<String, bool> _libraryEnabled = {};
|
||||||
@@ -64,6 +66,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||||
|
|
||||||
|
// Load home row order
|
||||||
|
final rowOrder = await SettingsService.getHomeRowOrder();
|
||||||
|
|
||||||
// Load audiobook library settings
|
// Load audiobook library settings
|
||||||
final discovered = await SettingsService.getDiscoveredAbsLibraries() ?? [];
|
final discovered = await SettingsService.getDiscoveredAbsLibraries() ?? [];
|
||||||
final enabled = await SettingsService.getEnabledAbsLibraries();
|
final enabled = await SettingsService.getEnabledAbsLibraries();
|
||||||
@@ -85,6 +90,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_showContinueListeningAudiobooks = showContinueAudiobooks;
|
_showContinueListeningAudiobooks = showContinueAudiobooks;
|
||||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||||
_showDiscoverSeries = showDiscSeries;
|
_showDiscoverSeries = showDiscSeries;
|
||||||
|
_homeRowOrder = rowOrder;
|
||||||
_discoveredLibraries = discovered;
|
_discoveredLibraries = discovered;
|
||||||
_libraryEnabled = libraryEnabled;
|
_libraryEnabled = libraryEnabled;
|
||||||
});
|
});
|
||||||
@@ -98,6 +104,102 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
super.dispose();
|
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 {
|
Future<void> _disconnect() async {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
await provider.disconnect();
|
await provider.disconnect();
|
||||||
@@ -379,221 +481,70 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Main rows section
|
// Reorderable home rows list
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
clipBehavior: Clip.antiAlias,
|
||||||
children: [
|
child: ReorderableListView.builder(
|
||||||
SwitchListTile(
|
shrinkWrap: true,
|
||||||
title: Text(
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
'Recently Played',
|
buildDefaultDragHandles: false,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
itemCount: _homeRowOrder.length,
|
||||||
),
|
onReorder: (oldIndex, newIndex) {
|
||||||
subtitle: Text(
|
setState(() {
|
||||||
'Show recently played albums',
|
if (newIndex > oldIndex) newIndex--;
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
final item = _homeRowOrder.removeAt(oldIndex);
|
||||||
),
|
_homeRowOrder.insert(newIndex, item);
|
||||||
value: _showRecentAlbums,
|
});
|
||||||
onChanged: (value) {
|
SettingsService.setHomeRowOrder(_homeRowOrder);
|
||||||
setState(() => _showRecentAlbums = value);
|
},
|
||||||
SettingsService.setShowRecentAlbums(value);
|
itemBuilder: (context, index) {
|
||||||
},
|
final rowId = _homeRowOrder[index];
|
||||||
activeColor: colorScheme.primary,
|
final rowInfo = _getRowInfo(rowId);
|
||||||
contentPadding: EdgeInsets.zero,
|
final isEnabled = _getRowEnabled(rowId);
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
return Container(
|
||||||
|
key: ValueKey(rowId),
|
||||||
Text(
|
decoration: BoxDecoration(
|
||||||
'Favorites',
|
border: index < _homeRowOrder.length - 1
|
||||||
style: textTheme.bodySmall?.copyWith(
|
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
|
||||||
color: colorScheme.onBackground.withOpacity(0.6),
|
: null,
|
||||||
),
|
|
||||||
),
|
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
child: Row(
|
||||||
'Show a row of your favorite albums',
|
children: [
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ class SettingsService {
|
|||||||
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
|
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
|
||||||
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
|
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
|
||||||
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
|
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
|
// View Mode Settings
|
||||||
static const String _keyArtistAlbumsSortOrder = 'artist_albums_sort_order'; // 'alpha' or 'year'
|
static const String _keyArtistAlbumsSortOrder = 'artist_albums_sort_order'; // 'alpha' or 'year'
|
||||||
@@ -442,6 +456,26 @@ class SettingsService {
|
|||||||
await prefs.setBool(_keyShowFavoriteTracks, show);
|
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
|
// View Mode Settings - Artist Albums
|
||||||
static Future<String> getArtistAlbumsSortOrder() async {
|
static Future<String> getArtistAlbumsSortOrder() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|||||||
+181
-38
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
@@ -30,6 +31,10 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
|||||||
// Cache for series cover images
|
// Cache for series cover images
|
||||||
final Map<String, List<String>> _seriesCovers = {};
|
final Map<String, List<String>> _seriesCovers = {};
|
||||||
final Set<String> _loadingCovers = {};
|
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
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
@@ -70,7 +75,10 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_seriesCovers[seriesId] = covers;
|
_seriesCovers[seriesId] = covers;
|
||||||
|
_seriesBookCounts[seriesId] = books.length;
|
||||||
});
|
});
|
||||||
|
// Extract colors asynchronously
|
||||||
|
_extractSeriesColors(seriesId, covers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} 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();
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -165,6 +211,8 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
|||||||
child: _SeriesCard(
|
child: _SeriesCard(
|
||||||
series: s,
|
series: s,
|
||||||
covers: _seriesCovers[s.id],
|
covers: _seriesCovers[s.id],
|
||||||
|
cachedBookCount: _seriesBookCounts[s.id],
|
||||||
|
extractedColors: _seriesExtractedColors[s.id],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
textTheme: textTheme,
|
textTheme: textTheme,
|
||||||
),
|
),
|
||||||
@@ -187,37 +235,59 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
|||||||
class _SeriesCard extends StatelessWidget {
|
class _SeriesCard extends StatelessWidget {
|
||||||
final AudiobookSeries series;
|
final AudiobookSeries series;
|
||||||
final List<String>? covers;
|
final List<String>? covers;
|
||||||
|
final int? cachedBookCount;
|
||||||
|
final List<Color>? extractedColors;
|
||||||
final ColorScheme colorScheme;
|
final ColorScheme colorScheme;
|
||||||
final TextTheme textTheme;
|
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({
|
const _SeriesCard({
|
||||||
required this.series,
|
required this.series,
|
||||||
required this.covers,
|
required this.covers,
|
||||||
|
this.cachedBookCount,
|
||||||
|
this.extractedColors,
|
||||||
required this.colorScheme,
|
required this.colorScheme,
|
||||||
required this.textTheme,
|
required this.textTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final heroTag = 'series_cover_home_${series.id}';
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => AudiobookSeriesScreen(series: series),
|
builder: (context) => AudiobookSeriesScreen(
|
||||||
|
series: series,
|
||||||
|
heroTag: heroTag,
|
||||||
|
initialCovers: covers,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
// Use AspectRatio to guarantee square cover
|
||||||
child: ClipRRect(
|
Hero(
|
||||||
borderRadius: BorderRadius.circular(8),
|
tag: heroTag,
|
||||||
child: Container(
|
child: AspectRatio(
|
||||||
width: double.infinity,
|
aspectRatio: 1,
|
||||||
height: double.infinity,
|
child: ClipRRect(
|
||||||
color: colorScheme.surfaceVariant,
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: _buildCoverGrid(),
|
child: _buildCoverGrid(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -231,15 +301,20 @@ class _SeriesCard extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
if (series.bookCount != null)
|
Builder(
|
||||||
Text(
|
builder: (context) {
|
||||||
'${series.bookCount} ${series.bookCount == 1 ? 'book' : 'books'}',
|
final count = series.bookCount ?? cachedBookCount;
|
||||||
style: textTheme.bodySmall?.copyWith(
|
if (count == null) return const SizedBox.shrink();
|
||||||
color: colorScheme.onSurface.withOpacity(0.6),
|
return Text(
|
||||||
),
|
'$count ${count == 1 ? 'book' : 'books'}',
|
||||||
maxLines: 1,
|
style: textTheme.bodySmall?.copyWith(
|
||||||
overflow: TextOverflow.ellipsis,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -247,7 +322,7 @@ class _SeriesCard extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildCoverGrid() {
|
Widget _buildCoverGrid() {
|
||||||
if (covers == null || covers!.isEmpty) {
|
if (covers == null || covers!.isEmpty) {
|
||||||
// Static placeholder - no animation
|
// Static placeholder
|
||||||
return Container(
|
return Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -270,29 +345,97 @@ class _SeriesCard extends StatelessWidget {
|
|||||||
gridSize = 3;
|
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(
|
// Use series ID to pick consistent colors
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
final colorSeed = series.id.hashCode;
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: gridSize,
|
// Get colors for empty cells
|
||||||
childAspectRatio: 1,
|
final emptyColors = (extractedColors != null && extractedColors!.isNotEmpty)
|
||||||
crossAxisSpacing: 1,
|
? extractedColors!
|
||||||
mainAxisSpacing: 1,
|
: _fallbackColors;
|
||||||
),
|
|
||||||
itemCount: displayCovers.length,
|
// Build grid using Column/Row (parent AspectRatio ensures square)
|
||||||
itemBuilder: (context, index) {
|
return Column(
|
||||||
return CachedNetworkImage(
|
children: List.generate(gridSize, (row) {
|
||||||
imageUrl: displayCovers[index],
|
return Expanded(
|
||||||
fit: BoxFit.cover,
|
child: Row(
|
||||||
placeholder: (_, __) => Container(
|
children: List.generate(gridSize, (col) {
|
||||||
color: colorScheme.surfaceContainerHighest,
|
final index = row * gridSize + col;
|
||||||
),
|
final coverUrl = displayCovers[index];
|
||||||
errorWidget: (_, __, ___) => Container(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
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
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.1.2-beta+18
|
version: 2.2.0-beta+19
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user