Compare commits

..
Author SHA1 Message Date
Your Name 8f1a1cd3bd Bump version to 2.4.1-beta+26 2025-12-24 21:50:59 +00:00
Your NameandClaude Opus 4.5 c841898d8c Add image pre-caching to album and artist rows
The root cause: second tap is smooth because image is cached,
but first tap shows grey flash because image isn't cached yet.

Recent Albums works perfectly because those images are already
cached from when the user played those albums.

Fix: Pre-cache the first 10 album/artist images when the row
loads, so they're ready in cache before the user taps.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 20:33:47 +00:00
Your NameandClaude Opus 4.5 bd2c2be54e Fix hero animation grey icon flash - root cause fixes
Artist card layout:
- Replace Expanded > Center > LayoutBuilder > Hero with AspectRatio > Hero
- LayoutBuilder inside Hero caused dynamic sizing during animation
- AspectRatio provides fixed geometry BEFORE hero animation starts

Album details screen:
- Defer _loadFreshAlbumData until 350ms after transition (same as colors)
- Previously called immediately in initState, causing setState during animation
- If fresh album had different image URL, caused grey icon flash

These are the actual root causes identified by comparing "Recent Albums"
(perfect animation) with other album/artist taps (janky animation).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 20:19:35 +00:00
Your NameandClaude Opus 4.5 1225255558 Comprehensive animation performance optimizations
Critical fixes:
- Replace Opacity widgets with color alpha to avoid saveLayer in player_controls
- Fix AnimatedBuilder not using child parameter in global_player_overlay
- Replace setState with ValueNotifier for bounce animation to prevent rebuilds
- Add flightShuttleBuilder to series Hero to simplify animation flight
- Remove Opacity+ClipRRect combo in expandable_player progress bar

Image loading optimizations:
- Add fadeInDuration: Duration.zero to audiobook_row, track_row, queue_panel
- Add memCacheWidth/Height to queue_panel (176x176 for 44x44 display)
- Move _loadQueue() from AnimationStatus.forward to .completed

Animation polish:
- Add curve: Curves.easeInOut to AnimatedContainer in settings_screen
- Use FadeSlidePageRoute for series navigation for consistent hero timing
- Add clipBehavior: Clip.hardEdge to progress bar ClipRRect

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 19:12:04 +00:00
Your NameandClaude Opus 4.5 4589d26039 Fix hero animation flicker in card widgets
Set fadeInDuration: Duration.zero on source card widgets to prevent
end-of-animation flicker:
- album_card.dart: was 150ms, now zero
- artist_card.dart: was 150ms, now zero
- artist_avatar.dart: added zero fade
- series_row.dart: added zero fade

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 16:44:43 +00:00
Your NameandClaude Opus 4.5 aa8a3413f2 Complete hero animation audit: add fadeInDuration and initialImageUrl
- audiobook_detail_screen: Add fadeInDuration: Duration.zero to hero image
- audiobook_series_screen: Add fadeInDuration to all 3 CachedNetworkImage usages
- audiobook_author_screen: Add initialAuthorImageUrl parameter for smooth hero,
  add fadeInDuration to all 3 CachedNetworkImage usages
- new_library_screen: Pass initialAuthorImageUrl to author screen navigation,
  pass initialImageUrl to audiobook grid cards, add fadeInDuration to author images

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 16:18:30 +00:00
Your NameandClaude Opus 4.5 f0c26cd96d Fix artist hero animation jank by using CachedNetworkImage
ArtistDetailsScreen was using Image.network which re-fetches images
from the network, ignoring the cache. This caused jank during hero
animations because the image wasn't ready instantly.

Changed to CachedNetworkImage with zero fade duration to match
AlbumDetailsScreen behavior - image loads instantly from cache
for smooth hero transitions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 15:48:52 +00:00
Your NameandClaude Opus 4.5 591923b43b Pass initialImageUrl to all detail screens for smooth hero animations
Apply the same pattern that fixed album card hero animations to all
navigation paths:
- AudiobookDetailScreen now accepts initialImageUrl parameter
- Library screen passes imageUrl when navigating to audiobooks/albums
- Audiobook series and author screens pass imageUrl
- Artist details screen passes imageUrl to album navigations
- Search screen passes imageUrl to album details

This ensures hero animations show the correct image throughout the
transition instead of showing a placeholder icon.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 14:52:41 +00:00
Your NameandClaude Opus 4.5 83b46ef916 Fix queue restore: properly serialize providerMappings and check for them
Two issues prevented queue restoration from working:

1. MediaItem.toJson() was passing providerMappings directly instead of
   converting each ProviderMapping to JSON. This meant jsonEncode would
   fail to serialize them, losing the provider mapping data.

2. _restoreQueueFromCache was filtering for track.uri, but queue items
   from MA API don't include URIs. playTracks uses providerMappings to
   build URIs via _buildTrackUri(), so we should check for those instead.

Now providerMappings are properly serialized/deserialized, and queue
restore checks for available providerMappings (or provider+itemId as
fallback) to match what playTracks expects.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 14:18:08 +00:00
Your NameandClaude Opus 4.5 c9300ec4d4 Pass imageUrl to AlbumDetailsScreen for seamless hero animation
When navigating from AlbumCard to AlbumDetailsScreen, the detail screen's
first build may not have access to image metadata immediately. This caused
the hero animation to flash to a record icon even though both screens
eventually show the artwork.

Fix: AlbumCard now passes its imageUrl to AlbumDetailsScreen via a new
initialImageUrl parameter. The detail screen uses this as a fallback,
ensuring the hero animation has the same image on both ends.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 13:51:15 +00:00
Your NameandClaude Opus 4.5 cd7012258f Always fetch fresh recent albums from API for complete image data
getRecentAlbumsWithCache was short-circuiting to local database data
which has OLD entries without image metadata. This prevented fresh
albums with images from ever being fetched.

Now the function always fetches from API when connected. Local/cached
data is only used:
- By getCachedRecentAlbums() for instant display in stale-while-revalidate
- As fallback when API is unavailable or errors

This ensures albums passed to detail screen have complete metadata
including images for proper hero animation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 13:47:34 +00:00
Your NameandClaude Opus 4.5 041258cfd9 Always update rows with fresh data to ensure images are available
The stale-while-revalidate pattern had a flawed hasChanged check that
only compared item IDs and counts. If cached albums had the same items
but without image metadata, fresh albums (with images) would never
replace them.

This caused the hero animation to show a record icon because the user
would tap on a cached album without images before fresh data loaded.

Now AlbumRow and ArtistRow always update with fresh data when it arrives,
ensuring albums have complete metadata including images for proper
artwork display.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 13:26:58 +00:00
Your NameandClaude Opus 4.5 7783899e49 Fetch full album details in getRecentAlbums for artwork
The getRecentAlbums() was extracting album data from track responses,
which only contains simplified/nested album objects without the full
metadata (including images array). This caused missing artwork in the
recent albums row and album details screen.

Now the function:
1. First fetches tracks to collect unique album URIs
2. Then fetches full album details for each URI via music/item_by_uri

This ensures albums have complete metadata with images for proper
artwork display and hero animations.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 13:09:23 +00:00
Your NameandClaude Opus 4.5 6724b1814e Store album images metadata in recently played for instant artwork
When recording album plays, now stores the images array from album
metadata. When reconstructing albums from recently played data, this
image metadata is included, enabling getImageUrl() to work without
needing to fetch fresh album data from the API.

This fixes the artwork "pop-in" issue where album art would flash into
place at the end of the hero animation when navigating from the recent
albums row to album details screen.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 12:53:25 +00:00
Your NameandClaude Opus 4.5 228b281fc8 Fix album artwork in detail screen for recently played albums
When tapping an album from recently played, the detail screen now
fetches fresh album data from the API to get full metadata including
the images array needed for artwork display.

- Added _freshAlbum state variable to store full album data
- Added _displayAlbum getter that returns fresh data if available
- _loadFreshAlbumData() fetches album by URI and triggers color extraction
- Build method now uses _displayAlbum for image URL

This fixes the placeholder icon showing in album details when navigating
from the recently played row.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:51:09 +00:00
Your Name a59ca9ecd9 Revert "Fix album artwork missing for recently played albums"
This reverts commit 85e1f6142f.
2025-12-24 11:49:38 +00:00
Your NameandClaude Opus 4.5 85e1f6142f Fix album artwork missing for recently played albums
The recently played albums were being stored with minimal metadata,
missing the 'images' array needed for getImageUrl().

Now recordAlbumPlayed stores the full album JSON including metadata,
and getRecentAlbums reconstructs full Album objects from that JSON.

Note: This only fixes newly played albums. Existing entries in the
database will still show placeholder icons until played again.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:33:28 +00:00
Your NameandClaude Opus 4.5 04b8f4f505 Fix resume playback when server queue is empty
When the app returns after a while, Music Assistant server may have
cleared its queue. The app would fail with "No playable item found".

Now when this error occurs, the app automatically:
1. Detects the "No playable item" error
2. Loads the cached queue from the database
3. Extracts tracks and re-queues them on the server
4. Starts playback from the beginning

This fixes the issue where Chris' phone couldn't play after
returning to the app - the 35 cached tracks are now restored.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:01:54 +00:00
Your NameandClaude Opus 4.5 f2754b6f2a Add stale-while-revalidate pattern to home screen rows
- AlbumRow and ArtistRow now show cached data instantly (no loading spinner)
- Fresh data is fetched in background and UI updates silently if changed
- Added getCachedAlbums/getCachedArtists optional params for sync cache access
- Added cache getter methods to MusicAssistantProvider

This fixes the issue where home screen rows showed loading spinners
when returning to the app after the 10-minute cache expired.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 10:49:47 +00:00
Your NameandClaude Opus 4.5 bdfce8c8cb Add offline action queuing for favorites
- Created OfflineActionQueue service to persist actions when offline
- Queue is stored in SharedPreferences and processed on reconnect
- Updated all favorite toggle methods in screens to use new provider methods
- Provider methods check connection state and queue if offline
- Optimistic UI updates with "Will sync when online" feedback
- Added localization strings for offline sync messages

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:45:39 +00:00
Your NameandClaude Opus 4.5 431b498156 Add queue persistence to database
- Queue is now persisted to database when fetched from API
- Expandable player shows cached queue immediately on load
- Added getCachedQueue() method to MusicAssistantProvider
- Background refresh updates queue silently

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:31:02 +00:00
Your NameandClaude Opus 4.5 a9570c6106 Add silent background refresh pattern to detail screens
Album, playlist, and artist detail screens now:
- Show cached data immediately (if available)
- Fetch fresh data in background (silent refresh)
- Update UI only if data actually changed

Added cache accessor methods to MusicAssistantProvider:
- getCachedAlbumTracks()
- getCachedPlaylistTracks()
- getCachedArtistAlbums()

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:29:42 +00:00
Your NameandClaude Opus 4.5 337ca775c8 Fix variable name conflict in search_screen.dart
Renamed hasResults to hasCachedResults to avoid conflict with
existing hasResults variable later in the same function.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:10:41 +00:00
Your NameandClaude Opus 4.5 183ec8024c Fix generated type names and eliminate loading spinners
Type name fixes:
- CachedPlayers table generates CachedPlayer data class (singular)
- Fixed type references in database.dart and database_service.dart

Loading spinner elimination:
- Show cached data immediately if available, even while loading
- Only show spinner if no cached data AND loading
- Applied to library_albums, library_artists, library_tracks, library_playlists
- Updated search_screen to show cached results while fetching

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:01:49 +00:00
Your NameandClaude Opus 4.5 e32f4fa591 Stop aggressive cache clearing and persist players to database
Cache clearing changes:
- Remove cache clearing on disconnect events in both providers
- Keep cached players and data for instant UI display on reconnect
- Add clearAllOnLogout() method for explicit logout clearing
- Add clearCachesOnLogout() to ConnectionProvider

Player persistence:
- Add player caching convenience methods to DatabaseService
- Load cached players from database on app startup (instant display)
- Persist players to database when loaded from API
- Restore selected player from database on startup

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 08:56:40 +00:00
Your NameandClaude Opus 4.5 e175c14f69 Add persistent caching tables for playback state, players, and queue
- Add PlaybackState table: persists current track, position, playing state
- Add CachedPlayers table: stores player list with current track info
- Add CachedQueue table: persists queue items per player
- Increment schema version to 2 with migration logic
- Add CRUD operations for all new tables

Part of aggressive caching overhaul for instant app resume.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 08:51:07 +00:00
Your NameandClaude Opus 4.5 88a4cf333b refactor: Extract shared MiniPlayerContent widget for uniformity
Create a unified MiniPlayerContent widget used by all mini player
components to ensure pixel-perfect consistency:

- New MiniPlayerLayout class with shared constants:
  - height: 64px, artSize: 64px
  - textLeft: 74px, primaryTop: 13px, secondaryTop: 33px
  - iconSize: 28px, iconOpacity: 0.4
  - primaryFontSize: 16px, secondaryFontSize: 14px

- DeviceSelectorBar now uses MiniPlayerContent
- ExpandablePlayer uses MiniPlayerLayout constants for collapsed state
- Peek content uses MiniPlayerContent via Transform.translate

This eliminates the drift between components that was causing visual
jumps when swiping between playing and non-playing players.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 07:02:27 +00:00
Your NameandClaude Opus 4.5 878c473096 fix: Badge text styling and peek icon opacity
- Add explicit fontSize (11) and color to badge text for visibility
- Fix peek icon color: use textColor.withOpacity(0.4) to match
  DeviceSelectorBar (was using full opacity onSurfaceVariant)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 06:53:24 +00:00
