Compare commits

..
Author SHA1 Message Date
Your Name f0568e917b Merge feature/hardware-volume-control: Hardware volume button support 2025-12-11 23:31:00 +00:00
Your NameandClaude 2e3f3c1e25 Fix hardware volume for both MA players and builtin player
- Add dynamic volume interception toggling based on selected player
- MA player selected: intercept hardware buttons, control via API
- Builtin player selected: disable interception, let native volume work
- Use FlutterVolumeController for builtin player UI slider and MA commands

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 23:18:13 +00:00
Your NameandClaude Opus 4.5 e1b18c7f9e fix: Handle 'set_volume' event type for builtin player
MA server sends events with type 'set_volume' and 'volume' key,
but we were only checking for 'volume_set' and 'volume_level'.
Now handles both variations so hardware volume buttons properly
adjust the local audio volume for the builtin player.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:34:28 +00:00
Your NameandClaude Opus 4.5 2e0cb175cb fix: Track builtin player volume to prevent periodic state reset
The periodic state reporting was using just_audio player volume
(always 100%) instead of the MA-tracked volume. Now using a separate
_localPlayerVolume variable that updates when:
- volume_set event is received from MA server
- setVolume is called for the builtin player

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:20:10 +00:00
Your NameandClaude Opus 4.5 d7194d0f2f fix: Use MainActivity extending AudioServiceActivity for volume buttons
The AndroidManifest was using AudioServiceActivity directly, which meant
our custom MainActivity with volume button interception wasn't being used.

Changes:
- MainActivity now extends AudioServiceActivity (for audio_service support)
- AndroidManifest updated to reference .MainActivity instead of AudioServiceActivity
- This allows both background audio AND volume button interception to work

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 21:58:25 +00:00
Your NameandClaude Opus 4.5 4a94a0b596 fix: Use dispatchKeyEvent instead of onKeyDown for volume buttons
Flutter's engine uses dispatchKeyEvent which may consume key events
before they reach onKeyDown. Switching to dispatchKeyEvent ensures
we intercept volume button presses before Flutter processes them.

Also consume ACTION_UP events for volume keys to fully prevent
system volume from changing.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 21:47:26 +00:00
Your NameandClaude Opus 4.5 d28219df98 debug: Add step-by-step logging to trace volume init
- Add numbered steps [1/4] through [4/4] in main.dart init
- Add comprehensive logging in HardwareVolumeService.init()
- Wrap initialization in try-catch to capture any errors
- Log each stage of MethodChannel setup

This helps diagnose why volume initialization logs aren't appearing
in bug reports.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 21:33:08 +00:00
Your Name 27b2df7e98 debug: Add comprehensive logging for volume button tracing 2025-12-11 21:17:02 +00:00
Your Name 9682e877fb fix: Use correct Player property name (name not displayName) 2025-12-11 21:04:27 +00:00
Your NameandClaude Opus 4.5 daf624c8bc debug: Add logging to hardware volume control
Add detailed logging to diagnose why volume buttons aren't working
for remote players. This will help identify if:
- Volume button events are being captured
- Selected player is available
- API calls are succeeding

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 20:55:29 +00:00
Your NameandClaude Opus 4.5 2c2c5e7939 feat: Add hardware volume button control for selected player
Intercepts hardware volume up/down buttons and routes them to the
currently selected Music Assistant player instead of changing device
volume. Uses platform channel to capture volume key events at the
Android native level.

- Add HardwareVolumeService for Flutter/Android MethodChannel communication
- Update MainActivity.kt to intercept KEYCODE_VOLUME_UP/DOWN
- Integrate volume control in main.dart with 5% step size

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 20:43:41 +00:00
Your Name a6c35c6263 docs: reframe AI disclaimer in README 2025-12-11 17:26:34 +00:00
Your NameandClaude Opus 4.5 25b074c1e8 docs: update README features and screenshots
- Add Home Screen section (customizable rows, favorites, adaptive layout)
- Update Library Browsing (playlists, favorites filter, artist details)
- Update screenshots to reflect current UI

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 17:20:58 +00:00
Your NameandClaude Opus 4.5 6c97420f58 chore: enable full debug logging for user bug reports
Disable perf-only mode so all log levels (debug, info, warning, error)
are captured in bug reports, not just performance logs.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 16:23:00 +00:00
Your NameandClaude Opus 4.5 526e0d06b6 perf: apply scroll optimizations to all list views app-wide
Extends FPS optimizations from HomeScreen to all scrollable lists:
- Library screens (artists, albums, tracks, playlists)
- New library screen (all tabs)
- Search screen results
- Queue panel

Changes applied:
- Add cacheExtent: 500 for preloading items off-screen
- Add addAutomaticKeepAlives: false (items manage their own)
- Add addRepaintBoundaries: false (items have RepaintBoundary)
- Wrap list tiles in RepaintBoundary for isolated repaints
- Add ValueKey to items for efficient diffing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 15:39:01 +00:00
Your NameandClaude Opus 4.5 7092c2c08e chore: restore performance logging for user bug reports
Re-adds the scroll frame timing logs so users can report performance
issues with actual metrics. The underlying performance fixes remain.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 15:33:35 +00:00
Your NameandClaude Opus 4.5 c728815f8b perf: optimize HomeScreen scroll FPS for 120Hz displays
Major performance improvements targeting 17-23ms frame times on Pixel 9 Pro Fold:

- ArtistCard: Replace Image.network with CachedNetworkImage for disk caching
- ArtistCard: Move fallback image fetch from build() to initState()
- NewHomeScreen: Remove _loadSettings() call from build method
- NewHomeScreen: Remove NotificationListener frame callbacks (2-3ms overhead)
- AlbumRow/ArtistRow/TrackRow: Add cacheExtent, disable redundant keepAlives
- GlobalPlayerOverlay: Cache HSL color computations to avoid per-frame recalc

Expected improvement: 15-25ms frame time reduction, targeting <8.33ms for 120Hz

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 15:21:43 +00:00
Your Name 19055d1474 Enable perf-only logging mode for FPS diagnosis
All non-perf logs are now filtered out. Only SCROLL START/END,
SLOW FRAME, and BUILD timing logs will appear.
2025-12-11 14:33:06 +00:00
Your Name cac78f7a11 Add perf case to debug log screen color switch 2025-12-11 14:31:52 +00:00
Your Name 1ad5ab3f22 Add performance logging for FPS diagnosis
- Add perf log level to DebugLogger
- Track widget build times (logs if > 4ms)
- Track frame times during scroll (logs if > 16ms)
- Add scroll notification listener to home screen
- Logs SCROLL START/END and slow frames
2025-12-11 14:27:15 +00:00
Your Name 4f7fe9f4a4 Fix syntax errors in artist_row and track_row 2025-12-11 13:58:01 +00:00
Your Name 081f13d59f Add RepaintBoundary to row widgets for better scroll performance 2025-12-11 13:45:50 +00:00
Your Name ea4407f509 Fix text cutoff and scroll issues
1. Calculate card width based on artwork size (content height - text area)
   - Text area is ~44px (8px gap + title + subtitle)
   - Artwork is square, fits remaining height
   - Card width = artwork size

2. Add bottom padding to scroll view for >3 rows
   - Allows scrolling fully to bottom row
2025-12-11 13:13:17 +00:00
Your Name 6ffc98c680 Simplify row sizing: all rows same height = screen / 3
- rowHeight now represents total row height (title + content)
- Each row constrained to exact height with SizedBox
- Simple calculation: available height / 3
- No more ratio math between album/artist rows
- >3 rows enabled: same heights, stack and scroll
2025-12-11 12:43:07 +00:00
Your Name 0fe53c9115 Fix favorites spacing: remove extra 8px spacer before first row 2025-12-11 12:35:15 +00:00
Your Name d0a1e3a85b Fix row sizing: always calculate for 3 rows, stack and scroll if >3 enabled
- Row heights now always based on fitting 3 rows (2 album + 1 artist)
- If >3 rows enabled, they stack and scroll
- If ≤3 rows, fits perfectly without scroll
- Added 4px more bottom padding for proper gap above mini player
2025-12-11 12:20:12 +00:00
Your Name fa8c5bd26e Fix bottom spacing: increase mini player gap by 4px, restore 8px row spacing 2025-12-11 11:53:39 +00:00
Your NameandClaude Opus 4.5 62386b65d3 Fix home screen issues: consistent artist images, proper scaling, auto-reload
Fixes:
1. Artist card: Fixed height for text area (36px) so image size is
   consistent regardless of whether name takes 1 or 2 lines
2. All rows now use same height calculation - favorites rows get the
   same scaled heights as main rows, so 3 favorites rows = same height
   as 3 default rows (no scroll in either case)
3. Reduced spacing between rows from 8px to 4px for tighter fit
4. Settings auto-reload: Home screen reloads settings on each build
   so changes in Settings are reflected immediately without manual refresh

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 11:46:34 +00:00
Your NameandClaude Opus 4.5 82beb0db2c Home screen improvements: optional rows, random favorites, better spacing
Changes:
- All rows now optional in Settings > Home Screen
- Main rows (Recently Played, Discover Artists, Discover Albums) default on
- Favorites rows default off, appear in random order each session
- Added 4px more top padding on row titles for better spacing
- Fixed TrackRow artwork - now tries track image first, then album
- Dynamic height calculation adapts to number of enabled rows

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 11:18:22 +00:00
Your NameandClaude Opus 4.5 2fe78556e5 Fix home screen scroll - remove extra bottom spacing
The 3 main rows are calculated to fit exactly in available space.
Only add bottom spacing when favorites rows are enabled (they scroll).
Also reduced spacing between rows from 21+3 to 8+8.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 11:00:45 +00:00
Your NameandClaude Opus 4.5 82d0bb5c2d Add optional favorites rows to home screen
Settings toggles allow users to show Favorite Albums, Favorite Artists,
and Favorite Tracks rows on the home screen. These appear below the
main scaled section (Recently Played, Discover Artists, Discover Albums)
and use fixed heights, requiring scroll on any screen when enabled.

Changes:
- SettingsService: Added getters/setters for favorite row toggles
- SettingsScreen: Added Home Screen section with 3 toggle switches
- MusicAssistantProvider: Added getFavoriteAlbums/Artists/Tracks methods
- TrackRow: New widget for horizontal track browsing
- NewHomeScreen: Shows optional favorites rows based on settings

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 10:51:13 +00:00
Your NameandClaude Opus 4.5 add137c56c Fix artist card stretched images - ensure proper circles
Use LayoutBuilder to constrain the artist image to the smaller
of width/height, ensuring it's always a perfect circle regardless
of the container's aspect ratio.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 10:48:34 +00:00
Your NameandClaude Opus 4.5 3df1b9c756 Add adaptive home screen scaling for different aspect ratios
Home screen now scales proportionally to fit different screen heights
without requiring scrolling. On taller screens (like Pixel 9 Pro Fold),
rows are larger. On shorter/wider screens, rows scale down to fit.

Changes:
- AlbumRow/ArtistRow: Added rowHeight parameter for dynamic sizing
- ArtistCard: Made image size flexible with Expanded + AspectRatio
- NewHomeScreen: Uses LayoutBuilder to calculate available space and
  distribute it proportionally between the 3 rows

Row heights are clamped to min/max values to ensure usability on
extreme aspect ratios.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 10:38:44 +00:00
Your NameandClaude Opus 4.5 3852e83a3c Fix artist hero animation grey image during transition
The artist image was turning grey during the hero animation because
ArtistDetailsScreen was deferring image loading until after the 350ms
transition completed. This caused the destination hero widget to show
an empty container instead of the image.

