Compare commits

..
Author SHA1 Message Date
Your NameandClaude Opus 4.5 73ca6f230e chore: bump version to 2.3.0-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 23:19:31 +00:00
Your NameandClaude Opus 4.5 d0ab9ff618 feat(sendspin): add proxy authentication support for MA 2.7.1+
- Add auth token field and setAuthToken() method to SendspinService
- Send auth message before client/hello when connecting via HTTPS proxy
- Wait for auth_ok response before proceeding with handshake
- Provider now sets auth token from SettingsService before connecting
- Allows Sendspin to work through reverse proxies without exposing port 8927

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 23:02:31 +00:00
Your NameandClaude Opus 4.5 0989b322a2 fix(player): preload all tracks on app resume + preserve cached images
- Preload all player tracks in checkAndReconnect() after app resumes
- Preserve cached tracks that have images even if new event lacks image
- This ensures album art is available when swiping between players

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 22:06:09 +00:00
Your NameandClaude Opus 4.5 433eacfc43 fix(player): correct text width and artist parsing
Text overlap fix:
- Use collapsedWidth (with margins) instead of screenSize.width
- collapsedWidth is 24px narrower, fixing the overlap

Stale track data fix:
- Parse artist from title when title contains " - " and artist is unknown
- Don't overwrite cache if existing track has proper artist info
- Only set _currentTrack from event if not already set

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 22:02:41 +00:00
Your NameandClaude Opus 4.5 e24df29239 fix(player): proper text truncation in mini player
- Fix width calculation to use titleLeft position for accurate constraint
- Add softWrap: false in collapsed state to ensure ellipsis truncation
- Controls: 36 (prev) + 34 (play) + 36 (next) + 8 (margin) = 114px
- Reserve 122px from right edge for 8px gap to controls

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 21:38:42 +00:00
Your NameandClaude Opus 4.5 2736b4478a fix(player): await fresh track data on player select
When resuming app after background, cached track data from player_updated
events may have incomplete artist info (e.g., "Artist - Title" in name field
with null artist). Now awaits _updatePlayerState() before notifying UI to
ensure fresh queue data is displayed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:59:11 +00:00
Your NameandClaude Opus 4.5 020a440f11 fix(player): consistent 14px secondary text and 8px control gap
- Set secondary text to 14px in both mini player and device selector bar
- Adjust text width for consistent 8px gap before player controls

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:45:55 +00:00
Your NameandClaude Opus 4.5 2e7682facd fix(player): match mini player artist font size to device selector bar
Change artist font size from 14px to 12px in collapsed mini player to match
the DeviceSelectorBar styling for visual consistency.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:08:06 +00:00
Your NameandClaude Opus 4.5 f5221b3160 chore: bump version to 2.2.1-beta+21 for release
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 17:45:21 +00:00
Your NameandClaude Opus 4.5 4265b8a544 fix: Clear audiobook context when switching to music playback
When switching from audiobook to music track playback, the audiobook
context (_currentAudiobook) was not being cleared. This caused the
player UI to remain in audiobook mode (30-second skip buttons, author
instead of artist, chapters in queue) even when playing regular music.

The fix detects when media_type changes to 'track' in player_updated
events and clears the audiobook context, allowing the UI to correctly
switch to music mode.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 17:20:25 +00:00
Your NameandClaude Opus 4.5 014f0a4903 Fix PCM audio race condition with auto-recovery
When audio frames arrive before the stream/start message (or after
pause/release), the native PCM player isn't setup yet, causing
"must call setup first" errors and silent playback.

This fix adds auto-recovery in two places:
1. In _onAudioData: If audio arrives while paused, automatically
   re-initialize and resume playback
2. In _feedNextChunk: If feed() fails with setup error, re-initialize
   the native player and retry

