Compare commits

...
51 Commits
Author SHA1 Message Date
Your Name aa48c9e6d7 Bump version to 1.8.0 2025-12-10 21:24:52 +00:00
Your NameandClaude Opus 4.5 c3a197ff93 Merge fix/search-keyboard-behavior: Search UX + logging improvements
- Keep keyboard open while typing and searching
- Improved logging system with levels and bug report sharing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 21:24:26 +00:00
Your NameandClaude Opus 4.5 1edb5a73c9 Improve logging system for user bug reports
Upgraded DebugLogger with:
- Log levels (debug, info, warning, error) for better filtering
- Context parameter for categorizing log sources
- generateBugReport() with device info, app version, and log stats
- Increased log retention to 1000 entries

Updated debug log screen:
- Added share button to generate and share bug reports
- Added level filter chips (All, Info+, Warnings, Errors)
- Color-coded log entries by level
- Shows filtered entry count

Replaced raw print() statements with proper logging:
- metadata_service.dart: API errors as warnings
- artist_card.dart: Fallback image lookups as debug
- palette_helper.dart: Palette extraction errors as warnings
- theme_provider.dart: Color extraction as debug
- system_theme_helper.dart: System color errors as warnings
- playlist_details_screen.dart: Playlist operations as info/error

Added dependencies:
- package_info_plus: App version info for reports
- device_info_plus: Device details for reports
- share_plus: Native share functionality

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:45:32 +00:00
Your NameandClaude Opus 4.5 498ed53106 Fix search: keep keyboard open while typing and searching
- Restored debounce-based search for instant feedback while typing
- Added keepFocus parameter to _performSearch
- Re-request focus after search completes to keep keyboard open
- User can still dismiss keyboard manually or press search to submit

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:22:52 +00:00
Your NameandClaude Opus 4.5 6fcd34752f Fix search: only search on submit, not while typing
Changed from debounce-based search to submit-based:
- Removed auto-search after 500ms debounce timer
- Search only triggers when user presses Enter/Search on keyboard
- Added textInputAction: TextInputAction.search for search button on keyboard
- Keyboard stays up while typing, giving time to complete query
- Especially helpful for swipe typing where debounce was too aggressive

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:20:27 +00:00
Your NameandClaude Opus 4.5 710c772f1a Merge fix/back-navigation-clean: Back nav fix + animation optimizations
Includes:
- Fix back navigation for expanded player and detail screens
- Animation debugging utilities
- CPU optimizations for 60fps (TextPainter caching, Selector usage, deferred color extraction)
- GPU optimizations (replace Opacity with FadeTransition/color alpha, fixed BoxShadow)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:15:19 +00:00
Your NameandClaude Opus 4.5 4bfe450e8b GPU optimization: Replace Opacity widgets with efficient alternatives
Eliminates GPU saveLayer operations that cause animation judder:

1. Album art, track title, artist name:
   - Replace Opacity wrapper with conditional rendering (if statement)
   - Elements simply don't render when hidden instead of being rendered at 0 opacity

2. BoxShadow on album art:
   - Fixed blurRadius (20) and offset (0, 8) instead of animated values
   - Only shadow color opacity animates - GPU doesn't recalculate blur kernel

3. Progress bar, volume control:
   - Replace Opacity with FadeTransition (uses GPU-efficient compositing)
   - FadeTransition leverages hardware compositor without saveLayer

4. Album name, player name:
   - Use text color alpha instead of Opacity wrapper
   - Text renders directly at target alpha, no extra layer

5. Collapse, favorite, queue, shuffle, repeat buttons:
   - Use icon/button color alpha instead of Opacity wrapper
   - Removes 6+ Opacity widgets from the render tree

Total: ~12 Opacity widgets removed, replaced with GPU-efficient alternatives

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:03:13 +00:00
Your NameandClaude Opus 4.5 d4b910fae0 Optimize animations for 60fps performance
Critical fixes for player expand animation jank (was 25% jank, 52 FPS):

1. TextPainter layout caching (expandable_player.dart)
   - Cache title height calculation instead of running TextPainter.layout()
     every animation frame
   - Only recalculate when track name or width changes

2. Bottom nav rebuild optimization (global_player_overlay.dart)
   - Pass BottomNavigationBar as child to ValueListenableBuilder
   - Only Container decoration updates during animation, nav bar stays same
   - Move baseBgColor calculation outside the hot path

3. Image cache fix (expandable_player.dart)
   - Fixed memCacheWidth/Height from changing at t=0.5 to fixed 512px
   - Prevents mid-animation cache thrashing

4. RepaintBoundary for album art (expandable_player.dart)
   - Isolate album art repaints during scale animation

5. Consumer placement fix (global_player_overlay.dart)
   - Use Selector instead of Consumer for player visibility check
   - AnimatedBuilder now properly scoped for slide animation

6. Deferred color extraction (album/artist_details_screen.dart)
   - Defer palette extraction until 350ms after transition completes
   - Use context.select() instead of context.watch() to reduce rebuilds

7. Remove page transition log spam (page_transitions.dart)
   - Removed animation logging that was triggering on every rebuild

Expected improvement: ~15-25ms reduction per frame

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 19:55:30 +00:00
Your Name c2fdccc702 Fix: remove debugLabel property that conflicts with TransitionRoute 2025-12-10 19:55:23 +00:00
Your NameandClaude Opus 4.5 43f50dcd02 Add animation debugging for performance analysis
- New AnimationDebugger utility for tracking frame timing and jank
- Records every frame during player expand/collapse animations
- Logs summary with avg/max frame times, jank counts, effective FPS
- Flags jank frames (>16.67ms) and severe jank (>33.33ms) in real-time
- Adds page transition event logging

Usage: Expand/collapse player, then check logs for animation summary

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 19:55:13 +00:00
Your NameandClaude Opus 4.5 9a0c8c2b7e Fix back navigation for expanded player and detail screens
Two fixes:
1. Connect navigatorKey to MaterialApp - enables popUntil in bottom nav
   to properly dismiss album/artist detail screens when switching tabs