Fix: Pass initialImageUrl from source screens to ArtistDetailsScreen
so the hero has the same image content from the start of the animation.
The higher-res image and color extraction still happen after transition.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 08:21:58 +00:00
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
41 changed files with 3463 additions and 637 deletions
+11 -2
View File
@@ -11,7 +11,7 @@
## Disclaimer ## Disclaimer
I am not a developer. This application was created using **Claude Code** and **Gemini CLI**. This application was built with AI-assisted development using **Claude Code** and **Gemini CLI**.
--- ---
@@ -28,9 +28,18 @@ I am not a developer. This application was created using **Claude Code** and **G
- **Full Playback Controls** - Play, pause, skip, seek, and adjust volume - **Full Playback Controls** - Play, pause, skip, seek, and adjust volume
- **Queue Management** - View and manage the playback queue - **Queue Management** - View and manage the playback queue
### Home Screen
- **Customizable Rows** - Toggle Recently Played, Discover Artists, and Discover Albums
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, and Tracks
- **Adaptive Layout** - Rows scale properly for different screen sizes and aspect ratios
- **Pull to Refresh** - Refresh content with a simple pull gesture
### Library Browsing ### Library Browsing
- **Browse Your Collection** - Artists, albums, and tracks from all your music sources - **Browse Your Collection** - Artists, albums, playlists, and tracks from all your music sources
- **Favorites Filter** - Toggle to show only your favorite items
- **Album Details** - View track listings with artwork - **Album Details** - View track listings with artwork
- **Artist Details** - View artist albums and top tracks
- **Playlist Support** - Browse and play your playlists
- **Search** - Find music across your entire library - **Search** - Find music across your entire library
### Smart Features ### Smart Features
+1 -1
View File
@@ -14,7 +14,7 @@
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"> android:networkSecurityConfig="@xml/network_security_config">
<activity <activity
android:name="com.ryanheise.audioservice.AudioServiceActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
@@ -1,6 +1,87 @@
package com.musicassistant.music_assistant package com.musicassistant.music_assistant
import io.flutter.embedding.android.FlutterActivity import android.util.Log
import android.view.KeyEvent
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterActivity() { // Extend AudioServiceActivity instead of FlutterActivity to support audio_service package
// while also intercepting volume button events
class MainActivity: AudioServiceActivity() {
private val TAG = "EnsembleVolume"
private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons"
private var methodChannel: MethodChannel? = null
private var isListening = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
methodChannel?.setMethodCallHandler { call, result ->
Log.d(TAG, "Received method call: ${call.method}")
when (call.method) {
"startListening" -> {
isListening = true
Log.d(TAG, "Volume listening ENABLED")
result.success(null)
}
"stopListening" -> {
isListening = false
Log.d(TAG, "Volume listening DISABLED")
result.success(null)
}
else -> {
Log.d(TAG, "Unknown method: ${call.method}")
result.notImplemented()
}
}
}
}
// Use dispatchKeyEvent instead of onKeyDown - Flutter's engine uses dispatchKeyEvent
// and may consume events before they reach onKeyDown
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
if (event == null) {
return super.dispatchKeyEvent(event)
}
val keyCode = event.keyCode
val action = event.action
Log.d(TAG, "dispatchKeyEvent: keyCode=$keyCode, action=$action, isListening=$isListening")
// Only handle KEY_DOWN events to avoid double-triggering (down + up)
if (action != KeyEvent.ACTION_DOWN) {
// For volume keys when listening, also consume ACTION_UP to fully block system volume
if (isListening && (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) {
Log.d(TAG, "Consuming ACTION_UP for volume key")
return true
}
return super.dispatchKeyEvent(event)
}
if (!isListening) {
Log.d(TAG, "Not listening, passing to super")
return super.dispatchKeyEvent(event)
}
return when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
Log.d(TAG, "VOLUME UP pressed - sending to Flutter")
methodChannel?.invokeMethod("volumeUp", null)
true // Consume the event to prevent system volume change
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
Log.d(TAG, "VOLUME DOWN pressed - sending to Flutter")
methodChannel?.invokeMethod("volumeDown", null)
true // Consume the event to prevent system volume change
}
else -> {
Log.d(TAG, "Other key, passing to super")
super.dispatchKeyEvent(event)
}
}
}
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 804 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 686 KiB

After

Width:  |  Height:  |  Size: 498 KiB