This ensures audio playback starts even when binary frames race
ahead of JSON control messages on the WebSocket.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 16:55:13 +00:00
Your NameandClaude Opus 4.5 2f0fc03dc9 chore: bump version to 2.2.1-beta+20
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 16:41:21 +00:00
Your NameandClaude Opus 4.5 9ae8462c2e fix(home): use overlay for connecting banner to prevent layout shift
Changed from Column to Stack so the "Connecting..." banner overlays
content instead of pushing rows down when it appears/disappears.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 16:40:39 +00:00
Your NameandClaude Opus 4.5 290f302da6 chore: bump version to 2.2.0-beta+19
🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Matches the library screen grid sizing logic for consistency.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 09:25:10 +00:00
Your Name f03ae2f75b Bump version to 2.1.2-beta 2025-12-19 06:10:19 +00:00
Your NameandClaude Opus 4.5 23fe4a20c7 Fix auth detection for Tailscale/reverse proxy URLs
When checking for MA auth on HTTP URLs without a port, the code was
defaulting to port 8095 (MA's direct port). This caused auth detection
to fail for Tailscale URLs behind a reverse proxy on port 80.

Now uses default HTTP/HTTPS ports (80/443) when no port is specified.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 06:10:00 +00:00
Your Name 90ad14de28 Add cache-busting to screenshot URLs 2025-12-19 05:57:33 +00:00
Your NameandClaude Opus 4.5 b3562fee0c Update screenshots and make them more compact in README
- Updated screenshots 3, 4, 5 with new captures
- Added new screenshots 7 (Audiobooks) and 8 (Audiobook Player)
- Reduced screenshot width from 250px to 150px for compact display
- Arranged all 8 screenshots in a single row that wraps naturally

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 05:52:38 +00:00
Your NameandClaude Opus 4.5 baa6299130 Bump version to 2.1.1-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 05:07:22 +00:00
Your NameandClaude Opus 4.5 e6a02c191e Improve Tailscale and HTTP connection handling
- Add Tailscale URL detection (*.ts.net) to default to HTTP instead of HTTPS,
  since the VPN tunnel already provides encryption
- Implement HTTPS to HTTP automatic fallback when HTTPS connection fails
- Add HTTP connection info banner on login screen:
  - Shows "Tailscale VPN Connection" with VPN lock icon for *.ts.net URLs
  - Shows "Unencrypted Connection" warning for other HTTP connections
- Track resolved server URL from fallback and use it for actual connection

Fixes user-reported issue where connecting via Tailscale URL required
manually adding "http://" prefix and specifying port 80.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 04:48:59 +00:00
Your NameandClaude Opus 4.5 7d7e5266c7 Update README with audiobooks and new features
- Add Audiobooks section with full feature list
- Add Instant App Restore and Offline Browsing to Smart Features
- Update requirements for MA 2.7.0 beta 20+
- Add Audiobookshelf requirement for audiobook features
- Remove outdated Sendspin compatibility note

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 22:14:42 +00:00
20 changed files with 1225 additions and 490 deletions
+21 -13
View File
@@ -44,9 +44,19 @@ This application was built with AI-assisted development using **Claude Code** an
- **Playlist Support** - Browse and play your playlists - **Playlist Support** - Browse and play your playlists
- **Search** - Find music across your entire library - **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 ### Smart Features
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
- **Auto-Reconnect** - Automatically reconnects when connection is lost - **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 - **Hero Animations** - Smooth transitions between screens
### Theming ### Theming
@@ -57,15 +67,14 @@ This application was built with AI-assisted development using **Claude Code** an
## Screenshots ## Screenshots
<div align="center"> <div align="center">
<img src="assets/screenshots/1.png" alt="Connection Screen" width="250"> <img src="assets/screenshots/1.png?v=2" alt="Connection Screen" width="150">
<img src="assets/screenshots/2.png" alt="Home Screen" width="250"> <img src="assets/screenshots/2.png?v=2" alt="Home Screen" width="150">
<img src="assets/screenshots/3.png" alt="Album Details" width="250"> <img src="assets/screenshots/3.png?v=2" alt="Album Details" width="150">
</div> <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">
<div align="center"> <img src="assets/screenshots/6.png?v=2" alt="Settings" width="150">
<img src="assets/screenshots/4.png" alt="Now Playing" width="250"> <img src="assets/screenshots/7.png?v=2" alt="Audiobooks" width="150">
<img src="assets/screenshots/5.png" alt="Device Selection" width="250"> <img src="assets/screenshots/8.png?v=2" alt="Audiobook Player" width="150">
<img src="assets/screenshots/6.png" alt="Settings" width="250">
</div> </div>
## Download ## Download
@@ -94,11 +103,10 @@ Ensemble supports multiple authentication methods:
## Requirements ## 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 - Network connectivity to your Music Assistant server
- Android device (Android 5.0+) - Android device (Android 5.0+)
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
> **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.
## License ## License
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+57 -6
View File
@@ -416,6 +416,8 @@ class MusicAssistantProvider with ChangeNotifier {
try { try {
await refreshPlayers(); await refreshPlayers();
await _updatePlayerState(); 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'); _logger.log('🔄 Connection verified, players and state refreshed');
} catch (e) { } catch (e) {
_logger.log('🔄 Connection verification failed, reconnecting: $e'); _logger.log('🔄 Connection verification failed, reconnecting: $e');
@@ -581,6 +583,13 @@ class MusicAssistantProvider with ChangeNotifier {
_sendspinService?.dispose(); _sendspinService?.dispose();
_sendspinService = SendspinService(_serverUrl!); _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 // Initialize PCM audio player for raw audio streaming
_pcmAudioPlayer?.dispose(); _pcmAudioPlayer?.dispose();
_pcmAudioPlayer = PcmAudioPlayer(); _pcmAudioPlayer = PcmAudioPlayer();
@@ -1044,6 +1053,13 @@ class MusicAssistantProvider with ChangeNotifier {
if (currentMedia != null) { if (currentMedia != null) {
final mediaType = currentMedia['media_type'] as String?; 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') { if (mediaType != 'flow_stream') {
final durationSecs = (currentMedia['duration'] as num?)?.toInt(); final durationSecs = (currentMedia['duration'] as num?)?.toInt();
final albumName = currentMedia['album'] as String?; 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( final trackFromEvent = Track(
itemId: currentMedia['queue_item_id'] as String? ?? '', itemId: currentMedia['queue_item_id'] as String? ?? '',
provider: 'library', provider: 'library',
name: currentMedia['title'] as String? ?? 'Unknown Track', name: trackTitle,
uri: currentMedia['uri'] as String?, uri: currentMedia['uri'] as String?,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null, 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, album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
metadata: metadata, metadata: metadata,
); );
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) { // Only cache if we don't already have better data from queue
_currentTrack = trackFromEvent; 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(); notifyListeners();
} }
@@ -1742,6 +1788,11 @@ class MusicAssistantProvider with ChangeNotifier {
_preloadAdjacentPlayers(); _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) { if (!skipNotify) {
notifyListeners(); notifyListeners();
} }
+196 -35
View File
@@ -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]),
); );
}), }),
), ),
+97 -1
View File
@@ -32,6 +32,8 @@ class _LoginScreenState extends State<LoginScreen> {
String? _error; String? _error;
bool _showDebug = false; bool _showDebug = false;
List<String> _debugLogs = []; 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 @override
void initState() { void initState() {
@@ -96,10 +98,27 @@ class _LoginScreenState extends State<LoginScreen> {
return 'http://$url'; 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:// // For domain names, default to https://
return 'https://$url'; 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() { String _buildServerUrl() {
// Normalize the base URL // Normalize the base URL
var url = _normalizeServerUrl(_serverUrlController.text.trim()); var url = _normalizeServerUrl(_serverUrlController.text.trim());
@@ -149,6 +168,8 @@ class _LoginScreenState extends State<LoginScreen> {
_error = null; _error = null;
_detectedAuthStrategy = null; _detectedAuthStrategy = null;
_detectedAuthType = null; _detectedAuthType = null;
_resolvedServerUrl = null;
_usedHttpFallback = false;
_debugLogs.clear(); _debugLogs.clear();
}); });
@@ -162,6 +183,13 @@ class _LoginScreenState extends State<LoginScreen> {
// Auto-detect authentication requirements // Auto-detect authentication requirements
final strategy = await provider.authManager.detectAuthStrategy(serverUrl); 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'}'); _addDebugLog('Detection complete. Strategy: ${strategy?.name ?? 'null'}');
if (strategy == null) { if (strategy == null) {
@@ -175,6 +203,7 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() { setState(() {
_detectedAuthStrategy = strategy; _detectedAuthStrategy = strategy;
_detectedAuthType = _getAuthTypeName(strategy.name); _detectedAuthType = _getAuthTypeName(strategy.name);
_resolvedServerUrl = resolvedUrl;
_isDetectingAuth = false; _isDetectingAuth = false;
}); });
@@ -252,7 +281,9 @@ class _LoginScreenState extends State<LoginScreen> {
}); });
try { 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(); final port = _portController.text.trim();
// Validate port (if provided) // 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) // Authentication fields (shown only if auth required)
if (needsAuth) ...[ if (needsAuth) ...[
// Auth Server URL (only for Authelia) // Auth Server URL (only for Authelia)
+117 -127
View File
@@ -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;
}); });
} }
} }
@@ -186,43 +186,46 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
); );
} }
return Column( return Stack(
children: [ 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) if (!isConnected && syncService.hasCache)
Container( Positioned(
width: double.infinity, top: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), left: 0,
color: colorScheme.primaryContainer.withOpacity(0.5), right: 0,
child: Row( child: Container(
mainAxisAlignment: MainAxisAlignment.center, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
children: [ color: colorScheme.primaryContainer.withOpacity(0.9),
SizedBox( child: Row(
width: 12, mainAxisAlignment: MainAxisAlignment.center,
height: 12, children: [
child: CircularProgressIndicator( SizedBox(
strokeWidth: 2, width: 12,
color: colorScheme.primary, height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
), ),
), const SizedBox(width: 8),
const SizedBox(width: 8), Text(
Text( 'Connecting...',
'Connecting...', style: Theme.of(context).textTheme.bodySmall?.copyWith(
style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onPrimaryContainer,
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 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 +269,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 +278,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();
} }
} }
+166 -44
View File
@@ -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
View File
@@ -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,
),
],
), ),
), ),
+46 -4
View File
@@ -31,11 +31,42 @@ class AuthManager {
/// Auto-detect authentication requirements for a server /// Auto-detect authentication requirements for a server
/// Returns null if server is unreachable or auth method unknown /// 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 { Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl'); _logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
_lastSuccessfulUrl = null;
// Normalize URL // 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://')) { if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
// Use http for local IPs, https for domains // Use http for local IPs, https for domains
if (baseUrl.startsWith('192.') || if (baseUrl.startsWith('192.') ||
@@ -44,13 +75,22 @@ class AuthManager {
baseUrl == 'localhost' || baseUrl == 'localhost' ||
baseUrl.startsWith('127.')) { baseUrl.startsWith('127.')) {
baseUrl = 'http://$baseUrl'; 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 { } else {
baseUrl = 'https://$baseUrl'; 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 // 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); final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
if (maAuthResult != null) { if (maAuthResult != null) {
if (maAuthResult == 'required') { if (maAuthResult == 'required') {
@@ -124,7 +164,7 @@ class AuthManager {
} }
} catch (e) { } catch (e) {
_logger.log('✗ Auth detection error: $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'); _logger.log('⚠️ Could not determine auth method');
@@ -136,10 +176,12 @@ class AuthManager {
Future<String?> _checkMusicAssistantAuth(String baseUrl) async { Future<String?> _checkMusicAssistantAuth(String baseUrl) async {
try { try {
final uri = Uri.parse(baseUrl); 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( final apiUrl = Uri(
scheme: uri.scheme, scheme: uri.scheme,
host: uri.host, host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095), port: uri.hasPort ? uri.port : null,
path: '/api', path: '/api',
); );
+92 -7
View File
@@ -68,6 +68,7 @@ class PcmAudioPlayer {
final List<Uint8List> _audioBuffer = []; final List<Uint8List> _audioBuffer = [];
bool _isFeeding = false; bool _isFeeding = false;
bool _isStarted = false; bool _isStarted = false;
bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors
Completer<void>? _feedCompleter; // Tracks when current feed operation completes Completer<void>? _feedCompleter; // Tracks when current feed operation completes
// Error callback for operation failures // Error callback for operation failures
@@ -202,11 +203,32 @@ class PcmAudioPlayer {
/// Handle incoming audio data from the stream /// Handle incoming audio data from the stream
void _onAudioData(Uint8List audioData) { 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; 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) { if (_shouldBlockFeeding) {
// Silently ignore audio data when paused or transitioning // If we're paused and audio is arriving, this is likely a new stream
// This prevents buffer accumulation during pause and any race conditions // 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; return;
} }
@@ -218,15 +240,44 @@ class PcmAudioPlayer {
_startPlayback(); _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 // Feed data if not currently feeding
if (!_isFeeding && _isStarted) { if (!_isFeeding && _isStarted) {
_feedNextChunk(); _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 /// Start audio playback
Future<void> _startPlayback() async { Future<void> _startPlayback() async {
if (_isStarted) return; if (_isStarted) return;
@@ -298,7 +349,41 @@ class PcmAudioPlayer {
// Check state again before the async feed call // Check state again before the async feed call
if (samples.isNotEmpty && !_shouldBlockFeeding) { 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 // Don't update stats if we got paused during the feed
if (!_shouldBlockFeeding) { if (!_shouldBlockFeeding) {
+50 -3
View File
@@ -84,8 +84,16 @@ class SendspinService {
bool _isDisposed = false; bool _isDisposed = false;
// Auth token for proxy authentication (MA 2.7.1+)
String? _authToken;
SendspinService(this.serverUrl); SendspinService(this.serverUrl);
/// Set the MA auth token for proxy authentication
void setAuthToken(String? token) {
_authToken = token;
}
/// Initialize and connect to Sendspin server /// Initialize and connect to Sendspin server
/// Uses persistent player ID and username-based naming /// Uses persistent player ID and username-based naming
Future<bool> connect() async { Future<bool> connect() async {
@@ -105,10 +113,11 @@ class SendspinService {
bool connected = false; bool connected = false;
// Strategy 1: If server is HTTPS, try external wss:// first // 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()) { if (_isHttpsServer()) {
final externalUrl = _buildExternalSendspinUrl(); final externalUrl = _buildExternalSendspinUrl();
_logger.log('Sendspin: Trying external URL: $externalUrl'); _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 // 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 /// 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 { try {
// Connect to the base URL without query params - we send player info in hello message // 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 // Create WebSocket connection
final webSocket = await WebSocket.connect(url).timeout(timeout); final webSocket = await WebSocket.connect(url).timeout(timeout);
@@ -176,6 +186,29 @@ class SendspinService {
onDone: _handleDone, 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 // Send client/hello message immediately after connecting
// This is required by the Sendspin protocol - server waits for this // This is required by the Sendspin protocol - server waits for this
// Message format follows aiosendspin protocol spec // Message format follows aiosendspin protocol spec
@@ -228,12 +261,18 @@ class SendspinService {
/// Wait for server acknowledgment after connection /// Wait for server acknowledgment after connection
Completer<bool>? _ackCompleter; Completer<bool>? _ackCompleter;
Completer<bool>? _authCompleter;
Future<bool> _waitForAck() async { Future<bool> _waitForAck() async {
_ackCompleter = Completer<bool>(); _ackCompleter = Completer<bool>();
return _ackCompleter!.future; return _ackCompleter!.future;
} }
Future<bool> _waitForAuth() async {
_authCompleter = Completer<bool>();
return _authCompleter!.future;
}
/// Handle incoming WebSocket messages /// Handle incoming WebSocket messages
/// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data) /// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data)
void _handleMessage(dynamic message) { void _handleMessage(dynamic message) {
@@ -310,6 +349,14 @@ class SendspinService {
onStreamEnd?.call(); onStreamEnd?.call();
break; 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 'ack':
case 'connected': case 'connected':
case 'registered': case 'registered':
+34
View File
@@ -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();
+7 -2
View File
@@ -1001,7 +1001,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Artist: medium weight, secondary (18px expanded, was 16px) // Artist: medium weight, secondary (18px expanded, was 16px)
// Album: light, tertiary (15px expanded, was 13px) // Album: light, tertiary (15px expanded, was 13px)
final titleFontSize = _lerpDouble(16.0, 24.0, t); 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 collapsedTitleLeft = _collapsedArtSize + 8; // Reduced from 12 to 8 (4px less)
final expandedTitleLeft = contentPadding; 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 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 expandedTitleWidth = screenSize.width - (contentPadding * 2);
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t); final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
@@ -1304,6 +1307,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left, textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
maxLines: t > 0.5 ? 2 : 1, maxLines: t > 0.5 ? 2 : 1,
softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@@ -1331,6 +1335,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left, textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
maxLines: 1, maxLines: 1,
softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
+2 -2
View File
@@ -87,7 +87,7 @@ class DeviceSelectorBar extends StatelessWidget {
'Swipe to switch device', 'Swipe to switch device',
style: TextStyle( style: TextStyle(
color: textColor.withOpacity(0.6), color: textColor.withOpacity(0.6),
fontSize: 12, fontSize: 14,
), ),
), ),
], ],
@@ -147,7 +147,7 @@ class DeviceSelectorBar extends StatelessWidget {
'Swipe to switch device', 'Swipe to switch device',
style: TextStyle( style: TextStyle(
color: textColor.withOpacity(0.6), color: textColor.withOpacity(0.6),
fontSize: 12, fontSize: 14,
), ),
), ),
], ],
+181 -38
View File
@@ -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
View File
@@ -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.0-beta+16 version: 2.3.0-beta+22
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'