2. Add didPopRoute override in _MusicAssistantAppState - intercepts back
   gesture at app level (before Navigator) to collapse expanded player
   first, then allows normal navigation on subsequent presses

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 19:55:05 +00:00
Your NameandClaude Opus 4.5 d55419514d Tracks tab: simple flat list with album art
- No headings, just a clean list of tracks
- Each row: [Album Art] Artist - Track
- Sorted by artist then track name
- Gets image from track metadata directly

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 13:45:42 +00:00
Your NameandClaude Opus 4.5 a7f41827cb Tracks tab: group albums under artist heading
- Artist name as main header
- Multiple albums from same artist grouped together
- Album art + name as sub-header under artist
- Tracks indented under their album
- Cleaner hierarchy: Artist > Album > Tracks

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 13:18:17 +00:00
Your NameandClaude Opus 4.5 1dbd589ed7 Improve track favorites UX
- Track menu buttons now match artist/album view style (FilledButton.tonal)
- Tracks tab moved between Albums and Playlists
- Tracks grouped by artist, then album with album art
- Tapping a favorite track starts radio (not just play)
- Tracks indented under album headers

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 12:59:25 +00:00
Your NameandClaude Opus 4.5 3b824174a4 Fix Track constructor - remove non-existent parameters
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 12:21:52 +00:00
Your NameandClaude Opus 4.5 8eff3eb376 Add favorite buttons and Tracks tab to library
- Add favorite button to expanded player (top right, next to queue)
- Add favorite button to track long-press menu (3 icon buttons: radio, queue, favorite)
- Add Tracks tab to Library (only visible when Favourites toggle is on)
- Favorite tracks grouped by album in the Tracks tab
- Track favorite toggle with API integration

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 12:13:38 +00:00
Your NameandClaude Opus 4.5 2cec4f22ad Library: Replace title with favourites toggle switch
- Remove centered "Library" title from AppBar
- Add left-aligned Switch toggle with "Favourites" label
- Toggle filters artists, albums, playlists by favorite status
- Also: Remove Qobuz from radio providers (doesn't support radio mode)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 11:45:39 +00:00
Your NameandClaude Opus 4.5 fc642cd1d6 Fix build errors: add providerDomain, use availablePlayers
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 11:14:58 +00:00
Your NameandClaude Opus 4.5 e13873456d Add artist actions, improve radio, and library favorites filter
Radio improvements:
- Prefer streaming provider URIs (Spotify, Tidal, etc.) for better
  radio recommendations instead of library URIs
- Add playArtistRadio() for artist-based radio
- Add playArtistRadioToQueue() to add radio to existing queue

Artist screen:
- Add action buttons row (Radio, Play on..., Add to queue, Favorite)
- Add favorite toggle with refresh on screen load
- Add getArtistByUri() API method

Library screen:
- Add favorites filter toggle (heart icon in AppBar)
- Filter artists, albums, and playlists by favorite status
- Add EmptyState.custom() for "no favorites" messages

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 11:06:34 +00:00
Your NameandClaude Opus 4.5 f7378e7a25 Fix favorites not persisting after navigation
- Replace invalid 'music/album' API command with 'music/item_by_uri'
- Use album URI to fetch fresh favorite status on album screen load
- Invalidate home cache when favorite is toggled so home screen
  shows updated status

Fixes issue where favorites would appear in search/library but
not persist on home screen due to stale cached data.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 10:23:32 +00:00
Your NameandClaude Opus 4.5 9e045b1bd4 Fix favorites status not persisting after navigation
Fetch fresh album details on screen load to get current favorite
status from MA, instead of relying on potentially stale cached data.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 10:04:12 +00:00
Your NameandClaude Opus 4.5 7049128f01 Fix connection race condition on reconnect
- Add _isDisposed flag to prevent reconnects after API disposal
- Check if connection already in progress before reconnecting
- Check if already connected/authenticated before attempting reconnect
- Properly dispose old API when creating new connection
- Complete pending connection futures on dispose to prevent leaks

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 09:36:46 +00:00
Your NameandClaude Opus 4.5 e5dc9cbf89 Add note about local playback on MA beta 20+
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 09:10:54 +00:00
Your NameandClaude Opus 4.5 4f2e4cc6c0 Fix favorites API to use correct MA commands
- Use music/favorites/add_item with URI format for adding
- Use music/favorites/remove_item with media_type and library_item_id for removing
- Find actual provider from provider_mappings for adding (not "library")
- Extract numeric library_item_id for removal operations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 08:23:32 +00:00
Your NameandClaude Opus 4.5 13e933484c Fix favorites: use add/remove commands instead of toggle
- The 'music/favorites/toggle' command doesn't exist in MA 2.7.0b19
- Now uses 'music/favorites/add' and 'music/favorites/remove' based on state
- Keep button background grey when selected (only icon changes to filled red heart)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 08:05:58 +00:00
Your NameandClaude Opus 4.5 533993ee4e Fix album screen queue and favorites buttons
Add to Queue button:
- Now shows device selector like "Play on" button
- User can choose which player to add album to

Favorites button:
- Fixed visual state - icon now visible when selected
- Use FilledButton.tonal for consistency with other buttons
- Use provider_mappings for correct API call
- Use API return value for accurate state
- Show error snackbar if API call fails

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 07:42:52 +00:00
Your NameandClaude Opus 4.5 1e0321bb5e Move controls section up by half play button height
Adds 36px (half of 72px play button) to move controls
further from bottom nav bar while keeping track info centered

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 07:23:52 +00:00
Your NameandClaude Opus 4.5 e8d90689de Use safe area and proper calculation for controls position
- Account for bottom safe area (notch/gesture bar)
- Calculate controls section height properly (222px total)
- Add 24px extra padding from bottom
- Track info remains centered between art and controls

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 07:11:00 +00:00
Your NameandClaude Opus 4.5 69a770d548 Fix volume slider visibility - increase bottom margin
Progress bar now at 290px from bottom to fit:
- Progress bar + time labels
- Playback controls
- Volume slider with padding

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 07:06:16 +00:00
Your NameandClaude Opus 4.5 c7d6de4c9c Center track info between album art and progress bar
- Calculate total height of track info block (title + artist + album)
- Position progress bar at fixed distance from bottom (220px)
- Center track info vertically in space between art and progress bar
- Maintains proper spacing between title, artist, and album name

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 06:53:58 +00:00
Your NameandClaude Opus 4.5 d4611b1e2f Increase touch target area for progress and volume sliders
- Wrap both sliders in SizedBox with height: 48 for larger tap area
- Increase overlay radius from 14/16 to 20 for better visual feedback
- Makes it easier to tap and drag on touch screens

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 06:52:05 +00:00
Your NameandClaude Opus 4.5 4dbcd44705 Simplify metadata to Last.fm (descriptions) + Deezer (art)
- Remove Fanart.tv API integration entirely (wasn't working reliably)
- Simplify MetadataService to use only Deezer for artist images
- Remove Fanart.tv settings from UI and service
- Add missing CachedNetworkImage import in new_library_screen
- Update settings description to clarify Deezer is automatic

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 06:35:26 +00:00
Your NameandClaude Opus 4.5 c20745e207 fix: Add artist image fallback to ALL screens
Created ArtistAvatar widget that handles fallback automatically:
- Tries Music Assistant first
- Falls back to Deezer API (free, no key)
- Supports Hero tags for transitions
- Caches results

Updated screens to use ArtistAvatar:
- artist_details_screen.dart - full async image loading with fallback
- library_artists_screen.dart - uses ArtistAvatar widget
- new_library_screen.dart - uses ArtistAvatar with Hero support
- search_screen.dart - uses ArtistAvatar widget

Added getArtistImageUrlWithFallback() to MusicAssistantProvider
for screens that need manual async loading.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 06:27:48 +00:00
Your NameandClaude Opus 4.5 ceee3f667a feat: Add Deezer as primary artist image source (no API key needed)
Deezer API is:
- Completely free, no API key required
- Excellent coverage (has Kevin Ayers and many others)
- Returns high-quality images (1000x1000)

New fallback chain:
1. Deezer (free, best coverage)
2. Fanart.tv (free key, high quality when available)
3. TheAudioDB (optional)

This should fix missing artist images without requiring any API keys.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 06:10:08 +00:00
Your Name 9fe01654cb debug: Add logging to trace artist image fallback 2025-12-10 06:08:28 +00:00
Your NameandClaude Opus 4.5 a987320042 feat: Add Fanart.tv support for artist images
Changed fallback image fetching strategy:
- Added Fanart.tv API support (free registration required)
- Uses MusicBrainz to lookup artist MBID (completely free, no key)
- Fanart.tv provides high-quality artist thumbnails and backgrounds
- Removed Last.fm image fetching (deprecated, only returns blank images)
- TheAudioDB remains as fallback option

Settings:
- Added Fanart.tv API key field in settings (marked as recommended)
- Updated description to mention artist images
- Reordered fields: Last.fm (biographies), Fanart.tv (images), TheAudioDB

To get artist images for artists like Kevin Ayers:
1. Register free at fanart.tv
2. Get your personal API key
3. Enter it in Settings > Metadata APIs

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 05:44:08 +00:00
Your NameandClaude Opus 4.5 393c27b937 fix: Preserve home screen data on app resume + add artist image fallback
Issue 1: Home screen rows reloading on app resume
- Changed disconnected state to only clear detail caches, not home screen data
- Added stable ValueKeys to AlbumRow/ArtistRow widgets to prevent recreation
- Home row data now persists across connection state changes

Issue 2: Missing artist artwork (e.g., Kevin Ayers)
- Added fallback image fetching from Last.fm and TheAudioDB APIs
- ArtistCard now fetches external images when Music Assistant has none
- Images are cached in MetadataService to avoid repeated API calls

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 05:30:29 +00:00
Your NameandClaude Opus 4.5 829382cd68 fix: Eliminate flash during player swipe transitions
Root cause: Race condition where peek content was cleared before
the new player's content was ready to display.

Solution: Implement proper crossfade transition:
- Set _inTransition=true BEFORE animation starts
- Keep peek content visible at center position during transition
- Wait for new player's image to be cached before completing
- Only then hide peek and show main content

Changes:
- Add _precacheAndTransition() to ensure image is ready
- Add _completeTransition() for clean state cleanup
- Update peek position logic to show at center during transition
- Fix condition for showing peek content

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 21:00:30 +00:00
Your NameandClaude Opus 4.5 9720bf59f7 Fix remaining DebugLogger reference
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 20:23:08 +00:00
Your NameandClaude Opus 4.5 ebb069245a Fix peek content not showing during swipe and add image precaching
- Move _updatePeekPlayer logic inside setState to trigger UI rebuild
  when peek player changes during drag (fixes slow swipe not showing
  peek content)
- Add image precaching in _preloadPlayerTrack so artwork is ready
  instantly when swiping
- Await preload completion to ensure track data is ready before
  user can interact
- Show device icon for non-playing peek players instead of grey
  music note
- Remove debug logging

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 20:15:55 +00:00
Your NameandClaude Opus 4.5 f655be1fb6 fix: Keep peek visible during non-playing player transitions
When switching to a non-playing player, keep slideOffset at 1.0 and
peekPlayer set so the DeviceSelectorBar can show the peek content
(new player name) at center position. Wait two frames before clearing
to ensure the DeviceSelectorBar has fully rendered, preventing any
flash of stale content from async updates.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 20:02:05 +00:00
Your NameandClaude Opus 4.5 9d77379584 fix: Keep peekPlayer during transition to prevent grey flash
The bug was that onSwitch callback cleared _peekPlayer immediately after
selectPlayer(), but _animateCommit needed it to remain set so that:

1. DeviceSelectorBar could show peek content during transition
2. The delayed cleanup (for non-playing players) could work properly

Now the callback only calls selectPlayer() and lets _animateCommit handle
all cleanup. This ensures peek content remains visible until the
transition is complete.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 19:34:18 +00:00
Your NameandClaude Opus 4.5 79520aefc3 debug: Add logging to trace player switch transition
Add detailed logging to understand the exact sequence of events during
player switching, particularly when going from a playing player to a
non-playing player. This will help identify the source of the grey flash.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 19:20:53 +00:00
Your NameandClaude Opus 4.5 e50c7d6cdc fix: Delay peek state cleanup when switching to non-playing player
Add 50ms delay before clearing peek state when switching to a player
without an active track. This ensures the DeviceSelectorBar has time
to render before the peek content is removed, preventing a flash of
grey placeholder during the transition.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 19:06:15 +00:00
Your NameandClaude Opus 4.5 6940c6a51f fix: Clear track when switching to non-playing player
Always set _currentTrack from cache, even when null. This prevents
showing stale track info from the previous player when switching to
a player that has no active track.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 18:52:05 +00:00
Your NameandClaude Opus 4.5 3cb534d678 fix: Seamless player switch animation - eliminate grey icon flash
Root cause: Race condition between transition animation and provider update.
When swiping to switch players, the animation would complete before the
provider's currentTrack was updated, causing a brief flash showing the
grey placeholder icon instead of the new player's artwork.

Solution:
1. In MusicAssistantProvider.selectPlayer(): Immediately set _currentTrack
   from the cache when switching players. This ensures the correct track
   data is available synchronously before any rebuild occurs.

2. In ExpandablePlayer._animateCommit(): Simplified transition logic since
   the provider now has the correct data immediately. Removed the fragile
   double-addPostFrameCallback approach that was trying to wait for the
   provider to update asynchronously.

The fix leverages the existing preloaded track cache for adjacent players,
which already contains the correct track info and artwork URLs. By using
this cached data immediately during player selection, the UI shows the
correct content without any async delay.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 18:41:15 +00:00
Your NameandClaude Opus 4.5 601ca67a21 fix(mini-player): use cached peek data during transition
Instead of generic transition cache, keep showing the actual peek content
(with _peekTrack and _peekImageUrl) at center during transition. This
preserves the track info that was visible during the swipe.

- Removed unused _transitionTrack/_transitionImageUrl variables
- _buildPeekContent now uses _peekTrack directly for track info
- Peek data not cleared until after provider updates (2 frame delay)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:55:12 +00:00
Your NameandClaude Opus 4.5 e9ebc3e3c3 fix(mini-player): properly hide main content during transition
Added _inTransition flag to control visibility during player switch:
- When transitioning, hide main album art/title/artist with Opacity(0)
- Show peek content at center position during transition
- Peek content uses cached transition data (track/image)
- Main content reappears after 2 frames when provider has updated

This eliminates the flash of grey/empty content during player switches.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:31:43 +00:00
Your NameandClaude Opus 4.5 6f4475a28d fix(mini-player): prevent flash of empty content during player switch
When swiping to switch players, there was a brief moment where the
provider hadn't updated with the new player's track info, causing a
flash of grey album art and missing track info.

Added transition cache that holds the incoming player's track/image
data until the provider catches up. The cache is cleared once the
provider has the new track data.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:18:04 +00:00
Your NameandClaude Opus 4.5 49beea769b fix(mini-player): remove duplicate slide animation on swipe commit
The two-phase animation (slide out + slide in from opposite) was causing
a double animation effect. Simplified to just complete the slide out and
then instantly show the new player content at center position.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 16:39:42 +00:00
Your NameandClaude Opus 4.5 d06761124f feat(mini-player): smooth finger-following swipe to change player
Replace velocity-only swipe detection with real-time finger-tracking:
- Content now follows user's finger as they drag horizontally
- Peek player content slides in from the edge during drag
- Smooth spring-back animation when swipe is cancelled
- Commit animation completes the transition when threshold exceeded
- Works for both mini player with track and device selector bar

Technical changes:
- Add onHorizontalDragUpdate handler for real-time tracking
- Track peek player for preview during drag
- New _buildPeekContent() method for rendering incoming player
- Configurable thresholds: 30% width or 500px/s velocity to commit
- easeOutBack curve for satisfying snap-back feel

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 16:25:16 +00:00
27 changed files with 3164 additions and 655 deletions
+2
View File
@@ -87,6 +87,8 @@ Ensemble supports multiple authentication methods:
- Network connectivity to your Music Assistant server
- Android device (Android 5.0+)
> **Note:** Local device playback (streaming to your phone) is not currently supported on Music Assistant beta 20+. This is due to MA replacing the built-in player with Sendspin. Support will be added once Sendspin is more stable. Remote control of other players works fine.
## About Music Assistant
Ensemble is a mobile client for [Music Assistant](https://music-assistant.io/), an open-source music library manager that integrates with various music sources and streaming providers.
+14
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:audio_service/audio_service.dart';
import 'providers/music_assistant_provider.dart';
import 'providers/navigation_provider.dart';
import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'services/settings_service.dart';
@@ -85,6 +86,18 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
super.dispose();
}
@override
Future<bool> didPopRoute() async {
// Intercept back button at app level - runs BEFORE Navigator processes it
// If player is expanded, collapse it and consume the back gesture
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
return true; // We handled it, don't let Navigator process it
}
// Let Navigator handle the back gesture normally
return super.didPopRoute();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
@@ -134,6 +147,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
lightColorScheme: lightColorScheme,
darkColorScheme: darkColorScheme,
child: MaterialApp(
navigatorKey: navigationProvider.navigatorKey,
title: 'Ensemble',
debugShowCheckedModeBanner: false,
themeMode: themeProvider.themeMode,
+80 -6
View File
@@ -1,5 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/music_assistant_api.dart';
@@ -10,6 +12,7 @@ import '../services/auth/auth_manager.dart';
import '../services/device_id_service.dart';
import '../services/cache_service.dart';
import '../services/local_player_service.dart';
import '../services/metadata_service.dart';
import '../constants/timings.dart';
import '../main.dart' show audioHandler;
@@ -138,7 +141,8 @@ class MusicAssistantProvider with ChangeNotifier {
_serverUrl = serverUrl;
await SettingsService.setServerUrl(serverUrl);
await _api?.disconnect();
// Dispose the old API to stop any pending reconnects
_api?.dispose();
_api = MusicAssistantAPI(serverUrl, _authManager);
@@ -172,7 +176,9 @@ class MusicAssistantProvider with ChangeNotifier {
} else if (state == MAConnectionState.disconnected) {
_availablePlayers = [];
_selectedPlayer = null;
_cacheService.clearAll();
// Only clear detail caches on disconnect, NOT home screen caches
// Home screen data should persist across reconnects for better UX
_cacheService.clearAllDetailCaches();
}
},
onError: (error) {
@@ -1092,7 +1098,8 @@ class MusicAssistantProvider with ChangeNotifier {
selectPlayer(playerToSelect);
}
_preloadAdjacentPlayers(preloadAll: true);
// Await preload so track data and images are ready for swipe gestures
await _preloadAdjacentPlayers(preloadAll: true);
} catch (e) {
ErrorHandler.logError('Load and select players', e);
}
@@ -1103,6 +1110,13 @@ class MusicAssistantProvider with ChangeNotifier {
SettingsService.setLastSelectedPlayerId(player.playerId);
// Immediately set currentTrack from cache to avoid flash during player switch
// This ensures the UI shows the correct track info immediately, before
// the async _updatePlayerState() completes.
// IMPORTANT: Always set from cache, even if null - this prevents showing
// stale track info when switching to a non-playing player.
_currentTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
_startPlayerStatePolling();
_preloadAdjacentPlayers();
@@ -1140,9 +1154,10 @@ class MusicAssistantProvider with ChangeNotifier {
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
for (final player in playersToPreload) {
_preloadPlayerTrack(player);
}
// Await all preloads so images are ready for next swipe
await Future.wait(
playersToPreload.map((player) => _preloadPlayerTrack(player)),
);
}
Future<void> _preloadPlayerTrack(Player player) async {
@@ -1172,6 +1187,12 @@ class MusicAssistantProvider with ChangeNotifier {
} else {
_cacheService.setCachedTrackForPlayer(player.playerId, track);
_logger.log('🔍 Preload ${player.name}: CACHED track "${track.name}"');
// Also precache the image so it's ready for swipe preview
final imageUrl = getImageUrl(track, size: 512);
if (imageUrl != null) {
_precacheImage(imageUrl);
}
}
} else {
_logger.log('🔍 Preload ${player.name}: NO TRACK - queue empty');
@@ -1185,6 +1206,34 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Precache an image URL so it loads instantly when displayed
Future<void> _precacheImage(String url) async {
try {
final imageProvider = CachedNetworkImageProvider(url);
final imageStream = imageProvider.resolve(const ImageConfiguration());
final completer = Completer<void>();
late ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool synchronousCall) {
if (!completer.isCompleted) completer.complete();
imageStream.removeListener(listener);
},
onError: (exception, stackTrace) {
if (!completer.isCompleted) completer.completeError(exception);
imageStream.removeListener(listener);
},
);
imageStream.addListener(listener);
await completer.future.timeout(const Duration(seconds: 5), onTimeout: () {
imageStream.removeListener(listener);
});
} catch (e) {
// Silently ignore precache errors - image will load on demand
}
}
Future<void> preloadAllPlayerTracks() async {
if (_api == null) return;
@@ -1453,6 +1502,19 @@ class MusicAssistantProvider with ChangeNotifier {
return _api?.getImageUrl(item, size: size);
}
/// Get artist image URL with fallback to external sources (Deezer, Fanart.tv)
/// Returns a Future since fallback requires async API calls
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async {
// Try Music Assistant first
final maUrl = _api?.getImageUrl(artist, size: size);
if (maUrl != null) {
return maUrl;
}
// Fall back to external sources (Deezer, Fanart.tv, etc.)
return MetadataService.getArtistImageUrl(artist.name);
}
// ============================================================================
// PLAYER AND QUEUE MANAGEMENT
// ============================================================================
@@ -1507,6 +1569,18 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
Future<void> playArtistRadio(String playerId, Artist artist) async {
try {
await _api?.playArtistRadio(playerId, artist);
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Play artist radio');
_error = errorInfo.userMessage;
ErrorHandler.logError('Play artist radio', e);
notifyListeners();
rethrow;
}
}
Future<String?> getCurrentStreamUrl(String playerId) async {
return await _api?.getCurrentStreamUrl(playerId);
}
+336 -74
View File
@@ -43,8 +43,43 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
super.initState();
_isFavorite = widget.album.favorite ?? false;
_loadTracks();
_extractColors();
_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
WidgetsBinding.instance.addPostFrameCallback((_) {
// Add small delay to ensure transition is complete
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
}
});
});
}
Future<void> _refreshFavoriteStatus() 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');
return;
}
try {
final freshAlbum = await maProvider.api!.getAlbumByUri(albumUri);
if (freshAlbum != null && mounted) {
setState(() {
_isFavorite = freshAlbum.favorite ?? false;
});
}
} catch (e) {
_logger.log('Error refreshing favorite status: $e');
}
}
Future<void> _extractColors() async {
@@ -74,19 +109,63 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (maProvider.api == null) return;
try {
// Toggle the state locally first
final newState = !_isFavorite;
await maProvider.api!.toggleFavorite(
'album',
widget.album.itemId,
widget.album.provider,
);
if (newState) {
// For adding: use the actual provider and itemId from provider_mappings
String actualProvider = widget.album.provider;
String actualItemId = widget.album.itemId;
if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) {
// Find a non-library provider mapping (e.g., spotify, qobuz, etc.)
final mapping = widget.album.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.album.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.album.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
_logger.log('Adding to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('album', actualItemId, 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,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Error: Could not determine library_item_id for removal');
throw Exception('Could not determine library ID for this album');
}
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('album', libraryItemId);
}
setState(() {
_isFavorite = newState;
});
// Invalidate home cache so the home screen shows updated favorite status
maProvider.invalidateHomeCache();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -99,6 +178,100 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
} catch (e) {
_logger.log('Error toggling favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
Future<void> _toggleTrackFavorite(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
final track = _tracks[trackIndex];
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
final currentFavorite = track.favorite ?? false;
try {
if (currentFavorite) {
// Remove from favorites - need library_item_id
int? libraryItemId;
if (track.provider == 'library') {
libraryItemId = int.tryParse(track.itemId);
} else if (track.providerMappings != null) {
final libraryMapping = track.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => track.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
await maProvider.api!.removeFromFavorites('track', libraryItemId);
}
} else {
// Add to favorites
String actualProvider = track.provider;
String actualItemId = track.itemId;
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
final mapping = track.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => track.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => track.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
await maProvider.api!.addToFavorites('track', actualItemId, 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 (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
!currentFavorite ? 'Added to favorites' : 'Removed from favorites',
),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error toggling track favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
@@ -165,34 +338,84 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
}
Future<void> _addAlbumToQueue() async {
void _addAlbumToQueue() {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
try {
final player = maProvider.selectedPlayer;
if (player == null) {
_showError('No player selected');
return;
}
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
_logger.log('Adding album to queue on ${player.name}');
// Add all tracks to queue without clearing
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0, clearQueue: false);
_logger.log('Album added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Album added to queue'),
duration: Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding album to queue: $e');
_showError('Failed to add album to queue: $e');
}
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
'Add album to queue on...',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
const Padding(
padding: EdgeInsets.all(32.0),
child: Text('No players available'),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
_logger.log('Adding album to queue on ${player.name}');
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: 0,
clearQueue: false,
);
_logger.log('Album added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Album added to queue'),
duration: Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding album to queue: $e');
_showError('Failed to add album to queue: $e');
}
},
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Show mini player again when sheet closes
GlobalPlayerOverlay.showPlayer();
});
}
void _addTrackToQueue(BuildContext context, int index) {
@@ -318,18 +541,32 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final themeProvider = context.watch<ThemeProvider>();
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
// 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),
);
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveLightScheme,
);
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveDarkScheme,
);
// Determine if we should use adaptive theme colors
final useAdaptiveTheme = themeProvider.adaptiveTheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Get the color scheme to use
// Get the color scheme to use - prefer local state over provider
// Local state (_darkColorScheme/_lightColorScheme) is set by _extractColors()
ColorScheme? adaptiveScheme;
if (useAdaptiveTheme) {
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
if (adaptiveTheme) {
// Use local state first (from _extractColors), fallback to provider
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
}
// Use adaptive scheme if available, otherwise use global theme
@@ -523,18 +760,22 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
const SizedBox(width: 12),
// Favorite Button
Container(
SizedBox(
height: 50,
width: 50,
decoration: BoxDecoration(
color: _isFavorite ? colorScheme.errorContainer : colorScheme.surfaceVariant,
shape: BoxShape.circle,
),
child: IconButton(
child: FilledButton.tonal(
onPressed: _toggleFavorite,
icon: Icon(
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25), // Circular
),
),
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite ? colorScheme.error : colorScheme.onSurfaceVariant,
color: _isFavorite
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
),
),
@@ -645,39 +886,60 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
curve: Curves.easeInOut,
child: isExpanded
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(
child: SizedBox(
height: 48,
child: FilledButton.tonal(
onPressed: () => _showPlayRadioMenu(context, index),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.tertiaryContainer,
foregroundColor: colorScheme.onTertiaryContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
// Radio button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayRadioMenu(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: const Text('Start radio'),
),
child: const Icon(Icons.radio, size: 20),
),
),
const SizedBox(width: 12),
Expanded(
child: SizedBox(
height: 48,
child: FilledButton.tonal(
onPressed: () => _addTrackToQueue(context, index),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.secondaryContainer,
foregroundColor: colorScheme.onSecondaryContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addTrackToQueue(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: const Text('Add to queue'),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
const SizedBox(width: 10),
// Favorite button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _toggleTrackFavorite(index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
),
),
child: Icon(
track.favorite == true ? Icons.favorite : Icons.favorite_border,
size: 20,
color: track.favorite == true
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
),
),
+382 -14
View File
@@ -31,27 +31,295 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
List<Album> _albums = [];
List<Album> _providerAlbums = [];
bool _isLoading = true;
bool _isFavorite = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
bool _isDescriptionExpanded = false;
String? _artistDescription;
String? _artistImageUrl;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
void initState() {
super.initState();
_isFavorite = widget.artist.favorite ?? false;
_loadArtistAlbums();
_extractColors();
_loadArtistDescription();
_refreshFavoriteStatus();
// CRITICAL FIX: Defer image loading and color extraction until after transition
// This prevents expensive operations during hero animation
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_loadArtistImage();
// Note: _extractColors is called by _loadArtistImage after image loads
}
});
});
}
Future<void> _extractColors() async {
Future<void> _refreshFavoriteStatus() async {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
if (maProvider.api == null) return;
if (imageUrl == null) return;
final artistUri = widget.artist.uri;
if (artistUri == null || artistUri.isEmpty) {
_logger.log('Cannot refresh favorite status: artist has no URI');
return;
}
try {
final freshArtist = await maProvider.api!.getArtistByUri(artistUri);
if (freshArtist != null && mounted) {
setState(() {
_isFavorite = freshArtist.favorite ?? false;
});
}
} catch (e) {
_logger.log('Error refreshing artist favorite status: $e');
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
final newState = !_isFavorite;
if (newState) {
String actualProvider = widget.artist.provider;
String actualItemId = widget.artist.itemId;
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
final mapping = widget.artist.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.artist.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.artist.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
_logger.log('Adding artist to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('artist', actualItemId, actualProvider);
} else {
int? libraryItemId;
if (widget.artist.provider == 'library') {
libraryItemId = int.tryParse(widget.artist.itemId);
} else if (widget.artist.providerMappings != null) {
final libraryMapping = widget.artist.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.artist.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Error: Could not determine library_item_id for removal');
throw Exception('Could not determine library ID for this artist');
}
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('artist', libraryItemId);
}
setState(() {
_isFavorite = newState;
});
maProvider.invalidateHomeCache();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? 'Added to favorites' : 'Removed from favorites',
),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error toggling artist favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
Future<void> _startArtistRadio() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
);
return;
}
try {
await maProvider.playArtistRadio(selectedPlayer.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Starting ${widget.artist.name} radio'),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error starting artist radio: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to start radio: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Start ${widget.artist.name} radio on...',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
const Padding(
padding: EdgeInsets.all(32.0),
child: Text('No players available'),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
await maProvider.playArtistRadio(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Starting ${widget.artist.name} radio on ${player.name}'),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error starting artist radio on player: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to start radio: $e'),
backgroundColor: Colors.red,
),
);
}
}
},
);
},
),
),
const SizedBox(height: 16),
],
),
),
);
}
Future<void> _addArtistRadioToQueue() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
);
return;
}
try {
// For adding to queue with radio mode, we use radio_mode but with 'add' option
await maProvider.api?.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added ${widget.artist.name} radio to queue'),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding artist radio to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to add to queue: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _loadArtistImage() async {
final maProvider = context.read<MusicAssistantProvider>();
// Get image URL with fallback to Deezer/Fanart.tv
final imageUrl = await maProvider.getArtistImageUrlWithFallback(widget.artist, size: 512);
if (mounted && imageUrl != null) {
setState(() {
_artistImageUrl = imageUrl;
});
// Extract colors after we have the image
_extractColors(imageUrl);
}
}
Future<void> _extractColors(String imageUrl) async {
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
@@ -102,15 +370,30 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final themeProvider = context.watch<ThemeProvider>();
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
// Only rebuild when specific properties change, not on every provider update
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveLightScheme,
);
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveDarkScheme,
);
// Use the loaded image URL (with fallback) instead of directly from MA
final imageUrl = _artistImageUrl;
final useAdaptiveTheme = themeProvider.adaptiveTheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Get the color scheme to use - prefer local state over provider
ColorScheme? adaptiveScheme;
if (useAdaptiveTheme) {
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
if (adaptiveTheme) {
// Use local state first (from _extractColors), fallback to provider
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
}
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -214,6 +497,89 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
const SizedBox(height: 8),
],
// Action Buttons Row
Row(
children: [
// Main Radio Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _startArtistRadio,
icon: const Icon(Icons.radio),
label: const Text('Radio'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
const SizedBox(width: 12),
// "Play on..." Button (Square)
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// "Add to Queue" Button (Square)
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _addArtistRadioToQueue,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add),
),
),
const SizedBox(width: 12),
// Favorite Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleFavorite,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
),
),
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 16),
],
),
),
@@ -263,7 +629,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
delegate: SliverChildBuilderDelegate(
(context, index) {
final album = _albums[index];
return _buildAlbumCard(album, maProvider);
return _buildAlbumCard(album);
},
childCount: _albums.length,
),
@@ -297,7 +663,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
delegate: SliverChildBuilderDelegate(
(context, index) {
final album = _providerAlbums[index];
return _buildAlbumCard(album, maProvider);
return _buildAlbumCard(album);
},
childCount: _providerAlbums.length,
),
@@ -312,8 +678,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
);
}
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
final imageUrl = provider.getImageUrl(album, size: 256);
Widget _buildAlbumCard(Album album) {
// Use read instead of passing provider to avoid rebuild dependencies
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(album, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
const String heroSuffix = 'artist_albums';
+158 -68
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../services/debug_logger.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
@@ -15,6 +16,8 @@ class DebugLogScreen extends StatefulWidget {
class _DebugLogScreenState extends State<DebugLogScreen> {
final _logger = DebugLogger();
final _scrollController = ScrollController();
LogLevel _filterLevel = LogLevel.debug; // Show all by default
bool _isGeneratingReport = false;
@override
void initState() {
@@ -33,17 +36,37 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
super.dispose();
}
List<LogEntry> get _filteredEntries =>
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
void _copyLogs() {
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logs copied to clipboard!'),
content: Text('Logs copied to clipboard'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
Future<void> _shareBugReport() async {
setState(() => _isGeneratingReport = true);
try {
final report = await _logger.generateBugReport();
await Share.share(
report,
subject: 'Ensemble Bug Report',
);
} finally {
if (mounted) {
setState(() => _isGeneratingReport = false);
}
}
}
void _clearLogs() {
setState(() {
_logger.clear();
@@ -216,16 +239,39 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
),
centerTitle: true,
actions: [
// Share bug report button
_isGeneratingReport
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
)
: IconButton(
icon: const Icon(Icons.share_rounded),
onPressed: _shareBugReport,
color: Colors.white,
tooltip: 'Share bug report',
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white),
tooltip: 'Player tools',
tooltip: 'More options',
onSelected: (value) async {
switch (value) {
case 'show_players':
_showAllPlayers();
break;
// NOTE: Ghost cleanup and repair options removed - MA APIs don't work
// reliably and caused corrupt entries. See PLAYER_LIFECYCLE_GUIDE.md
case 'copy_logs':
_copyLogs();
break;
case 'clear_logs':
_clearLogs();
break;
}
},
itemBuilder: (context) => [
@@ -237,49 +283,61 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
dense: true,
),
),
const PopupMenuItem(
value: 'copy_logs',
child: ListTile(
leading: Icon(Icons.copy_rounded),
title: Text('Copy Logs'),
dense: true,
),
),
const PopupMenuItem(
value: 'clear_logs',
child: ListTile(
leading: Icon(Icons.delete_rounded),
title: Text('Clear Logs'),
dense: true,
),
),
],
),
IconButton(
icon: const Icon(Icons.copy_rounded),
onPressed: _copyLogs,
color: Colors.white,
tooltip: 'Copy logs',
),
IconButton(
icon: const Icon(Icons.delete_rounded),
onPressed: _clearLogs,
color: Colors.white,
tooltip: 'Clear logs',
),
],
),
body: Column(
children: [
// Filter chips and stats
Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: Colors.white.withOpacity(0.05),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.info_outline_rounded,
color: Colors.white70,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Last ${_logger.logs.length} log entries',
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
),
// Level filter chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterChip('All', LogLevel.debug),
const SizedBox(width: 8),
_buildFilterChip('Info+', LogLevel.info),
const SizedBox(width: 8),
_buildFilterChip('Warnings', LogLevel.warning),
const SizedBox(width: 8),
_buildFilterChip('Errors', LogLevel.error),
],
),
),
const SizedBox(height: 8),
// Stats
Text(
'Showing ${_filteredEntries.length} of ${_logger.entries.length} entries',
style: const TextStyle(color: Colors.white54, fontSize: 11),
),
],
),
),
Expanded(
child: _logger.logs.isEmpty
child: _filteredEntries.isEmpty
? const Center(
child: Text(
'No logs yet',
@@ -291,43 +349,11 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
)
: ListView.builder(
controller: _scrollController,
padding: EdgeInsets.fromLTRB(8, 8, 8, BottomSpacing.navBarOnly + 56), // Extra space for FAB
itemCount: _logger.logs.length,
padding: EdgeInsets.fromLTRB(8, 8, 8, BottomSpacing.navBarOnly + 56),
itemCount: _filteredEntries.length,
itemBuilder: (context, index) {
final log = _logger.logs[index];
final isError = log.contains('Error') ||
log.contains('error') ||
log.contains('ERROR') ||
log.contains('failed') ||
log.contains('Failed');
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: isError
? Colors.red.withOpacity(0.1)
: Colors.white.withOpacity(0.03),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isError
? Colors.red.withOpacity(0.3)
: Colors.white.withOpacity(0.1),
),
),
child: Text(
log,
style: TextStyle(
color: isError ? Colors.red[300] : Colors.white70,
fontSize: 11,
fontFamily: 'monospace',
height: 1.4,
),
),
);
final entry = _filteredEntries[index];
return _buildLogEntry(entry);
},
),
),
@@ -353,4 +379,68 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
),
);
}
Widget _buildFilterChip(String label, LogLevel level) {
final isSelected = _filterLevel == level;
return FilterChip(
label: Text(label),
selected: isSelected,
showCheckmark: false,
onSelected: (_) {
setState(() {
_filterLevel = level;
});
},
backgroundColor: Colors.white.withOpacity(0.1),
selectedColor: _getLevelColor(level).withOpacity(0.3),
labelStyle: TextStyle(
color: isSelected ? _getLevelColor(level) : Colors.white70,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
),
side: BorderSide(
color: isSelected ? _getLevelColor(level) : Colors.transparent,
),
);
}
Widget _buildLogEntry(LogEntry entry) {
final color = _getLevelColor(entry.level);
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Text(
entry.formatted,
style: TextStyle(
color: entry.level == LogLevel.error
? Colors.red[300]
: entry.level == LogLevel.warning
? Colors.orange[300]
: Colors.white70,
fontSize: 11,
fontFamily: 'monospace',
height: 1.4,
),
),
);
}
Color _getLevelColor(LogLevel level) {
switch (level) {
case LogLevel.debug:
return Colors.grey;
case LogLevel.info:
return Colors.blue;
case LogLevel.warning:
return Colors.orange;
case LogLevel.error:
return Colors.red;
}
}
}
+6 -11
View File
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../utils/page_transitions.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/artist_avatar.dart';
import 'artist_details_screen.dart';
class LibraryArtistsScreen extends StatelessWidget {
@@ -88,8 +88,6 @@ class LibraryArtistsScreen extends StatelessWidget {
Artist artist, {
Key? key,
}) {
final provider = context.read<MusicAssistantProvider>();
final imageUrl = provider.getImageUrl(artist, size: 128);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -97,14 +95,11 @@ class LibraryArtistsScreen extends StatelessWidget {
return RepaintBoundary(
child: ListTile(
key: key,
leading: CircleAvatar(
radius: 24,
backgroundColor: colorScheme.surfaceVariant,
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
child: imageUrl == null
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
leading: ArtistAvatar(
artist: artist,
radius: 24,
imageSize: 128,
),
title: Text(
artist.name,
style: textTheme.titleMedium?.copyWith(
+4
View File
@@ -106,7 +106,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Recently played albums (with caching)
// Stable keys prevent widget recreation on parent rebuilds
AlbumRow(
key: const ValueKey('recent-albums'),
title: 'Recently Played',
loadAlbums: () => provider.getRecentAlbumsWithCache(),
),
@@ -114,6 +116,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
// Discover Artists (with caching)
ArtistRow(
key: const ValueKey('discover-artists'),
title: 'Discover Artists',
loadArtists: () => provider.getDiscoverArtistsWithCache(),
),
@@ -121,6 +124,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
// Discover Albums (with caching)
AlbumRow(
key: const ValueKey('discover-albums'),
title: 'Discover Albums',
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
),
+263 -36
View File
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_selector.dart';
import '../widgets/album_card.dart';
import '../widgets/artist_avatar.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
@@ -26,11 +27,16 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
with SingleTickerProviderStateMixin, RestorationMixin {
late TabController _tabController;
List<Playlist> _playlists = [];
List<Track> _favoriteTracks = [];
bool _isLoadingPlaylists = true;
bool _isLoadingTracks = false;
bool _showFavoritesOnly = false;
// Restoration: Remember selected tab across app restarts
final RestorableInt _selectedTabIndex = RestorableInt(0);
int get _tabCount => _showFavoritesOnly ? 4 : 3;
@override
String? get restorationId => 'new_library_screen';
@@ -38,7 +44,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_selectedTabIndex, 'selected_tab_index');
// Apply restored tab index after TabController is created
if (_tabController.index != _selectedTabIndex.value) {
if (_tabController.index != _selectedTabIndex.value &&
_selectedTabIndex.value < _tabController.length) {
_tabController.index = _selectedTabIndex.value;
}
}
@@ -46,12 +53,24 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController = TabController(length: _tabCount, vsync: this);
// Listen to tab changes to persist selection
_tabController.addListener(_onTabChanged);
_loadPlaylists();
}
void _recreateTabController() {
final oldIndex = _tabController.index;
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
_tabController = TabController(length: _tabCount, vsync: this);
_tabController.addListener(_onTabChanged);
// Restore to previous index if valid, otherwise default to 0
if (oldIndex < _tabCount) {
_tabController.index = oldIndex;
}
}
void _onTabChanged() {
if (!_tabController.indexIsChanging) {
_selectedTabIndex.value = _tabController.index;
@@ -66,10 +85,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
super.dispose();
}
Future<void> _loadPlaylists() async {
Future<void> _loadPlaylists({bool? favoriteOnly}) async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final playlists = await maProvider.api!.getPlaylists(limit: 100);
final playlists = await maProvider.api!.getPlaylists(
limit: 100,
favoriteOnly: favoriteOnly,
);
if (mounted) {
setState(() {
_playlists = playlists;
@@ -85,6 +107,47 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
}
Future<void> _loadFavoriteTracks() async {
if (_isLoadingTracks) return;
setState(() {
_isLoadingTracks = true;
});
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final tracks = await maProvider.api!.getTracks(
limit: 500,
favoriteOnly: true,
);
if (mounted) {
setState(() {
_favoriteTracks = tracks;
_isLoadingTracks = false;
});
}
} else {
if (mounted) {
setState(() {
_isLoadingTracks = false;
});
}
}
}
void _toggleFavoritesMode(bool value) {
setState(() {
_showFavoritesOnly = value;
_recreateTabController();
});
if (value) {
_loadPlaylists(favoriteOnly: true);
_loadFavoriteTracks();
} else {
_loadPlaylists();
}
}
@override
Widget build(BuildContext context) {
// Use Selector for targeted rebuilds - only rebuild when connection state changes
@@ -124,15 +187,40 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
'Library',
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
),
titleSpacing: 16,
title: Row(
children: [
// Favorites toggle with label
GestureDetector(
onTap: () => _toggleFavoritesMode(!_showFavoritesOnly),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Switch(
value: _showFavoritesOnly,
onChanged: _toggleFavoritesMode,
activeColor: colorScheme.primary,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
const SizedBox(width: 4),
Text(
'Favourites',
style: textTheme.titleMedium?.copyWith(
color: _showFavoritesOnly
? colorScheme.primary
: colorScheme.onSurface.withOpacity(0.7),
fontWeight: _showFavoritesOnly ? FontWeight.w500 : FontWeight.w400,
),
),
],
),
),
],
),
centerTitle: true,
actions: const [PlayerSelector()],
centerTitle: false,
actions: const [
PlayerSelector(),
],
bottom: TabBar(
controller: _tabController,
labelColor: colorScheme.primary,
@@ -141,10 +229,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
indicatorWeight: 3,
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w400, fontSize: 14),
tabs: const [
Tab(text: 'Artists'),
Tab(text: 'Albums'),
Tab(text: 'Playlists'),
tabs: [
const Tab(text: 'Artists'),
const Tab(text: 'Albums'),
if (_showFavoritesOnly) const Tab(text: 'Tracks'),
const Tab(text: 'Playlists'),
],
),
),
@@ -153,6 +242,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
children: [
_buildArtistsTab(context),
_buildAlbumsTab(context),
if (_showFavoritesOnly) _buildTracksTab(context),
_buildPlaylistsTab(context),
],
),
@@ -167,14 +257,26 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return Selector<MusicAssistantProvider, (List<Artist>, bool)>(
selector: (_, provider) => (provider.artists, provider.isLoading),
builder: (context, data, _) {
final (artists, isLoading) = data;
final (allArtists, isLoading) = data;
final colorScheme = Theme.of(context).colorScheme;
if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Filter by favorites if enabled
final artists = _showFavoritesOnly
? allArtists.where((a) => a.favorite == true).toList()
: allArtists;
if (artists.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
icon: Icons.favorite_border,
title: 'No favorite artists',
subtitle: 'Tap the heart on an artist to add them to favorites',
);
}
return EmptyState.artists(
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
@@ -185,7 +287,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
backgroundColor: colorScheme.surface,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: ListView.builder(
key: const PageStorageKey<String>('library_artists_list'),
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}'),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
itemCount: artists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
@@ -208,8 +310,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
Artist artist, {
Key? key,
}) {
final provider = context.read<MusicAssistantProvider>();
final imageUrl = provider.getImageUrl(artist, size: 128);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final suffix = '_library';
@@ -217,17 +317,15 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return RepaintBoundary(
child: ListTile(
key: key,
leading: Hero(
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
child: CircleAvatar(
leading: ArtistAvatar(
artist: artist,
radius: 24,
backgroundColor: colorScheme.surfaceVariant,
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
child: imageUrl == null
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
: null,
imageSize: 128,
heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
onImageLoaded: (imageUrl) {
// Store for adaptive colors on tap
},
),
),
title: Hero(
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
child: Material(
@@ -244,8 +342,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
),
onTap: () {
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
@@ -266,14 +362,26 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return Selector<MusicAssistantProvider, (List<Album>, bool)>(
selector: (_, provider) => (provider.albums, provider.isLoading),
builder: (context, data, _) {
final (albums, isLoading) = data;
final (allAlbums, isLoading) = data;
final colorScheme = Theme.of(context).colorScheme;
if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Filter by favorites if enabled
final albums = _showFavoritesOnly
? allAlbums.where((a) => a.favorite == true).toList()
: allAlbums;
if (albums.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
icon: Icons.favorite_border,
title: 'No favorite albums',
subtitle: 'Tap the heart on an album to add it to favorites',
);
}
return EmptyState.albums(
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
@@ -284,7 +392,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
backgroundColor: colorScheme.surface,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: GridView.builder(
key: const PageStorageKey<String>('library_albums_grid'),
key: PageStorageKey<String>('library_albums_grid_${_showFavoritesOnly ? 'fav' : 'all'}'),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
@@ -317,15 +425,22 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
if (_playlists.isEmpty) {
return EmptyState.playlists(onRefresh: _loadPlaylists);
if (_showFavoritesOnly) {
return EmptyState.custom(
icon: Icons.favorite_border,
title: 'No favorite playlists',
subtitle: 'Tap the heart on a playlist to add it to favorites',
);
}
return EmptyState.playlists(onRefresh: () => _loadPlaylists());
}
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
onRefresh: _loadPlaylists,
onRefresh: () => _loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null),
child: ListView.builder(
key: const PageStorageKey<String>('library_playlists_list'),
key: PageStorageKey<String>('library_playlists_list_${_showFavoritesOnly ? 'fav' : 'all'}'),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
itemCount: _playlists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
@@ -393,4 +508,116 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
// ============ TRACKS TAB (favorites only) ============
Widget _buildTracksTab(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (_isLoadingTracks) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
if (_favoriteTracks.isEmpty) {
return EmptyState.custom(
icon: Icons.favorite_border,
title: 'No favorite tracks',
subtitle: 'Long-press a track and tap the heart to add it to favorites',
);
}
// Sort tracks by artist name, then track name
final sortedTracks = List<Track>.from(_favoriteTracks)
..sort((a, b) {
final artistCompare = a.artistsString.compareTo(b.artistsString);
if (artistCompare != 0) return artistCompare;
return a.name.compareTo(b.name);
});
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
onRefresh: _loadFavoriteTracks,
child: ListView.builder(
key: const PageStorageKey<String>('library_tracks_list'),
cacheExtent: 500,
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
itemCount: sortedTracks.length,
itemBuilder: (context, index) {
final track = sortedTracks[index];
return _buildTrackTile(context, track);
},
),
);
}
Widget _buildTrackTile(BuildContext context, Track track) {
final maProvider = context.read<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Get image URL from track itself
final imageUrl = maProvider.api?.getImageUrl(track, size: 128);
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(6),
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
),
child: imageUrl == null
? Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24)
: null,
),
title: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.name,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(
Icons.favorite,
color: Colors.red,
size: 20,
),
onTap: () async {
final player = maProvider.selectedPlayer;
if (player == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
);
return;
}
try {
// Start radio from this track
await maProvider.api?.playRadio(player.playerId, track);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to start radio: $e')),
);
}
}
},
);
}
}
+6 -3
View File
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
final _playlistLogger = DebugLogger();
class PlaylistDetailsScreen extends StatefulWidget {
final Playlist playlist;
@@ -61,11 +64,11 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
return;
}
print('🎵 Queueing playlist on ${player.name}: ${player.playerId}');
_playlistLogger.info('Queueing playlist on ${player.name}', context: 'Playlist');
// Queue all tracks via Music Assistant
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
print('✓ Playlist queued on ${player.name}');
_playlistLogger.info('Playlist queued successfully', context: 'Playlist');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -76,7 +79,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
);
}
} catch (e) {
print('Error playing playlist: $e');
_playlistLogger.error('Error playing playlist', context: 'Playlist', error: e);
_showError('Error playing playlist: $e');
}
}
+21 -12
View File
@@ -9,6 +9,7 @@ import '../services/debug_logger.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/common/disconnected_state.dart';
import '../widgets/artist_avatar.dart';
import 'album_details_screen.dart';
import 'artist_details_screen.dart';
@@ -99,11 +100,11 @@ class SearchScreenState extends State<SearchScreen> {
void _onSearchChanged(String query) {
_debounceTimer?.cancel();
_debounceTimer = Timer(Timings.searchDebounce, () {
_performSearch(query);
_performSearch(query, keepFocus: true);
});
}
Future<void> _performSearch(String query) async {
Future<void> _performSearch(String query, {bool keepFocus = false}) async {
if (query.isEmpty) {
setState(() {
_searchResults = {'artists': [], 'albums': [], 'tracks': []};
@@ -128,6 +129,10 @@ class SearchScreenState extends State<SearchScreen> {
_isSearching = false;
_hasSearched = true;
});
// Keep keyboard open if user is still typing
if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus();
}
}
} catch (e) {
_logger.log('Search error: $e');
@@ -137,6 +142,9 @@ class SearchScreenState extends State<SearchScreen> {
_hasSearched = true;
_searchError = 'Search failed. Please check your connection.';
});
if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus();
}
}
}
}
@@ -156,6 +164,7 @@ class SearchScreenState extends State<SearchScreen> {
focusNode: _focusNode,
style: TextStyle(color: colorScheme.onSurface),
cursorColor: colorScheme.primary,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Search music...',
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
@@ -165,15 +174,20 @@ class SearchScreenState extends State<SearchScreen> {
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
onPressed: () {
_searchController.clear();
_performSearch('');
setState(() {
_searchResults = {'artists': [], 'albums': [], 'tracks': []};
_hasSearched = false;
_searchError = null;
});
},
)
: null,
),
onChanged: (value) {
setState(() {}); // Update clear button visibility
_onSearchChanged(value);
},
onSubmitted: _performSearch,
onSubmitted: (query) => _performSearch(query),
),
),
body: !maProvider.isConnected
@@ -389,18 +403,13 @@ class SearchScreenState extends State<SearchScreen> {
}
Widget _buildArtistTile(Artist artist) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(artist, size: 128);
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: CircleAvatar(
leading: ArtistAvatar(
artist: artist,
radius: 24,
backgroundColor: colorScheme.surfaceVariant,
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
child: imageUrl == null
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
: null,
imageSize: 128,
),
title: Text(
artist.name,
+2 -2
View File
@@ -317,7 +317,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 8),
Text(
'Add API keys to fetch artist biographies and album descriptions when Music Assistant doesn\'t have them',
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
@@ -365,7 +365,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
controller: _audioDbApiKeyController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'TheAudioDB API Key (Premium)',
labelText: 'TheAudioDB API Key',
hintText: 'Use "2" for free tier or premium key',
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
+236
View File
@@ -0,0 +1,236 @@
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'debug_logger.dart';
/// Comprehensive animation debugging utility for diagnosing jank and performance issues.
///
/// Usage:
/// 1. Call AnimationDebugger.startSession('playerExpand') at animation start
/// 2. Call AnimationDebugger.recordFrame() in animation listener (every frame)
/// 3. Call AnimationDebugger.endSession() when animation completes
/// 4. Logs will show frame timing, jank detection, and summary stats
class AnimationDebugger {
static final _logger = DebugLogger();
static bool _enabled = true;
// Session tracking
static String? _currentSession;
static int _frameCount = 0;
static final List<_FrameData> _frames = [];
static DateTime? _sessionStart;
static double? _lastAnimationValue;
// Scheduler binding for frame timing
static Duration? _lastFrameTime;
/// Enable or disable animation debugging
static void setEnabled(bool enabled) {
_enabled = enabled;
}
/// Start a new animation debugging session
static void startSession(String name) {
if (!_enabled) return;
_currentSession = name;
_frameCount = 0;
_frames.clear();
_sessionStart = DateTime.now();
_lastFrameTime = null;
_lastAnimationValue = null;
// Listen to frame timing
SchedulerBinding.instance.addPostFrameCallback(_onFirstFrame);
_logger.log('🎬 ANIM[$name] START');
}
static void _onFirstFrame(Duration timestamp) {
_lastFrameTime = timestamp;
}
/// Record a frame during animation
/// [animationValue] - current animation progress (0.0 to 1.0)
/// [context] - optional context string for this frame
static void recordFrame(double animationValue, {String? context}) {
if (!_enabled || _currentSession == null) return;
final now = DateTime.now();
final frameTime = SchedulerBinding.instance.currentFrameTimeStamp;
Duration? frameDuration;
if (_lastFrameTime != null) {
frameDuration = frameTime - _lastFrameTime!;
}
_lastFrameTime = frameTime;
// Calculate animation delta
double? animationDelta;
if (_lastAnimationValue != null) {
animationDelta = (animationValue - _lastAnimationValue!).abs();
}
_lastAnimationValue = animationValue;
final frame = _FrameData(
frameNumber: _frameCount,
timestamp: now,
frameDuration: frameDuration,
animationValue: animationValue,
animationDelta: animationDelta,
context: context,
);
_frames.add(frame);
_frameCount++;
// Log jank immediately (frames > 16.67ms for 60fps)
if (frameDuration != null && frameDuration.inMicroseconds > 16667) {
final ms = frameDuration.inMicroseconds / 1000;
_logger.log('🔴 ANIM[$_currentSession] JANK frame $_frameCount: ${ms.toStringAsFixed(2)}ms (>${(ms/16.67).toStringAsFixed(1)}x target)');
}
}
/// Record a build/layout event during animation
static void recordBuild(String widgetName) {
if (!_enabled || _currentSession == null) return;
_logger.log('🔨 ANIM[$_currentSession] BUILD: $widgetName at frame $_frameCount');
}
/// End the current session and log summary
static void endSession() {
if (!_enabled || _currentSession == null) return;
final sessionName = _currentSession!;
final totalDuration = DateTime.now().difference(_sessionStart!);
// Calculate statistics
int jankFrames = 0;
int severeJankFrames = 0;
double maxFrameMs = 0;
double totalFrameMs = 0;
int measuredFrames = 0;
for (final frame in _frames) {
if (frame.frameDuration != null) {
final ms = frame.frameDuration!.inMicroseconds / 1000;
totalFrameMs += ms;
measuredFrames++;
if (ms > maxFrameMs) maxFrameMs = ms;
if (ms > 16.67) jankFrames++;
if (ms > 33.33) severeJankFrames++; // Dropped more than 1 frame
}
}
final avgFrameMs = measuredFrames > 0 ? totalFrameMs / measuredFrames : 0;
final effectiveFps = avgFrameMs > 0 ? 1000 / avgFrameMs : 0;
// Build summary
final buffer = StringBuffer();
buffer.writeln('');
buffer.writeln('═══════════════════════════════════════');
buffer.writeln('🎬 ANIMATION SUMMARY: $sessionName');
buffer.writeln('═══════════════════════════════════════');
buffer.writeln('Duration: ${totalDuration.inMilliseconds}ms');
buffer.writeln('Total frames: $_frameCount');
buffer.writeln('Measured frames: $measuredFrames');
buffer.writeln('Average frame: ${avgFrameMs.toStringAsFixed(2)}ms');
buffer.writeln('Max frame: ${maxFrameMs.toStringAsFixed(2)}ms');
buffer.writeln('Effective FPS: ${effectiveFps.toStringAsFixed(1)}');
buffer.writeln('Jank frames (>16.67ms): $jankFrames (${measuredFrames > 0 ? (jankFrames * 100 / measuredFrames).toStringAsFixed(1) : 0}%)');
buffer.writeln('Severe jank (>33.33ms): $severeJankFrames');
// List worst frames
if (_frames.isNotEmpty) {
final sortedFrames = List<_FrameData>.from(_frames)
..sort((a, b) {
final aMs = a.frameDuration?.inMicroseconds ?? 0;
final bMs = b.frameDuration?.inMicroseconds ?? 0;
return bMs.compareTo(aMs);
});
final worstFrames = sortedFrames.take(5).where((f) => f.frameDuration != null && f.frameDuration!.inMicroseconds > 16667).toList();
if (worstFrames.isNotEmpty) {
buffer.writeln('');
buffer.writeln('Worst frames:');
for (final frame in worstFrames) {
final ms = frame.frameDuration!.inMicroseconds / 1000;
buffer.writeln(' Frame ${frame.frameNumber}: ${ms.toStringAsFixed(2)}ms (anim=${frame.animationValue.toStringAsFixed(3)})');
}
}
}
buffer.writeln('═══════════════════════════════════════');
_logger.log(buffer.toString());
// Reset
_currentSession = null;
_frames.clear();
_sessionStart = null;
_lastAnimationValue = null;
}
/// Log a one-off animation event (for Hero transitions, page transitions, etc.)
static void logEvent(String event, {String? details}) {
if (!_enabled) return;
final detailStr = details != null ? ' - $details' : '';
_logger.log('🎬 ANIM_EVENT: $event$detailStr');
}
/// Log frame callback timing (call this from addPostFrameCallback)
static void logPostFrame(String context) {
if (!_enabled) return;
final frameTime = SchedulerBinding.instance.currentFrameTimeStamp;
_logger.log('📐 POST_FRAME[$context]: ${frameTime.inMilliseconds}ms');
}
/// Create a HeroFlightShuttleBuilder that logs Hero animation progress
static HeroFlightShuttleBuilder createHeroDebugShuttle(String heroTag) {
return (
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
// Log Hero flight start
if (animation.value < 0.1) {
logEvent('Hero flight START', details: 'tag=$heroTag, direction=${flightDirection.name}');
}
// Add listener for completion (one-shot)
void onComplete(AnimationStatus status) {
if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
logEvent('Hero flight END', details: 'tag=$heroTag, status=${status.name}');
animation.removeStatusListener(onComplete);
}
}
animation.addStatusListener(onComplete);
// Return default shuttle (the toHero's child)
final Hero toHero = toHeroContext.widget as Hero;
return toHero.child;
};
}
}
class _FrameData {
final int frameNumber;
final DateTime timestamp;
final Duration? frameDuration;
final double animationValue;
final double? animationDelta;
final String? context;
_FrameData({
required this.frameNumber,
required this.timestamp,
this.frameDuration,
required this.animationValue,
this.animationDelta,
this.context,
});
}
+165 -12
View File
@@ -1,32 +1,185 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:device_info_plus/device_info_plus.dart';
/// Log levels for filtering and display
enum LogLevel {
debug, // Verbose debugging info (hidden by default)
info, // General information
warning, // Potential issues
error, // Errors and failures
}
class LogEntry {
final DateTime timestamp;
final LogLevel level;
final String message;
final String? context;
LogEntry({
required this.timestamp,
required this.level,
required this.message,
this.context,
});
String get formatted {
final time = timestamp.toIso8601String().substring(11, 23);
final levelStr = level.name.toUpperCase().padRight(5);
final contextStr = context != null ? '[$context] ' : '';
return '[$time] $levelStr $contextStr$message';
}
}
class DebugLogger {
static final DebugLogger _instance = DebugLogger._internal();
factory DebugLogger() => _instance;
DebugLogger._internal();
final List<String> _logs = [];
final _maxLogs = 500;
final List<LogEntry> _entries = [];
final _maxLogs = 1000;
List<String> get logs => List.unmodifiable(_logs);
// Minimum level to display in debug console (debug builds show all)
LogLevel _minConsoleLevel = kDebugMode ? LogLevel.debug : LogLevel.info;
void log(String message) {
final timestamp = DateTime.now().toIso8601String().substring(11, 23);
final logMessage = '[$timestamp] $message';
List<LogEntry> get entries => List.unmodifiable(_entries);
debugPrint(logMessage);
/// Legacy getter for backward compatibility
List<String> get logs => _entries.map((e) => e.formatted).toList();
_logs.add(logMessage);
if (_logs.length > _maxLogs) {
_logs.removeAt(0);
void _addEntry(LogLevel level, String message, {String? context}) {
final entry = LogEntry(
timestamp: DateTime.now(),
level: level,
message: message,
context: context,
);
// Always store in memory
_entries.add(entry);
if (_entries.length > _maxLogs) {
_entries.removeAt(0);
}
// Only print to console if above minimum level
if (level.index >= _minConsoleLevel.index) {
debugPrint(entry.formatted);
}
}
/// Debug level - verbose info for development
void debug(String message, {String? context}) {
_addEntry(LogLevel.debug, message, context: context);
}
/// Info level - general operational info
void info(String message, {String? context}) {
_addEntry(LogLevel.info, message, context: context);
}
/// Warning level - potential issues
void warning(String message, {String? context}) {
_addEntry(LogLevel.warning, message, context: context);
}
/// Error level - failures and errors
void error(String message, {String? context, Object? error, StackTrace? stackTrace}) {
var fullMessage = message;
if (error != null) {
fullMessage += ': $error';
}
_addEntry(LogLevel.error, fullMessage, context: context);
if (stackTrace != null && kDebugMode) {
debugPrint('Stack trace: $stackTrace');
}
}
/// Legacy method for backward compatibility
void log(String message) {
_addEntry(LogLevel.info, message);
}
void clear() {
_logs.clear();
_entries.clear();
}
String getAllLogs() {
return _logs.join('\n');
return _entries.map((e) => e.formatted).join('\n');
}
/// Get logs filtered by minimum level
String getLogsFiltered({LogLevel minLevel = LogLevel.info}) {
return _entries
.where((e) => e.level.index >= minLevel.index)
.map((e) => e.formatted)
.join('\n');
}
/// Generate a formatted bug report with device info
Future<String> generateBugReport() async {
final buffer = StringBuffer();
buffer.writeln('═══════════════════════════════════════');
buffer.writeln('ENSEMBLE BUG REPORT');
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
buffer.writeln('═══════════════════════════════════════');
buffer.writeln();
// App info
try {
final packageInfo = await PackageInfo.fromPlatform();
buffer.writeln('APP INFO:');
buffer.writeln(' Version: ${packageInfo.version} (${packageInfo.buildNumber})');
buffer.writeln(' Package: ${packageInfo.packageName}');
buffer.writeln();
} catch (e) {
buffer.writeln('APP INFO: Unable to retrieve');
buffer.writeln();
}
// Device info
try {
final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo;
buffer.writeln('DEVICE INFO:');
buffer.writeln(' Model: ${android.manufacturer} ${android.model}');
buffer.writeln(' Android: ${android.version.release} (SDK ${android.version.sdkInt})');
buffer.writeln(' Device: ${android.device}');
buffer.writeln();
} else if (Platform.isIOS) {
final ios = await deviceInfo.iosInfo;
buffer.writeln('DEVICE INFO:');
buffer.writeln(' Model: ${ios.model}');
buffer.writeln(' iOS: ${ios.systemVersion}');
buffer.writeln(' Name: ${ios.name}');
buffer.writeln();
}
} catch (e) {
buffer.writeln('DEVICE INFO: Unable to retrieve');
buffer.writeln();
}
// Log statistics
final errorCount = _entries.where((e) => e.level == LogLevel.error).length;
final warningCount = _entries.where((e) => e.level == LogLevel.warning).length;
buffer.writeln('LOG SUMMARY:');
buffer.writeln(' Total entries: ${_entries.length}');
buffer.writeln(' Errors: $errorCount');
buffer.writeln(' Warnings: $warningCount');
buffer.writeln();
buffer.writeln('═══════════════════════════════════════');
buffer.writeln('LOGS (newest first):');
buffer.writeln('═══════════════════════════════════════');
buffer.writeln();
// Add logs in reverse order (newest first) for easier reading
for (final entry in _entries.reversed) {
buffer.writeln(entry.formatted);
}
return buffer.toString();
}
}
+50 -2
View File
@@ -1,11 +1,16 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'debug_logger.dart';
import 'settings_service.dart';
class MetadataService {
static final _logger = DebugLogger();
// Cache to avoid repeated API calls for the same artist/album
static final Map<String, String> _cache = {};
// Cache for artist images
static final Map<String, String?> _artistImageCache = {};
/// Fetches artist biography/description with fallback chain:
/// 1. Music Assistant metadata (passed in)
/// 2. Last.fm API (if key configured)
@@ -149,7 +154,7 @@ class MetadataService {
}
}
} catch (e) {
print('⚠️ Last.fm API error: $e');
_logger.warning('Last.fm API error: $e', context: 'Metadata');
}
return null;
}
@@ -182,7 +187,7 @@ class MetadataService {
}
}
} catch (e) {
print('⚠️ TheAudioDB API error: $e');
_logger.warning('TheAudioDB API error: $e', context: 'Metadata');
}
return null;
}
@@ -204,8 +209,51 @@ class MetadataService {
return text.trim();
}
/// Fetches artist image URL from Deezer (free, no API key required)
/// Returns the image URL if found, null otherwise
static Future<String?> getArtistImageUrl(String artistName) async {
// Check cache first
final cacheKey = 'artistImage:$artistName';
if (_artistImageCache.containsKey(cacheKey)) {
return _artistImageCache[cacheKey];
}
// Use Deezer API (free, no key, excellent coverage)
try {
final uri = Uri.https(
'api.deezer.com',
'/search/artist',
{
'q': artistName,
'limit': '1',
},
);
final response = await http.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final artists = data['data'] as List?;
if (artists != null && artists.isNotEmpty) {
final artist = artists[0];
// Use picture_xl for high quality, fall back to picture_medium
final imageUrl = artist['picture_xl'] ?? artist['picture_medium'] ?? artist['picture'];
_artistImageCache[cacheKey] = imageUrl;
return imageUrl;
}
}
} catch (e) {
_logger.warning('Deezer artist image error: $e', context: 'Metadata');
}
// Cache the null result to avoid repeated failed lookups
_artistImageCache[cacheKey] = null;
return null;
}
/// Clears the metadata cache
static void clearCache() {
_cache.clear();
_artistImageCache.clear();
}
}
+203 -47
View File
@@ -63,15 +63,24 @@ class MusicAssistantAPI {
// Guard to prevent multiple simultaneous connection attempts
Completer<void>? _connectionInProgress;
bool _isDisposed = false;
Future<void> connect() async {
// If already connected, nothing to do
if (_currentState == MAConnectionState.connected) {
// If disposed, don't try to connect
if (_isDisposed) {
_logger.log('Connection: API disposed, skipping connect');
return;
}
// If already connected or authenticated, nothing to do
if (_currentState == MAConnectionState.connected ||
_currentState == MAConnectionState.authenticated) {
_logger.log('Connection: Already connected, skipping');
return;
}
// If connection is in progress, wait for it instead of starting another
if (_connectionInProgress != null) {
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
_logger.log('Connection already in progress, waiting...');
return _connectionInProgress!.future;
}
@@ -611,13 +620,14 @@ class MusicAssistantAPI {
}
}
Future<Album?> getAlbumDetails(String provider, String itemId) async {
/// Get album details by URI
/// Use the album's uri field (e.g., "library://album/123" or "spotify://album/xyz")
Future<Album?> getAlbumByUri(String uri) async {
try {
final response = await _sendCommand(
'music/album',
'music/item_by_uri',
args: {
'provider': provider,
'item_id': itemId,
'uri': uri,
},
);
@@ -626,7 +636,27 @@ class MusicAssistantAPI {
return Album.fromJson(result as Map<String, dynamic>);
} catch (e) {
_logger.log('Error getting album details: $e');
_logger.log('Error getting album by URI: $e');
return null;
}
}
/// Get artist details by URI
Future<Artist?> getArtistByUri(String uri) async {
try {
final response = await _sendCommand(
'music/item_by_uri',
args: {
'uri': uri,
},
);
final result = response['result'];
if (result == null) return null;
return Artist.fromJson(result as Map<String, dynamic>);
} catch (e) {
_logger.log('Error getting artist by URI: $e');
return null;
}
}
@@ -749,35 +779,18 @@ class MusicAssistantAPI {
}
// Favorites
/// Toggle favorite status for any media item
Future<bool> toggleFavorite(String mediaType, String itemId, String provider) async {
try {
final response = await _sendCommand(
'music/favorites/toggle',
args: {
'media_type': mediaType,
'item_id': itemId,
'provider': provider,
},
);
final isFavorite = response['result'] as bool? ?? false;
return isFavorite;
} catch (e) {
_logger.log('Error toggling favorite: $e');
return false;
}
}
/// Mark item as favorite
/// Mark item as favorite using URI format
/// The item parameter should be a URI like "spotify://album/6XyFhbFtcRRR9peJ16em4h"
Future<void> addToFavorites(String mediaType, String itemId, String provider) async {
try {
// Build the URI for the item
final uri = '$provider://$mediaType/$itemId';
_logger.log('Adding to favorites: $uri');
await _sendCommand(
'music/favorites/add',
'music/favorites/add_item',
args: {
'media_type': mediaType,
'item_id': itemId,
'provider': provider,
'item': uri,
},
);
} catch (e) {
@@ -787,14 +800,16 @@ class MusicAssistantAPI {
}
/// Remove item from favorites
Future<void> removeFromFavorites(String mediaType, String itemId, String provider) async {
/// Requires the library_item_id (the numeric ID in the MA library)
Future<void> removeFromFavorites(String mediaType, int libraryItemId) async {
try {
_logger.log('Removing from favorites: mediaType=$mediaType, libraryItemId=$libraryItemId');
await _sendCommand(
'music/favorites/remove',
'music/favorites/remove_item',
args: {
'media_type': mediaType,
'item_id': itemId,
'provider': provider,
'library_item_id': libraryItemId,
},
);
} catch (e) {
@@ -1060,12 +1075,14 @@ class MusicAssistantAPI {
}
/// Play radio based on a track (generates similar tracks)
/// Prefers streaming provider URIs (Spotify, Tidal, etc.) for better recommendations
Future<void> playRadio(String playerId, Track track) async {
return await RetryHelper.retryCritical(
operation: () async {
// Try using library URI if available, otherwise use provider URI
final libraryUri = track.uri;
final trackUri = libraryUri ?? _buildTrackUri(track);
// For radio mode, prefer streaming providers (Spotify, Tidal, etc.)
// as they provide better dynamic track recommendations
final trackUri = _buildRadioUri(track);
_logger.log('Radio: Starting with URI $trackUri');
final args = {
'queue_id': playerId,
@@ -1074,7 +1091,6 @@ class MusicAssistantAPI {
'radio_mode': true, // Enable radio mode for similar tracks
};
await _sendCommand(
'player_queues/play_media',
args: args,
@@ -1084,6 +1100,114 @@ class MusicAssistantAPI {
);
}
/// Build URI for radio mode, preferring streaming providers for better recommendations
/// Priority: Spotify > Tidal > Deezer > Apple > YTM > Subsonic > any available > library
/// Note: Qobuz does NOT support radio mode (no dynamic_tracks API)
String _buildRadioUri(Track track) {
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
// Providers that support dynamic radio recommendations (in priority order)
// Qobuz is excluded - it doesn't support similar tracks API
const radioProviders = ['spotify', 'tidal', 'deezer', 'apple', 'ytmusic', 'subsonic', 'opensubsonic'];
// Try to find a streaming provider first
for (final providerPrefix in radioProviders) {
final mapping = track.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance.toLowerCase().startsWith(providerPrefix),
orElse: () => ProviderMapping(providerInstance: '', providerDomain: '', itemId: '', available: false),
);
if (mapping.providerInstance.isNotEmpty) {
return '${mapping.providerInstance}://track/${mapping.itemId}';
}
}
// Fall back to any available provider (but not library)
final nonLibraryMapping = track.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => ProviderMapping(providerInstance: '', providerDomain: '', itemId: '', available: false),
);
if (nonLibraryMapping.providerInstance.isNotEmpty) {
return '${nonLibraryMapping.providerInstance}://track/${nonLibraryMapping.itemId}';
}
}
// Last resort: use library URI or build from top-level provider
return track.uri ?? _buildTrackUri(track);
}
/// Play radio based on an artist (generates tracks from and similar to the artist)
/// Prefers streaming provider URIs (Spotify, Tidal, etc.) for better recommendations
Future<void> playArtistRadio(String playerId, Artist artist) async {
return await RetryHelper.retryCritical(
operation: () async {
final artistUri = _buildArtistRadioUri(artist);
_logger.log('Radio: Starting artist radio with URI $artistUri');
final args = {
'queue_id': playerId,
'media': [artistUri],
'option': 'replace',
'radio_mode': true,
};
await _sendCommand(
'player_queues/play_media',
args: args,
);
},
);
}
/// Add artist radio to queue (instead of replacing)
Future<void> playArtistRadioToQueue(String playerId, Artist artist) async {
return await RetryHelper.retryCritical(
operation: () async {
final artistUri = _buildArtistRadioUri(artist);
_logger.log('Radio: Adding artist radio to queue with URI $artistUri');
final args = {
'queue_id': playerId,
'media': [artistUri],
'option': 'add', // Add to queue instead of replace
'radio_mode': true,
};
await _sendCommand(
'player_queues/play_media',
args: args,
);
},
);
}
/// Build URI for artist radio mode, preferring streaming providers
/// Note: Qobuz does NOT support radio mode (no dynamic_tracks API)
String _buildArtistRadioUri(Artist artist) {
if (artist.providerMappings != null && artist.providerMappings!.isNotEmpty) {
// Qobuz is excluded - it doesn't support similar tracks API
const radioProviders = ['spotify', 'tidal', 'deezer', 'apple', 'ytmusic', 'subsonic', 'opensubsonic'];
for (final providerPrefix in radioProviders) {
final mapping = artist.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance.toLowerCase().startsWith(providerPrefix),
orElse: () => ProviderMapping(providerInstance: '', providerDomain: '', itemId: '', available: false),
);
if (mapping.providerInstance.isNotEmpty) {
return '${mapping.providerInstance}://artist/${mapping.itemId}';
}
}
final nonLibraryMapping = artist.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => ProviderMapping(providerInstance: '', providerDomain: '', itemId: '', available: false),
);
if (nonLibraryMapping.providerInstance.isNotEmpty) {
return '${nonLibraryMapping.providerInstance}://artist/${nonLibraryMapping.itemId}';
}
}
return artist.uri ?? '${artist.provider}://artist/${artist.itemId}';
}
/// Build track URI from provider mappings
String _buildTrackUri(Track track) {
// Use provider mappings to get the actual provider instance
@@ -1990,13 +2114,39 @@ class MusicAssistantAPI {
}
Future<void> _reconnect() async {
// Don't reconnect if disposed
if (_isDisposed) {
_logger.log('Reconnect: API disposed, skipping');
return;
}
// Don't reconnect if already connected or connecting
if (_currentState == MAConnectionState.connected ||
_currentState == MAConnectionState.authenticated ||
_currentState == MAConnectionState.connecting) {
_logger.log('Reconnect: Already connected/connecting, skipping');
return;
}
// Don't reconnect if another connection is in progress
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
_logger.log('Reconnect: Connection already in progress, skipping');
return;
}
await Future.delayed(Timings.reconnectDelay);
if (_currentState != MAConnectionState.connected) {
try {
await connect();
} catch (e) {
_logger.log('Reconnection failed: $e');
}
// Check again after delay
if (_isDisposed ||
_currentState == MAConnectionState.connected ||
_currentState == MAConnectionState.authenticated) {
return;
}
try {
await connect();
} catch (e) {
_logger.log('Reconnection failed: $e');
}
}
@@ -2009,7 +2159,13 @@ class MusicAssistantAPI {
}
void dispose() {
_isDisposed = true;
_stopHeartbeat();
// Complete any pending connection to prevent hanging futures
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
_connectionInProgress!.completeError(Exception('API disposed'));
}
_connectionInProgress = null;
disconnect();
_connectionStateController.close();
for (final stream in _eventStreams.values) {
+4 -1
View File
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:palette_generator/palette_generator.dart';
import '../services/debug_logger.dart';
final _logger = DebugLogger();
/// Extracted adaptive colors from album art
class AdaptiveColors {
@@ -40,7 +43,7 @@ class PaletteHelper {
);
return palette;
} catch (e) {
print('⚠️ Failed to extract palette: $e');
_logger.warning('Failed to extract palette: $e', context: 'Palette');
return null;
}
}
+4 -1
View File
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart';
import '../services/debug_logger.dart';
final _systemThemeLogger = DebugLogger();
class SystemThemeHelper {
/// Attempts to get system color schemes from the device (Android 12+)
@@ -35,7 +38,7 @@ class SystemThemeHelper {
}
}
} catch (e) {
print('⚠️ Failed to get system colors: $e');
_systemThemeLogger.warning('Failed to get system colors: $e', context: 'Theme');
}
return null;
+4 -1
View File
@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import 'palette_helper.dart';
final _themeLogger = DebugLogger();
/// Global function to update adaptive colors from an image URL
/// This can be called from anywhere in the app (e.g., when tapping an album/artist)
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
@@ -17,7 +20,7 @@ Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUr
}
} catch (e) {
// Silently fail - colors will update when track plays
print('Failed to extract colors on tap: $e');
_themeLogger.debug('Failed to extract colors on tap: $e', context: 'Theme');
}
}
+89
View File
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:provider/provider.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/metadata_service.dart';
/// A CircleAvatar that shows artist image with automatic fallback to Deezer/Fanart.tv
class ArtistAvatar extends StatefulWidget {
final Artist artist;
final double radius;
final int imageSize;
final String? heroTag;
final ValueChanged<String?>? onImageLoaded;
const ArtistAvatar({
super.key,
required this.artist,
this.radius = 24,
this.imageSize = 128,
this.heroTag,
this.onImageLoaded,
});
@override
State<ArtistAvatar> createState() => _ArtistAvatarState();
}
class _ArtistAvatarState extends State<ArtistAvatar> {
String? _imageUrl;
bool _triedFallback = false;
@override
void initState() {
super.initState();
_loadImage();
}
Future<void> _loadImage() async {
final provider = context.read<MusicAssistantProvider>();
// Try MA first
final maUrl = provider.getImageUrl(widget.artist, size: widget.imageSize);
if (maUrl != null) {
if (mounted) {
setState(() {
_imageUrl = maUrl;
});
widget.onImageLoaded?.call(maUrl);
}
return;
}
// Fallback to external sources
if (!_triedFallback) {
_triedFallback = true;
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
if (fallbackUrl != null && mounted) {
setState(() {
_imageUrl = fallbackUrl;
});
widget.onImageLoaded?.call(fallbackUrl);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final avatar = CircleAvatar(
radius: widget.radius,
backgroundColor: colorScheme.surfaceVariant,
backgroundImage: _imageUrl != null ? CachedNetworkImageProvider(_imageUrl!) : null,
child: _imageUrl == null
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
: null,
);
if (widget.heroTag != null) {
return Hero(
tag: widget.heroTag!,
child: avatar,
);
}
return avatar;
}
}
+40 -9
View File
@@ -6,8 +6,10 @@ import '../screens/artist_details_screen.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
class ArtistCard extends StatelessWidget {
class ArtistCard extends StatefulWidget {
final Artist artist;
final VoidCallback? onTap;
final String? heroTagSuffix;
@@ -19,26 +21,45 @@ class ArtistCard extends StatelessWidget {
this.heroTagSuffix,
});
@override
State<ArtistCard> createState() => _ArtistCardState();
}
class _ArtistCardState extends State<ArtistCard> {
static final _logger = DebugLogger();
String? _fallbackImageUrl;
bool _triedFallback = false;
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
final maImageUrl = maProvider.api?.getImageUrl(widget.artist, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
// Use MA image if available, otherwise try fallback
final imageUrl = maImageUrl ?? _fallbackImageUrl;
// Try to fetch fallback image if MA doesn't have one and we haven't tried yet
if (maImageUrl == null && !_triedFallback) {
_triedFallback = true;
_logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
_fetchFallbackImage();
}
return RepaintBoundary(
child: GestureDetector(
onTap: onTap ?? () {
onTap: widget.onTap ?? () {
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: ArtistDetailsScreen(
artist: artist,
heroTagSuffix: heroTagSuffix,
artist: widget.artist,
heroTagSuffix: widget.heroTagSuffix,
),
),
);
@@ -48,7 +69,7 @@ class ArtistCard extends StatelessWidget {
children: [
// Artist image - circular
Hero(
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: ClipOval(
child: Container(
width: 110,
@@ -73,11 +94,11 @@ class ArtistCard extends StatelessWidget {
const SizedBox(height: 12),
// Artist name
Hero(
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
artist.name,
widget.artist.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
@@ -93,4 +114,14 @@ class ArtistCard extends StatelessWidget {
),
);
}
Future<void> _fetchFallbackImage() async {
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
if (fallbackUrl != null && mounted) {
_logger.debug('Found fallback image for "${widget.artist.name}"', context: 'ArtistCard');
setState(() {
_fallbackImageUrl = fallbackUrl;
});
}
}
}
+82
View File
@@ -64,6 +64,20 @@ class EmptyState extends StatelessWidget {
message: 'No results found',
);
/// Creates a custom empty state with title and subtitle
factory EmptyState.custom({
required IconData icon,
required String title,
String? subtitle,
VoidCallback? onRefresh,
}) =>
_CustomEmptyState(
icon: icon,
title: title,
subtitle: subtitle,
onRefresh: onRefresh,
);
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -106,3 +120,71 @@ class EmptyState extends StatelessWidget {
);
}
}
/// A custom empty state with title and optional subtitle
class _CustomEmptyState extends EmptyState {
final String title;
final String? subtitle;
final VoidCallback? onRefresh;
const _CustomEmptyState({
required super.icon,
required this.title,
this.subtitle,
this.onRefresh,
}) : super(message: title);
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: Spacing.paddingAll24,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: iconSize,
color: colorScheme.onSurface.withOpacity(0.38),
),
Spacing.vGap16,
Text(
title,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 16,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
Spacing.vGap8,
Text(
subtitle!,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.4),
fontSize: 14,
),
textAlign: TextAlign.center,
),
],
if (onRefresh != null) ...[
Spacing.vGap24,
ElevatedButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+70 -68
View File
@@ -149,19 +149,65 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
}
// Base background: use adaptive surface color if available, otherwise default surface
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
? themeProvider.adaptiveSurfaceColor!
: colorScheme.surface;
return ValueListenableBuilder<PlayerExpansionState>(
valueListenable: playerExpansionNotifier,
builder: (context, expansionState, child) {
// Base background: use adaptive surface color if available, otherwise default surface
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
? themeProvider.adaptiveSurfaceColor!
: colorScheme.surface;
// Lerp between base color and player background when expanded
// Pass BottomNavigationBar as child to avoid rebuilding it every frame
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home_rounded),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.library_music_outlined),
activeIcon: Icon(Icons.library_music_rounded),
label: 'Library',
),
BottomNavigationBarItem(
icon: Icon(Icons.search_rounded),
activeIcon: Icon(Icons.search_rounded),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
label: 'Settings',
),
],
),
builder: (context, expansionState, navBar) {
// Only compute colors during animation - this is the hot path
final navBgColor = expansionState.progress > 0 && expansionState.backgroundColor != null
? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)!
: baseBgColor;
// Use AnimatedContainer for smoother transitions instead of rebuilding
return Container(
decoration: BoxDecoration(
color: navBgColor,
@@ -175,56 +221,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
]
: null,
),
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
// Collapse player if expanded
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
// Pop any pushed routes (album/artist details) first
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
// Hide mini player on Settings screen, show on other screens
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home_rounded),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.library_music_outlined),
activeIcon: Icon(Icons.library_music_rounded),
label: 'Library',
),
BottomNavigationBarItem(
icon: Icon(Icons.search_rounded),
activeIcon: Icon(Icons.search_rounded),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
label: 'Settings',
),
],
),
child: navBar, // Reuse pre-built navigation bar
);
},
);
@@ -234,17 +231,22 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
),
),
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav
AnimatedBuilder(
animation: _slideAnimation,
builder: (context, child) {
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
// Only show player if connected and has a selected player
// Mini player is now permanent - shows device selector even without a track
if (!maProvider.isConnected ||
maProvider.selectedPlayer == null) {
return const SizedBox.shrink();
}
// Use Selector instead of Consumer to avoid rebuilds during animation
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
selector: (_, provider) => (
isConnected: provider.isConnected,
hasPlayer: provider.selectedPlayer != null,
),
builder: (context, state, child) {
// Only show player if connected and has a selected player
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,
+109 -38
View File
@@ -4,6 +4,7 @@ import '../../theme/design_tokens.dart';
/// A compact device selector bar shown when no track is playing
class DeviceSelectorBar extends StatelessWidget {
final dynamic selectedPlayer;
final dynamic peekPlayer;
final bool hasMultiplePlayers;
final Color backgroundColor;
final Color textColor;
@@ -11,11 +12,14 @@ class DeviceSelectorBar extends StatelessWidget {
final double height;
final double borderRadius;
final double slideOffset;
final GestureDragStartCallback? onHorizontalDragStart;
final GestureDragUpdateCallback? onHorizontalDragUpdate;
final GestureDragEndCallback? onHorizontalDragEnd;
const DeviceSelectorBar({
super.key,
required this.selectedPlayer,
this.peekPlayer,
required this.hasMultiplePlayers,
required this.backgroundColor,
required this.textColor,
@@ -23,12 +27,16 @@ class DeviceSelectorBar extends StatelessWidget {
required this.height,
required this.borderRadius,
required this.slideOffset,
this.onHorizontalDragStart,
this.onHorizontalDragUpdate,
this.onHorizontalDragEnd,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragStart: hasMultiplePlayers ? onHorizontalDragStart : null,
onHorizontalDragUpdate: hasMultiplePlayers ? onHorizontalDragUpdate : null,
onHorizontalDragEnd: hasMultiplePlayers ? onHorizontalDragEnd : null,
child: Material(
color: backgroundColor,
@@ -40,47 +48,56 @@ class DeviceSelectorBar extends StatelessWidget {
width: width,
height: height,
child: ClipRect(
child: Transform.translate(
offset: Offset(slideOffset * width, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(selectedPlayer.name),
color: textColor,
size: IconSizes.md,
),
Spacing.hGap12,
Expanded(
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(
'Swipe to switch device',
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 12,
child: Stack(
children: [
// Peek player content (shows when dragging)
if (slideOffset.abs() > 0.01 && peekPlayer != null)
_buildPeekContent(),
// Current player content
Transform.translate(
offset: Offset(slideOffset * width, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(selectedPlayer.name),
color: textColor,
size: IconSizes.md,
),
Spacing.hGap12,
Expanded(
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(
'Swipe to switch device',
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 12,
),
),
],
),
),
],
),
],
),
),
),
],
),
),
),
@@ -88,6 +105,60 @@ class DeviceSelectorBar extends StatelessWidget {
);
}
/// Build peek player content that slides in from the edge
Widget _buildPeekContent() {
final isFromRight = slideOffset < 0;
final peekProgress = slideOffset.abs();
// Calculate peek position - slides in as main content slides out
final peekBaseOffset = isFromRight
? width * (1 - peekProgress)
: -width * (1 - peekProgress);
return Transform.translate(
offset: Offset(peekBaseOffset, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(peekPlayer.name),
color: textColor,
size: IconSizes.md,
),
Spacing.hGap12,
Expanded(
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(
'Swipe to switch device',
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 12,
),
),
],
),
),
],
),
),
);
}
/// Get appropriate icon for player based on name
IconData _getPlayerIcon(String playerName) {
final nameLower = playerName.toLowerCase();
+14 -11
View File
@@ -109,17 +109,19 @@ class _VolumeControlState extends State<VolumeControl> {
},
),
Expanded(
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white.withOpacity(0.3),
thumbColor: Colors.white,
overlayColor: Colors.white.withOpacity(0.2),
),
child: Slider(
child: SizedBox(
height: 48, // Increase touch target height
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white.withOpacity(0.3),
thumbColor: Colors.white,
overlayColor: Colors.white.withOpacity(0.2),
),
child: Slider(
value: currentVolume.clamp(0.0, 1.0),
onChanged: (value) {
setState(() {
@@ -163,6 +165,7 @@ class _VolumeControlState extends State<VolumeControl> {
}
},
),
),
),
),
SizedBox(
+4 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 1.6.1+8
version: 1.8.0+9
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -28,6 +28,9 @@ dependencies:
# Utilities
uuid: ^4.2.1
package_info_plus: ^8.0.0
device_info_plus: ^10.1.0
share_plus: ^9.0.0
# Audio - Local Playback
just_audio: ^0.9.36