+106
View File
@@ -1,14 +1,17 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'providers/music_assistant_provider.dart'; import 'providers/music_assistant_provider.dart';
import 'providers/navigation_provider.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'services/settings_service.dart'; import 'services/settings_service.dart';
import 'services/audio/massiv_audio_handler.dart'; import 'services/audio/massiv_audio_handler.dart';
import 'services/auth/auth_manager.dart'; import 'services/auth/auth_manager.dart';
import 'services/debug_logger.dart'; import 'services/debug_logger.dart';
import 'services/hardware_volume_service.dart';
import 'services/music_assistant_api.dart' show MAConnectionState; import 'services/music_assistant_api.dart' show MAConnectionState;
import 'theme/theme_provider.dart'; import 'theme/theme_provider.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
@@ -70,6 +73,14 @@ class MusicAssistantApp extends StatefulWidget {
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver { class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
late MusicAssistantProvider _musicProvider; late MusicAssistantProvider _musicProvider;
late ThemeProvider _themeProvider; late ThemeProvider _themeProvider;
final _hardwareVolumeService = HardwareVolumeService();
StreamSubscription? _volumeUpSub;
StreamSubscription? _volumeDownSub;
String? _lastSelectedPlayerId;
String? _builtinPlayerId;
// Volume step size (percentage points per button press)
static const int _volumeStep = 5;
@override @override
void initState() { void initState() {
@@ -77,14 +88,108 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
_musicProvider = MusicAssistantProvider(); _musicProvider = MusicAssistantProvider();
_themeProvider = ThemeProvider(); _themeProvider = ThemeProvider();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_initHardwareVolumeControl();
// Listen to player selection changes to toggle volume interception
_musicProvider.addListener(_onProviderChanged);
}
Future<void> _initHardwareVolumeControl() async {
try {
_logger.log('🔊 [1/5] Starting hardware volume control initialization...');
// Get builtin player ID for detecting when to disable interception
_builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_logger.log('🔊 [2/5] Builtin player ID: $_builtinPlayerId');
_logger.log('🔊 [3/5] Calling HardwareVolumeService.init()...');
await _hardwareVolumeService.init();
_logger.log('🔊 [4/5] HardwareVolumeService.init() completed, isListening=${_hardwareVolumeService.isListening}');
_volumeUpSub = _hardwareVolumeService.onVolumeUp.listen((_) {
_logger.log('🔊 Volume UP event received in main.dart');
_adjustVolume(_volumeStep);
});
_volumeDownSub = _hardwareVolumeService.onVolumeDown.listen((_) {
_logger.log('🔊 Volume DOWN event received in main.dart');
_adjustVolume(-_volumeStep);
});
_logger.log('🔊 [5/5] Hardware volume control fully initialized');
} catch (e, stack) {
_logger.error('Hardware volume control initialization FAILED', context: 'VolumeInit', error: e, stackTrace: stack);
}
}
/// Called when provider state changes - check if selected player changed
void _onProviderChanged() {
final currentPlayerId = _musicProvider.selectedPlayer?.playerId;
if (currentPlayerId != _lastSelectedPlayerId) {
_lastSelectedPlayerId = currentPlayerId;
_updateVolumeInterception();
}
}
/// Enable/disable volume button interception based on selected player.
/// For builtin player: disable interception so native volume works
/// For MA players: enable interception to control player via API
Future<void> _updateVolumeInterception() async {
final isBuiltinPlayer = _builtinPlayerId != null &&
_musicProvider.selectedPlayer?.playerId == _builtinPlayerId;
if (isBuiltinPlayer) {
_logger.log('🔊 Builtin player selected - disabling volume interception');
await _hardwareVolumeService.setIntercepting(false);
} else {
_logger.log('🔊 MA player selected - enabling volume interception');
await _hardwareVolumeService.setIntercepting(true);
}
}
Future<void> _adjustVolume(int delta) async {
final player = _musicProvider.selectedPlayer;
if (player == null) {
_logger.log('🔊 Hardware volume button pressed but no player selected');
return;
}
_logger.log('🔊 Hardware volume: player=${player.name}, current=${player.volume}, delta=$delta');
final newVolume = (player.volume + delta).clamp(0, 100);
if (newVolume != player.volume) {
try {
await _musicProvider.setVolume(player.playerId, newVolume);
_logger.log('🔊 Hardware volume adjusted: ${player.volume} -> $newVolume for ${player.name}');
} catch (e) {
_logger.error('Hardware volume adjustment failed', context: 'VolumeControl', error: e);
}
} else {
_logger.log('🔊 Hardware volume: already at limit ($newVolume)');
}
} }
@override @override
void dispose() { void dispose() {
_musicProvider.removeListener(_onProviderChanged);
_volumeUpSub?.cancel();
_volumeDownSub?.cancel();
_hardwareVolumeService.dispose();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); 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 @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state); super.didChangeAppLifecycleState(state);
@@ -134,6 +239,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
lightColorScheme: lightColorScheme, lightColorScheme: lightColorScheme,
darkColorScheme: darkColorScheme, darkColorScheme: darkColorScheme,
child: MaterialApp( child: MaterialApp(
navigatorKey: navigationProvider.navigatorKey,
title: 'Ensemble', title: 'Ensemble',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
themeMode: themeProvider.themeMode, themeMode: themeProvider.themeMode,
+60 -5
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../models/player.dart'; import '../models/player.dart';
import '../services/music_assistant_api.dart'; import '../services/music_assistant_api.dart';
@@ -50,6 +51,7 @@ class MusicAssistantProvider with ChangeNotifier {
// Local player state // Local player state
bool _isLocalPlayerPowered = true; bool _isLocalPlayerPowered = true;
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead) bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
StreamSubscription? _localPlayerEventSubscription; StreamSubscription? _localPlayerEventSubscription;
StreamSubscription? _playerUpdatedEventSubscription; StreamSubscription? _playerUpdatedEventSubscription;
@@ -491,7 +493,9 @@ class MusicAssistantProvider with ChangeNotifier {
final isPlaying = _localPlayer.isPlaying; final isPlaying = _localPlayer.isPlaying;
final position = _localPlayer.position.inSeconds; final position = _localPlayer.position.inSeconds;
final volume = (_localPlayer.volume * 100).round(); // Use tracked MA volume instead of just_audio player volume
// just_audio volume is for local playback, MA volume is for server sync
final volume = _localPlayerVolume;
final isPaused = !isPlaying && position > 0; final isPaused = !isPlaying && position > 0;
await _api!.updateBuiltinPlayerState( await _api!.updateBuiltinPlayerState(
@@ -501,7 +505,7 @@ class MusicAssistantProvider with ChangeNotifier {
paused: isPaused, paused: isPaused,
position: position, position: position,
volume: volume, volume: volume,
muted: _localPlayer.volume == 0.0, muted: _localPlayerVolume == 0,
); );
} }
@@ -590,9 +594,14 @@ class MusicAssistantProvider with ChangeNotifier {
break; break;
case 'volume_set': case 'volume_set':
final volume = event['volume_level'] as int?; case 'set_volume':
// MA sends 'set_volume' in event type but 'volume' in data
final volume = event['volume_level'] as int? ?? event['volume'] as int?;
if (volume != null) { if (volume != null) {
await _localPlayer.setVolume(volume / 100.0); _localPlayerVolume = volume; // Track MA volume
// Use device media volume (FlutterVolumeController), NOT just_audio software volume
await FlutterVolumeController.setVolume(volume / 100.0);
_logger.log('🔊 Builtin player DEVICE volume set to ${(volume / 100.0).toStringAsFixed(2)} from server event');
} }
break; break;
@@ -834,6 +843,28 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.invalidateHomeCache(); _cacheService.invalidateHomeCache();
} }
// ============================================================================
// FAVORITES FOR HOME SCREEN
// ============================================================================
/// Get favorite albums from the library
Future<List<Album>> getFavoriteAlbums() async {
// Filter from loaded library - favorites are already loaded
return _albums.where((a) => a.favorite == true).toList();
}
/// Get favorite artists from the library
Future<List<Artist>> getFavoriteArtists() async {
// Filter from loaded library - favorites are already loaded
return _artists.where((a) => a.favorite == true).toList();
}
/// Get favorite tracks from the library
Future<List<Track>> getFavoriteTracks() async {
// Filter from loaded library - favorites are already loaded
return _tracks.where((t) => t.favorite == true).toList();
}
// ============================================================================ // ============================================================================
// DETAIL SCREEN CACHING // DETAIL SCREEN CACHING
// ============================================================================ // ============================================================================
@@ -1569,6 +1600,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 { Future<String?> getCurrentStreamUrl(String playerId) async {
return await _api?.getCurrentStreamUrl(playerId); return await _api?.getCurrentStreamUrl(playerId);
} }
@@ -1666,7 +1709,19 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> setVolume(String playerId, int volumeLevel) async { Future<void> setVolume(String playerId, int volumeLevel) async {
try { try {
await _api?.setVolume(playerId, volumeLevel); // Check if this is the builtin player
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId) {
// For builtin player, control device media volume directly
_localPlayerVolume = volumeLevel;
await FlutterVolumeController.setVolume(volumeLevel / 100.0);
_logger.log('🔊 Builtin player DEVICE volume set to $volumeLevel%');
// Also notify MA server about the volume change
await _api?.setVolume(playerId, volumeLevel);
} else {
// For MA players, just send to MA server
await _api?.setVolume(playerId, volumeLevel);
}
} catch (e) { } catch (e) {
ErrorHandler.logError('Set volume', e); ErrorHandler.logError('Set volume', e);
rethrow; rethrow;
+174 -36
View File
@@ -43,9 +43,20 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
super.initState(); super.initState();
_isFavorite = widget.album.favorite ?? false; _isFavorite = widget.album.favorite ?? false;
_loadTracks(); _loadTracks();
_extractColors();
_loadAlbumDescription(); _loadAlbumDescription();
_refreshFavoriteStatus(); _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 { Future<void> _refreshFavoriteStatus() async {
@@ -178,6 +189,92 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
} }
} }
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),
),
);
}
}
}
Future<void> _loadTracks() async { Future<void> _loadTracks() async {
final provider = context.read<MusicAssistantProvider>(); final provider = context.read<MusicAssistantProvider>();
final tracks = await provider.getAlbumTracksWithCache( final tracks = await provider.getAlbumTracksWithCache(
@@ -403,10 +500,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void _navigateToArtist() { void _navigateToArtist() {
// Navigate to the first artist if available // Navigate to the first artist if available
if (widget.album.artists != null && widget.album.artists!.isNotEmpty) { if (widget.album.artists != null && widget.album.artists!.isNotEmpty) {
final artist = widget.album.artists!.first;
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(artist, size: 256);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ArtistDetailsScreen(artist: widget.album.artists!.first), builder: (context) => ArtistDetailsScreen(
artist: artist,
initialImageUrl: imageUrl,
),
), ),
); );
} }
@@ -444,18 +547,32 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>(); // CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
final themeProvider = context.watch<ThemeProvider>(); // Only rebuild when specific properties change, not on every provider update
final imageUrl = maProvider.getImageUrl(widget.album, size: 512); 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 // Determine if we should use adaptive theme colors
final useAdaptiveTheme = themeProvider.adaptiveTheme;
final isDark = Theme.of(context).brightness == Brightness.dark; 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; ColorScheme? adaptiveScheme;
if (useAdaptiveTheme) { if (adaptiveTheme) {
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme; // Use local state first (from _extractColors), fallback to provider
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
} }
// Use adaptive scheme if available, otherwise use global theme // Use adaptive scheme if available, otherwise use global theme
@@ -775,39 +892,60 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
curve: Curves.easeInOut, curve: Curves.easeInOut,
child: isExpanded child: isExpanded
? Padding( ? 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( child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Expanded( // Radio button
child: SizedBox( SizedBox(
height: 48, height: 44,
child: FilledButton.tonal( width: 44,
onPressed: () => _showPlayRadioMenu(context, index), child: FilledButton.tonal(
style: FilledButton.styleFrom( onPressed: () => _showPlayRadioMenu(context, index),
backgroundColor: colorScheme.tertiaryContainer, style: FilledButton.styleFrom(
foregroundColor: colorScheme.onTertiaryContainer, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
),
), ),
child: const Text('Start radio'),
), ),
child: const Icon(Icons.radio, size: 20),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 10),
Expanded( // Add to queue button
child: SizedBox( SizedBox(
height: 48, height: 44,
child: FilledButton.tonal( width: 44,
onPressed: () => _addTrackToQueue(context, index), child: FilledButton.tonal(
style: FilledButton.styleFrom( onPressed: () => _addTrackToQueue(context, index),
backgroundColor: colorScheme.secondaryContainer, style: FilledButton.styleFrom(
foregroundColor: colorScheme.onSecondaryContainer, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), 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,
), ),
), ),
), ),
+371 -11
View File
@@ -15,11 +15,13 @@ import '../utils/page_transitions.dart';
class ArtistDetailsScreen extends StatefulWidget { class ArtistDetailsScreen extends StatefulWidget {
final Artist artist; final Artist artist;
final String? heroTagSuffix; final String? heroTagSuffix;
final String? initialImageUrl;
const ArtistDetailsScreen({ const ArtistDetailsScreen({
super.key, super.key,
required this.artist, required this.artist,
this.heroTagSuffix, this.heroTagSuffix,
this.initialImageUrl,
}); });
@override @override
@@ -31,6 +33,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
List<Album> _albums = []; List<Album> _albums = [];
List<Album> _providerAlbums = []; List<Album> _providerAlbums = [];
bool _isLoading = true; bool _isLoading = true;
bool _isFavorite = false;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
bool _isDescriptionExpanded = false; bool _isDescriptionExpanded = false;
@@ -42,9 +45,267 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_isFavorite = widget.artist.favorite ?? false;
// Use initial image URL immediately for smooth hero animation
_artistImageUrl = widget.initialImageUrl;
_loadArtistAlbums(); _loadArtistAlbums();
_loadArtistImage();
_loadArtistDescription(); _loadArtistDescription();
_refreshFavoriteStatus();
// Defer higher-res image loading and color extraction until after transition
// But hero animation now works because we have initialImageUrl
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_loadArtistImage();
// Note: _extractColors is called by _loadArtistImage after image loads
}
});
});
}
Future<void> _refreshFavoriteStatus() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == 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 { Future<void> _loadArtistImage() async {
@@ -113,16 +374,30 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>(); // CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
final themeProvider = context.watch<ThemeProvider>(); // 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 // Use the loaded image URL (with fallback) instead of directly from MA
final imageUrl = _artistImageUrl; final imageUrl = _artistImageUrl;
final useAdaptiveTheme = themeProvider.adaptiveTheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
// Get the color scheme to use - prefer local state over provider
ColorScheme? adaptiveScheme; ColorScheme? adaptiveScheme;
if (useAdaptiveTheme) { if (adaptiveTheme) {
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme; // Use local state first (from _extractColors), fallback to provider
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
} }
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme; final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -226,6 +501,89 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
), ),
const SizedBox(height: 8), 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),
], ],
), ),
), ),
@@ -275,7 +633,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final album = _albums[index]; final album = _albums[index];
return _buildAlbumCard(album, maProvider); return _buildAlbumCard(album);
}, },
childCount: _albums.length, childCount: _albums.length,
), ),
@@ -309,7 +667,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final album = _providerAlbums[index]; final album = _providerAlbums[index];
return _buildAlbumCard(album, maProvider); return _buildAlbumCard(album);
}, },
childCount: _providerAlbums.length, childCount: _providerAlbums.length,
), ),
@@ -324,8 +682,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
); );
} }
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) { Widget _buildAlbumCard(Album album) {
final imageUrl = provider.getImageUrl(album, size: 256); // 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 colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
const String heroSuffix = 'artist_albums'; const String heroSuffix = 'artist_albums';
+160 -68
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
@@ -15,6 +16,8 @@ class DebugLogScreen extends StatefulWidget {
class _DebugLogScreenState extends State<DebugLogScreen> { class _DebugLogScreenState extends State<DebugLogScreen> {
final _logger = DebugLogger(); final _logger = DebugLogger();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
LogLevel _filterLevel = LogLevel.debug; // Show all by default
bool _isGeneratingReport = false;
@override @override
void initState() { void initState() {
@@ -33,17 +36,37 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
super.dispose(); super.dispose();
} }
List<LogEntry> get _filteredEntries =>
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
void _copyLogs() { void _copyLogs() {
Clipboard.setData(ClipboardData(text: _logger.getAllLogs())); Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Logs copied to clipboard!'), content: Text('Logs copied to clipboard'),
backgroundColor: Colors.green, backgroundColor: Colors.green,
duration: Duration(seconds: 2), 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() { void _clearLogs() {
setState(() { setState(() {
_logger.clear(); _logger.clear();
@@ -216,16 +239,39 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
), ),
centerTitle: true, centerTitle: true,
actions: [ 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>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white), icon: const Icon(Icons.more_vert, color: Colors.white),
tooltip: 'Player tools', tooltip: 'More options',
onSelected: (value) async { onSelected: (value) async {
switch (value) { switch (value) {
case 'show_players': case 'show_players':
_showAllPlayers(); _showAllPlayers();
break; break;
// NOTE: Ghost cleanup and repair options removed - MA APIs don't work case 'copy_logs':
// reliably and caused corrupt entries. See PLAYER_LIFECYCLE_GUIDE.md _copyLogs();
break;
case 'clear_logs':
_clearLogs();
break;
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
@@ -237,49 +283,61 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
dense: true, 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( body: Column(
children: [ children: [
// Filter chips and stats
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: Colors.white.withOpacity(0.05), color: Colors.white.withOpacity(0.05),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Icon( // Level filter chips
Icons.info_outline_rounded, SingleChildScrollView(
color: Colors.white70, scrollDirection: Axis.horizontal,
size: 16, child: Row(
), children: [
const SizedBox(width: 8), _buildFilterChip('All', LogLevel.debug),
Expanded( const SizedBox(width: 8),
child: Text( _buildFilterChip('Info+', LogLevel.info),
'Last ${_logger.logs.length} log entries', const SizedBox(width: 8),
style: const TextStyle( _buildFilterChip('Warnings', LogLevel.warning),
color: Colors.white70, const SizedBox(width: 8),
fontSize: 12, _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( Expanded(
child: _logger.logs.isEmpty child: _filteredEntries.isEmpty
? const Center( ? const Center(
child: Text( child: Text(
'No logs yet', 'No logs yet',
@@ -291,43 +349,11 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
) )
: ListView.builder( : ListView.builder(
controller: _scrollController, controller: _scrollController,
padding: EdgeInsets.fromLTRB(8, 8, 8, BottomSpacing.navBarOnly + 56), // Extra space for FAB padding: EdgeInsets.fromLTRB(8, 8, 8, BottomSpacing.navBarOnly + 56),
itemCount: _logger.logs.length, itemCount: _filteredEntries.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final log = _logger.logs[index]; final entry = _filteredEntries[index];
final isError = log.contains('Error') || return _buildLogEntry(entry);
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,
),
),
);
}, },
), ),
), ),
@@ -353,4 +379,70 @@ 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.perf:
return Colors.purple;
case LogLevel.debug:
return Colors.grey;
case LogLevel.info:
return Colors.blue;
case LogLevel.warning:
return Colors.orange;
case LogLevel.error:
return Colors.red;
}
}
} }
+2
View File
@@ -67,6 +67,8 @@ class LibraryAlbumsScreen extends StatelessWidget {
child: GridView.builder( child: GridView.builder(
key: const PageStorageKey<String>('library_albums_full_grid'), key: const PageStorageKey<String>('library_albums_full_grid'),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Cards have their own keep-alive
addRepaintBoundaries: false, // Cards have RepaintBoundary
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: 2,
childAspectRatio: 0.75, childAspectRatio: 0.75,
+6
View File
@@ -69,6 +69,8 @@ class LibraryArtistsScreen extends StatelessWidget {
child: ListView.builder( child: ListView.builder(
key: const PageStorageKey<String>('library_artists_full_list'), key: const PageStorageKey<String>('library_artists_full_list'),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles have their own keep-alive
addRepaintBoundaries: false, // Tiles have RepaintBoundary
itemCount: artists.length, itemCount: artists.length,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -90,6 +92,9 @@ class LibraryArtistsScreen extends StatelessWidget {
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final maProvider = context.read<MusicAssistantProvider>();
// Get image URL for hero animation
final imageUrl = maProvider.getImageUrl(artist, size: 256);
// RepaintBoundary isolates repaints to individual tiles // RepaintBoundary isolates repaints to individual tiles
return RepaintBoundary( return RepaintBoundary(
@@ -116,6 +121,7 @@ class LibraryArtistsScreen extends StatelessWidget {
child: ArtistDetailsScreen( child: ArtistDetailsScreen(
artist: artist, artist: artist,
heroTagSuffix: 'library', heroTagSuffix: 'library',
initialImageUrl: imageUrl,
), ),
), ),
); );
+6 -2
View File
@@ -91,6 +91,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
child: ListView.builder( child: ListView.builder(
itemCount: _playlists.length, itemCount: _playlists.length,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemBuilder: (context, index) { itemBuilder: (context, index) {
final playlist = _playlists[index]; final playlist = _playlists[index];
return _buildPlaylistTile(context, playlist, provider); return _buildPlaylistTile(context, playlist, provider);
@@ -104,8 +107,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return Builder( return RepaintBoundary(
builder: (context) => ListTile( child: ListTile(
key: ValueKey(playlist.itemId),
leading: Container( leading: Container(
width: 48, width: 48,
height: 48, height: 48,
+39 -33
View File
@@ -54,6 +54,9 @@ class LibraryTracksScreen extends StatelessWidget {
return ListView.builder( return ListView.builder(
itemCount: provider.tracks.length, itemCount: provider.tracks.length,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemBuilder: (context, index) { itemBuilder: (context, index) {
final track = provider.tracks[index]; final track = provider.tracks[index];
return _buildTrackTile(context, track, provider, index); return _buildTrackTile(context, track, provider, index);
@@ -73,43 +76,46 @@ class LibraryTracksScreen extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return ListTile( return RepaintBoundary(
leading: Container( child: ListTile(
width: 48, key: ValueKey(track.uri ?? track.itemId),
height: 48, leading: Container(
decoration: BoxDecoration( width: 48,
color: colorScheme.surfaceVariant, height: 48,
borderRadius: BorderRadius.circular(8), decoration: BoxDecoration(
image: imageUrl != null color: colorScheme.surfaceVariant,
? DecorationImage( borderRadius: BorderRadius.circular(8),
image: CachedNetworkImageProvider(imageUrl), image: imageUrl != null
fit: BoxFit.cover, ? DecorationImage(
) image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
),
child: imageUrl == null
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
: null, : null,
), ),
child: imageUrl == null title: Text(
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant) track.name,
style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.54)),
)
: null, : null,
onTap: () => _playTrack(context, maProvider, index),
), ),
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.54)),
)
: null,
onTap: () => _playTrack(context, maProvider, index),
); );
} }
+177 -34
View File
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/player_selector.dart'; import '../widgets/player_selector.dart';
import '../widgets/album_row.dart'; import '../widgets/album_row.dart';
import '../widgets/artist_row.dart'; import '../widgets/artist_row.dart';
import '../widgets/track_row.dart';
import '../widgets/common/disconnected_state.dart'; import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'search_screen.dart'; import 'search_screen.dart';
@@ -16,17 +20,73 @@ class NewHomeScreen extends StatefulWidget {
State<NewHomeScreen> createState() => _NewHomeScreenState(); State<NewHomeScreen> createState() => _NewHomeScreenState();
} }
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin { class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
static final _logger = DebugLogger();
Key _refreshKey = UniqueKey(); Key _refreshKey = UniqueKey();
// Main rows (default on)
bool _showRecentAlbums = true;
bool _showDiscoverArtists = true;
bool _showDiscoverAlbums = true;
// Favorites rows (default off)
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
// Random order for favorites (generated once per session)
late List<int> _favoritesOrder;
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Generate random order for favorites rows (0, 1, 2 shuffled)
_favoritesOrder = [0, 1, 2]..shuffle();
_loadSettings();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Reload settings when app resumes (coming back from settings)
if (state == AppLifecycleState.resumed) {
_loadSettings();
}
}
Future<void> _loadSettings() async {
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
});
}
}
Future<void> _onRefresh() async { Future<void> _onRefresh() async {
// Invalidate cache to force fresh data on pull-to-refresh // Invalidate cache to force fresh data on pull-to-refresh
final provider = context.read<MusicAssistantProvider>(); final provider = context.read<MusicAssistantProvider>();
provider.invalidateHomeCache(); provider.invalidateHomeCache();
// Reload settings in case they changed
await _loadSettings();
if (mounted) { if (mounted) {
setState(() { setState(() {
_refreshKey = UniqueKey(); _refreshKey = UniqueKey();
@@ -37,6 +97,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin super.build(context); // Required for AutomaticKeepAliveClientMixin
// Settings are loaded in initState and didChangeAppLifecycleState - no need to reload on every build
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
@@ -96,44 +157,126 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
Widget _buildConnectedView( Widget _buildConnectedView(
BuildContext context, MusicAssistantProvider provider) { BuildContext context, MusicAssistantProvider provider) {
// Use Android 12+ stretch overscroll effect // Use LayoutBuilder to get available screen height
return ScrollConfiguration( return LayoutBuilder(
behavior: const _StretchScrollBehavior(), builder: (context, constraints) {
child: SingleChildScrollView( // Simple calculation: available height divided by 3 rows
physics: const AlwaysScrollableScrollPhysics(), // Each row includes its title, artwork, and info
child: Column( final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
key: _refreshKey, final rowHeight = availableHeight / 3;
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(),
),
const SizedBox(height: 21),
// Discover Artists (with caching) // Build favorites rows in random order
ArtistRow( final favoritesWidgets = _buildFavoritesRows(provider, rowHeight);
key: const ValueKey('discover-artists'),
title: 'Discover Artists',
loadArtists: () => provider.getDiscoverArtistsWithCache(),
),
const SizedBox(height: 3),
// Discover Albums (with caching) // Use Android 12+ stretch overscroll effect
AlbumRow( return NotificationListener<ScrollNotification>(
key: const ValueKey('discover-albums'), onNotification: (notification) {
title: 'Discover Albums', if (notification is ScrollStartNotification) {
loadAlbums: () => provider.getDiscoverAlbumsWithCache(), _logger.resetFrameStats();
_logger.perf('SCROLL START', context: 'HomeScreen');
} else if (notification is ScrollUpdateNotification) {
_logger.startFrame();
SchedulerBinding.instance.addPostFrameCallback((_) {
_logger.endFrame();
});
} else if (notification is ScrollEndNotification) {
_logger.perf('SCROLL END', context: 'HomeScreen');
}
return false;
},
child: ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
child: Column(
key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Recently played albums (optional)
if (_showRecentAlbums)
AlbumRow(
key: const ValueKey('recent-albums'),
title: 'Recently Played',
loadAlbums: () => provider.getRecentAlbumsWithCache(),
rowHeight: rowHeight,
),
// Discover Artists (optional)
if (_showDiscoverArtists)
ArtistRow(
key: const ValueKey('discover-artists'),
title: 'Discover Artists',
loadArtists: () => provider.getDiscoverArtistsWithCache(),
rowHeight: rowHeight,
),
// Discover Albums (optional)
if (_showDiscoverAlbums)
AlbumRow(
key: const ValueKey('discover-albums'),
title: 'Discover Albums',
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
rowHeight: rowHeight,
),
// Favorites rows in random order
...favoritesWidgets,
],
),
),
), ),
SizedBox(height: BottomSpacing.withMiniPlayer), // Space for bottom nav + mini player );
], },
),
),
); );
} }
/// Build favorites rows in random order (order set once per session)
List<Widget> _buildFavoritesRows(MusicAssistantProvider provider, double rowHeight) {
// Create list of enabled favorites with their order index
final enabledFavorites = <MapEntry<int, Widget>>[];
if (_showFavoriteAlbums) {
enabledFavorites.add(MapEntry(
_favoritesOrder[0],
AlbumRow(
key: const ValueKey('favorite-albums'),
title: 'Favorite Albums',
loadAlbums: () => provider.getFavoriteAlbums(),
rowHeight: rowHeight,
),
));
}
if (_showFavoriteArtists) {
enabledFavorites.add(MapEntry(
_favoritesOrder[1],
ArtistRow(
key: const ValueKey('favorite-artists'),
title: 'Favorite Artists',
loadArtists: () => provider.getFavoriteArtists(),
rowHeight: rowHeight,
),
));
}
if (_showFavoriteTracks) {
enabledFavorites.add(MapEntry(
_favoritesOrder[2],
TrackRow(
key: const ValueKey('favorite-tracks'),
title: 'Favorite Tracks',
loadTracks: () => provider.getFavoriteTracks(),
rowHeight: rowHeight,
),
));
}
// Sort by the random order index
enabledFavorites.sort((a, b) => a.key.compareTo(b.key));
return enabledFavorites.map((e) => e.value).toList();
}
} }
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect /// Custom scroll behavior that uses Android 12+ stretch overscroll effect
+289 -42
View File
@@ -27,11 +27,16 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
with SingleTickerProviderStateMixin, RestorationMixin { with SingleTickerProviderStateMixin, RestorationMixin {
late TabController _tabController; late TabController _tabController;
List<Playlist> _playlists = []; List<Playlist> _playlists = [];
List<Track> _favoriteTracks = [];
bool _isLoadingPlaylists = true; bool _isLoadingPlaylists = true;
bool _isLoadingTracks = false;
bool _showFavoritesOnly = false;
// Restoration: Remember selected tab across app restarts // Restoration: Remember selected tab across app restarts
final RestorableInt _selectedTabIndex = RestorableInt(0); final RestorableInt _selectedTabIndex = RestorableInt(0);
int get _tabCount => _showFavoritesOnly ? 4 : 3;
@override @override
String? get restorationId => 'new_library_screen'; String? get restorationId => 'new_library_screen';
@@ -39,7 +44,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
void restoreState(RestorationBucket? oldBucket, bool initialRestore) { void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_selectedTabIndex, 'selected_tab_index'); registerForRestoration(_selectedTabIndex, 'selected_tab_index');
// Apply restored tab index after TabController is created // 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; _tabController.index = _selectedTabIndex.value;
} }
} }
@@ -47,12 +53,24 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this); _tabController = TabController(length: _tabCount, vsync: this);
// Listen to tab changes to persist selection // Listen to tab changes to persist selection
_tabController.addListener(_onTabChanged); _tabController.addListener(_onTabChanged);
_loadPlaylists(); _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() { void _onTabChanged() {
if (!_tabController.indexIsChanging) { if (!_tabController.indexIsChanging) {
_selectedTabIndex.value = _tabController.index; _selectedTabIndex.value = _tabController.index;
@@ -67,10 +85,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
super.dispose(); super.dispose();
} }
Future<void> _loadPlaylists() async { Future<void> _loadPlaylists({bool? favoriteOnly}) async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) { if (maProvider.api != null) {
final playlists = await maProvider.api!.getPlaylists(limit: 100); final playlists = await maProvider.api!.getPlaylists(
limit: 100,
favoriteOnly: favoriteOnly,
);
if (mounted) { if (mounted) {
setState(() { setState(() {
_playlists = playlists; _playlists = playlists;
@@ -86,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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Use Selector for targeted rebuilds - only rebuild when connection state changes // Use Selector for targeted rebuilds - only rebuild when connection state changes
@@ -125,15 +187,40 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
title: Text( titleSpacing: 16,
'Library', title: Row(
style: textTheme.titleLarge?.copyWith( children: [
color: colorScheme.onBackground, // Favorites toggle with label
fontWeight: FontWeight.w300, 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, centerTitle: false,
actions: const [PlayerSelector()], actions: const [
PlayerSelector(),
],
bottom: TabBar( bottom: TabBar(
controller: _tabController, controller: _tabController,
labelColor: colorScheme.primary, labelColor: colorScheme.primary,
@@ -142,10 +229,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
indicatorWeight: 3, indicatorWeight: 3,
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w400, fontSize: 14), unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w400, fontSize: 14),
tabs: const [ tabs: [
Tab(text: 'Artists'), const Tab(text: 'Artists'),
Tab(text: 'Albums'), const Tab(text: 'Albums'),
Tab(text: 'Playlists'), if (_showFavoritesOnly) const Tab(text: 'Tracks'),
const Tab(text: 'Playlists'),
], ],
), ),
), ),
@@ -154,6 +242,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
children: [ children: [
_buildArtistsTab(context), _buildArtistsTab(context),
_buildAlbumsTab(context), _buildAlbumsTab(context),
if (_showFavoritesOnly) _buildTracksTab(context),
_buildPlaylistsTab(context), _buildPlaylistsTab(context),
], ],
), ),
@@ -168,14 +257,26 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return Selector<MusicAssistantProvider, (List<Artist>, bool)>( return Selector<MusicAssistantProvider, (List<Artist>, bool)>(
selector: (_, provider) => (provider.artists, provider.isLoading), selector: (_, provider) => (provider.artists, provider.isLoading),
builder: (context, data, _) { builder: (context, data, _) {
final (artists, isLoading) = data; final (allArtists, isLoading) = data;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
if (isLoading) { if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary)); 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 (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( return EmptyState.artists(
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(), onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
); );
@@ -186,8 +287,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(), onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: ListView.builder( 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 cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles have their own keep-alive
addRepaintBoundaries: false, // Tiles have RepaintBoundary
itemCount: artists.length, itemCount: artists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly), padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -211,7 +314,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final maProvider = context.read<MusicAssistantProvider>();
final suffix = '_library'; final suffix = '_library';
// Get image URL for hero animation
final imageUrl = maProvider.getImageUrl(artist, size: 256);
return RepaintBoundary( return RepaintBoundary(
child: ListTile( child: ListTile(
@@ -221,9 +327,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
radius: 24, radius: 24,
imageSize: 128, imageSize: 128,
heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix, heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
onImageLoaded: (imageUrl) {
// Store for adaptive colors on tap
},
), ),
title: Hero( title: Hero(
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix, tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
@@ -247,6 +350,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: ArtistDetailsScreen( child: ArtistDetailsScreen(
artist: artist, artist: artist,
heroTagSuffix: 'library', heroTagSuffix: 'library',
initialImageUrl: imageUrl,
), ),
), ),
); );
@@ -261,14 +365,26 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return Selector<MusicAssistantProvider, (List<Album>, bool)>( return Selector<MusicAssistantProvider, (List<Album>, bool)>(
selector: (_, provider) => (provider.albums, provider.isLoading), selector: (_, provider) => (provider.albums, provider.isLoading),
builder: (context, data, _) { builder: (context, data, _) {
final (albums, isLoading) = data; final (allAlbums, isLoading) = data;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
if (isLoading) { if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary)); 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 (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( return EmptyState.albums(
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(), onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
); );
@@ -279,8 +395,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(), onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: GridView.builder( 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 cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Cards have their own keep-alive
addRepaintBoundaries: false, // Cards have RepaintBoundary
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly), padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: 2,
@@ -312,16 +430,25 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
} }
if (_playlists.isEmpty) { 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( return RefreshIndicator(
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
onRefresh: _loadPlaylists, onRefresh: () => _loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null),
child: ListView.builder( 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 cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemCount: _playlists.length, itemCount: _playlists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly), padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -338,8 +465,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return ListTile( return RepaintBoundary(
leading: Container( child: ListTile(
key: ValueKey(playlist.itemId),
leading: Container(
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -370,22 +499,140 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
trailing: playlist.favorite == true trailing: playlist.favorite == true
? const Icon(Icons.favorite, color: Colors.red, size: 20) ? const Icon(Icons.favorite, color: Colors.red, size: 20)
: null, : null,
onTap: () { onTap: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => PlaylistDetailsScreen( builder: (context) => PlaylistDetailsScreen(
playlist: playlist, playlist: playlist,
provider: playlist.provider, provider: playlist.provider,
itemId: playlist.itemId, itemId: playlist.itemId,
),
), ),
), );
); },
}, ),
); );
} }
// ============ 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,
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
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 RepaintBoundary(
child: ListTile(
key: ValueKey(track.uri ?? track.itemId),
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 'package:provider/provider.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
final _playlistLogger = DebugLogger();
class PlaylistDetailsScreen extends StatefulWidget { class PlaylistDetailsScreen extends StatefulWidget {
final Playlist playlist; final Playlist playlist;
@@ -61,11 +64,11 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
return; return;
} }
print('🎵 Queueing playlist on ${player.name}: ${player.playerId}'); _playlistLogger.info('Queueing playlist on ${player.name}', context: 'Playlist');
// Queue all tracks via Music Assistant // Queue all tracks via Music Assistant
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0); await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
print('✓ Playlist queued on ${player.name}'); _playlistLogger.info('Playlist queued successfully', context: 'Playlist');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -76,7 +79,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
); );
} }
} catch (e) { } catch (e) {
print('Error playing playlist: $e'); _playlistLogger.error('Error playing playlist', context: 'Playlist', error: e);
_showError('Error playing playlist: $e'); _showError('Error playing playlist: $e');
} }
} }
+62 -32
View File
@@ -100,11 +100,11 @@ class SearchScreenState extends State<SearchScreen> {
void _onSearchChanged(String query) { void _onSearchChanged(String query) {
_debounceTimer?.cancel(); _debounceTimer?.cancel();
_debounceTimer = Timer(Timings.searchDebounce, () { _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) { if (query.isEmpty) {
setState(() { setState(() {
_searchResults = {'artists': [], 'albums': [], 'tracks': []}; _searchResults = {'artists': [], 'albums': [], 'tracks': []};
@@ -129,6 +129,10 @@ class SearchScreenState extends State<SearchScreen> {
_isSearching = false; _isSearching = false;
_hasSearched = true; _hasSearched = true;
}); });
// Keep keyboard open if user is still typing
if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus();
}
} }
} catch (e) { } catch (e) {
_logger.log('Search error: $e'); _logger.log('Search error: $e');
@@ -138,6 +142,9 @@ class SearchScreenState extends State<SearchScreen> {
_hasSearched = true; _hasSearched = true;
_searchError = 'Search failed. Please check your connection.'; _searchError = 'Search failed. Please check your connection.';
}); });
if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus();
}
} }
} }
} }
@@ -157,6 +164,7 @@ class SearchScreenState extends State<SearchScreen> {
focusNode: _focusNode, focusNode: _focusNode,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
cursorColor: colorScheme.primary, cursorColor: colorScheme.primary,
textInputAction: TextInputAction.search,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search music...', hintText: 'Search music...',
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
@@ -166,15 +174,20 @@ class SearchScreenState extends State<SearchScreen> {
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)), icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
_performSearch(''); setState(() {
_searchResults = {'artists': [], 'albums': [], 'tracks': []};
_hasSearched = false;
_searchError = null;
});
}, },
) )
: null, : null,
), ),
onChanged: (value) { onChanged: (value) {
setState(() {}); // Update clear button visibility
_onSearchChanged(value); _onSearchChanged(value);
}, },
onSubmitted: _performSearch, onSubmitted: (query) => _performSearch(query),
), ),
), ),
body: !maProvider.isConnected body: !maProvider.isConnected
@@ -286,6 +299,9 @@ class SearchScreenState extends State<SearchScreen> {
final listItems = _buildListItems(artists, albums, tracks); final listItems = _buildListItems(artists, albums, tracks);
return ListView.builder( return ListView.builder(
padding: EdgeInsets.fromLTRB(16, 8, 16, BottomSpacing.navBarOnly), padding: EdgeInsets.fromLTRB(16, 8, 16, BottomSpacing.navBarOnly),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemCount: listItems.length, itemCount: listItems.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = listItems[index]; final item = listItems[index];
@@ -391,8 +407,12 @@ class SearchScreenState extends State<SearchScreen> {
Widget _buildArtistTile(Artist artist) { Widget _buildArtistTile(Artist artist) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(artist, size: 256);
return ListTile( return RepaintBoundary(
child: ListTile(
key: ValueKey(artist.uri ?? artist.itemId),
leading: ArtistAvatar( leading: ArtistAvatar(
artist: artist, artist: artist,
radius: 24, radius: 24,
@@ -411,14 +431,18 @@ class SearchScreenState extends State<SearchScreen> {
'Artist', 'Artist',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
), ),
onTap: () { onTap: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ArtistDetailsScreen(artist: artist), builder: (context) => ArtistDetailsScreen(
), artist: artist,
); initialImageUrl: imageUrl,
}, ),
),
);
},
),
); );
} }
@@ -427,8 +451,10 @@ class SearchScreenState extends State<SearchScreen> {
final imageUrl = maProvider.getImageUrl(album, size: 128); final imageUrl = maProvider.getImageUrl(album, size: 128);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return ListTile( return RepaintBoundary(
leading: Container( child: ListTile(
key: ValueKey(album.uri ?? album.itemId),
leading: Container(
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -460,14 +486,15 @@ class SearchScreenState extends State<SearchScreen> {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
onTap: () { onTap: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => AlbumDetailsScreen(album: album), builder: (context) => AlbumDetailsScreen(album: album),
), ),
); );
}, },
),
); );
} }
@@ -478,8 +505,10 @@ class SearchScreenState extends State<SearchScreen> {
: null; : null;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return ListTile( return RepaintBoundary(
leading: Container( child: ListTile(
key: ValueKey(track.uri ?? track.itemId),
leading: Container(
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -511,13 +540,14 @@ class SearchScreenState extends State<SearchScreen> {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
trailing: track.duration != null trailing: track.duration != null
? Text( ? Text(
_formatDuration(track.duration!), _formatDuration(track.duration!),
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
) )
: null, : null,
onTap: () => _playTrack(track), onTap: () => _playTrack(track),
),
); );
} }
+187
View File
@@ -18,6 +18,14 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
final _lastFmApiKeyController = TextEditingController(); final _lastFmApiKeyController = TextEditingController();
final _audioDbApiKeyController = TextEditingController(); final _audioDbApiKeyController = TextEditingController();
// Main rows (default on)
bool _showRecentAlbums = true;
bool _showDiscoverArtists = true;
bool _showDiscoverAlbums = true;
// Favorites rows (default off)
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
@override @override
void initState() { void initState() {
@@ -35,6 +43,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (audioDbKey != null) { if (audioDbKey != null) {
_audioDbApiKeyController.text = audioDbKey; _audioDbApiKeyController.text = audioDbKey;
} }
// Load home screen settings
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
});
}
} }
@override @override
@@ -308,6 +334,167 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
// Home Screen section
Text(
'Home Screen',
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Choose which rows to display on the home screen',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 16),
// Main rows section
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
SwitchListTile(
title: Text(
'Recently Played',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show recently played albums',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showRecentAlbums,
onChanged: (value) {
setState(() => _showRecentAlbums = value);
SettingsService.setShowRecentAlbums(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Discover Artists',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show random artists to discover',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showDiscoverArtists,
onChanged: (value) {
setState(() => _showDiscoverArtists = value);
SettingsService.setShowDiscoverArtists(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Discover Albums',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show random albums to discover',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showDiscoverAlbums,
onChanged: (value) {
setState(() => _showDiscoverAlbums = value);
SettingsService.setShowDiscoverAlbums(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
],
),
),
const SizedBox(height: 16),
Text(
'Favorites',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 8),
// Favorites rows section
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
SwitchListTile(
title: Text(
'Favorite Albums',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show a row of your favorite albums',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showFavoriteAlbums,
onChanged: (value) {
setState(() => _showFavoriteAlbums = value);
SettingsService.setShowFavoriteAlbums(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Favorite Artists',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show a row of your favorite artists',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showFavoriteArtists,
onChanged: (value) {
setState(() => _showFavoriteArtists = value);
SettingsService.setShowFavoriteArtists(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Favorite Tracks',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show a row of your favorite tracks',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showFavoriteTracks,
onChanged: (value) {
setState(() => _showFavoriteTracks = value);
SettingsService.setShowFavoriteTracks(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
],
),
),
const SizedBox(height: 32),
Text( Text(
'Metadata APIs (Optional)', 'Metadata APIs (Optional)',
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
+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,
});
}
+223 -12
View File
@@ -1,32 +1,243 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; 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 {
perf, // Performance timing (for FPS diagnosis)
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 { class DebugLogger {
static final DebugLogger _instance = DebugLogger._internal(); static final DebugLogger _instance = DebugLogger._internal();
factory DebugLogger() => _instance; factory DebugLogger() => _instance;
DebugLogger._internal(); DebugLogger._internal();
final List<String> _logs = []; final List<LogEntry> _entries = [];
final _maxLogs = 500; 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) { List<LogEntry> get entries => List.unmodifiable(_entries);
final timestamp = DateTime.now().toIso8601String().substring(11, 23);
final logMessage = '[$timestamp] $message';
debugPrint(logMessage); /// Legacy getter for backward compatibility
List<String> get logs => _entries.map((e) => e.formatted).toList();
_logs.add(logMessage); // Set to true to only log perf entries (for FPS diagnosis)
if (_logs.length > _maxLogs) { // Set to false for normal operation with full logging for user bug reports
_logs.removeAt(0); static const bool _perfOnlyMode = false;
void _addEntry(LogLevel level, String message, {String? context}) {
// In perf-only mode, skip all non-perf logs
if (_perfOnlyMode && level != LogLevel.perf) {
return;
}
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);
}
/// Performance logging - always prints to console for real-time monitoring
void perf(String message, {String? context}) {
_addEntry(LogLevel.perf, message, context: context);
}
/// Track build time of a widget
final Map<String, Stopwatch> _buildTimers = {};
void startBuild(String widgetName) {
_buildTimers[widgetName] = Stopwatch()..start();
}
void endBuild(String widgetName) {
final sw = _buildTimers.remove(widgetName);
if (sw != null) {
sw.stop();
if (sw.elapsedMilliseconds > 4) { // Only log if > 4ms (slow for 60fps)
perf('BUILD ${sw.elapsedMilliseconds}ms', context: widgetName);
}
}
}
/// Track frame times during scroll
Stopwatch? _frameTimer;
int _slowFrameCount = 0;
int _totalFrameCount = 0;
void startFrame() {
_frameTimer = Stopwatch()..start();
}
void endFrame() {
if (_frameTimer != null) {
_frameTimer!.stop();
_totalFrameCount++;
final ms = _frameTimer!.elapsedMilliseconds;
if (ms > 16) { // > 16ms means dropped frame at 60fps
_slowFrameCount++;
perf('SLOW FRAME ${ms}ms (${_slowFrameCount}/${_totalFrameCount} slow)', context: 'Scroll');
}
}
}
void resetFrameStats() {
_slowFrameCount = 0;
_totalFrameCount = 0;
}
void clear() { void clear() {
_logs.clear(); _entries.clear();
} }
String getAllLogs() { 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();
} }
} }
+110
View File
@@ -0,0 +1,110 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'debug_logger.dart';
/// Service to intercept hardware volume button presses
/// and route them to the selected Music Assistant player.
///
/// For MA players: intercept buttons and send volume commands via API
/// For builtin player: let native volume work (don't intercept)
class HardwareVolumeService {
static final HardwareVolumeService _instance = HardwareVolumeService._internal();
factory HardwareVolumeService() => _instance;
HardwareVolumeService._internal();
static const _channel = MethodChannel('com.musicassistant.music_assistant/volume_buttons');
final _logger = DebugLogger();
final _volumeUpController = StreamController<void>.broadcast();
final _volumeDownController = StreamController<void>.broadcast();
Stream<void> get onVolumeUp => _volumeUpController.stream;
Stream<void> get onVolumeDown => _volumeDownController.stream;
bool _isListening = false;
bool get isListening => _isListening;
bool _isIntercepting = false;
bool get isIntercepting => _isIntercepting;
/// Initialize the service and start listening for volume button events
Future<void> init() async {
_logger.log('🔊 HardwareVolumeService.init() called, _isListening=$_isListening');
if (_isListening) {
_logger.log('🔊 HardwareVolumeService.init() - already listening, returning early');
return;
}
_logger.log('🔊 Setting up MethodChannel handler for volume events...');
_channel.setMethodCallHandler((call) async {
_logger.log('🔊 MethodChannel received call: ${call.method}');
switch (call.method) {
case 'volumeUp':
_logger.log('🔊 Hardware VOLUME UP pressed - broadcasting event');
_volumeUpController.add(null);
break;
case 'volumeDown':
_logger.log('🔊 Hardware VOLUME DOWN pressed - broadcasting event');
_volumeDownController.add(null);
break;
default:
_logger.log('🔊 Unknown method call: ${call.method}');
}
});
_logger.log('🔊 MethodChannel handler set up');
try {
_logger.log('🔊 Invoking startListening on native channel...');
await _channel.invokeMethod('startListening');
_isListening = true;
_isIntercepting = true;
_logger.log('🔊 Native startListening succeeded, _isListening=$_isListening');
} catch (e) {
_logger.error('Failed to start volume button listening', context: 'VolumeService', error: e);
}
}
/// Enable or disable volume button interception.
/// When disabled, hardware volume buttons control device volume normally.
/// When enabled, volume button events are sent to Flutter for MA player control.
Future<void> setIntercepting(bool intercept) async {
if (!_isListening) {
_logger.log('🔊 setIntercepting($intercept) called but not listening yet');
return;
}
if (_isIntercepting == intercept) {
_logger.log('🔊 setIntercepting($intercept) - already in that state');
return;
}
try {
if (intercept) {
await _channel.invokeMethod('startListening');
_isIntercepting = true;
_logger.log('🔊 Volume interception ENABLED - will capture buttons for MA players');
} else {
await _channel.invokeMethod('stopListening');
_isIntercepting = false;
_logger.log('🔊 Volume interception DISABLED - native volume buttons work normally');
}
} catch (e) {
_logger.error('Failed to set volume interception to $intercept', context: 'VolumeService', error: e);
}
}
/// Stop listening for volume button events
Future<void> dispose() async {
if (!_isListening) return;
try {
await _channel.invokeMethod('stopListening');
_isListening = false;
_isIntercepting = false;
_logger.info('Hardware volume button listening stopped', context: 'VolumeService');
} catch (e) {
_logger.error('Failed to stop volume button listening', context: 'VolumeService', error: e);
}
}
}
+5 -3
View File
@@ -1,8 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'debug_logger.dart';
import 'settings_service.dart'; import 'settings_service.dart';
class MetadataService { class MetadataService {
static final _logger = DebugLogger();
// Cache to avoid repeated API calls for the same artist/album // Cache to avoid repeated API calls for the same artist/album
static final Map<String, String> _cache = {}; static final Map<String, String> _cache = {};
@@ -152,7 +154,7 @@ class MetadataService {
} }
} }
} catch (e) { } catch (e) {
print('⚠️ Last.fm API error: $e'); _logger.warning('Last.fm API error: $e', context: 'Metadata');
} }
return null; return null;
} }
@@ -185,7 +187,7 @@ class MetadataService {
} }
} }
} catch (e) { } catch (e) {
print('⚠️ TheAudioDB API error: $e'); _logger.warning('TheAudioDB API error: $e', context: 'Metadata');
} }
return null; return null;
} }
@@ -241,7 +243,7 @@ class MetadataService {
} }
} }
} catch (e) { } catch (e) {
print('⚠️ Deezer artist image error: $e'); _logger.warning('Deezer artist image error: $e', context: 'Metadata');
} }
// Cache the null result to avoid repeated failed lookups // Cache the null result to avoid repeated failed lookups
+133 -4
View File
@@ -641,6 +641,26 @@ class MusicAssistantAPI {
} }
} }
/// 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;
}
}
Future<List<Track>> getAlbumTracks(String provider, String itemId) async { Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
return await RetryHelper.retryNetwork( return await RetryHelper.retryNetwork(
operation: () async { operation: () async {
@@ -1055,12 +1075,14 @@ class MusicAssistantAPI {
} }
/// Play radio based on a track (generates similar tracks) /// 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 { Future<void> playRadio(String playerId, Track track) async {
return await RetryHelper.retryCritical( return await RetryHelper.retryCritical(
operation: () async { operation: () async {
// Try using library URI if available, otherwise use provider URI // For radio mode, prefer streaming providers (Spotify, Tidal, etc.)
final libraryUri = track.uri; // as they provide better dynamic track recommendations
final trackUri = libraryUri ?? _buildTrackUri(track); final trackUri = _buildRadioUri(track);
_logger.log('Radio: Starting with URI $trackUri');
final args = { final args = {
'queue_id': playerId, 'queue_id': playerId,
@@ -1069,7 +1091,6 @@ class MusicAssistantAPI {
'radio_mode': true, // Enable radio mode for similar tracks 'radio_mode': true, // Enable radio mode for similar tracks
}; };
await _sendCommand( await _sendCommand(
'player_queues/play_media', 'player_queues/play_media',
args: args, args: args,
@@ -1079,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 /// Build track URI from provider mappings
String _buildTrackUri(Track track) { String _buildTrackUri(Track track) {
// Use provider mappings to get the actual provider instance // Use provider mappings to get the actual provider instance
+68
View File
@@ -21,6 +21,12 @@ class SettingsService {
static const String _keyLocalPlayerName = 'local_player_name'; static const String _keyLocalPlayerName = 'local_player_name';
static const String _keyOwnerName = 'owner_name'; static const String _keyOwnerName = 'owner_name';
static const String _keyLastSelectedPlayerId = 'last_selected_player_id'; static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
static Future<String?> getServerUrl() async { static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -313,4 +319,66 @@ class SettingsService {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.clear(); await prefs.clear();
} }
// Home Screen Row Settings (Main rows - default on)
static Future<bool> getShowRecentAlbums() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowRecentAlbums) ?? true;
}
static Future<void> setShowRecentAlbums(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowRecentAlbums, show);
}
static Future<bool> getShowDiscoverArtists() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowDiscoverArtists) ?? true;
}
static Future<void> setShowDiscoverArtists(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowDiscoverArtists, show);
}
static Future<bool> getShowDiscoverAlbums() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowDiscoverAlbums) ?? true;
}
static Future<void> setShowDiscoverAlbums(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowDiscoverAlbums, show);
}
// Home Screen Favorites Settings (default off)
static Future<bool> getShowFavoriteAlbums() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoriteAlbums) ?? false;
}
static Future<void> setShowFavoriteAlbums(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteAlbums, show);
}
static Future<bool> getShowFavoriteArtists() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoriteArtists) ?? false;
}
static Future<void> setShowFavoriteArtists(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteArtists, show);
}
static Future<bool> getShowFavoriteTracks() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoriteTracks) ?? false;
}
static Future<void> setShowFavoriteTracks(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteTracks, show);
}
} }
+4 -1
View File
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.dart';
import '../services/debug_logger.dart';
final _logger = DebugLogger();
/// Extracted adaptive colors from album art /// Extracted adaptive colors from album art
class AdaptiveColors { class AdaptiveColors {
@@ -40,7 +43,7 @@ class PaletteHelper {
); );
return palette; return palette;
} catch (e) { } catch (e) {
print('⚠️ Failed to extract palette: $e'); _logger.warning('Failed to extract palette: $e', context: 'Palette');
return null; return null;
} }
} }
+4 -1
View File
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import '../services/debug_logger.dart';
final _systemThemeLogger = DebugLogger();
class SystemThemeHelper { class SystemThemeHelper {
/// Attempts to get system color schemes from the device (Android 12+) /// Attempts to get system color schemes from the device (Android 12+)
@@ -35,7 +38,7 @@ class SystemThemeHelper {
} }
} }
} catch (e) { } catch (e) {
print('⚠️ Failed to get system colors: $e'); _systemThemeLogger.warning('Failed to get system colors: $e', context: 'Theme');
} }
return null; return null;
+4 -1
View File
@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import 'palette_helper.dart'; import 'palette_helper.dart';
final _themeLogger = DebugLogger();
/// Global function to update adaptive colors from an image URL /// 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) /// This can be called from anywhere in the app (e.g., when tapping an album/artist)
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async { Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
@@ -17,7 +20,7 @@ Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUr
} }
} catch (e) { } catch (e) {
// Silently fail - colors will update when track plays // 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');
} }
} }
+84 -57
View File
@@ -2,18 +2,21 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import 'album_card.dart'; import 'album_card.dart';
class AlbumRow extends StatefulWidget { class AlbumRow extends StatefulWidget {
final String title; final String title;
final Future<List<Album>> Function() loadAlbums; final Future<List<Album>> Function() loadAlbums;
final String? heroTagSuffix; final String? heroTagSuffix;
final double? rowHeight;
const AlbumRow({ const AlbumRow({
super.key, super.key,
required this.title, required this.title,
required this.loadAlbums, required this.loadAlbums,
this.heroTagSuffix, this.heroTagSuffix,
this.rowHeight,
}); });
@override @override
@@ -44,79 +47,103 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
} }
} }
static final _logger = DebugLogger();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_logger.startBuild('AlbumRow:${widget.title}');
super.build(context); // Required for AutomaticKeepAliveClientMixin super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Column( // Total row height includes title + content
crossAxisAlignment: CrossAxisAlignment.start, final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
children: [ const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
Padding( final contentHeight = totalHeight - titleHeight;
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text( final result = RepaintBoundary(
widget.title, child: SizedBox(
style: textTheme.titleLarge?.copyWith( height: totalHeight,
fontWeight: FontWeight.bold, child: Column(
color: colorScheme.onBackground, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
), ),
), ),
), Expanded(
SizedBox( child: FutureBuilder<List<Album>>(
height: 193, future: _albumsFuture,
child: FutureBuilder<List<Album>>( builder: (context, snapshot) {
future: _albumsFuture, // Use cached data immediately if available (prevents flash on rebuild)
builder: (context, snapshot) { final albums = snapshot.data ?? _cachedAlbums;
// Use cached data immediately if available (prevents flash on rebuild)
final albums = snapshot.data ?? _cachedAlbums;
// Only show loading if we have no data at all // Only show loading if we have no data at all
if (albums == null && snapshot.connectionState == ConnectionState.waiting) { if (albums == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError && albums == null) { if (snapshot.hasError && albums == null) {
return Center( return Center(
child: Text('Error: ${snapshot.error}'), child: Text('Error: ${snapshot.error}'),
); );
} }
if (albums == null || albums.isEmpty) { if (albums == null || albums.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'No albums found', 'No albums found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: albums.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final album = albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
), ),
); );
} },
),
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: albums.length,
itemExtent: 162, // 150px width + 12px horizontal margins
itemBuilder: (context, index) {
final album = albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: 150,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
},
), ),
],
), ),
], ),
); );
_logger.endBuild('AlbumRow:${widget.title}');
return result;
} }
} }
+85 -46
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../screens/artist_details_screen.dart'; import '../screens/artist_details_screen.dart';
@@ -7,17 +8,20 @@ import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
class ArtistCard extends StatefulWidget { class ArtistCard extends StatefulWidget {
final Artist artist; final Artist artist;
final VoidCallback? onTap; final VoidCallback? onTap;
final String? heroTagSuffix; final String? heroTagSuffix;
final double? imageSize;
const ArtistCard({ const ArtistCard({
super.key, super.key,
required this.artist, required this.artist,
this.onTap, this.onTap,
this.heroTagSuffix, this.heroTagSuffix,
this.imageSize,
}); });
@override @override
@@ -25,13 +29,39 @@ class ArtistCard extends StatefulWidget {
} }
class _ArtistCardState extends State<ArtistCard> { class _ArtistCardState extends State<ArtistCard> {
static final _logger = DebugLogger();
String? _fallbackImageUrl; String? _fallbackImageUrl;
bool _triedFallback = false; bool _triedFallback = false;
String? _cachedMaImageUrl;
@override
void initState() {
super.initState();
// Fetch fallback image once in initState, not during build
_initFallbackImage();
}
void _initFallbackImage() {
// We'll check if MA has an image after first build, then fetch fallback if needed
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
final maImageUrl = maProvider.api?.getImageUrl(widget.artist, size: 256);
_cachedMaImageUrl = maImageUrl;
if (maImageUrl == null && !_triedFallback) {
_triedFallback = true;
_logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
_fetchFallbackImage();
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final maImageUrl = maProvider.api?.getImageUrl(widget.artist, size: 256); // Use cached URL if available, otherwise get fresh (but don't trigger fetches during build)
final maImageUrl = _cachedMaImageUrl ?? maProvider.api?.getImageUrl(widget.artist, size: 256);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -40,15 +70,6 @@ class _ArtistCardState extends State<ArtistCard> {
// Use MA image if available, otherwise try fallback // Use MA image if available, otherwise try fallback
final imageUrl = maImageUrl ?? _fallbackImageUrl; 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;
print('🎨 No MA image for "${widget.artist.name}", trying fallback...');
_fetchFallbackImage();
} else if (maImageUrl != null && !_triedFallback) {
print('🎨 MA has image for "${widget.artist.name}": $maImageUrl');
}
return RepaintBoundary( return RepaintBoundary(
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap ?? () { onTap: widget.onTap ?? () {
@@ -60,6 +81,7 @@ class _ArtistCardState extends State<ArtistCard> {
child: ArtistDetailsScreen( child: ArtistDetailsScreen(
artist: widget.artist, artist: widget.artist,
heroTagSuffix: widget.heroTagSuffix, heroTagSuffix: widget.heroTagSuffix,
initialImageUrl: imageUrl,
), ),
), ),
); );
@@ -67,44 +89,61 @@ class _ArtistCardState extends State<ArtistCard> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Artist image - circular // Artist image - circular, constrained to be a proper circle
Hero( Expanded(
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix, child: Center(
child: ClipOval( child: LayoutBuilder(
child: Container( builder: (context, constraints) {
width: 110, // Use the smaller dimension to ensure a circle
height: 110, final size = constraints.maxWidth < constraints.maxHeight
color: colorScheme.surfaceVariant, ? constraints.maxWidth
child: imageUrl != null : constraints.maxHeight;
? Image.network( return Hero(
imageUrl, tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
fit: BoxFit.cover, child: ClipOval(
cacheWidth: 256, child: Container(
cacheHeight: 256, width: size,
errorBuilder: (_, __, ___) => Icon( height: size,
Icons.person_rounded, color: colorScheme.surfaceVariant,
size: 60, child: imageUrl != null
color: colorScheme.onSurfaceVariant, ? CachedNetworkImage(
), imageUrl: imageUrl,
) fit: BoxFit.cover,
: Icon(Icons.person_rounded, size: 60, color: colorScheme.onSurfaceVariant), memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Icon(
Icons.person_rounded,
size: size * 0.55,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(Icons.person_rounded, size: size * 0.55, color: colorScheme.onSurfaceVariant),
),
),
);
},
), ),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
// Artist name // Artist name - fixed height container so image size is consistent
Hero( SizedBox(
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix, height: 36, // Fixed height for 2 lines of text
child: Material( child: Hero(
color: Colors.transparent, tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: Text( child: Material(
widget.artist.name, color: Colors.transparent,
maxLines: 2, child: Text(
overflow: TextOverflow.ellipsis, widget.artist.name,
textAlign: TextAlign.center, maxLines: 2,
style: textTheme.titleSmall?.copyWith( overflow: TextOverflow.ellipsis,
color: colorScheme.onSurface, textAlign: TextAlign.center,
fontWeight: FontWeight.w500, style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
), ),
), ),
), ),
@@ -117,8 +156,8 @@ class _ArtistCardState extends State<ArtistCard> {
Future<void> _fetchFallbackImage() async { Future<void> _fetchFallbackImage() async {
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name); final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
print('🎨 Fallback result for "${widget.artist.name}": $fallbackUrl');
if (fallbackUrl != null && mounted) { if (fallbackUrl != null && mounted) {
_logger.debug('Found fallback image for "${widget.artist.name}"', context: 'ArtistCard');
setState(() { setState(() {
_fallbackImageUrl = fallbackUrl; _fallbackImageUrl = fallbackUrl;
}); });
+84 -57
View File
@@ -2,18 +2,21 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import 'artist_card.dart'; import 'artist_card.dart';
class ArtistRow extends StatefulWidget { class ArtistRow extends StatefulWidget {
final String title; final String title;
final Future<List<Artist>> Function() loadArtists; final Future<List<Artist>> Function() loadArtists;
final String? heroTagSuffix; final String? heroTagSuffix;
final double? rowHeight;
const ArtistRow({ const ArtistRow({
super.key, super.key,
required this.title, required this.title,
required this.loadArtists, required this.loadArtists,
this.heroTagSuffix, this.heroTagSuffix,
this.rowHeight,
}); });
@override @override
@@ -44,79 +47,103 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
} }
} }
static final _logger = DebugLogger();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_logger.startBuild('ArtistRow:${widget.title}');
super.build(context); // Required for AutomaticKeepAliveClientMixin super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Column( // Total row height includes title + content
crossAxisAlignment: CrossAxisAlignment.start, final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
children: [ const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
Padding( final contentHeight = totalHeight - titleHeight;
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text( final result = RepaintBoundary(
widget.title, child: SizedBox(
style: textTheme.titleLarge?.copyWith( height: totalHeight,
fontWeight: FontWeight.bold, child: Column(
color: colorScheme.onBackground, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
), ),
), ),
), Expanded(
SizedBox( child: FutureBuilder<List<Artist>>(
height: 163, future: _artistsFuture,
child: FutureBuilder<List<Artist>>( builder: (context, snapshot) {
future: _artistsFuture, // Use cached data immediately if available (prevents flash on rebuild)
builder: (context, snapshot) { final artists = snapshot.data ?? _cachedArtists;
// Use cached data immediately if available (prevents flash on rebuild)
final artists = snapshot.data ?? _cachedArtists;
// Only show loading if we have no data at all // Only show loading if we have no data at all
if (artists == null && snapshot.connectionState == ConnectionState.waiting) { if (artists == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError && artists == null) { if (snapshot.hasError && artists == null) {
return Center( return Center(
child: Text('Error: ${snapshot.error}'), child: Text('Error: ${snapshot.error}'),
); );
} }
if (artists == null || artists.isEmpty) { if (artists == null || artists.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'No artists found', 'No artists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: circle image + name below
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: artists.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final artist = artists[index];
return Container(
key: ValueKey(artist.uri ?? artist.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: ArtistCard(
artist: artist,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
), ),
); );
} },
),
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: artists.length,
itemExtent: 136, // 120px width + 16px horizontal margins
itemBuilder: (context, index) {
final artist = artists[index];
return Container(
key: ValueKey(artist.uri ?? artist.itemId),
width: 120,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: ArtistCard(
artist: artist,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
},
), ),
],
), ),
], ),
); );
_logger.endBuild('ArtistRow:${widget.title}');
return result;
} }
} }
+82
View File
@@ -64,6 +64,20 @@ class EmptyState extends StatelessWidget {
message: 'No results found', 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; 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,
),
),
],
],
),
),
);
}
}
+224 -104
View File
@@ -7,6 +7,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../models/player.dart'; import '../models/player.dart';
import '../services/animation_debugger.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import 'animated_icon_button.dart'; import 'animated_icon_button.dart';
@@ -81,6 +82,15 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// When true, we hide main content and show peek content at center // When true, we hide main content and show peek content at center
bool _inTransition = false; bool _inTransition = false;
// Track favorite state for current track
bool _isCurrentTrackFavorite = false;
String? _lastTrackUri; // Track which track we last checked favorite status for
// Cached title height to avoid TextPainter.layout() every animation frame
double? _cachedExpandedTitleHeight;
String? _lastMeasuredTrackName;
double? _lastMeasuredTitleWidth;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -97,6 +107,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Notify listeners of expansion progress changes // Notify listeners of expansion progress changes
_controller.addListener(_notifyExpansionProgress); _controller.addListener(_notifyExpansionProgress);
// Animation debugging - record every frame
_controller.addListener(_recordAnimationFrame);
// Queue panel animation // Queue panel animation
_queuePanelController = AnimationController( _queuePanelController = AnimationController(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@@ -164,15 +177,25 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
super.dispose(); super.dispose();
} }
void _recordAnimationFrame() {
AnimationDebugger.recordFrame(_controller.value);
}
void expand() { void expand() {
_controller.forward(); AnimationDebugger.startSession('playerExpand');
_controller.forward().then((_) {
AnimationDebugger.endSession();
});
} }
void collapse() { void collapse() {
AnimationDebugger.startSession('playerCollapse');
// Instantly hide queue panel when collapsing to avoid visual glitches // Instantly hide queue panel when collapsing to avoid visual glitches
// during Android's predictive back gesture // during Android's predictive back gesture
_queuePanelController.value = 0; _queuePanelController.value = 0;
_controller.reverse(); _controller.reverse().then((_) {
AnimationDebugger.endSession();
});
} }
bool get isExpanded => _controller.value > 0.5; bool get isExpanded => _controller.value > 0.5;
@@ -283,6 +306,64 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
bool get isQueuePanelOpen => _queuePanelController.value > 0.5; bool get isQueuePanelOpen => _queuePanelController.value > 0.5;
/// Update favorite status when track changes
void _updateFavoriteStatus(dynamic currentTrack) {
if (currentTrack == null) {
_isCurrentTrackFavorite = false;
_lastTrackUri = null;
return;
}
final trackUri = currentTrack.uri as String?;
if (trackUri != _lastTrackUri) {
_lastTrackUri = trackUri;
_isCurrentTrackFavorite = currentTrack.favorite == true;
}
}
/// Toggle favorite status for current track
Future<void> _toggleCurrentTrackFavorite(dynamic currentTrack) async {
if (currentTrack == null) return;
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
if (_isCurrentTrackFavorite) {
// Remove from favorites
final libraryItemId = currentTrack.itemId;
if (libraryItemId is int) {
await maProvider.api!.removeFromFavorites('track', libraryItemId);
} else if (libraryItemId is String) {
final parsed = int.tryParse(libraryItemId);
if (parsed != null) {
await maProvider.api!.removeFromFavorites('track', parsed);
}
}
} else {
// Add to favorites
final provider = currentTrack.provider as String? ?? 'library';
final itemId = currentTrack.itemId?.toString() ?? '';
await maProvider.api!.addToFavorites('track', itemId, provider);
}
// Toggle local state
setState(() {
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
});
} catch (e) {
// Show error feedback
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
/// Get available players sorted alphabetically (consistent with device selector) /// Get available players sorted alphabetically (consistent with device selector)
List<dynamic> _getAvailablePlayersSorted(MusicAssistantProvider maProvider) { List<dynamic> _getAvailablePlayersSorted(MusicAssistantProvider maProvider) {
// Use the provider's already-sorted list, filter for available only // Use the provider's already-sorted list, filter for available only
@@ -612,6 +693,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_extractColors(imageUrl); _extractColors(imageUrl);
} }
// Update favorite status when track changes
_updateFavoriteStatus(currentTrack);
return AnimatedBuilder( return AnimatedBuilder(
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]), animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]),
builder: (context, _) { builder: (context, _) {
@@ -821,19 +905,27 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final expandedTitleWidth = screenSize.width - (contentPadding * 2); final expandedTitleWidth = screenSize.width - (contentPadding * 2);
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t); final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
// Measure actual title height for dynamic layout // Measure actual title height for dynamic layout (CACHED to avoid layout every frame)
final titleStyle = TextStyle( final titleStyle = TextStyle(
fontSize: 24.0, fontSize: 24.0,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
); );
final titlePainter = TextPainter( // Only recalculate if track name or width changed
text: TextSpan(text: currentTrack.name, style: titleStyle), if (_lastMeasuredTrackName != currentTrack.name ||
maxLines: 2, _lastMeasuredTitleWidth != expandedTitleWidth ||
textDirection: TextDirection.ltr, _cachedExpandedTitleHeight == null) {
)..layout(maxWidth: expandedTitleWidth); final titlePainter = TextPainter(
final expandedTitleHeight = titlePainter.height; text: TextSpan(text: currentTrack.name, style: titleStyle),
maxLines: 2,
textDirection: TextDirection.ltr,
)..layout(maxWidth: expandedTitleWidth);
_cachedExpandedTitleHeight = titlePainter.height;
_lastMeasuredTrackName = currentTrack.name;
_lastMeasuredTitleWidth = expandedTitleWidth;
}
final expandedTitleHeight = _cachedExpandedTitleHeight!;
// Calculate track info block height (title + gap + artist + gap + album) // Calculate track info block height (title + gap + artist + gap + album)
final titleToArtistGap = 12.0; final titleToArtistGap = 12.0;
@@ -992,53 +1084,57 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Album art - with slide animation when collapsed // Album art - with slide animation when collapsed
// Hidden during transition to prevent flash (peek content shows instead) // Hidden during transition to prevent flash (peek content shows instead)
Positioned( // GPU PERF: Use conditional instead of Opacity to avoid saveLayer
left: artLeft + miniPlayerSlideOffset, if (!(_inTransition && t < 0.1))
top: artTop, Positioned(
child: Opacity( left: artLeft + miniPlayerSlideOffset,
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0, top: artTop,
child: Container( child: Container(
width: artSize, width: artSize,
height: artSize, height: artSize,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(artBorderRadius), borderRadius: BorderRadius.circular(artBorderRadius),
// GPU PERF: Fixed blur/offset, only animate shadow opacity
boxShadow: t > 0.3 boxShadow: t > 0.3
? [ ? [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.3 * t), color: Colors.black.withOpacity(0.25 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
blurRadius: 24 * t, blurRadius: 20,
offset: Offset(0, 8 * t), offset: const Offset(0, 8),
), ),
] ]
: null, : null,
), ),
child: ClipRRect( // Use RepaintBoundary to isolate art repaints during animation
borderRadius: BorderRadius.circular(artBorderRadius), child: RepaintBoundary(
child: imageUrl != null child: ClipRRect(
? CachedNetworkImage( borderRadius: BorderRadius.circular(artBorderRadius),
imageUrl: imageUrl, child: imageUrl != null
fit: BoxFit.cover, ? CachedNetworkImage(
memCacheWidth: t > 0.5 ? 1024 : 256, imageUrl: imageUrl,
memCacheHeight: t > 0.5 ? 1024 : 256, fit: BoxFit.cover,
fadeInDuration: Duration.zero, // Fixed cache size to avoid mid-animation cache thrashing
fadeOutDuration: Duration.zero, memCacheWidth: 512,
placeholderFadeInDuration: Duration.zero, memCacheHeight: 512,
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t), fadeInDuration: Duration.zero,
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t), fadeOutDuration: Duration.zero,
) placeholderFadeInDuration: Duration.zero,
: _buildPlaceholderArt(colorScheme, t), placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
)
: _buildPlaceholderArt(colorScheme, t),
),
), ),
), ),
), ),
),
// Track title - with slide animation when collapsed // Track title - with slide animation when collapsed
// Hidden during transition to prevent flash // Hidden during transition to prevent flash
Positioned( // GPU PERF: Use conditional instead of Opacity to avoid saveLayer
left: titleLeft + miniPlayerSlideOffset, if (!(_inTransition && t < 0.1))
top: titleTop, Positioned(
child: Opacity( left: titleLeft + miniPlayerSlideOffset,
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0, top: titleTop,
child: SizedBox( child: SizedBox(
width: titleWidth, width: titleWidth,
child: Text( child: Text(
@@ -1056,15 +1152,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
), ),
), ),
),
// Artist name - with slide animation when collapsed // Artist name - with slide animation when collapsed
// Hidden during transition to prevent flash // Hidden during transition to prevent flash
Positioned( // GPU PERF: Use conditional instead of Opacity to avoid saveLayer
left: titleLeft + miniPlayerSlideOffset, if (!(_inTransition && t < 0.1))
top: artistTop, Positioned(
child: Opacity( left: titleLeft + miniPlayerSlideOffset,
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0, top: artistTop,
child: SizedBox( child: SizedBox(
width: titleWidth, width: titleWidth,
child: Text( child: Text(
@@ -1080,38 +1175,40 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
), ),
), ),
),
// Album name (expanded only) // Album name (expanded only)
// GPU PERF: Use color alpha instead of Opacity widget
if (currentTrack.album != null && t > 0.3) if (currentTrack.album != null && t > 0.3)
Positioned( Positioned(
left: contentPadding, left: contentPadding,
right: contentPadding, right: contentPadding,
top: _lerpDouble(artistTop + 24, expandedAlbumTop, t), top: _lerpDouble(artistTop + 24, expandedAlbumTop, t),
child: Opacity( child: Text(
opacity: ((t - 0.3) / 0.7).clamp(0.0, 1.0), currentTrack.album!.name,
child: Text( style: TextStyle(
currentTrack.album!.name, color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
style: TextStyle( fontSize: 15,
color: textColor.withOpacity(0.45), fontWeight: FontWeight.w300,
fontSize: 15, // Increased from 13 to 15
fontWeight: FontWeight.w300,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
), ),
// Progress bar (expanded only) // Progress bar (expanded only)
// GPU PERF: Use FadeTransition instead of Opacity
if (t > 0.5 && currentTrack.duration != null) if (t > 0.5 && currentTrack.duration != null)
Positioned( Positioned(
left: contentPadding, left: contentPadding,
right: contentPadding, right: contentPadding,
top: expandedProgressTop, top: expandedProgressTop,
child: Opacity( child: FadeTransition(
opacity: expandedElementsOpacity, opacity: _expandAnimation.drive(
Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: const Interval(0.5, 1.0, curve: Curves.easeIn)),
),
),
child: ValueListenableBuilder<int>( child: ValueListenableBuilder<int>(
valueListenable: _progressNotifier, valueListenable: _progressNotifier,
builder: (context, elapsedTime, child) { builder: (context, elapsedTime, child) {
@@ -1202,14 +1299,13 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end, mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [ children: [
// Shuffle (expanded only) // Shuffle (expanded only)
if (t > 0.5) // GPU PERF: Use color alpha instead of Opacity
Opacity( if (t > 0.5 && expandedElementsOpacity > 0.1)
opacity: expandedElementsOpacity, _buildSecondaryButton(
child: _buildSecondaryButton( icon: Icons.shuffle_rounded,
icon: Icons.shuffle_rounded, color: (_queue?.shuffle == true ? primaryColor : textColor.withOpacity(0.5))
color: _queue?.shuffle == true ? primaryColor : textColor.withOpacity(0.5), .withOpacity(expandedElementsOpacity),
onPressed: _isLoadingQueue ? null : _toggleShuffle, onPressed: _isLoadingQueue ? null : _toggleShuffle,
),
), ),
if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)), if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)),
@@ -1247,85 +1343,109 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
// Repeat (expanded only) // Repeat (expanded only)
// GPU PERF: Use color alpha instead of Opacity
if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)), if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)),
if (t > 0.5) if (t > 0.5 && expandedElementsOpacity > 0.1)
Opacity( _buildSecondaryButton(
opacity: expandedElementsOpacity, icon: _queue?.repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
child: _buildSecondaryButton( color: (_queue?.repeatMode != null && _queue!.repeatMode != 'off'
icon: _queue?.repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded, ? primaryColor
color: _queue?.repeatMode != null && _queue!.repeatMode != 'off' : textColor.withOpacity(0.5))
? primaryColor .withOpacity(expandedElementsOpacity),
: textColor.withOpacity(0.5), onPressed: _isLoadingQueue ? null : _cycleRepeat,
onPressed: _isLoadingQueue ? null : _cycleRepeat,
),
), ),
], ],
), ),
), ),
// Volume control (expanded only) // Volume control (expanded only)
// GPU PERF: Use FadeTransition instead of Opacity
if (t > 0.5) if (t > 0.5)
Positioned( Positioned(
left: 48, left: 48,
right: 48, right: 48,
top: volumeTop, top: volumeTop,
child: Opacity( child: FadeTransition(
opacity: expandedElementsOpacity, opacity: _expandAnimation.drive(
Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: const Interval(0.5, 1.0, curve: Curves.easeIn)),
),
),
child: const VolumeControl(compact: false), child: const VolumeControl(compact: false),
), ),
), ),
// Collapse button (expanded only) // Collapse button (expanded only)
// GPU PERF: Use icon color alpha instead of Opacity
if (t > 0.3) if (t > 0.3)
Positioned( Positioned(
top: topPadding + 4, top: topPadding + 4,
left: 4, left: 4,
child: Opacity( child: IconButton(
opacity: ((t - 0.3) / 0.7).clamp(0.0, 1.0), icon: Icon(
child: IconButton( Icons.keyboard_arrow_down_rounded,
icon: Icon(Icons.keyboard_arrow_down_rounded, color: textColor, size: 28), color: textColor.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0)),
onPressed: collapse, size: 28,
padding: const EdgeInsets.all(12),
), ),
onPressed: collapse,
padding: const EdgeInsets.all(12),
),
),
// Favorite button (expanded only) - hide when queue panel is open
// GPU PERF: Use icon color alpha instead of Opacity
if (t > 0.3 && queueT < 0.5)
Positioned(
top: topPadding + 4,
right: 52,
child: IconButton(
icon: Icon(
_isCurrentTrackFavorite ? Icons.favorite : Icons.favorite_border,
color: (_isCurrentTrackFavorite ? Colors.red : textColor)
.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)),
size: 24,
),
onPressed: () => _toggleCurrentTrackFavorite(currentTrack),
padding: const EdgeInsets.all(12),
), ),
), ),
// Queue button (expanded only) - hide when queue panel is open // Queue button (expanded only) - hide when queue panel is open
// GPU PERF: Use icon color alpha instead of Opacity
if (t > 0.3 && queueT < 0.5) if (t > 0.3 && queueT < 0.5)
Positioned( Positioned(
top: topPadding + 4, top: topPadding + 4,
right: 4, right: 4,
child: Opacity( child: IconButton(
opacity: ((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0), icon: Icon(
child: IconButton( Icons.queue_music_rounded,
icon: Icon(Icons.queue_music_rounded, color: textColor, size: 24), color: textColor.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)),
onPressed: _toggleQueuePanel, size: 24,
padding: const EdgeInsets.all(12),
), ),
onPressed: _toggleQueuePanel,
padding: const EdgeInsets.all(12),
), ),
), ),
// Player name (expanded only) // Player name (expanded only)
// GPU PERF: Use text color alpha instead of Opacity
if (t > 0.5) if (t > 0.5)
Positioned( Positioned(
top: topPadding + 12, top: topPadding + 12,
left: 56, left: 56,
right: 56, right: 56,
child: IgnorePointer( child: IgnorePointer(
child: Opacity( child: Text(
opacity: ((t - 0.5) / 0.5).clamp(0.0, 1.0), selectedPlayer.name,
child: Text( style: TextStyle(
selectedPlayer.name, color: textColor.withOpacity(0.6 * ((t - 0.5) / 0.5).clamp(0.0, 1.0)),
style: TextStyle( fontSize: 15,
color: textColor.withOpacity(0.6), fontWeight: FontWeight.w500,
fontSize: 15, // Increased from 13 to 15 letterSpacing: 0.2,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
), ),
), ),
+108 -78
View File
@@ -5,6 +5,40 @@ import '../providers/navigation_provider.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import 'expandable_player.dart'; import 'expandable_player.dart';
/// Cached color with contrast adjustment
/// Avoids expensive HSL conversions during scroll
class _CachedNavColor {
Color? _sourceColor;
bool? _isDark;
Color? _adjustedColor;
Color getAdjustedColor(Color sourceColor, bool isDark) {
// Return cached value if inputs haven't changed
if (_sourceColor == sourceColor && _isDark == isDark && _adjustedColor != null) {
return _adjustedColor!;
}
// Compute new adjusted color
var navSelectedColor = sourceColor;
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
}
// Cache the result
_sourceColor = sourceColor;
_isDark = isDark;
_adjustedColor = navSelectedColor;
return navSelectedColor;
}
}
final _cachedNavColor = _CachedNavColor();
/// A global key to access the player state from anywhere in the app /// A global key to access the player state from anywhere in the app
final globalPlayerKey = GlobalKey<ExpandablePlayerState>(); final globalPlayerKey = GlobalKey<ExpandablePlayerState>();
@@ -23,7 +57,7 @@ class BottomSpacing {
static const double navBarOnly = navBarHeight + 16.0; static const double navBarOnly = navBarHeight + 16.0;
/// Space needed when mini player is also visible /// Space needed when mini player is also visible
static const double withMiniPlayer = navBarHeight + miniPlayerHeight + 14.0; static const double withMiniPlayer = navBarHeight + miniPlayerHeight + 22.0;
} }
/// ValueNotifier for player expansion progress (0.0 to 1.0) and background color /// ValueNotifier for player expansion progress (0.0 to 1.0) and background color
@@ -135,33 +169,73 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
return Consumer<ThemeProvider>( return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) { builder: (context, themeProvider, _) {
// Use adaptive primary color for bottom nav when adaptive theme is enabled // Use adaptive primary color for bottom nav when adaptive theme is enabled
var navSelectedColor = themeProvider.adaptiveTheme final sourceColor = themeProvider.adaptiveTheme
? themeProvider.adaptivePrimaryColor ? themeProvider.adaptivePrimaryColor
: colorScheme.primary; : colorScheme.primary;
// Ensure nav color has sufficient contrast against the surface background // Use cached color computation to avoid expensive HSL operations during scroll
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
if (isDark && navSelectedColor.computeLuminance() < 0.2) { final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor(); // Base background: use adaptive surface color if available, otherwise default surface
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) { final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
final hsl = HSLColor.fromColor(navSelectedColor); ? themeProvider.adaptiveSurfaceColor!
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor(); : colorScheme.surface;
}
return ValueListenableBuilder<PlayerExpansionState>( return ValueListenableBuilder<PlayerExpansionState>(
valueListenable: playerExpansionNotifier, valueListenable: playerExpansionNotifier,
builder: (context, expansionState, child) { // Pass BottomNavigationBar as child to avoid rebuilding it every frame
// Base background: use adaptive surface color if available, otherwise default surface child: BottomNavigationBar(
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null) currentIndex: navigationProvider.selectedIndex,
? themeProvider.adaptiveSurfaceColor! onTap: (index) {
: colorScheme.surface; if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
// Lerp between base color and player background when expanded }
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 final navBgColor = expansionState.progress > 0 && expansionState.backgroundColor != null
? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)! ? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)!
: baseBgColor; : baseBgColor;
// Use AnimatedContainer for smoother transitions instead of rebuilding
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: navBgColor, color: navBgColor,
@@ -175,56 +249,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
] ]
: null, : null,
), ),
child: BottomNavigationBar( child: navBar, // Reuse pre-built navigation bar
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',
),
],
),
); );
}, },
); );
@@ -234,17 +259,22 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
), ),
), ),
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav // Global player overlay - slides down when hidden, renders ON TOP of bottom nav
AnimatedBuilder( // Use Selector instead of Consumer to avoid rebuilds during animation
animation: _slideAnimation, Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
builder: (context, child) { selector: (_, provider) => (
return Consumer<MusicAssistantProvider>( isConnected: provider.isConnected,
builder: (context, maProvider, _) { hasPlayer: provider.selectedPlayer != null,
// Only show player if connected and has a selected player ),
// Mini player is now permanent - shows device selector even without a track builder: (context, state, child) {
if (!maProvider.isConnected || // Only show player if connected and has a selected player
maProvider.selectedPlayer == null) { if (!state.isConnected || !state.hasPlayer) {
return const SizedBox.shrink(); 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( return ExpandablePlayer(
key: globalPlayerKey, key: globalPlayerKey,
slideOffset: _slideAnimation.value, slideOffset: _slideAnimation.value,
+5 -1
View File
@@ -89,6 +89,8 @@ class QueuePanel extends StatelessWidget {
return ListView.builder( return ListView.builder(
padding: Spacing.paddingH8, padding: Spacing.paddingH8,
cacheExtent: 500, cacheExtent: 500,
addAutomaticKeepAlives: false, // Items don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually
itemCount: items.length, itemCount: items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; final item = items[index];
@@ -96,7 +98,8 @@ class QueuePanel extends StatelessWidget {
final isPastItem = index < currentIndex; final isPastItem = index < currentIndex;
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80); final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
return Opacity( return RepaintBoundary(
child: Opacity(
opacity: isPastItem ? 0.5 : 1.0, opacity: isPastItem ? 0.5 : 1.0,
child: Container( child: Container(
margin: EdgeInsets.symmetric(vertical: Spacing.xxs), margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
@@ -162,6 +165,7 @@ class QueuePanel extends StatelessWidget {
}, },
), ),
), ),
),
); );
}, },
); );
+260
View File
@@ -0,0 +1,260 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
class TrackRow extends StatefulWidget {
final String title;
final Future<List<Track>> Function() loadTracks;
final double? rowHeight;
const TrackRow({
super.key,
required this.title,
required this.loadTracks,
this.rowHeight,
});
@override
State<TrackRow> createState() => _TrackRowState();
}
class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin {
late Future<List<Track>> _tracksFuture;
List<Track>? _cachedTracks;
bool _hasLoaded = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_loadTracksOnce();
}
void _loadTracksOnce() {
if (!_hasLoaded) {
_tracksFuture = widget.loadTracks().then((tracks) {
_cachedTracks = tracks;
return tracks;
});
_hasLoaded = true;
}
}
static final _logger = DebugLogger();
@override
Widget build(BuildContext context) {
_logger.startBuild('TrackRow:${widget.title}');
super.build(context);
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 224.0; // Default: 44 title + 180 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
child: SizedBox(
height: totalHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
Expanded(
child: FutureBuilder<List<Track>>(
future: _tracksFuture,
builder: (context, snapshot) {
final tracks = snapshot.data ?? _cachedTracks;
if (tracks == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && tracks == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (tracks == null || tracks.isEmpty) {
return Center(
child: Text(
'No tracks found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: tracks.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final track = tracks[index];
return Container(
key: ValueKey(track.uri ?? track.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: _TrackCard(
track: track,
tracks: tracks,
index: index,
),
);
},
),
);
},
),
),
],
),
),
);
_logger.endBuild('TrackRow:${widget.title}');
return result;
}
}
class _TrackCard extends StatelessWidget {
final Track track;
final List<Track> tracks;
final int index;
const _TrackCard({
required this.track,
required this.tracks,
required this.index,
});
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
// Try track image first, then album image
final trackImageUrl = maProvider.getImageUrl(track, size: 256);
final albumImageUrl = track.album != null
? maProvider.getImageUrl(track.album!, size: 256)
: null;
final imageUrl = trackImageUrl ?? albumImageUrl;
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return RepaintBoundary(
child: GestureDetector(
onTap: () => _playTrack(context, maProvider),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Album artwork
AspectRatio(
aspectRatio: 1.0,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Icon(
Icons.music_note_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
)
: Center(
child: Icon(
Icons.music_note_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
const SizedBox(height: 8),
// Track title
Text(
track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// Artist name
Text(
track.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
);
}
Future<void> _playTrack(BuildContext context, MusicAssistantProvider maProvider) async {
if (maProvider.selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
);
return;
}
await maProvider.playTracks(
maProvider.selectedPlayer!.playerId,
tracks,
startIndex: index,
);
}
}
class _StretchScrollBehavior extends ScrollBehavior {
const _StretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context, Widget child, ScrollableDetails details) {
return StretchingOverscrollIndicator(
axisDirection: details.direction,
child: child,
);
}
}
+4 -1
View File
@@ -1,7 +1,7 @@
name: ensemble name: ensemble
description: A minimalistic Music Assistant client built with Flutter description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none' publish_to: 'none'
version: 1.6.1+8 version: 1.8.0+9
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -28,6 +28,9 @@ dependencies:
# Utilities # Utilities
uuid: ^4.2.1 uuid: ^4.2.1
package_info_plus: ^8.0.0
device_info_plus: ^10.1.0
share_plus: ^9.0.0
# Audio - Local Playback # Audio - Local Playback
just_audio: ^0.9.36 just_audio: ^0.9.36