Your NameandClaude Opus 4.5 80ac93c867 fix: Match peek content to DeviceSelectorBar exactly
- Remove height: 1.2 from peek content text (DeviceSelectorBar doesn't have it)
- Make height: 1.2 conditional on expanded state for main title
- Fix icon size in peek content: 28px to match DeviceSelectorBar

These mismatches caused visible text movement and icon size
changes when swiping from playing to non-playing player.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 06:36:46 +00:00
Your NameandClaude Opus 4.5 23567c6c7a fix: Use Badge widget and match mini player layouts
- Replace manual badge implementation with Flutter's Badge widget
  for proper centering and styling
- Standardize mini player text positioning between playing and
  non-playing states:
  - Title: left=74px (art+10), top=13px
  - Artist: left=74px, top=33px (gap of 20)
- Convert DeviceSelectorBar to absolute positioning to match
  expandable player structure

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 06:19:59 +00:00
Your Name bccf069379 Revert "fix: Match playing mini player layout to non-playing"
This reverts commit 62c300090d.
2025-12-23 22:16:48 +00:00
Your NameandClaude Opus 4.5 62c300090d fix: Match playing mini player layout to non-playing
- Left padding: 8px → 12px
- Title-artist gap: 22px → 18px
- Vertical centering adjusted
- Remove height: 1.2 from collapsed title (was causing offset)
- Badge: larger (22px), bolder (w900), better centered

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:56:32 +00:00
Your NameandClaude Opus 4.5 6b3e2e8cb6 fix: Improve badge vertical centering and make bolder
- Adjust padding to shift number up (less top padding)
- Increase font weight to w800 (extra bold)
- Set line height to 1.0 for tighter baseline

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:20:23 +00:00
Your NameandClaude Opus 4.5 daae42fbdc fix: Prefer Local Player now takes priority over current selection
When "Prefer Local Player" is enabled, the local player is now selected
even if another player was already selected. This ensures that when the
local player registers (via Sendspin), it becomes selected automatically.

Previously, the "keep current selection" logic ran before checking the
prefer local setting, which meant the local player was never selected
when it registered after initial connection.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:04:15 +00:00
Your NameandClaude Opus 4.5 23cd2df5ca feat: Add player UX improvements (Issue #21)
1. Smart Player Auto-Selection:
   - Priority 1: Single playing player (skip if multiple playing)
   - Priority 2: Local player (this device)
   - Priority 3: Last manually selected player
   - Priority 4: First available player
   - New "Prefer Local Player" toggle skips to local player first

2. Active Player Count Badge:
   - Shows number of currently playing devices
   - Round badge with tertiary color in top-right of device selector

3. Dynamic Player List Sorting:
   - New "Smart Sort" toggle in settings
   - When enabled: Local player → Playing → On → Off
   - Applies to device list and mini player swipe
   - Default: alphabetical (unchanged)

4. Settings reorganization:
   - Moved Language selector to its own section
   - Added new Players section with toggles

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 20:48:06 +00:00
Your NameandClaude Opus 4.5 2c7f578fd1 feat: Add accent color selection in settings
Add 6 predefined accent colors (purple, blue, teal, deep orange, pink,
green) that users can choose when Material You is disabled. The colors
are displayed as circular swatches above the Material You toggle and
are greyed out when Material You is enabled.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 20:15:35 +00:00
44 changed files with 2340 additions and 827 deletions
+200 -3
View File
@@ -93,12 +93,72 @@ class SyncMetadata extends Table {
Set<Column> get primaryKey => {syncType};
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata])
/// Persisted playback state - survives app restarts and disconnects
class PlaybackState extends Table {
/// Always 'current' - single row table
TextColumn get id => text().withDefault(const Constant('current'))();
/// Selected player ID
TextColumn get playerId => text().nullable()();
/// Selected player name (for display if player unavailable)
TextColumn get playerName => text().nullable()();
/// Current track as JSON
TextColumn get currentTrackJson => text().nullable()();
/// Current position in seconds
RealColumn get positionSeconds => real().withDefault(const Constant(0.0))();
/// Whether playback was active
BoolColumn get isPlaying => boolean().withDefault(const Constant(false))();
/// When this state was saved
DateTimeColumn get savedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
/// Cached player list - for instant display on app resume
class CachedPlayers extends Table {
/// Player ID from Music Assistant
TextColumn get playerId => text()();
/// Player data as JSON
TextColumn get playerJson => text()();
/// Current track for this player as JSON (for mini player display)
TextColumn get currentTrackJson => text().nullable()();
/// When this was last updated
DateTimeColumn get lastUpdated => dateTime()();
@override
Set<Column> get primaryKey => {playerId};
}
/// Cached queue items for selected player
class CachedQueue extends Table {
/// Auto-incrementing ID for ordering
IntColumn get id => integer().autoIncrement()();
/// Player ID this queue belongs to
TextColumn get playerId => text()();
/// Queue item as JSON
TextColumn get itemJson => text()();
/// Position in queue
IntColumn get position => integer()();
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
@override
MigrationStrategy get migration {
@@ -107,7 +167,12 @@ class AppDatabase extends _$AppDatabase {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
// Future migrations will go here
// Migration from v1 to v2: Add playback state, cached players, cached queue tables
if (from < 2) {
await m.createTable(playbackState);
await m.createTable(cachedPlayers);
await m.createTable(cachedQueue);
}
},
);
}
@@ -327,4 +392,136 @@ class AppDatabase extends _$AppDatabase {
if (lastSync == null) return true;
return DateTime.now().difference(lastSync) > maxAge;
}
// ============================================
// Playback State Operations
// ============================================
/// Save current playback state
Future<void> savePlaybackState({
String? playerId,
String? playerName,
String? currentTrackJson,
double positionSeconds = 0.0,
bool isPlaying = false,
}) async {
await into(playbackState).insertOnConflictUpdate(PlaybackStateCompanion.insert(
id: const Value('current'),
playerId: Value(playerId),
playerName: Value(playerName),
currentTrackJson: Value(currentTrackJson),
positionSeconds: Value(positionSeconds),
isPlaying: Value(isPlaying),
savedAt: DateTime.now(),
));
}
/// Get saved playback state
Future<PlaybackStateData?> getPlaybackState() async {
return (select(playbackState)..where((p) => p.id.equals('current')))
.getSingleOrNull();
}
/// Clear playback state
Future<void> clearPlaybackState() async {
await delete(playbackState).go();
}
// ============================================
// Cached Players Operations
// ============================================
/// Cache a player with its current track
Future<void> cachePlayer({
required String playerId,
required String playerJson,
String? currentTrackJson,
}) async {
await into(cachedPlayers).insertOnConflictUpdate(CachedPlayersCompanion.insert(
playerId: playerId,
playerJson: playerJson,
currentTrackJson: Value(currentTrackJson),
lastUpdated: DateTime.now(),
));
}
/// Cache multiple players at once
Future<void> cachePlayers(List<Map<String, dynamic>> players) async {
await transaction(() async {
for (final player in players) {
await into(cachedPlayers).insertOnConflictUpdate(CachedPlayersCompanion.insert(
playerId: player['playerId'] as String,
playerJson: player['playerJson'] as String,
currentTrackJson: Value(player['currentTrackJson'] as String?),
lastUpdated: DateTime.now(),
));
}
});
}
/// Get all cached players
Future<List<CachedPlayer>> getCachedPlayers() async {
return (select(cachedPlayers)
..orderBy([(p) => OrderingTerm.desc(p.lastUpdated)]))
.get();
}
/// Get a specific cached player
Future<CachedPlayer?> getCachedPlayer(String playerId) async {
return (select(cachedPlayers)..where((p) => p.playerId.equals(playerId)))
.getSingleOrNull();
}
/// Update cached track for a player
Future<void> updateCachedPlayerTrack(String playerId, String? trackJson) async {
await (update(cachedPlayers)..where((p) => p.playerId.equals(playerId)))
.write(CachedPlayersCompanion(
currentTrackJson: Value(trackJson),
lastUpdated: Value(DateTime.now()),
));
}
/// Clear all cached players
Future<void> clearCachedPlayers() async {
await delete(cachedPlayers).go();
}
// ============================================
// Cached Queue Operations
// ============================================
/// Save queue for a player
Future<void> saveQueue(String playerId, List<String> itemJsonList) async {
await transaction(() async {
// Clear existing queue for this player
await (delete(cachedQueue)..where((q) => q.playerId.equals(playerId))).go();
// Insert new queue items
for (var i = 0; i < itemJsonList.length; i++) {
await into(cachedQueue).insert(CachedQueueCompanion.insert(
playerId: playerId,
itemJson: itemJsonList[i],
position: i,
));
}
});
}
/// Get cached queue for a player
Future<List<CachedQueueData>> getCachedQueue(String playerId) async {
return (select(cachedQueue)
..where((q) => q.playerId.equals(playerId))
..orderBy([(q) => OrderingTerm.asc(q.position)]))
.get();
}
/// Clear queue for a player
Future<void> clearCachedQueue(String playerId) async {
await (delete(cachedQueue)..where((q) => q.playerId.equals(playerId))).go();
}
/// Clear all cached queues
Future<void> clearAllCachedQueues() async {
await delete(cachedQueue).go();
}
}
+9 -1
View File
@@ -269,8 +269,16 @@
"audiobookSingular": "Hörbuch",
"trackCount": "{count} {count, plural, =1{Song} other{Songs}}",
"materialYouDescription": "Systemfarben verwenden (Android 12+)",
"accentColor": "Akzentfarbe",
"players": "Geräte",
"preferLocalPlayer": "Lokales Gerät bevorzugen",
"preferLocalPlayerDescription": "Dieses Gerät immer zuerst auswählen, wenn verfügbar",
"smartSortPlayers": "Intelligente Sortierung",
"smartSortPlayersDescription": "Nach Status sortieren (spielt, an, aus) statt alphabetisch",
"playerStateUnavailable": "Nicht verfügbar",
"playerStateOff": "Aus",
"playerStateIdle": "Inaktiv",
"playerSelected": "Ausgewählt"
"playerSelected": "Ausgewählt",
"actionQueuedForSync": "Wird synchronisiert wenn online",
"pendingOfflineActions": "{count} ausstehende Synchronisierung"
}
+45
View File
@@ -1143,6 +1143,36 @@
"description": "Description for Material You theme option"
},
"accentColor": "Accent Color",
"@accentColor": {
"description": "Accent color selection label"
},
"players": "Players",
"@players": {
"description": "Players settings section title"
},
"preferLocalPlayer": "Prefer Local Player",
"@preferLocalPlayer": {
"description": "Setting to always prefer local player"
},
"preferLocalPlayerDescription": "Always select this device first when available",
"@preferLocalPlayerDescription": {
"description": "Description for prefer local player setting"
},
"smartSortPlayers": "Smart Sort",
"@smartSortPlayers": {
"description": "Setting to sort players by status"
},
"smartSortPlayersDescription": "Sort by status (playing, on, off) instead of alphabetically",
"@smartSortPlayersDescription": {
"description": "Description for smart sort players setting"
},
"playerStateUnavailable": "Unavailable",
"@playerStateUnavailable": {
"description": "Player state when unavailable"
@@ -1161,5 +1191,20 @@
"playerSelected": "Selected",
"@playerSelected": {
"description": "Label for selected player"
},
"actionQueuedForSync": "Will sync when online",
"@actionQueuedForSync": {
"description": "Message when action is queued for sync while offline"
},
"pendingOfflineActions": "{count} pending sync",
"@pendingOfflineActions": {
"description": "Shows number of pending offline actions",
"placeholders": {
"count": {
"type": "int"
}
}
}
}
+9 -1
View File
@@ -465,8 +465,16 @@
}
},
"materialYouDescription": "Usar colores del sistema (Android 12+)",
"accentColor": "Color de acento",
"players": "Dispositivos",
"preferLocalPlayer": "Preferir dispositivo local",
"preferLocalPlayerDescription": "Seleccionar siempre este dispositivo primero cuando esté disponible",
"smartSortPlayers": "Orden inteligente",
"smartSortPlayersDescription": "Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente",
"playerStateUnavailable": "No disponible",
"playerStateOff": "Apagado",
"playerStateIdle": "Inactivo",
"playerSelected": "Seleccionado"
"playerSelected": "Seleccionado",
"actionQueuedForSync": "Se sincronizará cuando esté en línea",
"pendingOfflineActions": "{count} sincronización pendiente"
}
+3 -3
View File
@@ -230,9 +230,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
lightColorScheme = light;
darkColorScheme = dark;
} else {
// Use brand color schemes
lightColorScheme = brandLightColorScheme;
darkColorScheme = brandDarkColorScheme;
// Use custom color from theme provider
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
darkColorScheme = generateDarkColorScheme(themeProvider.customColor);
}
return SystemUIWrapper(
+2 -1
View File
@@ -110,7 +110,8 @@ class MediaItem {
'media_type': mediaType.name,
if (sortName != null) 'sort_name': sortName,
if (uri != null) 'uri': uri,
if (providerMappings != null) 'provider_mappings': providerMappings,
if (providerMappings != null)
'provider_mappings': providerMappings!.map((m) => m.toJson()).toList(),
if (metadata != null) 'metadata': metadata,
if (favorite != null) 'favorite': favorite,
if (position != null) 'position': position,
+9 -1
View File
@@ -189,7 +189,9 @@ class ConnectionProvider with ChangeNotifier {
_logger.log('✅ MA authentication successful');
onAuthenticated?.call();
} else if (state == MAConnectionState.disconnected) {
_cacheService.clearAll();
// DON'T clear caches on disconnect - keep showing cached data
// This allows instant UI display when app resumes or reconnects
// Caches will be refreshed when connection is restored
onDisconnected?.call();
}
},
@@ -215,6 +217,12 @@ class ConnectionProvider with ChangeNotifier {
Future<void> disconnect() async {
await _api?.disconnect();
_connectionState = MAConnectionState.disconnected;
// DON'T clear caches on disconnect - keep showing cached data for instant reconnect
notifyListeners();
}
/// Clear all caches and state (for logout or server change)
void clearCachesOnLogout() {
_cacheService.clearAll();
notifyListeners();
}
+445 -43
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:audio_service/audio_service.dart' as audio_service;
@@ -20,7 +21,9 @@ import '../services/metadata_service.dart';
import '../services/position_tracker.dart';
import '../services/sendspin_service.dart';
import '../services/pcm_audio_player.dart';
import '../services/offline_action_queue.dart';
import '../constants/timings.dart';
import '../services/database_service.dart';
import '../main.dart' show audioHandler;
/// Main provider that coordinates connection, player, and library state.
@@ -177,6 +180,12 @@ class MusicAssistantProvider with ChangeNotifier {
return getImageUrl(track, size: size);
}
/// Number of pending offline actions
int get pendingOfflineActionsCount => OfflineActionQueue.instance.pendingCount;
/// Whether there are pending offline actions
bool get hasPendingOfflineActions => OfflineActionQueue.instance.hasPendingActions;
// ============================================================================
// INITIALIZATION
// ============================================================================
@@ -188,6 +197,13 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> _initialize() async {
_serverUrl = await SettingsService.getServerUrl();
// Load cached players from database for instant display (before connecting)
await _loadPlayersFromDatabase();
// Initialize offline action queue
await OfflineActionQueue.instance.initialize();
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
await _restoreAuthCredentials();
await connectToServer(_serverUrl!);
@@ -195,6 +211,74 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Load cached players from database for instant UI display
Future<void> _loadPlayersFromDatabase() async {
try {
if (!DatabaseService.instance.isInitialized) {
await DatabaseService.instance.initialize();
}
final cachedPlayers = await DatabaseService.instance.getCachedPlayers();
if (cachedPlayers.isEmpty) {
_logger.log('📦 No cached players in database');
return;
}
final players = <Player>[];
for (final cached in cachedPlayers) {
try {
final playerData = jsonDecode(cached.playerJson) as Map<String, dynamic>;
players.add(Player.fromJson(playerData));
} catch (e) {
_logger.log('⚠️ Error parsing cached player: $e');
}
}
if (players.isNotEmpty) {
_availablePlayers = players;
_cacheService.setCachedPlayers(players);
// Restore selected player from settings
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (lastSelectedPlayerId != null) {
try {
_selectedPlayer = players.firstWhere((p) => p.playerId == lastSelectedPlayerId);
_cacheService.setCachedSelectedPlayer(_selectedPlayer);
_logger.log('📦 Restored selected player from database: ${_selectedPlayer?.name}');
} catch (_) {
// Player not in cached list
}
}
_logger.log('📦 Loaded ${players.length} players from database (instant)');
notifyListeners();
}
} catch (e) {
_logger.log('⚠️ Error loading players from database: $e');
}
}
/// Persist players to database for app restart persistence
void _persistPlayersToDatabase(List<Player> players) {
// Run async but don't await - this is fire-and-forget persistence
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
final playerMaps = players.map((p) => {
'playerId': p.playerId,
'playerJson': jsonEncode(p.toJson()),
'currentTrackJson': null as String?, // Will be updated separately when track changes
}).toList();
await DatabaseService.instance.cachePlayers(playerMaps);
_logger.log('💾 Persisted ${players.length} players to database');
} catch (e) {
_logger.log('⚠️ Error persisting players to database: $e');
}
}();
}
Future<void> _restoreAuthCredentials() async {
final savedCredentials = await SettingsService.getAuthCredentials();
if (savedCredentials != null) {
@@ -247,11 +331,10 @@ class MusicAssistantProvider with ChangeNotifier {
// Now safe to initialize since we're authenticated
await _initializeAfterConnection();
} else if (state == MAConnectionState.disconnected) {
_availablePlayers = [];
_selectedPlayer = null;
// Only clear detail caches on disconnect, NOT home screen caches
// Home screen data should persist across reconnects for better UX
_cacheService.clearAllDetailCaches();
// DON'T clear players or caches on disconnect!
// Keep showing cached data for instant UI display on reconnect
// Player list and state will be refreshed when connection is restored
_logger.log('📡 Disconnected - keeping cached players and data for instant resume');
}
},
onError: (error) {
@@ -378,6 +461,9 @@ class MusicAssistantProvider with ChangeNotifier {
loadLibrary();
// Process any queued offline actions now that we're connected
await _processOfflineQueue();
_logger.log('✅ Post-connection initialization complete');
} catch (e) {
_logger.log('❌ Error during post-connection initialization: $e');
@@ -386,6 +472,131 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Process queued offline actions (favorites, playlist modifications, etc.)
Future<void> _processOfflineQueue() async {
if (_api == null || !isConnected) return;
final queue = OfflineActionQueue.instance;
if (!queue.hasPendingActions) return;
_logger.log('📋 Processing ${queue.pendingCount} offline actions...');
await queue.processQueue((action) async {
try {
switch (action.type) {
case OfflineActionTypes.toggleFavorite:
return await _executeToggleFavorite(action.params);
case OfflineActionTypes.addToPlaylist:
return await _executeAddToPlaylist(action.params);
case OfflineActionTypes.removeFromPlaylist:
return await _executeRemoveFromPlaylist(action.params);
default:
_logger.log('⚠️ Unknown offline action type: ${action.type}');
return false;
}
} catch (e) {
_logger.log('❌ Error executing offline action ${action.type}: $e');
return false;
}
});
}
/// Execute a queued toggle favorite action
Future<bool> _executeToggleFavorite(Map<String, dynamic> params) async {
if (_api == null) return false;
final mediaType = params['mediaType'] as String;
final add = params['add'] as bool;
if (add) {
final itemId = params['itemId'] as String;
final provider = params['provider'] as String;
await _api!.addToFavorites(mediaType, itemId, provider);
} else {
final libraryItemId = params['libraryItemId'] as int;
await _api!.removeFromFavorites(mediaType, libraryItemId);
}
return true;
}
/// Execute a queued add to playlist action
Future<bool> _executeAddToPlaylist(Map<String, dynamic> params) async {
// TODO: Implement when playlist modification is added
return false;
}
/// Execute a queued remove from playlist action
Future<bool> _executeRemoveFromPlaylist(Map<String, dynamic> params) async {
// TODO: Implement when playlist modification is added
return false;
}
// ============================================================================
// FAVORITE MANAGEMENT (WITH OFFLINE SUPPORT)
// ============================================================================
/// Add item to favorites with offline queuing support
/// Returns true if action was executed or queued successfully
Future<bool> addToFavorites({
required String mediaType,
required String itemId,
required String provider,
}) async {
if (isConnected && _api != null) {
// Online - execute immediately
try {
await _api!.addToFavorites(mediaType, itemId, provider);
return true;
} catch (e) {
_logger.log('❌ Failed to add to favorites: $e');
return false;
}
} else {
// Offline - queue the action
await OfflineActionQueue.instance.queueAction(
OfflineActionTypes.toggleFavorite,
{
'mediaType': mediaType,
'add': true,
'itemId': itemId,
'provider': provider,
},
);
_logger.log('📋 Queued add to favorites (offline): $mediaType');
return true;
}
}
/// Remove item from favorites with offline queuing support
/// Returns true if action was executed or queued successfully
Future<bool> removeFromFavorites({
required String mediaType,
required int libraryItemId,
}) async {
if (isConnected && _api != null) {
// Online - execute immediately
try {
await _api!.removeFromFavorites(mediaType, libraryItemId);
return true;
} catch (e) {
_logger.log('❌ Failed to remove from favorites: $e');
return false;
}
} else {
// Offline - queue the action
await OfflineActionQueue.instance.queueAction(
OfflineActionTypes.toggleFavorite,
{
'mediaType': mediaType,
'add': false,
'libraryItemId': libraryItemId,
},
);
_logger.log('📋 Queued remove from favorites (offline): $mediaType');
return true;
}
}
Future<void> disconnect() async {
_playerStateTimer?.cancel();
_playerStateTimer = null;
@@ -404,11 +615,21 @@ class MusicAssistantProvider with ChangeNotifier {
}
await _api?.disconnect();
_connectionState = MAConnectionState.disconnected;
// DON'T clear caches or player state - keep for instant reconnect
_logger.log('📡 Explicit disconnect - keeping cached data for instant resume');
notifyListeners();
}
/// Clear all caches and state (for logout or server change)
void clearAllOnLogout() {
_availablePlayers = [];
_selectedPlayer = null;
_artists = [];
_albums = [];
_tracks = [];
_currentTrack = null;
_cacheService.clearAll();
_logger.log('🗑️ Cleared all data on logout');
notifyListeners();
}
@@ -1399,25 +1620,17 @@ class MusicAssistantProvider with ChangeNotifier {
// ============================================================================
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
// Try local database first for instant results
if (!forceRefresh) {
final localAlbums = await RecentlyPlayedService.instance.getRecentAlbums(
// Always fetch from API to ensure we have full album data with images
// Local/cached data is used by getCachedRecentAlbums() for instant display
if (_api == null) {
// Fallback when offline: try memory cache, then local database
final cached = _cacheService.getCachedRecentAlbums();
if (cached != null && cached.isNotEmpty) return cached;
return RecentlyPlayedService.instance.getRecentAlbums(
limit: LibraryConstants.defaultRecentLimit,
);
if (localAlbums.isNotEmpty) {
_logger.log('📦 Using ${localAlbums.length} local recently played albums');
return localAlbums;
}
}
// Fall back to memory cache
if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached recent albums');
return _cacheService.getCachedRecentAlbums()!;
}
if (_api == null) return _cacheService.getCachedRecentAlbums() ?? [];
try {
_logger.log('🔄 Fetching fresh recent albums from MA...');
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
@@ -1425,7 +1638,12 @@ class MusicAssistantProvider with ChangeNotifier {
return albums;
} catch (e) {
_logger.log('❌ Failed to fetch recent albums: $e');
return _cacheService.getCachedRecentAlbums() ?? [];
// Fallback on error: try memory cache, then local database
final cached = _cacheService.getCachedRecentAlbums();
if (cached != null && cached.isNotEmpty) return cached;
return RecentlyPlayedService.instance.getRecentAlbums(
limit: LibraryConstants.defaultRecentLimit,
);
}
}
@@ -1471,6 +1689,15 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.invalidateHomeCache();
}
/// Get cached recent albums synchronously (for instant display)
List<Album>? getCachedRecentAlbums() => _cacheService.getCachedRecentAlbums();
/// Get cached discover artists synchronously (for instant display)
List<Artist>? getCachedDiscoverArtists() => _cacheService.getCachedDiscoverArtists();
/// Get cached discover albums synchronously (for instant display)
List<Album>? getCachedDiscoverAlbums() => _cacheService.getCachedDiscoverAlbums();
/// Force a full library sync (for pull-to-refresh)
Future<void> forceLibrarySync() async {
if (_api == null) return;
@@ -1717,6 +1944,21 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.clearAllDetailCaches();
}
/// Get cached album tracks (for instant display before background refresh)
List<Track>? getCachedAlbumTracks(String cacheKey) {
return _cacheService.getCachedAlbumTracks(cacheKey);
}
/// Get cached playlist tracks (for instant display before background refresh)
List<Track>? getCachedPlaylistTracks(String cacheKey) {
return _cacheService.getCachedPlaylistTracks(cacheKey);
}
/// Get cached artist albums (for instant display before background refresh)
List<Album>? getCachedArtistAlbums(String artistName) {
return _cacheService.getCachedArtistAlbums(artistName.toLowerCase());
}
// ============================================================================
// PLAYER SELECTION
// ============================================================================
@@ -1786,17 +2028,63 @@ class MusicAssistantProvider with ChangeNotifier {
return true;
}).toList();
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
// Sort players - check if smart sort is enabled
final smartSort = await SettingsService.getSmartSortPlayers();
if (smartSort) {
// Smart sort: local player first, then playing, then on, then off
_availablePlayers.sort((a, b) {
// Local player always first
final aIsLocal = builtinPlayerId != null && a.playerId == builtinPlayerId;
final bIsLocal = builtinPlayerId != null && b.playerId == builtinPlayerId;
if (aIsLocal && !bIsLocal) return -1;
if (bIsLocal && !aIsLocal) return 1;
// Then by status: playing > on > off
int statusPriority(Player p) {
if (p.state == 'playing') return 0;
if (p.powered && p.state != 'off') return 1;
return 2;
}
final aPriority = statusPriority(a);
final bPriority = statusPriority(b);
if (aPriority != bPriority) return aPriority.compareTo(bPriority);
// Within same status, sort alphabetically
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
} else {
// Default alphabetical sort
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
}
// Cache players for instant display on app resume
_cacheService.setCachedPlayers(_availablePlayers);
// Persist players to database for app restart persistence
_persistPlayersToDatabase(_availablePlayers);
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
if (_availablePlayers.isNotEmpty) {
Player? playerToSelect;
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (_selectedPlayer != null) {
// If "Prefer Local Player" is ON, always try to select local player first
// This takes priority even over the currently selected player
if (preferLocalPlayer && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Auto-selected local player (preferred): ${playerToSelect?.name}');
} catch (e) {
// Local player not available yet, will fall through to other options
}
}
// Keep currently selected player if still available (and not overridden by prefer local)
if (playerToSelect == null && _selectedPlayer != null) {
final stillAvailable = _availablePlayers.any(
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
);
@@ -1808,9 +2096,32 @@ class MusicAssistantProvider with ChangeNotifier {
}
if (playerToSelect == null) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
// Smart auto-selection priority:
// If "Prefer Local Player" is OFF: Single playing player -> Local player -> Last selected -> First available
if (lastSelectedPlayerId != null) {
if (!preferLocalPlayer) {
// Priority 1 (normal): Single playing player (skip if multiple are playing)
final playingPlayers = _availablePlayers.where(
(p) => p.state == 'playing' && p.available,
).toList();
if (playingPlayers.length == 1) {
playerToSelect = playingPlayers.first;
_logger.log('🎵 Auto-selected playing player: ${playerToSelect?.name}');
}
// Priority 2: Local player
if (playerToSelect == null && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
} catch (e) {}
}
}
// Priority 3: Last manually selected player
if (playerToSelect == null && lastSelectedPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == lastSelectedPlayerId && p.available,
@@ -1819,23 +2130,7 @@ class MusicAssistantProvider with ChangeNotifier {
} catch (e) {}
}
if (playerToSelect == null && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
} catch (e) {}
}
if (playerToSelect == null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.state == 'playing' && p.available,
);
} catch (e) {}
}
// Priority 4: First available player
if (playerToSelect == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
@@ -2729,7 +3024,60 @@ class MusicAssistantProvider with ChangeNotifier {
}
Future<PlayerQueue?> getQueue(String playerId) async {
return await _api?.getQueue(playerId);
final queue = await _api?.getQueue(playerId);
// Persist queue to database for instant display on app resume
if (queue != null) {
_persistQueueToDatabase(playerId, queue);
}
return queue;
}
/// Get cached queue for instant display (before API refresh)
Future<PlayerQueue?> getCachedQueue(String playerId) async {
try {
if (!DatabaseService.instance.isInitialized) return null;
final cachedItems = await DatabaseService.instance.getCachedQueue(playerId);
if (cachedItems.isEmpty) return null;
final items = <QueueItem>[];
for (final cached in cachedItems) {
try {
final itemData = jsonDecode(cached.itemJson) as Map<String, dynamic>;
items.add(QueueItem.fromJson(itemData));
} catch (e) {
_logger.log('⚠️ Error parsing cached queue item: $e');
}
}
if (items.isEmpty) return null;
return PlayerQueue(
playerId: playerId,
items: items,
currentIndex: 0, // Will be updated from fresh data
);
} catch (e) {
_logger.log('⚠️ Error loading cached queue: $e');
return null;
}
}
/// Persist queue to database for app restart persistence
void _persistQueueToDatabase(String playerId, PlayerQueue queue) {
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
await DatabaseService.instance.saveQueue(playerId, itemJsonList);
_logger.log('💾 Persisted ${queue.items.length} queue items to database');
} catch (e) {
_logger.log('⚠️ Error persisting queue to database: $e');
}
}();
}
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
@@ -2830,11 +3178,65 @@ class MusicAssistantProvider with ChangeNotifier {
// The stream_start event will trigger local playback
await _api?.resumePlayer(playerId);
} catch (e) {
// Check if this is a "No playable item" error - means server queue is empty
final errorStr = e.toString();
if (errorStr.contains('No playable item')) {
_logger.log('⚠️ Server queue empty - attempting to restore from cached queue...');
// Try to restore queue from cached data
final restored = await _restoreQueueFromCache(playerId);
if (restored) {
_logger.log('✅ Queue restored from cache - playback started');
return; // Successfully restored and playing
}
_logger.log('❌ Could not restore queue from cache');
}
ErrorHandler.logError('Resume player', e);
rethrow;
}
}
/// Attempt to restore the queue from cached data and start playback
Future<bool> _restoreQueueFromCache(String playerId) async {
try {
final cachedQueue = await getCachedQueue(playerId);
if (cachedQueue == null || cachedQueue.items.isEmpty) {
_logger.log('⚠️ No cached queue to restore');
return false;
}
// Extract tracks from queue items
// playTracks needs either: providerMappings with available entries, OR provider+itemId
final tracks = cachedQueue.items
.map((item) => item.track)
.where((track) {
// Check for providerMappings with at least one available entry
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
return track.providerMappings!.any((m) => m.available);
}
// Fallback: provider + itemId can be used to construct URI
return track.provider.isNotEmpty && track.itemId.isNotEmpty;
})
.toList();
if (tracks.isEmpty) {
_logger.log('⚠️ Cached queue has no valid tracks');
return false;
}
_logger.log('🔄 Restoring ${tracks.length} tracks from cached queue');
// Re-queue all tracks and start playback
await playTracks(playerId, tracks, startIndex: 0);
return true;
} catch (e) {
_logger.log('❌ Error restoring queue from cache: $e');
return false;
}
}
Future<void> nextTrack(String playerId) async {
try {
// Optimistic local stop for builtin player on skip - non-blocking
+134 -65
View File
@@ -16,11 +16,14 @@ import 'artist_details_screen.dart';
class AlbumDetailsScreen extends StatefulWidget {
final Album album;
final String? heroTagSuffix;
/// Initial image URL from the source (e.g., AlbumCard) for seamless hero animation
final String? initialImageUrl;
const AlbumDetailsScreen({
super.key,
required this.album,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
@@ -37,6 +40,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
int? _expandedTrackIndex;
bool _isDescriptionExpanded = false;
String? _albumDescription;
Album? _freshAlbum; // Full album data with image metadata
/// Get the best album data available (fresh with images, or widget.album as fallback)
Album get _displayAlbum => _freshAlbum ?? widget.album;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -46,29 +53,31 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
_isFavorite = widget.album.favorite ?? false;
_loadTracks();
_loadAlbumDescription();
_refreshFavoriteStatus();
// CRITICAL FIX: Defer color extraction until after the transition completes
// This prevents expensive palette extraction during hero animation
// Wait for the first frame to be painted, then extract colors
// CRITICAL FIX: Defer both fresh data loading AND color extraction until
// after the hero animation completes. This prevents:
// 1. setState with new image URL during animation → grey icon flash
// 2. Expensive palette extraction blocking animation frames
WidgetsBinding.instance.addPostFrameCallback((_) {
// Add small delay to ensure transition is complete
// 350ms matches FadeSlidePageRoute duration (300ms) + buffer
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_loadFreshAlbumData();
_extractColors();
}
});
});
}
Future<void> _refreshFavoriteStatus() async {
/// Load fresh album data from API to get full metadata including images
Future<void> _loadFreshAlbumData() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
// Need a URI to fetch fresh album data
final albumUri = widget.album.uri;
if (albumUri == null || albumUri.isEmpty) {
_logger.log('Cannot refresh favorite status: album has no URI');
_logger.log('Cannot load fresh album: album has no URI');
return;
}
@@ -76,17 +85,20 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
final freshAlbum = await maProvider.api!.getAlbumByUri(albumUri);
if (freshAlbum != null && mounted) {
setState(() {
_freshAlbum = freshAlbum;
_isFavorite = freshAlbum.favorite ?? false;
});
// Re-extract colors now that we have fresh album with images
_extractColors();
}
} catch (e) {
_logger.log('Error refreshing favorite status: $e');
_logger.log('Error loading fresh album data: $e');
}
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
final imageUrl = maProvider.getImageUrl(_displayAlbum, size: 512);
if (imageUrl == null) return;
@@ -108,10 +120,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
final newState = !_isFavorite;
bool success;
if (newState) {
// For adding: use the actual provider and itemId from provider_mappings
@@ -132,17 +144,18 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
_logger.log('Adding to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('album', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'album',
itemId: actualItemId,
provider: actualProvider,
);
} else {
// For removing: need the library_item_id (numeric)
// When provider is "library", itemId is the library ID as a string
// Otherwise, we need to find it from provider_mappings
int? libraryItemId;
if (widget.album.provider == 'library') {
libraryItemId = int.tryParse(widget.album.itemId);
} else if (widget.album.providerMappings != null) {
// Find the library mapping
final libraryMapping = widget.album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.album.providerMappings!.first,
@@ -158,25 +171,34 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('album', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'album',
libraryItemId: libraryItemId,
);
}
setState(() {
_isFavorite = newState;
});
if (success) {
// Optimistically update UI regardless of online/offline state
setState(() {
_isFavorite = newState;
});
// Invalidate home cache so the home screen shows updated favorite status
maProvider.invalidateHomeCache();
// Invalidate home cache so the home screen shows updated favorite status
maProvider.invalidateHomeCache();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
duration: const Duration(seconds: 1),
),
);
);
}
}
} catch (e) {
_logger.log('Error toggling favorite: $e');
@@ -196,11 +218,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
final track = _tracks[trackIndex];
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
final currentFavorite = track.favorite ?? false;
try {
bool success;
if (currentFavorite) {
// Remove from favorites - need library_item_id
int? libraryItemId;
@@ -217,7 +239,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
if (libraryItemId != null) {
await maProvider.api!.removeFromFavorites('track', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'track',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
} else {
// Add to favorites
@@ -236,33 +263,42 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
actualItemId = mapping.itemId;
}
await maProvider.api!.addToFavorites('track', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'track',
itemId: actualItemId,
provider: actualProvider,
);
}
// Update local state - create new track with toggled favorite
setState(() {
_tracks[trackIndex] = Track(
itemId: track.itemId,
provider: track.provider,
name: track.name,
uri: track.uri,
favorite: !currentFavorite,
artists: track.artists,
album: track.album,
duration: track.duration,
providerMappings: track.providerMappings,
);
});
if (success) {
// Optimistically update local state
setState(() {
_tracks[trackIndex] = Track(
itemId: track.itemId,
provider: track.provider,
name: track.name,
uri: track.uri,
favorite: !currentFavorite,
artists: track.artists,
album: track.album,
duration: track.duration,
providerMappings: track.providerMappings,
);
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
!currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (!currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
duration: const Duration(seconds: 1),
),
);
);
}
}
} catch (e) {
_logger.log('Error toggling track favorite: $e');
@@ -279,16 +315,46 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Future<void> _loadTracks() async {
final provider = context.read<MusicAssistantProvider>();
final tracks = await provider.getAlbumTracksWithCache(
widget.album.provider,
widget.album.itemId,
);
final cacheKey = '${widget.album.provider}_${widget.album.itemId}';
if (mounted) {
setState(() {
_tracks = tracks;
_isLoading = false;
});
// 1. Show cached data immediately (if available)
final cachedTracks = provider.getCachedAlbumTracks(cacheKey);
if (cachedTracks != null && cachedTracks.isNotEmpty) {
if (mounted) {
setState(() {
_tracks = cachedTracks;
_isLoading = false;
});
}
}
// 2. Fetch fresh data in background (silent refresh)
try {
final freshTracks = await provider.getAlbumTracksWithCache(
widget.album.provider,
widget.album.itemId,
forceRefresh: cachedTracks != null, // Force refresh if we had cache
);
// 3. Update if we got different data
if (mounted && freshTracks.isNotEmpty) {
final tracksChanged = _tracks.length != freshTracks.length ||
(_tracks.isNotEmpty && freshTracks.isNotEmpty &&
_tracks.first.itemId != freshTracks.first.itemId);
if (tracksChanged || _tracks.isEmpty) {
setState(() {
_tracks = freshTracks;
_isLoading = false;
});
}
}
} catch (e) {
_logger.log('⚠️ Background refresh failed: $e');
}
// Ensure loading is false even if everything failed
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
@@ -595,9 +661,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
// Only rebuild when specific properties change, not on every provider update
final imageUrl = context.select<MusicAssistantProvider, String?>(
(provider) => provider.getImageUrl(widget.album, size: 512),
// Use _displayAlbum which has fresh data with images if available
final providerImageUrl = context.select<MusicAssistantProvider, String?>(
(provider) => provider.getImageUrl(_displayAlbum, size: 512),
);
// Use initialImageUrl as fallback for seamless hero animation
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
+87 -36
View File
@@ -155,10 +155,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
final newState = !_isFavorite;
bool success;
if (newState) {
String actualProvider = widget.artist.provider;
@@ -177,7 +177,11 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
_logger.log('Adding artist to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('artist', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'artist',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
@@ -199,24 +203,32 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('artist', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
}
setState(() {
_isFavorite = newState;
});
if (success) {
setState(() {
_isFavorite = newState;
});
maProvider.invalidateHomeCache();
maProvider.invalidateHomeCache();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
duration: const Duration(seconds: 1),
),
);
);
}
}
} catch (e) {
_logger.log('Error toggling artist favorite: $e');
@@ -427,20 +439,51 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
Future<void> _loadArtistAlbums() async {
final provider = context.read<MusicAssistantProvider>();
// Use cached artist albums method
final allAlbums = await provider.getArtistAlbumsWithCache(widget.artist.name);
// 1. Show cached data immediately (if available)
final cachedAlbums = provider.getCachedArtistAlbums(widget.artist.name);
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
final libraryAlbums = cachedAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = cachedAlbums.where((a) => !a.inLibrary).toList();
if (mounted) {
// Separate library albums from provider-only albums
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
if (mounted) {
setState(() {
_albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums;
_sortAlbums();
_isLoading = false;
});
}
}
setState(() {
_albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums;
_sortAlbums(); // Apply saved sort order
_isLoading = false;
});
// 2. Fetch fresh data in background (silent refresh)
try {
final allAlbums = await provider.getArtistAlbumsWithCache(
widget.artist.name,
forceRefresh: cachedAlbums != null,
);
if (mounted && allAlbums.isNotEmpty) {
// Check if data actually changed
final albumsChanged = _albums.length != allAlbums.where((a) => a.inLibrary).length;
if (albumsChanged || _albums.isEmpty) {
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
setState(() {
_albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums;
_sortAlbums();
_isLoading = false;
});
}
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
@@ -510,10 +553,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
height: 200,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? Image.network(
imageUrl,
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Icon(
Icons.person_rounded,
size: 100,
color: colorScheme.onSurfaceVariant,
@@ -817,6 +862,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: AlbumDetailsScreen(
album: album,
heroTagSuffix: heroSuffix,
initialImageUrl: imageUrl,
),
),
);
@@ -833,12 +879,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? Image.network(
imageUrl,
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (_, __, ___) => Center(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.album_rounded,
size: 64,
@@ -940,10 +988,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
height: 56,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? Image.network(
imageUrl,
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
color: colorScheme.onSurfaceVariant,
),
@@ -979,6 +1029,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: AlbumDetailsScreen(
album: album,
heroTagSuffix: 'artist_albums',
initialImageUrl: imageUrl,
),
),
);
+14 -3
View File
@@ -19,12 +19,14 @@ class AudiobookAuthorScreen extends StatefulWidget {
final String authorName;
final List<Audiobook> audiobooks;
final String? heroTagSuffix;
final String? initialAuthorImageUrl;
const AudiobookAuthorScreen({
super.key,
required this.authorName,
required this.audiobooks,
this.heroTagSuffix,
this.initialAuthorImageUrl,
});
@override
@@ -48,6 +50,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
void initState() {
super.initState();
_audiobooks = List.from(widget.audiobooks);
// Use initial image URL immediately for smooth hero animation
_authorImageUrl = widget.initialAuthorImageUrl;
_loadViewPreferences();
_sortAudiobooks();
_loadAuthorImage();
@@ -181,6 +185,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
fit: BoxFit.cover,
width: 200,
height: 200,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.accountOutline,
size: 100,
@@ -329,7 +335,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final heroSuffix = 'author${_heroTagSuffix}';
return InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -349,6 +355,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
@@ -443,6 +451,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
@@ -508,17 +518,18 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
);
}
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: book,
heroTagSuffix: heroTagSuffix,
initialImageUrl: initialImageUrl,
),
),
);
+34 -15
View File
@@ -15,11 +15,14 @@ import '../l10n/app_localizations.dart';
class AudiobookDetailScreen extends StatefulWidget {
final Audiobook audiobook;
final String? heroTagSuffix;
/// Initial image URL from the source for seamless hero animation
final String? initialImageUrl;
const AudiobookDetailScreen({
super.key,
required this.audiobook,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
@@ -119,10 +122,10 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
final newState = !_isFavorite;
bool success;
if (newState) {
String actualProvider = widget.audiobook.provider;
@@ -141,7 +144,11 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
}
_logger.log('Adding audiobook to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('audiobook', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
@@ -162,22 +169,30 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
}
_logger.log('Removing audiobook from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('audiobook', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
}
setState(() {
_isFavorite = newState;
});
if (success) {
setState(() {
_isFavorite = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
duration: const Duration(seconds: 1),
),
);
);
}
}
} catch (e) {
_logger.log('Error toggling audiobook favorite: $e');
@@ -414,7 +429,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
final providerImageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
// Use initialImageUrl as fallback for seamless hero animation
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
@@ -528,6 +545,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
+10 -3
View File
@@ -517,6 +517,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
@@ -633,7 +635,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
final heroSuffix = 'series_${widget.series.id}';
return InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -653,6 +655,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
@@ -746,6 +750,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
@@ -811,17 +817,18 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
);
}
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: book,
heroTagSuffix: heroTagSuffix,
initialImageUrl: initialImageUrl,
),
),
);
+3 -1
View File
@@ -47,7 +47,9 @@ class LibraryAlbumsScreen extends StatelessWidget {
Widget _buildAlbumsList(BuildContext context, List<Album> albums, bool isLoading) {
final colorScheme = Theme.of(context).colorScheme;
if (isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (albums.isEmpty && isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
+3 -1
View File
@@ -49,7 +49,9 @@ class LibraryArtistsScreen extends StatelessWidget {
Widget _buildArtistsList(BuildContext context, List<Artist> artists, bool isLoading) {
final colorScheme = Theme.of(context).colorScheme;
if (isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (artists.isEmpty && isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
+3 -1
View File
@@ -75,7 +75,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (_isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (_playlists.isEmpty && _isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
+3 -1
View File
@@ -42,7 +42,9 @@ class LibraryTracksScreen extends StatelessWidget {
BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (provider.isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (provider.tracks.isEmpty && provider.isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
+3
View File
@@ -304,6 +304,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('recent-albums'),
title: S.of(context)!.recentlyPlayed,
loadAlbums: () => provider.getRecentAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedRecentAlbums(),
rowHeight: rowHeight,
);
case 'discover-artists':
@@ -312,6 +313,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('discover-artists'),
title: S.of(context)!.discoverArtists,
loadArtists: () => provider.getDiscoverArtistsWithCache(),
getCachedArtists: () => provider.getCachedDiscoverArtists(),
rowHeight: rowHeight,
);
case 'discover-albums':
@@ -320,6 +322,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('discover-albums'),
title: S.of(context)!.discoverAlbums,
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
rowHeight: rowHeight,
);
case 'continue-listening':
+18 -7
View File
@@ -1005,6 +1005,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
fit: BoxFit.cover,
width: 48,
height: 48,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Text(
authorName.isNotEmpty ? authorName[0].toUpperCase() : '?',
style: TextStyle(
@@ -1047,7 +1049,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
color: colorScheme.onSurface.withOpacity(0.6),
),
),
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix'),
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix', initialAuthorImageUrl: authorImageUrl),
);
}
@@ -1059,7 +1061,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Match music artist card layout
return GestureDetector(
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix'),
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix', initialAuthorImageUrl: authorImageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -1088,6 +1090,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
fit: BoxFit.cover,
width: size,
height: size,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Text(
authorName.isNotEmpty ? authorName[0].toUpperCase() : '?',
@@ -1142,7 +1146,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
void _navigateToAuthor(String authorName, List<Audiobook> books, {String? heroTagSuffix}) {
void _navigateToAuthor(String authorName, List<Audiobook> books, {String? heroTagSuffix, String? initialAuthorImageUrl}) {
Navigator.push(
context,
FadeSlidePageRoute(
@@ -1150,6 +1154,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
authorName: authorName,
audiobooks: books,
heroTagSuffix: heroTagSuffix,
initialAuthorImageUrl: initialAuthorImageUrl,
),
),
);
@@ -1222,17 +1227,18 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix'),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix', initialImageUrl: imageUrl),
);
}
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: book,
heroTagSuffix: heroTagSuffix,
initialImageUrl: initialImageUrl,
),
),
);
@@ -1325,7 +1331,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final heroSuffix = _showFavoritesOnly ? '_fav' : '';
return GestureDetector(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix'),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix', initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1346,6 +1352,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
@@ -2188,7 +2196,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
Navigator.push(
context,
FadeSlidePageRoute(
child: AlbumDetailsScreen(album: album),
child: AlbumDetailsScreen(
album: album,
initialImageUrl: imageUrl,
),
),
);
},
+40 -13
View File
@@ -35,21 +35,48 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
}
Future<void> _loadTracks() async {
setState(() {
_isLoading = true;
});
final maProvider = context.read<MusicAssistantProvider>();
final tracks = await maProvider.getPlaylistTracksWithCache(
widget.provider,
widget.itemId,
);
final cacheKey = '${widget.provider}_${widget.itemId}';
if (mounted) {
setState(() {
_tracks = tracks;
_isLoading = false;
});
// 1. Show cached data immediately (if available)
final cachedTracks = maProvider.getCachedPlaylistTracks(cacheKey);
if (cachedTracks != null && cachedTracks.isNotEmpty) {
if (mounted) {
setState(() {
_tracks = cachedTracks;
_isLoading = false;
});
}
} else {
setState(() => _isLoading = true);
}
// 2. Fetch fresh data in background (silent refresh)
try {
final freshTracks = await maProvider.getPlaylistTracksWithCache(
widget.provider,
widget.itemId,
forceRefresh: cachedTracks != null,
);
// 3. Update if we got different data
if (mounted && freshTracks.isNotEmpty) {
final tracksChanged = _tracks.length != freshTracks.length ||
(_tracks.isNotEmpty && freshTracks.isNotEmpty &&
_tracks.first.itemId != freshTracks.first.itemId);
if (tracksChanged || _tracks.isEmpty) {
setState(() {
_tracks = freshTracks;
_isLoading = false;
});
}
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
+10 -2
View File
@@ -200,7 +200,12 @@ class SearchScreenState extends State<SearchScreen> {
Widget _buildSearchContent() {
final colorScheme = Theme.of(context).colorScheme;
if (_isSearching) {
// Show cached results even while searching - only show spinner if no cached results
final hasCachedResults = _searchResults['artists']?.isNotEmpty == true ||
_searchResults['albums']?.isNotEmpty == true ||
_searchResults['tracks']?.isNotEmpty == true;
if (_isSearching && !hasCachedResults) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
@@ -491,7 +496,10 @@ class SearchScreenState extends State<SearchScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AlbumDetailsScreen(album: album),
builder: (context) => AlbumDetailsScreen(
album: album,
initialImageUrl: imageUrl,
),
),
);
},
+197 -53
View File
@@ -6,6 +6,7 @@ import '../providers/music_assistant_provider.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import '../theme/app_theme.dart';
import '../widgets/global_player_overlay.dart';
import 'debug_log_screen.dart';
import 'login_screen.dart';
@@ -37,6 +38,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Audiobook libraries
List<Map<String, String>> _discoveredLibraries = [];
Map<String, bool> _libraryEnabled = {};
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
@override
void initState() {
@@ -81,6 +85,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
libraryEnabled[path] = enabled == null || enabled.contains(path);
}
// Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -95,6 +103,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
_homeRowOrder = rowOrder;
_discoveredLibraries = discovered;
_libraryEnabled = libraryEnabled;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
});
}
}
@@ -410,7 +420,137 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 16),
// Language picker
// Accent color picker
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
final isDisabled = themeProvider.useMaterialTheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.accentColor,
style: TextStyle(
color: isDisabled
? colorScheme.onSurface.withOpacity(0.4)
: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: accentColorOptions.map((color) {
final isSelected = themeProvider.customColor.value == color.value;
return GestureDetector(
onTap: isDisabled ? null : () {
themeProvider.setCustomColor(color);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: 40,
height: 40,
decoration: BoxDecoration(
color: isDisabled ? color.withOpacity(0.3) : color,
shape: BoxShape.circle,
border: isSelected && !isDisabled
? Border.all(color: colorScheme.onSurface, width: 3)
: null,
boxShadow: isSelected && !isDisabled
? [BoxShadow(color: color.withOpacity(0.5), blurRadius: 8, spreadRadius: 2)]
: null,
),
child: isSelected && !isDisabled
? Icon(Icons.check, color: Colors.white, size: 20)
: null,
),
);
}).toList(),
),
],
),
);
},
),
const SizedBox(height: 16),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.materialYou,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.materialYouDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.useMaterialTheme,
onChanged: (value) {
themeProvider.setUseMaterialTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
),
const SizedBox(height: 16),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.adaptiveTheme,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.extractColorsFromArtwork,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.adaptiveTheme,
onChanged: (value) {
themeProvider.setAdaptiveTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
),
const SizedBox(height: 32),
// Language section
Text(
S.of(context)!.language,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Consumer<LocaleProvider>(
builder: (context, localeProvider, _) {
String getLanguageName(String? code) {
@@ -493,64 +633,68 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
const SizedBox(height: 32),
// Player section
Text(
S.of(context)!.players,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.materialYou,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.materialYouDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.useMaterialTheme,
onChanged: (value) {
themeProvider.setUseMaterialTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.preferLocalPlayer,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.preferLocalPlayerDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _preferLocalPlayer,
onChanged: (value) {
setState(() => _preferLocalPlayer = value);
SettingsService.setPreferLocalPlayer(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 16),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.adaptiveTheme,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.extractColorsFromArtwork,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.adaptiveTheme,
onChanged: (value) {
themeProvider.setAdaptiveTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.smartSortPlayers,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.smartSortPlayersDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _smartSortPlayers,
onChanged: (value) {
setState(() => _smartSortPlayers = value);
SettingsService.setSmartSortPlayers(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
+66
View File
@@ -156,4 +156,70 @@ class DatabaseService {
/// Clear all cached data (useful for logout)
Future<void> clearAllCache() => db.clearAllCache();
// ============================================
// Player Cache Convenience Methods
// ============================================
/// Save playback state for instant resume
Future<void> savePlaybackState({
String? playerId,
String? playerName,
String? currentTrackJson,
double positionSeconds = 0.0,
bool isPlaying = false,
}) {
return db.savePlaybackState(
playerId: playerId,
playerName: playerName,
currentTrackJson: currentTrackJson,
positionSeconds: positionSeconds,
isPlaying: isPlaying,
);
}
/// Get saved playback state
Future<PlaybackStateData?> getPlaybackState() => db.getPlaybackState();
/// Cache a player with optional track info
Future<void> cachePlayer({
required String playerId,
required String playerJson,
String? currentTrackJson,
}) {
return db.cachePlayer(
playerId: playerId,
playerJson: playerJson,
currentTrackJson: currentTrackJson,
);
}
/// Cache multiple players at once
Future<void> cachePlayers(List<Map<String, dynamic>> players) {
return db.cachePlayers(players);
}
/// Get all cached players
Future<List<CachedPlayer>> getCachedPlayers() => db.getCachedPlayers();
/// Update cached track for a player
Future<void> updateCachedPlayerTrack(String playerId, String? trackJson) {
return db.updateCachedPlayerTrack(playerId, trackJson);
}
/// Clear all cached players
Future<void> clearCachedPlayers() => db.clearCachedPlayers();
/// Save queue for a player
Future<void> saveQueue(String playerId, List<String> itemJsonList) {
return db.saveQueue(playerId, itemJsonList);
}
/// Get cached queue for a player
Future<List<CachedQueueData>> getCachedQueue(String playerId) {
return db.getCachedQueue(playerId);
}
/// Clear queue for a player
Future<void> clearCachedQueue(String playerId) => db.clearCachedQueue(playerId);
}
+38 -15
View File
@@ -1069,16 +1069,14 @@ class MusicAssistantAPI {
return [];
}
// Batch fetch track details using parallel requests (max 5 concurrent)
final seenAlbumIds = <String>{};
final albums = <Album>[];
// Step 1: Fetch tracks in batches and collect unique album URIs
final seenAlbumUris = <String>{};
final albumUris = <String>[];
// Process in batches to avoid overwhelming the server
const batchSize = LibraryConstants.recentAlbumsBatchSize;
for (var i = 0; i < trackUris.length && albums.length < limit; i += batchSize) {
for (var i = 0; i < trackUris.length && albumUris.length < limit; i += batchSize) {
final batch = trackUris.skip(i).take(batchSize).toList();
// Fetch batch in parallel
final futures = batch.map((uri) => _sendCommand(
'music/item_by_uri',
args: {'uri': uri},
@@ -1087,27 +1085,52 @@ class MusicAssistantAPI {
final results = await Future.wait(futures, eagerError: false);
for (final trackResponse in results) {
if (albums.length >= limit) break;
if (albumUris.length >= limit) break;
try {
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
final albumUri = albumData?['uri'] as String?;
if (albumData != null) {
final albumId = albumData['item_id']?.toString() ?? albumData['uri']?.toString();
if (albumId != null && !seenAlbumIds.contains(albumId)) {
seenAlbumIds.add(albumId);
albums.add(Album.fromJson(albumData));
}
if (albumUri != null && !seenAlbumUris.contains(albumUri)) {
seenAlbumUris.add(albumUri);
albumUris.add(albumUri);
}
} catch (_) {
// Skip tracks that fail to parse
continue;
}
}
}
_logger.log('📚 Fetched ${albums.length} recent albums using ${(trackUris.length / batchSize).ceil()} batch requests');
if (albumUris.isEmpty) {
return [];
}
// Step 2: Fetch full album details (with images) for each album URI
final albums = <Album>[];
for (var i = 0; i < albumUris.length; i += batchSize) {
final batch = albumUris.skip(i).take(batchSize).toList();
final futures = batch.map((uri) => _sendCommand(
'music/item_by_uri',
args: {'uri': uri},
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null}));
final results = await Future.wait(futures, eagerError: false);
for (final albumResponse in results) {
try {
final albumData = albumResponse['result'] as Map<String, dynamic>?;
if (albumData != null) {
albums.add(Album.fromJson(albumData));
}
} catch (_) {
continue;
}
}
}
_logger.log('📚 Fetched ${albums.length} recent albums with full details');
return albums;
} catch (e) {
_logger.log('❌ Error getting recent albums: $e');
+138
View File
@@ -0,0 +1,138 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'debug_logger.dart';
/// Represents a queued action that will be executed when connection is restored
class QueuedAction {
final String type;
final Map<String, dynamic> params;
final DateTime queuedAt;
QueuedAction({
required this.type,
required this.params,
required this.queuedAt,
});
Map<String, dynamic> toJson() => {
'type': type,
'params': params,
'queuedAt': queuedAt.toIso8601String(),
};
factory QueuedAction.fromJson(Map<String, dynamic> json) => QueuedAction(
type: json['type'] as String,
params: json['params'] as Map<String, dynamic>,
queuedAt: DateTime.parse(json['queuedAt'] as String),
);
}
/// Service for queuing actions when offline and executing them when back online
class OfflineActionQueue {
static OfflineActionQueue? _instance;
static OfflineActionQueue get instance => _instance ??= OfflineActionQueue._();
OfflineActionQueue._();
final _logger = DebugLogger();
static const _prefsKey = 'offline_action_queue';
List<QueuedAction> _queue = [];
bool _isProcessing = false;
/// Get the current queue (for display)
List<QueuedAction> get queue => List.unmodifiable(_queue);
/// Check if there are pending actions
bool get hasPendingActions => _queue.isNotEmpty;
/// Number of pending actions
int get pendingCount => _queue.length;
/// Initialize and load persisted queue
Future<void> initialize() async {
try {
final prefs = await SharedPreferences.getInstance();
final queueJson = prefs.getString(_prefsKey);
if (queueJson != null) {
final List<dynamic> decoded = jsonDecode(queueJson);
_queue = decoded.map((j) => QueuedAction.fromJson(j as Map<String, dynamic>)).toList();
_logger.log('📋 Loaded ${_queue.length} offline actions from storage');
}
} catch (e) {
_logger.log('⚠️ Error loading offline action queue: $e');
}
}
/// Queue an action for later execution
Future<void> queueAction(String type, Map<String, dynamic> params) async {
final action = QueuedAction(
type: type,
params: params,
queuedAt: DateTime.now(),
);
_queue.add(action);
await _persistQueue();
_logger.log('📋 Queued offline action: $type');
}
/// Process all queued actions (call when connection is restored)
Future<void> processQueue(Future<bool> Function(QueuedAction action) executor) async {
if (_isProcessing || _queue.isEmpty) return;
_isProcessing = true;
_logger.log('🔄 Processing ${_queue.length} offline actions...');
final processed = <QueuedAction>[];
for (final action in _queue) {
try {
final success = await executor(action);
if (success) {
processed.add(action);
_logger.log('✅ Processed offline action: ${action.type}');
} else {
_logger.log('⚠️ Failed to process offline action: ${action.type}');
}
} catch (e) {
_logger.log('❌ Error processing offline action: $e');
}
}
// Remove processed actions
_queue.removeWhere((a) => processed.contains(a));
await _persistQueue();
_isProcessing = false;
_logger.log('📋 ${processed.length} actions processed, ${_queue.length} remaining');
}
/// Clear all queued actions
Future<void> clearQueue() async {
_queue.clear();
await _persistQueue();
_logger.log('🗑️ Offline action queue cleared');
}
/// Persist queue to storage
Future<void> _persistQueue() async {
try {
final prefs = await SharedPreferences.getInstance();
if (_queue.isEmpty) {
await prefs.remove(_prefsKey);
} else {
final queueJson = jsonEncode(_queue.map((a) => a.toJson()).toList());
await prefs.setString(_prefsKey, queueJson);
}
} catch (e) {
_logger.log('⚠️ Error persisting offline action queue: $e');
}
}
}
/// Action types for offline queuing
class OfflineActionTypes {
static const String toggleFavorite = 'toggle_favorite';
static const String addToPlaylist = 'add_to_playlist';
static const String removeFromPlaylist = 'remove_from_playlist';
}
+10
View File
@@ -25,6 +25,8 @@ class RecentlyPlayedService {
if (!_db.isInitialized) return;
try {
// Store images metadata for instant artwork display
final images = album.metadata?['images'];
await _db.addRecentlyPlayed(
mediaId: album.itemId,
mediaType: 'album',
@@ -33,6 +35,7 @@ class RecentlyPlayedService {
metadata: {
'provider': album.provider,
'uri': album.uri,
if (images != null) 'images': images,
},
);
_logger.log('📝 Recorded album play: ${album.name}');
@@ -151,6 +154,12 @@ class RecentlyPlayedService {
} catch (_) {}
}
// Build metadata with images if available (for instant artwork display)
Map<String, dynamic>? albumMetadata;
if (metadata?['images'] != null) {
albumMetadata = {'images': metadata!['images']};
}
albums.add(Album(
itemId: item.mediaId,
provider: metadata?['provider'] ?? 'library',
@@ -160,6 +169,7 @@ class RecentlyPlayedService {
artists: item.artistName != null
? [Artist(itemId: '', provider: '', name: item.artistName!)]
: null,
metadata: albumMetadata,
));
if (albums.length >= limit) break;
+24
View File
@@ -22,6 +22,8 @@ class SettingsService {
static const String _keyLocalPlayerName = 'local_player_name';
static const String _keyOwnerName = 'owner_name';
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
static const String _keyPreferLocalPlayer = 'prefer_local_player';
static const String _keySmartSortPlayers = 'smart_sort_players';
static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
@@ -364,6 +366,28 @@ class SettingsService {
}
}
// Prefer Local Player - always select local player first when available
static Future<bool> getPreferLocalPlayer() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyPreferLocalPlayer) ?? false;
}
static Future<void> setPreferLocalPlayer(bool prefer) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyPreferLocalPlayer, prefer);
}
// Smart Sort Players - sort by status (playing > on > off) instead of alphabetically
static Future<bool> getSmartSortPlayers() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keySmartSortPlayers) ?? false;
}
static Future<void> setSmartSortPlayers(bool smartSort) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keySmartSortPlayers, smartSort);
}
// Helper to create player name with possessive apostrophe
static String _makePlayerName(String ownerName) {
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
+32 -1
View File
@@ -4,7 +4,17 @@ import 'package:flutter/material.dart';
const Color brandPrimaryColor = Color(0xFF1a1a1a);
const Color brandAccentColor = Color(0xFF604CEC);
// Brand color schemes
// Predefined accent colors for custom theme selection
const List<Color> accentColorOptions = [
Color(0xFF604CEC), // Purple (default)
Color(0xFF2196F3), // Blue
Color(0xFF009688), // Teal
Color(0xFFFF5722), // Deep Orange
Color(0xFFE91E63), // Pink
Color(0xFF4CAF50), // Green
];
// Brand color schemes (default)
final ColorScheme brandLightColorScheme = ColorScheme.fromSeed(
seedColor: brandAccentColor,
brightness: Brightness.light,
@@ -21,6 +31,27 @@ final ColorScheme brandDarkColorScheme = ColorScheme.fromSeed(
background: const Color(0xFF1a1a1a),
);
// Generate color schemes from a custom seed color
ColorScheme generateLightColorScheme(Color seedColor) {
return ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.light,
background: const Color(0xFFFAFAFA),
onBackground: const Color(0xFF1a1a1a),
surface: const Color(0xFFFFFFFF),
onSurface: const Color(0xFF1a1a1a),
);
}
ColorScheme generateDarkColorScheme(Color seedColor) {
return ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
surface: const Color(0xFF2a2a2a),
background: const Color(0xFF1a1a1a),
);
}
class AppTheme {
// Generate light theme
static ThemeData lightTheme({ColorScheme? colorScheme}) {
+3 -1
View File
@@ -97,6 +97,7 @@ class _AlbumCardState extends State<AlbumCard> {
child: AlbumDetailsScreen(
album: widget.album,
heroTagSuffix: widget.heroTagSuffix,
initialImageUrl: imageUrl,
),
),
);
@@ -119,7 +120,8 @@ class _AlbumCardState extends State<AlbumCard> {
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) {
// Try fallback on error (only for MA URLs, not fallback URLs)
+111 -69
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -10,6 +11,8 @@ class AlbumRow extends StatefulWidget {
final Future<List<Album>> Function() loadAlbums;
final String? heroTagSuffix;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Album>? Function()? getCachedAlbums;
const AlbumRow({
super.key,
@@ -17,6 +20,7 @@ class AlbumRow extends StatefulWidget {
required this.loadAlbums,
this.heroTagSuffix,
this.rowHeight,
this.getCachedAlbums,
});
@override
@@ -24,8 +28,8 @@ class AlbumRow extends StatefulWidget {
}
class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin {
late Future<List<Album>> _albumsFuture;
List<Album>? _cachedAlbums; // Keep last loaded data to prevent flash
List<Album> _albums = [];
bool _isLoading = true;
bool _hasLoaded = false;
@override
@@ -34,21 +38,117 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
@override
void initState() {
super.initState();
_loadAlbumsOnce();
_loadAlbums();
}
void _loadAlbumsOnce() {
if (!_hasLoaded) {
_albumsFuture = widget.loadAlbums().then((albums) {
_cachedAlbums = albums;
return albums;
});
_hasLoaded = true;
Future<void> _loadAlbums() async {
if (_hasLoaded) return;
_hasLoaded = true;
// 1. Try to get cached data synchronously for instant display
final cachedAlbums = widget.getCachedAlbums?.call();
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
if (mounted) {
setState(() {
_albums = cachedAlbums;
_isLoading = false;
});
}
}
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshAlbums = await widget.loadAlbums();
if (mounted && freshAlbums.isNotEmpty) {
setState(() {
_albums = freshAlbums;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precacheAlbumImages(freshAlbums);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
/// Pre-cache album images so hero animations are smooth on first tap
void _precacheAlbumImages(List<Album> albums) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
// Only precache first ~10 visible items to avoid excessive network/memory use
final albumsToCache = albums.take(10);
for (final album in albumsToCache) {
final imageUrl = maProvider.api?.getImageUrl(album, size: 256);
if (imageUrl != null) {
// Use CachedNetworkImageProvider to warm the cache
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
}
static final _logger = DebugLogger();
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
// Only show loading if we have no data at all
if (_albums.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_albums.isEmpty) {
return Center(
child: Text(
'No albums found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _albums.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final album = _albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('AlbumRow:${widget.title}');
@@ -78,65 +178,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
),
),
Expanded(
child: FutureBuilder<List<Album>>(
future: _albumsFuture,
builder: (context, snapshot) {
// Use cached data immediately if available (prevents flash on rebuild)
final albums = snapshot.data ?? _cachedAlbums;
// Only show loading if we have no data at all
if (albums == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && albums == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (albums == null || albums.isEmpty) {
return Center(
child: Text(
'No albums found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: albums.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final album = albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
},
),
child: _buildContent(contentHeight, colorScheme),
),
],
),
+2
View File
@@ -100,6 +100,8 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
width: widget.radius * 2,
height: widget.radius * 2,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
color: colorScheme.surfaceVariant,
child: Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant),
+44 -43
View File
@@ -100,49 +100,50 @@ class _ArtistCardState extends State<ArtistCard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Artist image - circular, constrained to be a proper circle
Expanded(
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
// Use the smaller dimension to ensure a circle
final size = constraints.maxWidth < constraints.maxHeight
? constraints.maxWidth
: constraints.maxHeight;
return Hero(
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: ClipOval(
child: Container(
width: size,
height: size,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) {
// Try fallback on error (only for MA URLs, not fallback URLs)
if (!_maImageFailed && url == maImageUrl) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_onImageError();
});
}
return Icon(
Icons.person_rounded,
size: size * 0.55,
color: colorScheme.onSurfaceVariant,
);
},
)
: Icon(Icons.person_rounded, size: size * 0.55, color: colorScheme.onSurfaceVariant),
),
),
);
},
// Artist image - circular
// PERF: Use AspectRatio instead of LayoutBuilder to provide fixed geometry
// before Hero animation starts. This prevents grey icon flash caused by
// dynamic sizing inside the Hero widget.
AspectRatio(
aspectRatio: 1.0,
child: Hero(
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: ClipOval(
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) {
// Try fallback on error (only for MA URLs, not fallback URLs)
if (!_maImageFailed && url == maImageUrl) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_onImageError();
});
}
return Center(
child: Icon(
Icons.person_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
);
},
)
: Center(
child: Icon(
Icons.person_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
+111 -69
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -10,6 +11,8 @@ class ArtistRow extends StatefulWidget {
final Future<List<Artist>> Function() loadArtists;
final String? heroTagSuffix;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Artist>? Function()? getCachedArtists;
const ArtistRow({
super.key,
@@ -17,6 +20,7 @@ class ArtistRow extends StatefulWidget {
required this.loadArtists,
this.heroTagSuffix,
this.rowHeight,
this.getCachedArtists,
});
@override
@@ -24,8 +28,8 @@ class ArtistRow extends StatefulWidget {
}
class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixin {
late Future<List<Artist>> _artistsFuture;
List<Artist>? _cachedArtists; // Keep last loaded data to prevent flash
List<Artist> _artists = [];
bool _isLoading = true;
bool _hasLoaded = false;
@override
@@ -34,21 +38,117 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
@override
void initState() {
super.initState();
_loadArtistsOnce();
_loadArtists();
}
void _loadArtistsOnce() {
if (!_hasLoaded) {
_artistsFuture = widget.loadArtists().then((artists) {
_cachedArtists = artists;
return artists;
});
_hasLoaded = true;
Future<void> _loadArtists() async {
if (_hasLoaded) return;
_hasLoaded = true;
// 1. Try to get cached data synchronously for instant display
final cachedArtists = widget.getCachedArtists?.call();
if (cachedArtists != null && cachedArtists.isNotEmpty) {
if (mounted) {
setState(() {
_artists = cachedArtists;
_isLoading = false;
});
}
}
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshArtists = await widget.loadArtists();
if (mounted && freshArtists.isNotEmpty) {
setState(() {
_artists = freshArtists;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precacheArtistImages(freshArtists);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
/// Pre-cache artist images so hero animations are smooth on first tap
void _precacheArtistImages(List<Artist> artists) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
// Only precache first ~10 visible items to avoid excessive network/memory use
final artistsToCache = artists.take(10);
for (final artist in artistsToCache) {
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
if (imageUrl != null) {
// Use CachedNetworkImageProvider to warm the cache
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
}
static final _logger = DebugLogger();
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
// Only show loading if we have no data at all
if (_artists.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_artists.isEmpty) {
return Center(
child: Text(
'No artists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: circle image + name below
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _artists.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final artist = _artists[index];
return Container(
key: ValueKey(artist.uri ?? artist.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: ArtistCard(
artist: artist,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('ArtistRow:${widget.title}');
@@ -78,65 +178,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
),
),
Expanded(
child: FutureBuilder<List<Artist>>(
future: _artistsFuture,
builder: (context, snapshot) {
// Use cached data immediately if available (prevents flash on rebuild)
final artists = snapshot.data ?? _cachedArtists;
// Only show loading if we have no data at all
if (artists == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && artists == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (artists == null || artists.isEmpty) {
return Center(
child: Text(
'No artists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: circle image + name below
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: artists.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final artist = artists[index];
return Container(
key: ValueKey(artist.uri ?? artist.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: ArtistCard(
artist: artist,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
},
),
child: _buildContent(contentHeight, colorScheme),
),
],
),
+6
View File
@@ -173,6 +173,7 @@ class _AudiobookCard extends StatelessWidget {
child: AudiobookDetailScreen(
audiobook: audiobook,
heroTagSuffix: heroTagSuffix,
initialImageUrl: imageUrl,
),
),
);
@@ -195,6 +196,11 @@ class _AudiobookCard extends StatelessWidget {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// PERF: Duration.zero for hero-wrapped images
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
memCacheWidth: 256,
memCacheHeight: 256,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
+124 -193
View File
@@ -15,6 +15,7 @@ import 'animated_icon_button.dart';
import 'global_player_overlay.dart';
import 'volume_control.dart';
import 'player/player_widgets.dart';
import 'player/mini_player_content.dart';
/// A unified player widget that seamlessly expands from mini to full-screen.
///
@@ -150,7 +151,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
);
_controller.addStatusListener((status) {
if (status == AnimationStatus.forward) {
// PERF: Load queue after animation completes, not during
// This prevents network I/O from competing with animation frames
if (status == AnimationStatus.completed) {
_loadQueue();
} else if (status == AnimationStatus.dismissed) {
// Close queue panel when player collapses
@@ -320,24 +323,49 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
Future<void> _loadQueue() async {
if (_isLoadingQueue) return;
setState(() => _isLoadingQueue = true);
final maProvider = context.read<MusicAssistantProvider>();
final player = maProvider.selectedPlayer;
if (player != null && maProvider.api != null) {
final queue = await maProvider.api!.getQueue(player.playerId);
if (mounted) {
setState(() {
_queue = queue;
_isLoadingQueue = false;
});
}
if (player == null) {
if (mounted) setState(() => _isLoadingQueue = false);
return;
}
// 1. Show cached queue immediately (if available)
final cachedQueue = await maProvider.getCachedQueue(player.playerId);
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
setState(() {
_queue = cachedQueue;
_isLoadingQueue = false;
});
} else {
if (mounted) {
setState(() => _isLoadingQueue = false);
setState(() => _isLoadingQueue = true);
}
// 2. Fetch fresh queue in background
if (maProvider.api != null) {
try {
final freshQueue = await maProvider.getQueue(player.playerId);
if (mounted && freshQueue != null) {
// Only update if queue changed
final queueChanged = _queue == null ||
_queue!.items.length != freshQueue.items.length ||
_queue!.currentIndex != freshQueue.currentIndex;
if (queueChanged) {
setState(() {
_queue = freshQueue;
_isLoadingQueue = false;
});
}
}
} catch (e) {
// Silent failure - keep showing cached queue
}
}
if (mounted && _isLoadingQueue) {
setState(() => _isLoadingQueue = false);
}
}
Future<void> _extractColors(String imageUrl) async {
@@ -417,14 +445,15 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}
}
/// Toggle favorite status for current track
/// Toggle favorite status for current track (with offline queuing support)
Future<void> _toggleCurrentTrackFavorite(dynamic currentTrack) async {
if (currentTrack == null) return;
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
bool success;
if (_isCurrentTrackFavorite) {
// Remove from favorites - need library_item_id (numeric)
int? libraryItemId;
@@ -446,7 +475,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}
if (libraryItemId != null) {
await maProvider.api!.removeFromFavorites('track', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'track',
libraryItemId: libraryItemId,
);
} else {
throw Exception('Could not determine library ID for this track');
}
@@ -479,16 +511,37 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}
}
await maProvider.api!.addToFavorites('track', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'track',
itemId: actualItemId,
provider: actualProvider,
);
}
// Toggle local state
setState(() {
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
});
if (success) {
// Toggle local state
setState(() {
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
});
// Invalidate home cache so favorites are updated
maProvider.invalidateHomeCache();
// Invalidate home cache so favorites are updated
maProvider.invalidateHomeCache();
// Show feedback (different message if offline)
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isCurrentTrackFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
// Show error feedback
if (mounted) {
@@ -1101,23 +1154,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final expandedArtTop = topPadding + headerHeight + 16;
final artTop = _lerpDouble(collapsedArtTop, expandedArtTop, t);
// Typography - clear hierarchy
// Title: bold, prominent (24px)
// Artist: medium weight, secondary (18px expanded, was 16px)
// Album: light, tertiary (15px expanded, was 13px)
final titleFontSize = _lerpDouble(16.0, 24.0, t);
final artistFontSize = _lerpDouble(14.0, 18.0, t); // 14px collapsed, 18px expanded
// Typography - uses shared MiniPlayerLayout constants for collapsed state
final titleFontSize = _lerpDouble(MiniPlayerLayout.primaryFontSize, 24.0, t);
final artistFontSize = _lerpDouble(MiniPlayerLayout.secondaryFontSize, 18.0, t);
final collapsedTitleLeft = _collapsedArtSize + 8; // Reduced from 12 to 8 (4px less)
final expandedTitleLeft = contentPadding;
final titleLeft = _lerpDouble(collapsedTitleLeft, expandedTitleLeft, t);
final titleLeft = _lerpDouble(MiniPlayerLayout.textLeft, expandedTitleLeft, t);
final collapsedTitleTop = (_collapsedHeight - 36) / 2; // Centered vertically (adjusted for increased track/artist gap)
final collapsedTitleTop = MiniPlayerLayout.primaryTop;
// 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 collapsedTitleWidth = collapsedWidth - MiniPlayerLayout.textLeft - 122;
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
@@ -1174,7 +1222,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final titleTop = _lerpDouble(collapsedTitleTop, expandedTitleTop, t);
// Artist positioned dynamically based on actual title height
final collapsedArtistTop = collapsedTitleTop + 22; // Increased gap from 18 to 22
final collapsedArtistTop = MiniPlayerLayout.secondaryTop;
final expandedArtistTop = expandedTitleTop + expandedTitleHeight + titleToArtistGap;
final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t);
@@ -1315,41 +1363,41 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Shows played portion with brighter color, unplayed with darker
// Starts after album art so progress is visible
// Slides with content during swipe, hidden during transition if peek available
// GPU PERF: Use conditional rendering + color alpha instead of Opacity
// to avoid double saveLayer (Opacity + ClipRRect)
if (t < 0.5 && currentTrack?.duration != null && !(_inTransition && t < 0.1 && _peekPlayer != null))
Positioned(
left: _collapsedArtSize + miniPlayerSlideOffset,
top: 0,
width: width - _collapsedArtSize,
bottom: 0,
child: ValueListenableBuilder<int>(
valueListenable: _progressNotifier,
builder: (context, elapsedSeconds, _) {
final totalSeconds = currentTrack!.duration!.inSeconds;
if (totalSeconds <= 0) return const SizedBox.shrink();
final progress = (elapsedSeconds / totalSeconds).clamp(0.0, 1.0);
// Fade out as we expand
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
final progressAreaWidth = width - _collapsedArtSize;
return Opacity(
opacity: progressOpacity,
child: ClipRRect(
left: _collapsedArtSize + miniPlayerSlideOffset,
top: 0,
width: width - _collapsedArtSize,
bottom: 0,
child: ValueListenableBuilder<int>(
valueListenable: _progressNotifier,
builder: (context, elapsedSeconds, _) {
final totalSeconds = currentTrack!.duration!.inSeconds;
if (totalSeconds <= 0) return const SizedBox.shrink();
final progress = (elapsedSeconds / totalSeconds).clamp(0.0, 1.0);
// Fade out as we expand - use color alpha instead of Opacity widget
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
final progressAreaWidth = width - _collapsedArtSize;
return ClipRRect(
borderRadius: BorderRadius.only(
topRight: Radius.circular(borderRadius),
bottomRight: Radius.circular(borderRadius),
),
clipBehavior: Clip.hardEdge,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: progressAreaWidth * progress,
height: height,
color: collapsedBg,
color: collapsedBg.withOpacity(progressOpacity),
),
),
),
);
},
);
},
),
),
),
// Peek player content (shows when dragging OR during transition)
// Show when: actively dragging (slideOffset != 0) OR in transition state
@@ -1357,20 +1405,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
if (t < 0.1 && _peekPlayer != null && (_slideOffset.abs() > 0.01 || _inTransition))
_buildPeekContent(
context: context,
maProvider: maProvider,
peekPlayer: _peekPlayer,
peekImageUrl: _peekImageUrl,
slideOffset: _slideOffset,
containerWidth: collapsedWidth,
artSize: _collapsedArtSize,
titleLeft: collapsedTitleLeft,
titleTop: collapsedTitleTop,
titleWidth: collapsedTitleWidth,
artistTop: collapsedArtistTop,
titleFontSize: 16.0,
artistFontSize: 14.0,
backgroundColor: collapsedBg,
textColor: textColor,
colorScheme: colorScheme,
),
// Album art - with slide animation when collapsed
@@ -1442,9 +1482,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
style: TextStyle(
color: textColor,
fontSize: titleFontSize,
fontWeight: t > 0.5 ? FontWeight.w600 : FontWeight.w500,
fontWeight: t > 0.5 ? FontWeight.w600 : MiniPlayerLayout.primaryFontWeight,
letterSpacing: t > 0.5 ? -0.5 : 0,
height: 1.2,
height: t > 0.5 ? 1.2 : null, // Only use line height when expanded
),
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
maxLines: t > 0.5 ? 2 : 1,
@@ -1470,7 +1510,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
: currentTrack.artistsString,
style: TextStyle(
color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6),
color: textColor.withOpacity(t > 0.5 ? 0.7 : MiniPlayerLayout.secondaryTextOpacity),
fontSize: artistFontSize,
fontWeight: t > 0.5 ? FontWeight.w400 : FontWeight.normal,
),
@@ -1851,158 +1891,49 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}
/// Build the peek player content that slides in from the edge during drag
/// Uses shared MiniPlayerContent for consistency
Widget _buildPeekContent({
required BuildContext context,
required MusicAssistantProvider maProvider,
required dynamic peekPlayer,
required String? peekImageUrl,
required double slideOffset,
required double containerWidth,
required double artSize,
required double titleLeft,
required double titleTop,
required double titleWidth,
required double artistTop,
required double titleFontSize,
required double artistFontSize,
required Color backgroundColor,
required Color textColor,
required ColorScheme colorScheme,
}) {
// Calculate sliding position
// During transition (_inTransition = true), peek should be at center (offset 0)
// When sliding left (negative offset), peek comes from right
// When sliding right (positive offset), peek comes from left
double peekBaseOffset;
if (_inTransition && slideOffset.abs() < 0.01) {
// During transition with slideOffset at 0, show peek at center
peekBaseOffset = 0.0;
} else {
final isFromRight = slideOffset < 0;
final peekProgress = slideOffset.abs();
// Position calculation:
// From right: starts at containerWidth (off right edge), moves to 0 as progress increases
// From left: starts at -containerWidth (off left edge), moves to 0 as progress increases
peekBaseOffset = isFromRight
? containerWidth * (1 - peekProgress) // Slides in from right
: -containerWidth * (1 - peekProgress); // Slides in from left
? containerWidth * (1 - peekProgress)
: -containerWidth * (1 - peekProgress);
}
// Check if peek player has a track - if not, show device info instead
// Check if peek player has a track
final hasTrack = _peekTrack != null && peekImageUrl != null;
final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? S.of(context)!.unknown);
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : S.of(context)!.swipeToSwitchDevice;
return Stack(
children: [
// Peek album art / device icon
Positioned(
left: peekBaseOffset,
top: 0,
child: Container(
width: artSize,
height: artSize,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
),
child: hasTrack
? CachedNetworkImage(
imageUrl: peekImageUrl!,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholderFadeInDuration: Duration.zero,
placeholder: (_, __) => _buildMiniPlaceholderArt(colorScheme),
errorWidget: (_, __, ___) => _buildMiniPlaceholderArt(colorScheme),
)
: _buildDeviceIcon(peekPlayer?.name ?? '', artSize, colorScheme),
),
),
// Peek track title / device name
Positioned(
left: titleLeft + peekBaseOffset,
top: titleTop,
child: SizedBox(
width: titleWidth,
child: Text(
peekTrackName,
style: TextStyle(
color: textColor,
fontSize: titleFontSize,
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
// Peek artist name / device hint
Positioned(
left: titleLeft + peekBaseOffset,
top: artistTop,
child: SizedBox(
width: titleWidth,
child: Text(
peekArtistName,
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: artistFontSize,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
);
}
/// Build device icon for non-playing peek player
Widget _buildDeviceIcon(String playerName, double size, ColorScheme colorScheme) {
final nameLower = playerName.toLowerCase();
IconData icon;
if (nameLower.contains('phone') || nameLower.contains('ensemble')) {
icon = Icons.phone_android_rounded;
} else if (nameLower.contains('group') || nameLower.contains('all')) {
icon = Icons.speaker_group_rounded;
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
icon = Icons.tv_rounded;
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
icon = Icons.cast_rounded;
} else {
icon = Icons.speaker_rounded;
}
return Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
icon,
color: colorScheme.onSurfaceVariant,
size: size * 0.5,
),
return Transform.translate(
offset: Offset(peekBaseOffset, 0),
child: MiniPlayerContent(
primaryText: peekTrackName,
secondaryText: peekArtistName,
imageUrl: hasTrack ? peekImageUrl : null,
playerName: peekPlayer?.name ?? '',
backgroundColor: backgroundColor,
textColor: textColor,
width: containerWidth,
slideOffset: 0, // Transform handles positioning
),
);
}
/// Simplified placeholder for mini player peek
Widget _buildMiniPlaceholderArt(ColorScheme colorScheme) {
return Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note_rounded,
color: colorScheme.onSurfaceVariant,
size: 24,
),
);
}
/// Build peek player progress bar that slides in during swipe
Widget _buildPeekProgressBar({
+26 -18
View File
@@ -150,7 +150,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
// State for player reveal overlay
bool _isRevealVisible = false;
double _bounceOffset = 0.0;
// Use ValueNotifier instead of setState for bounce offset
// This prevents full widget tree rebuilds during animation
final _bounceOffsetNotifier = ValueNotifier<double>(0.0);
// Key for the reveal overlay
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
@@ -178,13 +181,12 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
curve: Curves.easeOut,
);
// Use ValueNotifier instead of setState to avoid full widget rebuilds
// This isolates the rebuild to only the widgets that depend on bounce offset
_bounceController.addListener(() {
setState(() {
// Quick dip: goes down 10px at peak (0.5), then back to 0
final t = _bounceAnimation.value;
// Sine curve: 0 -> 1 -> 0 as t goes 0 -> 0.5 -> 1
_bounceOffset = 10.0 * (t < 0.5 ? t * 2 : (1.0 - t) * 2);
});
final t = _bounceAnimation.value;
// Sine curve: 0 -> 1 -> 0 as t goes 0 -> 0.5 -> 1
_bounceOffsetNotifier.value = 10.0 * (t < 0.5 ? t * 2 : (1.0 - t) * 2);
});
}
@@ -192,6 +194,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
void dispose() {
_slideController.dispose();
_bounceController.dispose();
_bounceOffsetNotifier.dispose();
super.dispose();
}
@@ -218,9 +221,9 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
void _hidePlayerReveal() {
if (!_isRevealVisible) return;
_bounceOffsetNotifier.value = 0;
setState(() {
_isRevealVisible = false;
_bounceOffset = 0;
});
}
@@ -380,16 +383,21 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
if (!state.isConnected || !state.hasPlayer) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: _slideAnimation,
child: ExpandablePlayer(key: globalPlayerKey, slideOffset: 0),
builder: (context, staticChild) {
// Only slideOffset changes during animation, player widget stays same
return ExpandablePlayer(
key: globalPlayerKey,
slideOffset: _slideAnimation.value,
bounceOffset: _bounceOffset,
onRevealPlayers: _showPlayerReveal,
// Combine slide and bounce animations with ValueListenableBuilder
// This prevents full widget tree rebuilds - only ExpandablePlayer updates
return ValueListenableBuilder<double>(
valueListenable: _bounceOffsetNotifier,
builder: (context, bounceOffset, _) {
return AnimatedBuilder(
animation: _slideAnimation,
builder: (context, _) {
return ExpandablePlayer(
key: globalPlayerKey,
slideOffset: _slideAnimation.value,
bounceOffset: bounceOffset,
onRevealPlayers: _showPlayerReveal,
);
},
);
},
);
+23 -115
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import 'mini_player_content.dart';
/// A compact device selector bar shown when no track is playing
class DeviceSelectorBar extends StatelessWidget {
@@ -34,6 +35,8 @@ class DeviceSelectorBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final swipeHint = hasMultiplePlayers ? S.of(context)!.swipeToSwitchDevice : null;
return GestureDetector(
onHorizontalDragStart: hasMultiplePlayers ? onHorizontalDragStart : null,
onHorizontalDragUpdate: hasMultiplePlayers ? onHorizontalDragUpdate : null,
@@ -52,59 +55,18 @@ class DeviceSelectorBar extends StatelessWidget {
children: [
// Peek player content (shows when dragging)
if (slideOffset.abs() > 0.01 && peekPlayer != null)
_buildPeekContent(context),
_buildPeekContent(context, swipeHint),
// Current player content
Transform.translate(
offset: Offset(slideOffset * width, 0),
child: Row(
children: [
// Speaker icon - same size as album art for consistent text alignment
SizedBox(
width: height,
height: height,
child: Container(
color: Color.lerp(backgroundColor, Colors.black, 0.15),
child: Center(
child: Icon(
_getPlayerIcon(selectedPlayer.name),
color: textColor.withOpacity(0.4),
size: 28,
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedPlayer.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (hasMultiplePlayers)
Text(
S.of(context)!.swipeToSwitchDevice,
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 14,
),
),
],
),
),
),
],
),
MiniPlayerContent(
primaryText: selectedPlayer.name,
secondaryText: swipeHint,
imageUrl: null,
playerName: selectedPlayer.name,
backgroundColor: backgroundColor,
textColor: textColor,
width: width,
slideOffset: slideOffset,
),
],
),
@@ -115,7 +77,7 @@ class DeviceSelectorBar extends StatelessWidget {
}
/// Build peek player content that slides in from the edge
Widget _buildPeekContent(BuildContext context) {
Widget _buildPeekContent(BuildContext context, String? swipeHint) {
final isFromRight = slideOffset < 0;
final peekProgress = slideOffset.abs();
@@ -126,70 +88,16 @@ class DeviceSelectorBar extends StatelessWidget {
return Transform.translate(
offset: Offset(peekBaseOffset, 0),
child: Row(
children: [
// Speaker icon - same size as album art for consistent text alignment
SizedBox(
width: height,
height: height,
child: Container(
color: Color.lerp(backgroundColor, Colors.black, 0.15),
child: Center(
child: Icon(
_getPlayerIcon(peekPlayer.name),
color: textColor.withOpacity(0.4),
size: 28,
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
peekPlayer.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (hasMultiplePlayers)
Text(
S.of(context)!.swipeToSwitchDevice,
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 14,
),
),
],
),
),
),
],
child: MiniPlayerContent(
primaryText: peekPlayer.name,
secondaryText: swipeHint,
imageUrl: null,
playerName: peekPlayer.name,
backgroundColor: backgroundColor,
textColor: textColor,
width: width,
slideOffset: 0, // No additional slide - Transform handles positioning
),
);
}
/// Get appropriate icon for player based on name
IconData _getPlayerIcon(String playerName) {
final nameLower = playerName.toLowerCase();
if (nameLower.contains('phone') || nameLower.contains('ensemble')) {
return Icons.phone_android_rounded;
} else if (nameLower.contains('group')) {
return Icons.speaker_group_rounded;
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
return Icons.tv_rounded;
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
return Icons.cast_rounded;
} else {
return Icons.speaker_rounded;
}
}
}
+189
View File
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
/// Shared constants for mini player layout
class MiniPlayerLayout {
static const double height = 64.0;
static const double artSize = 64.0;
static const double textLeftOffset = 10.0; // Gap between art and text
static const double textLeft = artSize + textLeftOffset; // 74px
static const double primaryTop = 13.0;
static const double secondaryTop = 33.0;
static const double textRightPadding = 12.0;
static const double iconSize = 28.0;
static const double iconOpacity = 0.4;
static const double secondaryTextOpacity = 0.6;
static const double primaryFontSize = 16.0;
static const double secondaryFontSize = 14.0;
static const FontWeight primaryFontWeight = FontWeight.w500;
}
/// Unified mini player content widget used by:
/// - DeviceSelectorBar (non-playing state)
/// - ExpandablePlayer collapsed state (playing state)
/// - Peek content during swipe gestures
class MiniPlayerContent extends StatelessWidget {
/// Primary text line (track name or device name)
final String primaryText;
/// Secondary text line (artist name or "Swipe to switch device")
/// If null, primary text will be vertically centered
final String? secondaryText;
/// Album art URL - if null, shows device icon
final String? imageUrl;
/// Player name for icon selection when no image
final String playerName;
/// Background color for the content area
final Color backgroundColor;
/// Text color
final Color textColor;
/// Width of the content area
final double width;
/// Horizontal slide offset for swipe animation (-1 to 1)
final double slideOffset;
/// Whether to show the progress bar overlay (for playing state)
final bool showProgress;
/// Progress value 0.0 to 1.0 (only used if showProgress is true)
final double progress;
const MiniPlayerContent({
super.key,
required this.primaryText,
this.secondaryText,
this.imageUrl,
required this.playerName,
required this.backgroundColor,
required this.textColor,
required this.width,
this.slideOffset = 0.0,
this.showProgress = false,
this.progress = 0.0,
});
@override
Widget build(BuildContext context) {
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
final slidePixels = slideOffset * width;
// Calculate text width (leaves room for right padding)
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
// Darkened background for icon area (matches DeviceSelectorBar)
final iconAreaBackground = Color.lerp(backgroundColor, Colors.black, 0.15)!;
return SizedBox(
width: width,
height: MiniPlayerLayout.height,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
// Progress bar background (only for playing state)
if (showProgress && progress > 0)
Positioned(
left: MiniPlayerLayout.artSize + slidePixels,
top: 0,
bottom: 0,
width: (width - MiniPlayerLayout.artSize) * progress,
child: Container(color: backgroundColor),
),
// Art / Icon area
Positioned(
left: slidePixels,
top: 0,
child: SizedBox(
width: MiniPlayerLayout.artSize,
height: MiniPlayerLayout.artSize,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl!,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => _buildIconArea(iconAreaBackground),
errorWidget: (_, __, ___) => _buildIconArea(iconAreaBackground),
)
: _buildIconArea(iconAreaBackground),
),
),
// Primary text line
Positioned(
left: MiniPlayerLayout.textLeft + slidePixels,
top: hasSecondaryLine
? MiniPlayerLayout.primaryTop
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
right: MiniPlayerLayout.textRightPadding - slidePixels,
child: Text(
primaryText,
style: TextStyle(
color: textColor,
fontSize: MiniPlayerLayout.primaryFontSize,
fontWeight: MiniPlayerLayout.primaryFontWeight,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Secondary text line
if (hasSecondaryLine)
Positioned(
left: MiniPlayerLayout.textLeft + slidePixels,
top: MiniPlayerLayout.secondaryTop,
right: MiniPlayerLayout.textRightPadding - slidePixels,
child: Text(
secondaryText!,
style: TextStyle(
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
fontSize: MiniPlayerLayout.secondaryFontSize,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
/// Build the icon area with device-appropriate icon
Widget _buildIconArea(Color background) {
return Container(
color: background,
child: Center(
child: Icon(
_getPlayerIcon(playerName),
color: textColor.withOpacity(MiniPlayerLayout.iconOpacity),
size: MiniPlayerLayout.iconSize,
),
),
);
}
/// Get appropriate icon based on player name
static IconData _getPlayerIcon(String playerName) {
final nameLower = playerName.toLowerCase();
if (nameLower.contains('phone') || nameLower.contains('ensemble') || nameLower.contains('mobile')) {
return Icons.phone_android_rounded;
} else if (nameLower.contains('group') || nameLower.contains('sync') || nameLower.contains('all')) {
return Icons.speaker_group_rounded;
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
return Icons.tv_rounded;
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
return Icons.cast_rounded;
} else {
return Icons.speaker_rounded;
}
}
}
+16 -18
View File
@@ -55,14 +55,13 @@ class PlayerControls extends StatelessWidget {
mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [
// Shuffle (expanded only)
if (isExpanded)
Opacity(
opacity: expandedElementsOpacity,
child: _buildSecondaryButton(
icon: Icons.shuffle_rounded,
color: shuffle == true ? primaryColor : textColor.withOpacity(0.5),
onPressed: isLoadingQueue ? null : onToggleShuffle,
),
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
if (isExpanded && expandedElementsOpacity > 0.1)
_buildSecondaryButton(
icon: Icons.shuffle_rounded,
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
onPressed: isLoadingQueue ? null : onToggleShuffle,
),
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
@@ -90,17 +89,16 @@ class PlayerControls extends StatelessWidget {
),
// Repeat (expanded only)
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
if (isExpanded)
Opacity(
opacity: expandedElementsOpacity,
child: _buildSecondaryButton(
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
color: repeatMode != null && repeatMode != 'off'
? primaryColor
: textColor.withOpacity(0.5),
onPressed: isLoadingQueue ? null : onCycleRepeat,
),
if (isExpanded && expandedElementsOpacity > 0.1)
_buildSecondaryButton(
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
color: (repeatMode != null && repeatMode != 'off'
? primaryColor
: textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
onPressed: isLoadingQueue ? null : onCycleRepeat,
),
],
);
+5
View File
@@ -118,6 +118,11 @@ class QueuePanel extends StatelessWidget {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// PERF: 4x cache size for 44x44 display, zero fade for scrolling
memCacheWidth: 176,
memCacheHeight: 176,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
+43 -27
View File
@@ -18,40 +18,56 @@ class PlayerSelector extends StatelessWidget {
final availablePlayers = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
// Count players that are currently playing
final playingCount = availablePlayers.where((p) => p.state == 'playing').length;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Material(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
child: Badge(
isLabelVisible: playingCount > 0,
backgroundColor: colorScheme.tertiary,
textColor: colorScheme.onTertiary,
label: Text(
playingCount.toString(),
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 11,
color: colorScheme.onTertiary,
),
),
child: Material(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (selectedPlayer != null) ...[
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(
selectedPlayer.name,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
child: InkWell(
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (selectedPlayer != null) ...[
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(
selectedPlayer.name,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 8),
],
Icon(
MdiIcons.castAudio,
color: colorScheme.onPrimaryContainer,
size: 20,
),
const SizedBox(width: 8),
],
Icon(
MdiIcons.castAudio,
color: colorScheme.onPrimaryContainer,
size: 20,
),
],
),
),
),
),
+44 -2
View File
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import '../screens/audiobook_series_screen.dart';
import '../utils/page_transitions.dart';
class SeriesRow extends StatefulWidget {
final String title;
@@ -267,10 +268,11 @@ class _SeriesCard extends StatelessWidget {
return GestureDetector(
onTap: () {
// PERF: Use FadeSlidePageRoute for consistent hero animation timing
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AudiobookSeriesScreen(
FadeSlidePageRoute(
child: AudiobookSeriesScreen(
series: series,
heroTag: heroTag,
initialCovers: covers,
@@ -284,6 +286,44 @@ class _SeriesCard extends StatelessWidget {
// Use AspectRatio to guarantee square cover
Hero(
tag: heroTag,
// PERF: Use flightShuttleBuilder to avoid animating complex grid
// during hero flight. Show cached first cover image instead.
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
// Use first cover as simple placeholder during flight
final heroChild = toContext.widget as Hero;
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return ClipRRect(
borderRadius: BorderRadius.circular(8 + 4 * animation.value),
child: child,
);
},
child: covers != null && covers!.isNotEmpty
? CachedNetworkImage(
imageUrl: covers!.first,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => Container(
color: colorScheme.surfaceContainerHighest,
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.collections_bookmark_rounded,
size: 48,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
),
);
},
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
@@ -373,6 +413,8 @@ class _SeriesCard extends StatelessWidget {
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
+3 -1
View File
@@ -186,7 +186,9 @@ class _TrackCard extends StatelessWidget {
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
// PERF: Duration.zero for smooth scrolling
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Icon(
Icons.music_note_rounded,
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.3.3-beta+25
version: 2.4.1-beta+26
environment:
sdk: '>=3.0.0 <4.0.0'