Compare commits
@@ -44,9 +44,19 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
- **Playlist Support** - Browse and play your playlists
|
||||
- **Search** - Find music across your entire library
|
||||
|
||||
### Audiobooks
|
||||
- **Audiobooks Tab** - Browse your audiobook library with grid/list view options
|
||||
- **Series Support** - View audiobooks organized by series with collage cover art
|
||||
- **Author Browsing** - Browse audiobooks by author
|
||||
- **Chapter Navigation** - Jump between chapters with timestamp display
|
||||
- **Progress Tracking** - Track your listening progress across sessions
|
||||
- **Continue Listening** - Pick up where you left off
|
||||
- **Mark as Finished/Unplayed** - Manage your reading progress
|
||||
|
||||
### Smart Features
|
||||
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
|
||||
- **Auto-Reconnect** - Automatically reconnects when connection is lost
|
||||
- **Caching** - Fast navigation with cached content
|
||||
- **Offline Browsing** - Browse your cached library even when disconnected
|
||||
- **Hero Animations** - Smooth transitions between screens
|
||||
|
||||
### Theming
|
||||
@@ -57,15 +67,14 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/screenshots/1.png" alt="Connection Screen" width="250">
|
||||
<img src="assets/screenshots/2.png" alt="Home Screen" width="250">
|
||||
<img src="assets/screenshots/3.png" alt="Album Details" width="250">
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/screenshots/4.png" alt="Now Playing" width="250">
|
||||
<img src="assets/screenshots/5.png" alt="Device Selection" width="250">
|
||||
<img src="assets/screenshots/6.png" alt="Settings" width="250">
|
||||
<img src="assets/screenshots/1.png?v=2" alt="Connection Screen" width="150">
|
||||
<img src="assets/screenshots/2.png?v=2" alt="Home Screen" width="150">
|
||||
<img src="assets/screenshots/3.png?v=2" alt="Album Details" width="150">
|
||||
<img src="assets/screenshots/4.png?v=2" alt="Now Playing" width="150">
|
||||
<img src="assets/screenshots/5.png?v=2" alt="Queue" width="150">
|
||||
<img src="assets/screenshots/6.png?v=2" alt="Settings" width="150">
|
||||
<img src="assets/screenshots/7.png?v=2" alt="Audiobooks" width="150">
|
||||
<img src="assets/screenshots/8.png?v=2" alt="Audiobook Player" width="150">
|
||||
</div>
|
||||
|
||||
## Download
|
||||
@@ -94,11 +103,10 @@ Ensemble supports multiple authentication methods:
|
||||
|
||||
## Requirements
|
||||
|
||||
- Music Assistant server (v2.7.0 or later recommended)
|
||||
- Music Assistant server (v2.7.0 beta 20 or later)
|
||||
- Network connectivity to your Music Assistant server
|
||||
- Android device (Android 5.0+)
|
||||
|
||||
> **Note:** Local device playback (streaming to your phone) is not currently supported on Music Assistant beta 20+. This is due to MA replacing the built-in player with Sendspin. Support will be added once Sendspin is more stable. Remote control of other players works fine.
|
||||
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -416,6 +416,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
try {
|
||||
await refreshPlayers();
|
||||
await _updatePlayerState();
|
||||
// Preload all player tracks to ensure fresh data after app resume
|
||||
await _preloadAdjacentPlayers(preloadAll: true);
|
||||
_logger.log('🔄 Connection verified, players and state refreshed');
|
||||
} catch (e) {
|
||||
_logger.log('🔄 Connection verification failed, reconnecting: $e');
|
||||
@@ -581,6 +583,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_sendspinService?.dispose();
|
||||
_sendspinService = SendspinService(_serverUrl!);
|
||||
|
||||
// Set auth token for MA 2.7.1+ proxy authentication
|
||||
final authToken = await SettingsService.getMaAuthToken();
|
||||
if (authToken != null) {
|
||||
_sendspinService!.setAuthToken(authToken);
|
||||
_logger.log('Sendspin: Auth token set for proxy authentication');
|
||||
}
|
||||
|
||||
// Initialize PCM audio player for raw audio streaming
|
||||
_pcmAudioPlayer?.dispose();
|
||||
_pcmAudioPlayer = PcmAudioPlayer();
|
||||
@@ -1044,6 +1053,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
if (currentMedia != null) {
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
|
||||
// Clear audiobook context when switching to music (track) playback
|
||||
if (mediaType == 'track' && _currentAudiobook != null) {
|
||||
_logger.log('📚 Media type changed to track - clearing audiobook context');
|
||||
clearCurrentAudiobook();
|
||||
}
|
||||
|
||||
if (mediaType != 'flow_stream') {
|
||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||
final albumName = currentMedia['album'] as String?;
|
||||
@@ -1075,23 +1091,53 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
};
|
||||
}
|
||||
|
||||
// Parse artist from title if artist is missing but title contains " - "
|
||||
var trackTitle = currentMedia['title'] as String? ?? 'Unknown Track';
|
||||
var artistName = currentMedia['artist'] as String?;
|
||||
|
||||
if ((artistName == null || artistName == 'Unknown Artist') && trackTitle.contains(' - ')) {
|
||||
final parts = trackTitle.split(' - ');
|
||||
if (parts.length >= 2) {
|
||||
artistName = parts[0].trim();
|
||||
trackTitle = parts.sublist(1).join(' - ').trim();
|
||||
}
|
||||
}
|
||||
artistName ??= 'Unknown Artist';
|
||||
|
||||
final trackFromEvent = Track(
|
||||
itemId: currentMedia['queue_item_id'] as String? ?? '',
|
||||
provider: 'library',
|
||||
name: currentMedia['title'] as String? ?? 'Unknown Track',
|
||||
name: trackTitle,
|
||||
uri: currentMedia['uri'] as String?,
|
||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')],
|
||||
artists: [Artist(itemId: '', provider: 'library', name: artistName)],
|
||||
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
|
||||
metadata: metadata,
|
||||
);
|
||||
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
||||
|
||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
||||
_currentTrack = trackFromEvent;
|
||||
// Only cache if we don't already have better data from queue
|
||||
final existingTrack = _cacheService.getCachedTrackForPlayer(playerId);
|
||||
final existingHasProperArtist = existingTrack != null &&
|
||||
existingTrack.artistsString != 'Unknown Artist' &&
|
||||
!existingTrack.name.contains(' - ');
|
||||
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
||||
final newHasImage = metadata != null;
|
||||
|
||||
// Keep existing if it has proper artist OR has image that new one lacks
|
||||
final keepExisting = existingHasProperArtist || (existingHasImage && !newHasImage);
|
||||
|
||||
if (!keepExisting) {
|
||||
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
||||
_logger.log('📋 Cached track for $playerName from player_updated: ${trackFromEvent.name}');
|
||||
} else {
|
||||
_logger.log('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
|
||||
}
|
||||
|
||||
_logger.log('📋 Cached track for $playerName from player_updated: ${trackFromEvent.name}');
|
||||
// For selected player, _updatePlayerState() is already called above which fetches queue data
|
||||
// Only update _currentTrack here if we don't have it yet (initial load)
|
||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId && _currentTrack == null) {
|
||||
_currentTrack = trackFromEvent;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -1742,6 +1788,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
_preloadAdjacentPlayers();
|
||||
|
||||
// Immediately fetch fresh track data from queue to avoid showing stale cache
|
||||
// This is important when resuming app after being in background - cached data
|
||||
// from player_updated events may have incomplete artist info
|
||||
await _updatePlayerState();
|
||||
|
||||
if (!skipNotify) {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -32,6 +32,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
String? _error;
|
||||
bool _showDebug = false;
|
||||
List<String> _debugLogs = [];
|
||||
String? _resolvedServerUrl; // The actual URL after fallback (e.g., HTTP if HTTPS failed)
|
||||
bool _usedHttpFallback = false; // True if HTTPS failed and we fell back to HTTP
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -96,10 +98,27 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
return 'http://$url';
|
||||
}
|
||||
|
||||
// Tailscale URLs (*.ts.net) - default to http:// since VPN tunnel is already encrypted
|
||||
if (url.endsWith('.ts.net') || url.contains('.ts.net:')) {
|
||||
return 'http://$url';
|
||||
}
|
||||
|
||||
// For domain names, default to https://
|
||||
return 'https://$url';
|
||||
}
|
||||
|
||||
/// Check if the URL is using HTTP (for security warning display)
|
||||
bool _isHttpConnection(String url) {
|
||||
final normalized = _normalizeServerUrl(url);
|
||||
return normalized.startsWith('http://') && !normalized.startsWith('https://');
|
||||
}
|
||||
|
||||
/// Check if the URL is a Tailscale URL (safe for HTTP)
|
||||
bool _isTailscaleUrl(String url) {
|
||||
final normalized = url.trim().toLowerCase();
|
||||
return normalized.contains('.ts.net');
|
||||
}
|
||||
|
||||
String _buildServerUrl() {
|
||||
// Normalize the base URL
|
||||
var url = _normalizeServerUrl(_serverUrlController.text.trim());
|
||||
@@ -149,6 +168,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
_error = null;
|
||||
_detectedAuthStrategy = null;
|
||||
_detectedAuthType = null;
|
||||
_resolvedServerUrl = null;
|
||||
_usedHttpFallback = false;
|
||||
_debugLogs.clear();
|
||||
});
|
||||
|
||||
@@ -162,6 +183,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
// Auto-detect authentication requirements
|
||||
final strategy = await provider.authManager.detectAuthStrategy(serverUrl);
|
||||
|
||||
// Check if auth manager used a different URL (e.g., HTTP fallback)
|
||||
final resolvedUrl = provider.authManager.lastSuccessfulUrl;
|
||||
if (resolvedUrl != null && resolvedUrl != serverUrl) {
|
||||
_addDebugLog('URL resolved via fallback: $resolvedUrl');
|
||||
_usedHttpFallback = serverUrl.startsWith('https://') && resolvedUrl.startsWith('http://');
|
||||
}
|
||||
|
||||
_addDebugLog('Detection complete. Strategy: ${strategy?.name ?? 'null'}');
|
||||
|
||||
if (strategy == null) {
|
||||
@@ -175,6 +203,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
setState(() {
|
||||
_detectedAuthStrategy = strategy;
|
||||
_detectedAuthType = _getAuthTypeName(strategy.name);
|
||||
_resolvedServerUrl = resolvedUrl;
|
||||
_isDetectingAuth = false;
|
||||
});
|
||||
|
||||
@@ -252,7 +281,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final serverUrl = _buildServerUrl();
|
||||
// Use the resolved URL if available (e.g., from HTTP fallback), otherwise build it
|
||||
final serverUrl = _resolvedServerUrl ?? _buildServerUrl();
|
||||
_addDebugLog('Using server URL for connection: $serverUrl');
|
||||
final port = _portController.text.trim();
|
||||
|
||||
// Validate port (if provided)
|
||||
@@ -635,6 +666,71 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// HTTP connection info banner
|
||||
if (_resolvedServerUrl != null && _isHttpConnection(_resolvedServerUrl!))
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: _isTailscaleUrl(_serverUrlController.text)
|
||||
? colorScheme.secondaryContainer.withOpacity(0.3)
|
||||
: colorScheme.errorContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: _isTailscaleUrl(_serverUrlController.text)
|
||||
? null
|
||||
: Border.all(color: colorScheme.error.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isTailscaleUrl(_serverUrlController.text)
|
||||
? Icons.vpn_lock_rounded
|
||||
: Icons.warning_amber_rounded,
|
||||
color: _isTailscaleUrl(_serverUrlController.text)
|
||||
? colorScheme.secondary
|
||||
: colorScheme.error,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_isTailscaleUrl(_serverUrlController.text)
|
||||
? 'Tailscale VPN Connection'
|
||||
: 'Unencrypted Connection',
|
||||
style: TextStyle(
|
||||
color: _isTailscaleUrl(_serverUrlController.text)
|
||||
? colorScheme.onSecondaryContainer
|
||||
: colorScheme.onErrorContainer,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
_isTailscaleUrl(_serverUrlController.text)
|
||||
? 'Using HTTP over Tailscale (encrypted tunnel)'
|
||||
: _usedHttpFallback
|
||||
? 'HTTPS failed, using HTTP fallback'
|
||||
: 'HTTP connection - data is not encrypted',
|
||||
style: TextStyle(
|
||||
color: (_isTailscaleUrl(_serverUrlController.text)
|
||||
? colorScheme.onSecondaryContainer
|
||||
: colorScheme.onErrorContainer).withOpacity(0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Authentication fields (shown only if auth required)
|
||||
if (needsAuth) ...[
|
||||
// Auth Server URL (only for Authelia)
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -186,43 +186,46 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
return Stack(
|
||||
children: [
|
||||
// Connecting banner when showing cached data
|
||||
// Main content
|
||||
RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
child: _buildConnectedView(context, maProvider),
|
||||
),
|
||||
// Connecting banner overlay (doesn't affect layout)
|
||||
if (!isConnected && syncService.hasCache)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: colorScheme.primaryContainer.withOpacity(0.5),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
color: colorScheme.primaryContainer.withOpacity(0.9),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Connecting...',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Connecting...',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
child: _buildConnectedView(context, maProvider),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -242,9 +245,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 +269,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 +278,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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -31,11 +31,42 @@ class AuthManager {
|
||||
|
||||
/// Auto-detect authentication requirements for a server
|
||||
/// Returns null if server is unreachable or auth method unknown
|
||||
/// Also returns the final URL used (in case of HTTP fallback) via the callback
|
||||
String? _lastSuccessfulUrl;
|
||||
String? get lastSuccessfulUrl => _lastSuccessfulUrl;
|
||||
|
||||
Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
|
||||
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
|
||||
_lastSuccessfulUrl = null;
|
||||
|
||||
// Normalize URL
|
||||
var baseUrl = serverUrl;
|
||||
var baseUrl = _normalizeUrl(serverUrl);
|
||||
_logger.log('Normalized URL: $baseUrl');
|
||||
|
||||
// Try detection with the initial URL
|
||||
var result = await _tryDetectAuth(baseUrl);
|
||||
|
||||
// If HTTPS failed and we haven't tried HTTP yet, try HTTP fallback
|
||||
if (result == null && baseUrl.startsWith('https://')) {
|
||||
final httpUrl = baseUrl.replaceFirst('https://', 'http://');
|
||||
_logger.log('🔄 HTTPS failed, trying HTTP fallback: $httpUrl');
|
||||
result = await _tryDetectAuth(httpUrl);
|
||||
if (result != null) {
|
||||
baseUrl = httpUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
_lastSuccessfulUrl = baseUrl;
|
||||
_logger.log('✅ Auth detection successful with URL: $baseUrl');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Normalize a server URL with appropriate protocol
|
||||
String _normalizeUrl(String url) {
|
||||
var baseUrl = url;
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
// Use http for local IPs, https for domains
|
||||
if (baseUrl.startsWith('192.') ||
|
||||
@@ -44,13 +75,22 @@ class AuthManager {
|
||||
baseUrl == 'localhost' ||
|
||||
baseUrl.startsWith('127.')) {
|
||||
baseUrl = 'http://$baseUrl';
|
||||
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
|
||||
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
|
||||
baseUrl = 'http://$baseUrl';
|
||||
_logger.log('🔗 Detected Tailscale URL, using HTTP');
|
||||
} else {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/// Try to detect auth strategy for a given URL
|
||||
/// Returns the strategy if successful, null if connection failed
|
||||
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
|
||||
// First, check if this is a Music Assistant server with native auth
|
||||
_logger.log('Checking for Music Assistant native auth...');
|
||||
_logger.log('Checking for Music Assistant native auth at $baseUrl...');
|
||||
final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
|
||||
if (maAuthResult != null) {
|
||||
if (maAuthResult == 'required') {
|
||||
@@ -124,7 +164,7 @@ class AuthManager {
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('✗ Auth detection error: $e');
|
||||
return null; // Return null instead of rethrow to show generic error
|
||||
return null; // Return null to trigger fallback
|
||||
}
|
||||
|
||||
_logger.log('⚠️ Could not determine auth method');
|
||||
@@ -136,10 +176,12 @@ class AuthManager {
|
||||
Future<String?> _checkMusicAssistantAuth(String baseUrl) async {
|
||||
try {
|
||||
final uri = Uri.parse(baseUrl);
|
||||
// Use the port from URL if specified, otherwise use default ports (80/443)
|
||||
// Don't default to 8095 here - that's only for direct connections without reverse proxy
|
||||
final apiUrl = Uri(
|
||||
scheme: uri.scheme,
|
||||
host: uri.host,
|
||||
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
|
||||
port: uri.hasPort ? uri.port : null,
|
||||
path: '/api',
|
||||
);
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ class PcmAudioPlayer {
|
||||
final List<Uint8List> _audioBuffer = [];
|
||||
bool _isFeeding = false;
|
||||
bool _isStarted = false;
|
||||
bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors
|
||||
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
|
||||
|
||||
// Error callback for operation failures
|
||||
@@ -202,11 +203,32 @@ class PcmAudioPlayer {
|
||||
|
||||
/// Handle incoming audio data from the stream
|
||||
void _onAudioData(Uint8List audioData) {
|
||||
// Ignore data if in error state or if in transitional/paused state
|
||||
// Ignore data if in error state
|
||||
if (_state == PcmPlayerState.error) return;
|
||||
|
||||
// Handle audio arriving during paused/transitional states
|
||||
// This can happen when audio frames arrive before stream/start message (race condition)
|
||||
// We need to auto-recover in this case
|
||||
if (_shouldBlockFeeding) {
|
||||
// Silently ignore audio data when paused or transitioning
|
||||
// This prevents buffer accumulation during pause and any race conditions
|
||||
// If we're paused and audio is arriving, this is likely a new stream
|
||||
// starting before the stream/start message. Queue for auto-recovery.
|
||||
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
|
||||
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
|
||||
_isAutoRecovering = true;
|
||||
_audioBuffer.add(audioData);
|
||||
|
||||
// Trigger async recovery
|
||||
_autoRecoverFromPause();
|
||||
return;
|
||||
}
|
||||
|
||||
// If already recovering or in transitional state, buffer the data
|
||||
if (_isAutoRecovering) {
|
||||
_audioBuffer.add(audioData);
|
||||
return;
|
||||
}
|
||||
|
||||
// For other blocking states (pausing, stopping, resuming), ignore
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -218,15 +240,44 @@ class PcmAudioPlayer {
|
||||
_startPlayback();
|
||||
}
|
||||
|
||||
// NOTE: Auto-resume from paused state has been removed to prevent race conditions.
|
||||
// The provider should explicitly call play() when ready to resume.
|
||||
|
||||
// Feed data if not currently feeding
|
||||
if (!_isFeeding && _isStarted) {
|
||||
_feedNextChunk();
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-recover from paused state when audio arrives unexpectedly
|
||||
Future<void> _autoRecoverFromPause() async {
|
||||
_logger.log('PcmAudioPlayer: Auto-recovering from pause');
|
||||
|
||||
try {
|
||||
// Re-initialize the native player
|
||||
await pcm.FlutterPcmSound.setup(
|
||||
sampleRate: _format.sampleRate,
|
||||
channelCount: _format.channels,
|
||||
);
|
||||
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
|
||||
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
|
||||
await pcm.FlutterPcmSound.start();
|
||||
|
||||
_isStarted = true;
|
||||
_state = PcmPlayerState.playing;
|
||||
_startElapsedTimeTimer();
|
||||
_logger.log('PcmAudioPlayer: Auto-recovery from pause successful');
|
||||
|
||||
// Start feeding buffered data
|
||||
_isAutoRecovering = false;
|
||||
if (_audioBuffer.isNotEmpty && !_isFeeding) {
|
||||
_feedNextChunk();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('PcmAudioPlayer: Auto-recovery from pause failed: $e');
|
||||
_isAutoRecovering = false;
|
||||
_state = PcmPlayerState.error;
|
||||
_emitError(PcmPlayerError.resumeFailed, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Start audio playback
|
||||
Future<void> _startPlayback() async {
|
||||
if (_isStarted) return;
|
||||
@@ -298,7 +349,41 @@ class PcmAudioPlayer {
|
||||
|
||||
// Check state again before the async feed call
|
||||
if (samples.isNotEmpty && !_shouldBlockFeeding) {
|
||||
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
|
||||
try {
|
||||
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
|
||||
} catch (feedError) {
|
||||
// Auto-recover from "must call setup first" error
|
||||
// This happens when audio frames arrive before stream/start message
|
||||
if (feedError.toString().contains('must call setup first') && !_isAutoRecovering) {
|
||||
_logger.log('PcmAudioPlayer: Auto-recovering from setup error');
|
||||
_isAutoRecovering = true;
|
||||
|
||||
try {
|
||||
// Re-initialize the native player
|
||||
await pcm.FlutterPcmSound.setup(
|
||||
sampleRate: _format.sampleRate,
|
||||
channelCount: _format.channels,
|
||||
);
|
||||
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
|
||||
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
|
||||
await pcm.FlutterPcmSound.start();
|
||||
_isStarted = true;
|
||||
_state = PcmPlayerState.playing;
|
||||
_startElapsedTimeTimer();
|
||||
_logger.log('PcmAudioPlayer: Auto-recovery successful');
|
||||
|
||||
// Retry the feed with the current chunk
|
||||
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
|
||||
} catch (recoveryError) {
|
||||
_logger.log('PcmAudioPlayer: Auto-recovery failed: $recoveryError');
|
||||
_isAutoRecovering = false;
|
||||
rethrow;
|
||||
}
|
||||
_isAutoRecovering = false;
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't update stats if we got paused during the feed
|
||||
if (!_shouldBlockFeeding) {
|
||||
|
||||
@@ -84,8 +84,16 @@ class SendspinService {
|
||||
|
||||
bool _isDisposed = false;
|
||||
|
||||
// Auth token for proxy authentication (MA 2.7.1+)
|
||||
String? _authToken;
|
||||
|
||||
SendspinService(this.serverUrl);
|
||||
|
||||
/// Set the MA auth token for proxy authentication
|
||||
void setAuthToken(String? token) {
|
||||
_authToken = token;
|
||||
}
|
||||
|
||||
/// Initialize and connect to Sendspin server
|
||||
/// Uses persistent player ID and username-based naming
|
||||
Future<bool> connect() async {
|
||||
@@ -105,10 +113,11 @@ class SendspinService {
|
||||
bool connected = false;
|
||||
|
||||
// Strategy 1: If server is HTTPS, try external wss:// first
|
||||
// Use proxy auth for MA 2.7.1+ - the proxy requires authentication before hello
|
||||
if (_isHttpsServer()) {
|
||||
final externalUrl = _buildExternalSendspinUrl();
|
||||
_logger.log('Sendspin: Trying external URL: $externalUrl');
|
||||
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 3));
|
||||
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
|
||||
}
|
||||
|
||||
// Strategy 2: If external failed or server is HTTP, try local connection
|
||||
@@ -158,10 +167,11 @@ class SendspinService {
|
||||
}
|
||||
|
||||
/// Attempt to connect to a specific WebSocket URL
|
||||
Future<bool> _tryConnect(String url, {Duration timeout = const Duration(seconds: 5)}) async {
|
||||
/// If useProxyAuth is true and we have an auth token, authenticate first (for MA 2.7.1+ proxy)
|
||||
Future<bool> _tryConnect(String url, {Duration timeout = const Duration(seconds: 5), bool useProxyAuth = false}) async {
|
||||
try {
|
||||
// Connect to the base URL without query params - we send player info in hello message
|
||||
_logger.log('Sendspin: Connecting to $url');
|
||||
_logger.log('Sendspin: Connecting to $url${useProxyAuth ? ' (with proxy auth)' : ''}');
|
||||
|
||||
// Create WebSocket connection
|
||||
final webSocket = await WebSocket.connect(url).timeout(timeout);
|
||||
@@ -176,6 +186,29 @@ class SendspinService {
|
||||
onDone: _handleDone,
|
||||
);
|
||||
|
||||
// If using proxy auth (MA 2.7.1+), authenticate first
|
||||
if (useProxyAuth && _authToken != null) {
|
||||
_logger.log('Sendspin: Sending proxy auth with token');
|
||||
_sendMessage({
|
||||
'type': 'auth',
|
||||
'token': _authToken,
|
||||
}, allowDuringHandshake: true);
|
||||
|
||||
// Wait for auth_ok response
|
||||
final authOk = await _waitForAuth().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => false,
|
||||
);
|
||||
|
||||
if (!authOk) {
|
||||
_logger.log('Sendspin: Proxy authentication failed or timed out');
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
return false;
|
||||
}
|
||||
_logger.log('Sendspin: Proxy auth successful, proceeding with hello');
|
||||
}
|
||||
|
||||
// Send client/hello message immediately after connecting
|
||||
// This is required by the Sendspin protocol - server waits for this
|
||||
// Message format follows aiosendspin protocol spec
|
||||
@@ -228,12 +261,18 @@ class SendspinService {
|
||||
|
||||
/// Wait for server acknowledgment after connection
|
||||
Completer<bool>? _ackCompleter;
|
||||
Completer<bool>? _authCompleter;
|
||||
|
||||
Future<bool> _waitForAck() async {
|
||||
_ackCompleter = Completer<bool>();
|
||||
return _ackCompleter!.future;
|
||||
}
|
||||
|
||||
Future<bool> _waitForAuth() async {
|
||||
_authCompleter = Completer<bool>();
|
||||
return _authCompleter!.future;
|
||||
}
|
||||
|
||||
/// Handle incoming WebSocket messages
|
||||
/// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data)
|
||||
void _handleMessage(dynamic message) {
|
||||
@@ -310,6 +349,14 @@ class SendspinService {
|
||||
onStreamEnd?.call();
|
||||
break;
|
||||
|
||||
case 'auth_ok':
|
||||
// Proxy authentication successful (MA 2.7.1+)
|
||||
_logger.log('Sendspin: Proxy authentication successful');
|
||||
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||
_authCompleter!.complete(true);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ack':
|
||||
case 'connected':
|
||||
case 'registered':
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1001,7 +1001,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// Artist: medium weight, secondary (18px expanded, was 16px)
|
||||
// Album: light, tertiary (15px expanded, was 13px)
|
||||
final titleFontSize = _lerpDouble(16.0, 24.0, t);
|
||||
final artistFontSize = _lerpDouble(14.0, 18.0, t); // Increased from 16 to 18
|
||||
final artistFontSize = _lerpDouble(14.0, 18.0, t); // 14px collapsed, 18px expanded
|
||||
|
||||
final collapsedTitleLeft = _collapsedArtSize + 8; // Reduced from 12 to 8 (4px less)
|
||||
final expandedTitleLeft = contentPadding;
|
||||
@@ -1009,7 +1009,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
final collapsedTitleTop = (_collapsedHeight - 36) / 2; // Centered vertically (adjusted for increased track/artist gap)
|
||||
|
||||
final collapsedTitleWidth = screenSize.width - _collapsedArtSize - 150;
|
||||
// Controls: 36 (prev) + 34 (play) + 36 (next) + 8 (right margin) = 114px from widget right
|
||||
// For 8px gap: text ends at widgetWidth - 114 - 8 = widgetWidth - 122
|
||||
// Note: collapsedWidth = screenSize.width - 24 (12px margin each side)
|
||||
final collapsedTitleWidth = collapsedWidth - collapsedTitleLeft - 122;
|
||||
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
|
||||
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
|
||||
|
||||
@@ -1304,6 +1307,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
||||
maxLines: t > 0.5 ? 2 : 1,
|
||||
softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -1331,6 +1335,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
||||
maxLines: 1,
|
||||
softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -87,7 +87,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
'Swipe to switch device',
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -147,7 +147,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
'Swipe to switch device',
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.1.0-beta+16
|
||||
version: 2.3.0-beta+22
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||